Public: anyone can read this; the crew can change it.
An issue the crew parked twice is tried again when something changes; it is n… #12160
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/12155-an-issue-the-crew-parked-twice-is-tried → main 9 commits fork 6 threads · 6 unresolved
docs/PLANNING.md +5 −1
| @@ -135,7 +135,11 @@ same, one entry per repository, each the repository's map data plus | ||
| 135 | 135 | ## Parked twice |
| 136 | 136 | |
| 137 | 137 | An issue the crew parked twice since someone who may direct the crew last |
| 138 | spoke on it is struck out: the clock no longer dispatches it. A reader's | |
| 138 | spoke on it is struck out: the clock holds it, but never for good (`crew/retry.ts`). | |
| 139 | It is startable again when its inputs change since its newest park (a comment, | |
| 140 | a dependency closed, its text or the direction issue edited by someone who | |
| 141 | may direct the crew; a restart is not a change) and otherwise once a day, the builder given every earlier reason; the | |
| 142 | Crew page lists each with a "Try again" button, and the terminal's Crew hand-overs screen has `t` (`docs/features/crew-try-again.md`). A reader's | |
| 139 | 143 | comment does not start the count again, and neither does the planner's. A |
| 140 | 144 | park is the crew's own note, written in the name of someone who directs |
| 141 | 145 | it or by one of the repository's agents; a comment that only reads like one |
docs/features/crew-try-again.md added +18
| @@ -0,0 +1,18 @@ | ||
| 1 | # Try again on an issue the crew parked twice | |
| 2 | ||
| 3 | An issue the crew parked twice is handed to the planner, but never held for good (`src/crew/retry.ts`). It is startable again when something changes after its newest park, and otherwise a day after it: | |
| 4 | ||
| 5 | - a comment on it by someone who may direct the crew (not a park note); | |
| 6 | - a dependency of it closed; | |
| 7 | - its own text, or the direction issue's, edited by someone who may direct the crew (a reader editing their own issue changes nothing). | |
| 8 | ||
| 9 | A restart of the forge is not a change: it restarts on every deploy. A try that parks again is a fresh park, so the issue waits for the next change or the next day. The builder is given every earlier reason (`earlierParks` in `src/crew/brief.ts`) and told not to repeat what failed. | |
| 10 | ||
| 11 | ## Trying one again now | |
| 12 | ||
| 13 | A forge admin does not have to wait. Both doors call `tryAgain` (`src/actions/try_again.ts`), which writes a comment in the admin's name; a person who may direct the crew speaking on an issue starts its park count from zero, so the next tick may start it. | |
| 14 | ||
| 15 | - **Web**: Settings → Crew lists each issue parked twice, with a **Try again** button. | |
| 16 | - **Terminal**: the palette's **Crew hand-overs** screen; move to the issue and press `t`. `enter` opens it, `r` reads again. | |
| 17 | ||
| 18 | Only a site admin may do it, in either door; anyone else is told the issue is not waiting. |
src/actions/try_again.ts added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | /** | |
| 2 | * "Try again" on an issue the crew parked twice: the owner's word on it. A | |
| 3 | * person who may direct the crew speaking on an issue starts its park count | |
| 4 | * from zero (`crew/parks.ts`), so the next tick may start it; the comment says | |
| 5 | * why it came back, and the builder is given the earlier reasons. | |
| 6 | */ | |
| 7 | ||
| 8 | import type { App } from "../app.ts"; | |
| 9 | import { getIssue } from "../core/issues.ts"; | |
| 10 | import { getRepo } from "../core/repos.ts"; | |
| 11 | import type { User } from "../core/users.ts"; | |
| 12 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 13 | import { addComment } from "./issues.ts"; | |
| 14 | ||
| 15 | export const TRY_AGAIN_NOTE = "Try again: the owner asked for another go at this."; | |
| 16 | ||
| 17 | export function tryAgain( | |
| 18 | app: App, | |
| 19 | actor: User | undefined, | |
| 20 | owner: string, | |
| 21 | name: string, | |
| 22 | number: number, | |
| 23 | ): Result<null, "not_found"> { | |
| 24 | if (actor?.isAdmin !== true) return err("not_found"); | |
| 25 | const repo = getRepo(app.db, owner, name); | |
| 26 | const issue = repo === undefined ? undefined : getIssue(app.db, repo.id, number); | |
| 27 | if (repo === undefined || issue === undefined || issue.state !== "open") return err("not_found"); | |
| 28 | const said = addComment(app, actor, repo, issue, TRY_AGAIN_NOTE); | |
| 29 | return said.ok ? ok(null) : err("not_found"); | |
| 30 | } |
src/crew/brief.ts +13 −1
| @@ -288,6 +288,8 @@ export interface TaskInput extends BriefInput { | ||
| 288 | 288 | readonly looks?: readonly string[] | undefined; |
| 289 | 289 | /** For a feedback run asked again: why the last try did not land (`requested.ts`). */ |
| 290 | 290 | readonly lastTry?: LastTry | undefined; |
| 291 | /** For a builder: why the crew parked this issue before, when it is tried again (`retry.ts`). */ | |
| 292 | readonly earlierParks?: readonly string[] | undefined; | |
| 291 | 293 | /** For a planner: the issues the crew parked twice, with why. */ |
| 292 | 294 | readonly parkedTwice?: readonly HandedOver[] | undefined; |
| 293 | 295 | /** For a planner: the limbs a person handed over to re-plan as fewer, larger pieces. */ |
| @@ -407,7 +409,7 @@ function taskBody(input: TaskInput): string { | ||
| 407 | 409 | const { issue } = input; |
| 408 | 410 | const out: string[] = []; |
| 409 | 411 | const isAgent = (name: string): boolean => name === input.agent.user.username; |
| 410 | const handed = [reserveLines(input), parkedTwiceLines(input), landedLines(input), limbLines(input), stuckLines(input), teamLines(input), hotLines(input)] | |
| 412 | const handed = [reserveLines(input), earlierParkLines(input), parkedTwiceLines(input), landedLines(input), limbLines(input), stuckLines(input), teamLines(input), hotLines(input)] | |
| 411 | 413 | .filter((lines) => lines.length > 0) |
| 412 | 414 | .flatMap((lines, n) => (n === 0 ? lines : ["", ...lines])); |
| 413 | 415 | const readers = input.readers ?? new Set<string>(); |
| @@ -744,6 +746,16 @@ function taskBody(input: TaskInput): string { | ||
| 744 | 746 | return out.join("\n"); |
| 745 | 747 | } |
| 746 | 748 | |
| 749 | /** For a builder tried again on an issue the crew parked before: why each earlier try parked. */ | |
| 750 | export function earlierParkLines(input: TaskInput): string[] { | |
| 751 | const reasons = input.role === "builder" ? (input.earlierParks ?? []) : []; | |
| 752 | if (reasons.length === 0) return []; | |
| 753 | return [ | |
| 754 | "The crew parked this issue before and it is tried again now. Why each earlier try was parked; do not repeat what failed:", | |
| 755 | ...reasons.map((reason, n) => ` ${n + 1}. ${reason}`), | |
| 756 | ]; | |
| 757 | } | |
outdated
This function was placed under the doc comment that belongs to |
||
| 758 | ||
| 747 | 759 | /** |
| 748 | 760 | * What a planner is told of the issues the crew parked twice: each with |
| 749 | 761 | * every reason and whose it is, and the states it may leave one in — split, |
src/crew/held.ts +8 −2
| @@ -24,6 +24,7 @@ | ||
| 24 | 24 | * - `run_cap`: it has used its runs for the day (`guard.ts`). |
| 25 | 25 | * - `parked_twice`: the crew parked it twice; it is the planner's (`parks.ts`). |
| 26 | 26 | * Only under ask: a repository on go's builders retry it, bounded by `run_cap`. |
| 27 | * Never for good: a change in its inputs, or a day, tries it again (`retry.ts`). | |
| 27 | 28 | * - `red_fix`: a fix for a red main already covers it (`red_fix.ts`). |
| 28 | 29 | * - `hot_file`: a file it names is touched by `HOT_PULLS` or more open |
| 29 | 30 | * pulls, or an admin holds it (`file_holds.ts`); it starts when that pile |
| @@ -50,6 +51,7 @@ import { capReached, capReachedFor, runCapOf } from "./guard.ts"; | ||
| 50 | 51 | import { interruptionOf } from "./interrupted.ts"; |
| 51 | 52 | import { landedUnanswered } from "./landed.ts"; |
| 52 | 53 | import { PARK_STRIKES, parkedCount } from "./parks.ts"; |
| 54 | import { retryReason } from "./retry.ts"; | |
| 53 | 55 | import { heldFiles, type FileHold } from "./file_holds.ts"; |
| 54 | 56 | import { expectedFiles, hotFiles, hotOverlap, pullsTouching, relievedFiles } from "./hot_files.ts"; |
| 55 | 57 | import { coveredByRedFix } from "./red_fix.ts"; |
| @@ -169,6 +171,7 @@ export function heldBy( | ||
| 169 | 171 | crew: readonly { agent: Agent; role: CrewRole }[], |
| 170 | 172 | active: readonly Run[], |
| 171 | 173 | admit: (pullNumber: number) => boolean = () => true, |
| 174 | now: number = Date.now(), | |
| 172 | 175 | ): Held { |
| 173 | 176 | const busyIssues = new Set(active.map((r) => r.issueId)); |
| 174 | 177 | // Only the pulls the viewer may see count: a sealed pull holds nothing for a guest. |
| @@ -237,7 +240,8 @@ export function heldBy( | ||
| 237 | 240 | if (landedUnanswered(app.db, repo, issue) !== undefined) return "landed"; |
| 238 | 241 | if (stalled(app, repo, issue, lastRuns)) return "stalled"; |
| 239 | 242 | if (capped().get(issue.id) ?? atRunCap(app, repo, issue)) return "run_cap"; |
| 240 | if (!isGo(repo) && parkedCount(app.db, repo, issue.id) >= PARK_STRIKES) return "parked_twice"; | |
| 243 | if (!isGo(repo) && parkedCount(app.db, repo, issue.id) >= PARK_STRIKES && retryReason(app.db, repo, issue, now) === undefined) | |
| 244 | return "parked_twice"; | |
| 241 | 245 | if (coveredByRedFix(app.db, repo.id, issue)) return "red_fix"; |
| 242 | 246 | const pin = pinOf(issue); |
| 243 | 247 | if (pin !== undefined) { |
| @@ -286,13 +290,15 @@ export function replannable(held: Held): Issue[] { | ||
| 286 | 290 | } |
| 287 | 291 | |
| 288 | 292 | /** The ready list sorted now, from the map, the crew and the runs in flight. */ |
| 289 | export function heldNow(app: App, repo: Repo): Held { | |
| 293 | export function heldNow(app: App, repo: Repo, now: number = Date.now()): Held { | |
| 290 | 294 | return heldBy( |
| 291 | 295 | app, |
| 292 | 296 | repo, |
| 293 | 297 | readyNow(repoGraph(app.db, repo.id, { sealed: true })), |
| 294 | 298 | listCrewRoles(app.db, repo.id), |
| 295 | 299 | listActive(app.db, "agent").filter((r) => r.repoId === repo.id), |
| 300 | undefined, | |
| 301 | now, | |
| 296 | 302 | ); |
| 297 | 303 | } |
| 298 | 304 |
src/crew/retry.ts added +81
| @@ -0,0 +1,81 @@ | ||
| 1 | /** | |
| 2 | * When an issue the crew parked twice is tried again. Parked twice hands an | |
| 3 | * issue to the planner, but a guard may slow the engine, not stop it: the | |
| 4 | * issue is startable again as soon as its inputs change since its newest | |
| 5 | * park, and otherwise once a day, the earlier failures given to the builder | |
| 6 | * (`earlierParks` in `brief.ts`). A try that parks again is a fresh park, | |
| 7 | * so the issue waits for the next change or the next day. | |
| 8 | * | |
| 9 | * Inputs: a comment on it by someone who may direct the crew (not a park | |
| 10 | * note); a dependency of it closed; its own text, or the direction issue's, | |
| 11 | * edited since by someone who may direct the crew. A restart is not an input: | |
| 12 | * the forge restarts on every deploy, which would hold nothing. | |
| 13 | */ | |
| 14 | ||
| 15 | import type { Database } from "bun:sqlite"; | |
| 16 | import { PARK_LIKE } from "../core/done_on_main.ts"; | |
| 17 | import { getIssueById, type Issue } from "../core/issues.ts"; | |
| 18 | import type { Repo } from "../core/repos.ts"; | |
| 19 | import { directorsAmong } from "./direct.ts"; | |
| 20 | import { handedOver } from "./parks.ts"; | |
| 21 | ||
| 22 | /** An issue parked twice is tried again this long after its newest park, whatever has changed. */ | |
| 23 | export const RETRY_AFTER_MS = 24 * 60 * 60_000; | |
| 24 | ||
| 25 | export type RetryReason = "comment" | "dependency" | "direction" | "day"; | |
| 26 | ||
| 27 | /** | |
| 28 | * Whether issue number `n` was edited after `since` by someone who may direct | |
| 29 | * the crew, by the event the edit writes: a reader editing their own issue | |
| 30 | * changes nothing. | |
| 31 | */ | |
outdated
This counts an |
||
| 32 | function editedSince(db: Database, repo: Repo, n: number, since: string): boolean { | |
| 33 | const actors = db | |
| 34 | .query<{ id: string }, [string, number, string]>( | |
| 35 | `SELECT DISTINCT actor_id AS id FROM event INDEXED BY event_repo_kind_number_at | |
| 36 | WHERE repo_id = ?1 AND kind = 'issue.edited' AND json_extract(payload, '$.number') = ?2 | |
| 37 | AND created_at > ?3 AND actor_id IS NOT NULL`, | |
| 38 | ) | |
| 39 | .all(repo.id, n, since) | |
| 40 | .map((r) => r.id); | |
| 41 | return directorsAmong(db, repo, actors).length > 0; | |
| 42 | } | |
| 43 | ||
| 44 | /** | |
| 45 | * Why a parked-twice issue may be tried again, or undefined when nothing has | |
| 46 | * changed and the day is not up. | |
| 47 | */ | |
| 48 | export function retryReason( | |
| 49 | db: Database, | |
| 50 | repo: Repo, | |
| 51 | issue: Issue, | |
| 52 | now: number = Date.now(), | |
| 53 | ): RetryReason | undefined { | |
| 54 | const parked = handedOver(db, repo, issue.id); | |
| 55 | if (parked === undefined) return undefined; | |
| 56 | const at = parked.at; | |
| 57 | const newer = (sql: string, ...args: string[]): boolean => | |
| 58 | (db.query<{ n: number }, string[]>(sql).get(...args)?.n ?? 0) > 0; | |
| 59 | const speakers = db | |
| 60 | .query<{ id: string }, [string, string]>( | |
| 61 | `SELECT DISTINCT author_id AS id FROM comment | |
| 62 | WHERE issue_id = ?1 AND created_at > ?2 AND origin_author IS NULL AND body NOT LIKE '${PARK_LIKE}'`, | |
| 63 | ) | |
| 64 | .all(issue.id, at) | |
| 65 | .map((r) => r.id); | |
| 66 | if (directorsAmong(db, repo, speakers).length > 0) return "comment"; | |
| 67 | if ( | |
| 68 | newer( | |
| 69 | `SELECT COUNT(*) AS n FROM issue_dependency d JOIN issue b ON b.id = d.blocked_by_id | |
| 70 | WHERE d.issue_id = ?1 AND b.state <> 'open' AND b.closed_at > ?2`, | |
| 71 | issue.id, | |
| 72 | at, | |
| 73 | ) | |
| 74 | ) | |
| 75 | return "dependency"; | |
outdated
A restart after the park retries every parked-twice issue. Deploys restart the forge constantly, so the hold is meaningless. Remove this reason or key it to a changed code version. |
||
| 76 | if (editedSince(db, repo, issue.number, at)) return "direction"; | |
| 77 | const direction = repo.directionIssueId === null ? undefined : getIssueById(db, repo.directionIssueId); | |
| 78 | if (direction !== undefined && editedSince(db, repo, direction.number, at)) return "direction"; | |
| 79 | if (now - Date.parse(at) >= RETRY_AFTER_MS) return "day"; | |
| 80 | return undefined; | |
| 81 | } |
src/crew/runner.ts +4 −1
| @@ -154,7 +154,7 @@ import { interruptedSummary, interruptionOf, landedPullOf, landedSummary, settle | ||
| 154 | 154 | import { laterGate } from "./later.ts"; |
| 155 | 155 | import { MERGES_CLEAN_NOW, mergesCleanNow } from "./clean_now.ts"; |
| 156 | 156 | import { handedLimbs } from "./handed_limbs.ts"; |
| 157 | import { parkedTwice } from "./parks.ts"; | |
| 157 | import { parkedTwice, parkReasons } from "./parks.ts"; | |
| 158 | 158 | import { budgetLimit, budgetVerdict, cutOffBy, isOwnForge, NO_ACCOUNT_SAID, NO_KEY_SAID } from "./forge_budget.ts"; |
| 159 | 159 | import { boxesOf, landedBoxes } from "./box_pass.ts"; |
| 160 | 160 | import { reviewRange } from "./review_range.ts"; |
| @@ -1309,6 +1309,9 @@ async function work( | ||
| 1309 | 1309 | .get(issue.id, run.id, run.createdAt)!.n > 0, |
| 1310 | 1310 | } |
| 1311 | 1311 | : {}), |
| 1312 | ...(brief.role === "builder" && issue.id !== repo.directionIssueId | |
| 1313 | ? { earlierParks: parkReasons(app.db, repo, issue.id) } | |
| 1314 | : {}), | |
| 1312 | 1315 | // A planning run is given what the crew handed over (`parks.ts`), |
| 1313 | 1316 | // what holds the ready list when nothing on it can start (`held.ts`), |
| 1314 | 1317 | // and the standing reserve it keeps (`reserve.ts`). |
src/http/route_marks.ts +1
| @@ -362,6 +362,7 @@ export const ROUTES: Readonly<Record<string, Mark>> = { | ||
| 362 | 362 | "/settings/teams/:slug": "admin", |
| 363 | 363 | "/settings/crew/:username": "admin", |
| 364 | 364 | "/settings/crew/:username/delete": "admin", |
| 365 | "/settings/crew/try-again/:owner/:name/:number": "admin", | |
| 365 | 366 | "/settings/crew/model-review/:id/take": "admin", |
| 366 | 367 | "/settings/crew/model-review/:id/decline": "admin", |
| 367 | 368 | "/settings/origins/callback": "open", |
src/http/settings_routes.ts +2
| @@ -94,6 +94,7 @@ import { | ||
| 94 | 94 | agentDelete, |
| 95 | 95 | modelReviewDecline, |
| 96 | 96 | modelReviewTake, |
| 97 | tryAgainPost, | |
| 97 | 98 | agentPage, |
| 98 | 99 | agentUpdate, |
| 99 | 100 | crewPage, |
| @@ -316,6 +317,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea | ||
| 316 | 317 | router.add("GET", "/settings/crew/:username", agentPage); |
| 317 | 318 | router.add("POST", "/settings/crew/:username", agentUpdate); |
| 318 | 319 | router.add("POST", "/settings/crew/:username/delete", agentDelete); |
| 320 | router.add("POST", "/settings/crew/try-again/:owner/:name/:number", tryAgainPost); | |
| 319 | 321 | router.add("POST", "/settings/crew/model-review/:id/take", modelReviewTake); |
| 320 | 322 | router.add("POST", "/settings/crew/model-review/:id/decline", modelReviewDecline); |
| 321 | 323 | // Where an OAuth authorization comes back to. Deliberately a plain route |
src/tui/screens/crewhandover.ts +24 −2
| @@ -1,9 +1,12 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The crew's hand-overs in the terminal, a forge admin's: the issues it |
| 3 | 3 | * parked twice and the last restore drill, as Settings → Crew draws them |
| 4 | * (`crewHandover`) — each section only when it has something to say. | |
| 4 | * (`crewHandover`) — each section only when it has something to say. `t` | |
| 5 | * tries the issue under the cursor again, through `actions/try_again.ts`, the | |
| 6 | * same action and the same admin gate as the page's "Try again" button. | |
| 5 | 7 | */ |
| 6 | 8 | |
| 9 | import { tryAgain } from "../../actions/try_again.ts"; | |
| 7 | 10 | import { getRepo } from "../../core/repos.ts"; |
| 8 | 11 | import { crewHandover } from "../../crew/handed.ts"; |
| 9 | 12 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -51,9 +54,24 @@ export function crewHandoverScreen(f: Session, ctx: Ctx): Screen { | ||
| 51 | 54 | const repo = owner === undefined || name === undefined ? undefined : getRepo(f.db, owner, name); |
| 52 | 55 | if (repo !== undefined) ctx.push(issueScreen(f, ctx, local(f, repo), p.number)); |
| 53 | 56 | }; |
| 57 | const retry = (): void => { | |
| 58 | const p = parked[state.index]; | |
| 59 | if (p === undefined) return; | |
| 60 | const [owner, name] = p.repo.split("/"); | |
| 61 | const done = tryAgain(f.app, f.user, owner ?? "", name ?? "", p.number); | |
| 62 | ctx.toast(done.ok ? `#${p.number} will be tried again` : "that issue is not waiting", done.ok ? undefined : "attn"); | |
| 63 | read(); | |
| 64 | rows = build(); | |
| 65 | state.clamp(rows.length); | |
| 66 | }; | |
| 54 | 67 | return { |
| 55 | 68 | title: "Crew hand-overs", |
| 56 | keys: [binding(["j", "k"], "move cursor"), binding(["enter"], "open"), binding(["r"], "refresh")], | |
| 69 | keys: [ | |
| 70 | binding(["j", "k"], "move cursor"), | |
| 71 | binding(["enter"], "open"), | |
| 72 | binding(["t"], "try the issue again"), | |
| 73 | binding(["r"], "refresh"), | |
| 74 | ], | |
| 57 | 75 | render(area, s) { |
| 58 | 76 | const c = ctx.theme.c; |
| 59 | 77 | const [head, list] = split(area, "v", [len(1), fill(1)]); |
| @@ -71,6 +89,10 @@ export function crewHandoverScreen(f: Session, ctx: Ctx): Screen { | ||
| 71 | 89 | open(); |
| 72 | 90 | return true; |
| 73 | 91 | } |
| 92 | if (e.key === "t") { | |
| 93 | retry(); | |
| 94 | return true; | |
| 95 | } | |
| 74 | 96 | if (e.key === "r") { |
| 75 | 97 | read(); |
| 76 | 98 | rows = build(); |
src/web/pages/crew.ts +22
| @@ -48,6 +48,7 @@ import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; | ||
| 48 | 48 | import { listTeams, type Team } from "../../core/teams.ts"; |
| 49 | 49 | import { isHostedChild } from "../../spawn/hosted-mark.ts"; |
| 50 | 50 | import { crewFor } from "../../crew/dispatch.ts"; |
| 51 | import { tryAgain } from "../../actions/try_again.ts"; | |
| 51 | 52 | import { crewHandover, type CrewHandover } from "../../crew/handed.ts"; |
| 52 | 53 | import { MODELS } from "../../crew/models.ts"; |
| 53 | 54 | import type { Ctx } from "../../http/context.ts"; |
| @@ -810,6 +811,7 @@ function handedCard(parked: CrewHandover["parked"]): Html { | ||
| 810 | 811 | <th>Issue</th> |
| 811 | 812 | <th>Last parked</th> |
| 812 | 813 | <th>Why</th> |
| 814 | <th></th> | |
| 813 | 815 | </tr> |
| 814 | 816 | </thead> |
| 815 | 817 | <tbody> |
| @@ -823,6 +825,11 @@ function handedCard(parked: CrewHandover["parked"]): Html { | ||
| 823 | 825 | <td> |
| 824 | 826 | ${r.reasons.map((why) => html`<div class="small">${why}</div>`)} |
| 825 | 827 | </td> |
| 828 | <td> | |
| 829 | <form method="post" action="/settings/crew/try-again/${r.repo}/${r.number}"> | |
| 830 | ${btn("Try again", { type: "submit", size: "sm" })} | |
| 831 | </form> | |
| 832 | </td> | |
| 826 | 833 | </tr>`, |
| 827 | 834 | )} |
| 828 | 835 | </tbody> |
| @@ -1148,6 +1155,21 @@ export async function agentDelete(ctx: Ctx): Promise<Response> { | ||
| 1148 | 1155 | }); |
| 1149 | 1156 | } |
| 1150 | 1157 | |
| 1158 | // POST /settings/crew/try-again/:owner/:name/:number | |
| 1159 | export async function tryAgainPost(ctx: Ctx): Promise<Response> { | |
| 1160 | const form = await adminForm(ctx); | |
| 1161 | if (form instanceof Response) return form; | |
| 1162 | const number = Number(ctx.params.number); | |
| 1163 | const done = Number.isInteger(number) | |
| 1164 | ? tryAgain(ctx.app, ctx.auth.user, ctx.params.owner ?? "", ctx.params.name ?? "", number) | |
| 1165 | : undefined; | |
| 1166 | return flashTo(ctx, "/settings/crew", { | |
| 1167 | ...(done?.ok | |
| 1168 | ? { message: `#${number} will be tried again.` } | |
| 1169 | : { tone: "danger" as const, message: "That issue is not waiting." }), | |
| 1170 | }); | |
| 1171 | } | |
| 1172 | ||
| 1151 | 1173 | // POST /settings/crew/model-review/:id/take and /decline |
| 1152 | 1174 | export async function modelReviewDecide(ctx: Ctx, take: boolean): Promise<Response> { |
| 1153 | 1175 | const form = await adminForm(ctx); |
test/crew-retry-parked.test.ts added +179
| @@ -0,0 +1,179 @@ | ||
| 1 | /** | |
| 2 | * An issue the crew parked twice is not held for good (`retry.ts`): a day | |
| 3 | * with nothing changed, or any change in its inputs, makes it startable | |
| 4 | * again, and the builder is told why the earlier tries were parked. | |
| 5 | */ | |
| 6 | import { quietWeek } from "./helpers/week-quiet.ts"; | |
| 7 | import { seatAs } from "./helpers/seat.ts"; | |
| 8 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 14 | import { addComment, editIssue, openIssue } from "../src/actions/issues.ts"; | |
| 15 | import { createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 16 | import { getIssueById, type Issue } from "../src/core/issues.ts"; | |
| 17 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 18 | import { getUserByName, createUser, type User } from "../src/core/users.ts"; | |
| 19 | import { heldNow } from "../src/crew/held.ts"; | |
| 20 | import { PARK_NOTE } from "../src/crew/parks.ts"; | |
| 21 | import { retryReason, RETRY_AFTER_MS } from "../src/crew/retry.ts"; | |
| 22 | import { tryAgain } from "../src/actions/try_again.ts"; | |
| 23 | import { taskPrompt } from "../src/crew/brief.ts"; | |
| 24 | import { getAgent } from "../src/core/agents.ts"; | |
| 25 | import { parkReasons } from "../src/crew/parks.ts"; | |
| 26 | ||
| 27 | describe("a parked-twice issue is tried again", () => { | |
| 28 | let dataDir: string; | |
| 29 | let app: App; | |
| 30 | let alice: User; | |
| 31 | let repo: Repo; | |
| 32 | let mason: User; | |
| 33 | ||
| 34 | const make = (title: string): Issue => { | |
| 35 | const o = openIssue(app, alice, getRepoById(app.db, repo.id)!, { title, body: "" }); | |
| 36 | if (!o.ok) throw new Error(String(o.error)); | |
| 37 | return o.value; | |
| 38 | }; | |
| 39 | const parkTwice = (i: Issue): void => { | |
| 40 | addComment(app, mason, repo, i, `${PARK_NOTE}first failure`); | |
| 41 | addComment(app, mason, repo, getIssueById(app.db, i.id)!, `${PARK_NOTE}second failure`); | |
| 42 | }; | |
| 43 | const held = (i: Issue, now = Date.now()): boolean => | |
| 44 | heldNow(app, getRepoById(app.db, repo.id)!, now).by.parked_twice.some((x) => x.id === i.id); | |
| 45 | ||
| 46 | beforeAll(async () => { | |
| 47 | dataDir = await mkdtemp(join(tmpdir(), "forge-retry-")); | |
| 48 | app = createApp({ dataDir, port: 0 }); | |
| 49 | quietWeek(app.db); | |
| 50 | const a = await createUser(app.db, { | |
| 51 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 52 | password: "a-good-passphrase", adminIfFirst: true, | |
| 53 | }); | |
| 54 | if (!a.ok) throw new Error(a.error); | |
| 55 | alice = a.value; | |
| 56 | const made = await createRepo(app, alice, { name: "retry", visibility: "private" }); | |
| 57 | if (!made.ok) throw new Error(String(made.error)); | |
| 58 | for (const agent of ["atlas", "mason"]) { | |
| 59 | const created = await createAgent(app, alice, { username: agent }); | |
| 60 | if (!created.ok) throw new Error(String(created.error)); | |
| 61 | expect(addToCrew(app, alice, made.value, agent).ok).toBe(true); | |
| 62 | } | |
| 63 | seatAs(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner"); | |
| 64 | const set = await updateRepoSettings(app, alice, made.value, { | |
| 65 | autonomy: "autonomous", autonomyDirection: "Keep it small.", autonomyCeilingUsd: 100, | |
| 66 | autonomySlots: 1, autonomyBy: alice.id, | |
| 67 | }); | |
| 68 | if (!set.ok) throw new Error(JSON.stringify(set.error)); | |
| 69 | app.db.run("UPDATE repo SET plans_need_nod = 1, decisions_by = 'me' WHERE id = ?1", [made.value.id]); | |
| 70 | repo = getRepoById(app.db, made.value.id)!; | |
| 71 | mason = getUserByName(app.db, "mason")!; | |
| 72 | }); | |
| 73 | ||
| 74 | afterAll(async () => { | |
| 75 | app.close(); | |
| 76 | await rm(dataDir, { recursive: true, force: true }); | |
| 77 | }); | |
| 78 | ||
| 79 | test("nothing changed: held, then retried on the daily pass", () => { | |
| 80 | const i = make("Parked twice, untouched"); | |
| 81 | parkTwice(i); | |
| 82 | expect(held(i)).toBe(true); | |
| 83 | expect(held(i, Date.now() + RETRY_AFTER_MS - 60_000)).toBe(true); | |
| 84 | expect(held(i, Date.now() + RETRY_AFTER_MS + 60_000)).toBe(false); | |
| 85 | expect(retryReason(app.db, repo, i, Date.now() + RETRY_AFTER_MS + 60_000)).toBe("day"); | |
| 86 | }); | |
| 87 | ||
| 88 | test("a landed dependency retries it at once", () => { | |
| 89 | const i = make("Parked twice, then unblocked"); | |
| 90 | parkTwice(i); | |
| 91 | expect(held(i)).toBe(true); | |
| 92 | const blocker = make("The blocker"); | |
| 93 | app.db.run("INSERT INTO issue_dependency (issue_id, blocked_by_id) VALUES (?1, ?2)", [i.id, blocker.id]); | |
| 94 | const later = new Date(Date.now() + 5_000).toISOString(); | |
| 95 | app.db.run("UPDATE issue SET state = 'closed', closed_at = ?2 WHERE id = ?1", [blocker.id, later]); | |
| 96 | expect(held(i)).toBe(false); | |
| 97 | expect(retryReason(app.db, repo, i)).toBe("dependency"); | |
| 98 | }); | |
| 99 | ||
| 100 | test("a reader's comment retries nothing; a director's clears the strikes", async () => { | |
| 101 | const i = make("Parked twice, then spoken to"); | |
| 102 | parkTwice(i); | |
| 103 | expect(held(i)).toBe(true); | |
| 104 | const bob = await createUser(app.db, { origin: "seed", trusted: true, username: `bob${i.number}`, email: `bob${i.number}@forge.local`, password: "a-good-passphrase" }); | |
| 105 | if (!bob.ok) throw new Error(bob.error); | |
| 106 | const later = new Date(Date.now() + 5_000).toISOString(); | |
| 107 | app.db.run("INSERT INTO comment (id, issue_id, author_id, body, created_at) VALUES ('c-retry-1', ?1, ?2, 'try the other file', ?3)", [i.id, bob.value.id, later]); | |
| 108 | expect(retryReason(app.db, repo, i)).toBeUndefined(); | |
| 109 | app.db.run("INSERT INTO comment (id, issue_id, author_id, body, created_at) VALUES ('c-retry-2', ?1, ?2, 'only the other file', ?3)", [i.id, alice.id, later]); | |
| 110 | // A director's word resets the strikes (`parks.ts`): it is no longer held at all. | |
| 111 | expect(held(i)).toBe(false); | |
| 112 | }); | |
| 113 | ||
| 114 | test("editing its direction retries it", () => { | |
| 115 | const i = make("Parked twice, then rewritten"); | |
| 116 | parkTwice(i); | |
| 117 | expect(held(i)).toBe(true); | |
| 118 | const edited = editIssue(app, alice, repo, getIssueById(app.db, i.id)!, { body: "Done looks like this now." }); | |
| 119 | expect(edited.ok).toBe(true); | |
| 120 | expect(retryReason(app.db, repo, i, Date.now() + 1_000)).toBe("direction"); | |
| 121 | }); | |
| 122 | ||
| 123 | test("the owner's try again starts it now; a stranger's does nothing", () => { | |
| 124 | const i = make("Parked twice, owner says go"); | |
| 125 | parkTwice(i); | |
| 126 | expect(held(i)).toBe(true); | |
| 127 | expect(tryAgain(app, undefined, "alice", "retry", i.number).ok).toBe(false); | |
| 128 | expect(held(i)).toBe(true); | |
| 129 | expect(tryAgain(app, alice, "alice", "retry", i.number).ok).toBe(true); | |
| 130 | expect(held(i)).toBe(false); | |
| 131 | }); | |
| 132 | ||
| 133 | test("a restart after the park changes nothing: deploys restart the forge", () => { | |
| 134 | const i = make("Parked twice before a restart"); | |
| 135 | parkTwice(i); | |
| 136 | expect(retryReason(app.db, repo, i)).toBeUndefined(); | |
| 137 | expect(held(i)).toBe(true); | |
| 138 | }); | |
| 139 | ||
| 140 | test("a reader's edit of their own issue does not retry it", async () => { | |
| 141 | const bob = await createUser(app.db, { origin: "seed", trusted: true, username: "reader", email: "[email protected]", password: "a-good-passphrase" }); | |
| 142 | if (!bob.ok) throw new Error(bob.error); | |
| 143 | const i = make("Parked twice, edited by its reader"); | |
| 144 | parkTwice(i); | |
| 145 | const later = new Date(Date.now() + 5_000).toISOString(); | |
| 146 | app.db.run( | |
| 147 | "INSERT INTO event (id, kind, repo_id, actor_id, payload, created_at) VALUES ('e-reader-edit', 'issue.edited', ?1, ?2, ?3, ?4)", | |
| 148 | [repo.id, bob.value.id, JSON.stringify({ number: i.number }), later], | |
| 149 | ); | |
| 150 | expect(retryReason(app.db, repo, i, Date.now() + 1_000)).toBeUndefined(); | |
| 151 | expect(held(i)).toBe(true); | |
| 152 | }); | |
| 153 | ||
| 154 | test("the builder's brief carries the earlier reasons", () => { | |
| 155 | const i = make("Parked twice, briefed"); | |
| 156 | parkTwice(i); | |
| 157 | const fresh = getRepoById(app.db, repo.id)!; | |
| 158 | const prompt = (role: "builder" | "planner"): string => | |
| 159 | taskPrompt({ | |
| 160 | agent: getAgent(app.db, mason.id)!, | |
| 161 | repo: fresh, | |
| 162 | issue: getIssueById(app.db, i.id)!, | |
| 163 | comments: [], | |
| 164 | reviews: [], | |
| 165 | reviewComments: [], | |
| 166 | since: "", | |
| 167 | reason: "assigned", | |
| 168 | branch: "mason/1", | |
| 169 | forgeUrl: "http://forge.test", | |
| 170 | isApp: false, | |
| 171 | resuming: false, | |
| 172 | role, | |
| 173 | earlierParks: parkReasons(app.db, fresh, i.id), | |
| 174 | }); | |
| 175 | expect(prompt("builder")).toContain("1. first failure"); | |
| 176 | expect(prompt("builder")).toContain("2. second failure"); | |
| 177 | expect(prompt("planner")).not.toContain("do not repeat what failed"); | |
| 178 | }); | |
| 179 | }); |
test/tui-crewhandover-open.test.ts +26
| @@ -6,6 +6,7 @@ import { crewHandoverScreen } from "../src/tui/screens/crewhandover.ts"; | ||
| 6 | 6 | import type { Session } from "../src/tui/session.ts"; |
| 7 | 7 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 8 | 8 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 9 | import { parkedCount } from "../src/crew/autonomy.ts"; | |
| 9 | 10 | import { engine, tick, type Engine } from "./helpers/parks-engine.ts"; |
| 10 | 11 | import { quietLogs } from "./helpers/globals.ts"; |
| 11 | 12 | import { settleApp } from "./helpers/tui.ts"; |
| @@ -126,3 +127,28 @@ test("Enter on the drill row opens nothing, and r clamps the cursor when the lis | ||
| 126 | 127 | expect(t.text()).not.toContain("Restore drill: passed"); |
| 127 | 128 | t.app.stop(); |
| 128 | 129 | }); |
| 130 | ||
| 131 | test("t tries the parked issue again, and a non-admin's t does nothing", async () => { | |
| 132 | const work = e.issue("Tried again from the terminal"); | |
| 133 | expect(e.ended(work, "c")).toBe(true); | |
| 134 | await tick(); | |
| 135 | expect(e.ended(work, "d")).toBe(true); | |
| 136 | await tick(); | |
| 137 | const bob = getUserByName(e.app.db, "bob")!; | |
| 138 | const toasts: string[] = []; | |
| 139 | const asBob = crewHandoverScreen( | |
| 140 | { db: e.app.db, app: e.app, user: bob } as unknown as Session, | |
| 141 | { toast: (m: string) => toasts.push(m), push: () => {} } as unknown as Ctx, | |
| 142 | ); | |
| 143 | asBob.key({ type: "key", key: "t", ctrl: false, alt: false, shift: false }); | |
| 144 | expect(parkedCount(e.app, e.fresh(), work.id)).toBe(2); | |
| 145 | ||
| 146 | const t = await crewScreen("alice"); | |
| 147 | await t.key("r"); | |
| 148 | expect(t.text()).toContain("Handed to the planner: alice/engine#" + work.number); | |
| 149 | expect(t.text()).toMatch(/t\s+try the issue again/); | |
| 150 | await t.key("t"); | |
| 151 | expect(parkedCount(e.app, e.fresh(), work.id)).toBe(0); | |
| 152 | expect(t.text()).not.toContain("Handed to the planner: alice/engine#" + work.number); | |
| 153 | t.app.stop(); | |
| 154 | }); |
Both doors: this action is reachable only through the web. Add a
src/tui/screen with its entry, anddocs/features/<name>.md.