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

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/12155-an-issue-the-crew-parked-twice-is-tried → main 9 commits fork 6 threads · 6 unresolved

  • Merged. mason/12155-an-issue-the-crew-parked-twice-is-tried was merged into main as fc82d1a.

Review

Sign in to review this change.
13 files changed +413 −7
docs/PLANNING.md +5 −1
@@ -135,7 +135,11 @@ same, one entry per repository, each the repository's map data plus
135135 ## Parked twice
136136
137137 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
139143 comment does not start the count again, and neither does the planner's. A
140144 park is the crew's own note, written in the name of someone who directs
141145 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 /**

Both doors: this action is reachable only through the web. Add a src/tui/ screen with its entry, and docs/features/<name>.md.

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 {
288288 readonly looks?: readonly string[] | undefined;
289289 /** For a feedback run asked again: why the last try did not land (`requested.ts`). */
290290 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;
291293 /** For a planner: the issues the crew parked twice, with why. */
292294 readonly parkedTwice?: readonly HandedOver[] | undefined;
293295 /** 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 {
407409 const { issue } = input;
408410 const out: string[] = [];
409411 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)]
411413 .filter((lines) => lines.length > 0)
412414 .flatMap((lines, n) => (n === 0 ? lines : ["", ...lines]));
413415 const readers = input.readers ?? new Set<string>();
@@ -744,6 +746,16 @@ function taskBody(input: TaskInput): string {
744746 return out.join("\n");
745747 }
746748
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 parkedTwiceLines. Move it above that comment and give it its own.

758
747759 /**
748760 * What a planner is told of the issues the crew parked twice: each with
749761 * 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 @@
2424 * - `run_cap`: it has used its runs for the day (`guard.ts`).
2525 * - `parked_twice`: the crew parked it twice; it is the planner's (`parks.ts`).
2626 * 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`).
2728 * - `red_fix`: a fix for a red main already covers it (`red_fix.ts`).
2829 * - `hot_file`: a file it names is touched by `HOT_PULLS` or more open
2930 * 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";
5051 import { interruptionOf } from "./interrupted.ts";
5152 import { landedUnanswered } from "./landed.ts";
5253 import { PARK_STRIKES, parkedCount } from "./parks.ts";
54 import { retryReason } from "./retry.ts";
5355 import { heldFiles, type FileHold } from "./file_holds.ts";
5456 import { expectedFiles, hotFiles, hotOverlap, pullsTouching, relievedFiles } from "./hot_files.ts";
5557 import { coveredByRedFix } from "./red_fix.ts";
@@ -169,6 +171,7 @@ export function heldBy(
169171 crew: readonly { agent: Agent; role: CrewRole }[],
170172 active: readonly Run[],
171173 admit: (pullNumber: number) => boolean = () => true,
174 now: number = Date.now(),
172175 ): Held {
173176 const busyIssues = new Set(active.map((r) => r.issueId));
174177 // Only the pulls the viewer may see count: a sealed pull holds nothing for a guest.
@@ -237,7 +240,8 @@ export function heldBy(
237240 if (landedUnanswered(app.db, repo, issue) !== undefined) return "landed";
238241 if (stalled(app, repo, issue, lastRuns)) return "stalled";
239242 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";
241245 if (coveredByRedFix(app.db, repo.id, issue)) return "red_fix";
242246 const pin = pinOf(issue);
243247 if (pin !== undefined) {
@@ -286,13 +290,15 @@ export function replannable(held: Held): Issue[] {
286290 }
287291
288292 /** 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 {
290294 return heldBy(
291295 app,
292296 repo,
293297 readyNow(repoGraph(app.db, repo.id, { sealed: true })),
294298 listCrewRoles(app.db, repo.id),
295299 listActive(app.db, "agent").filter((r) => r.repoId === repo.id),
300 undefined,
301 now,
296302 );
297303 }
298304
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 issue.edited event from any author. A reader editing their own issue re-arms it. Restrict it to editors who may direct the crew.

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
154154 import { laterGate } from "./later.ts";
155155 import { MERGES_CLEAN_NOW, mergesCleanNow } from "./clean_now.ts";
156156 import { handedLimbs } from "./handed_limbs.ts";
157 import { parkedTwice } from "./parks.ts";
157 import { parkedTwice, parkReasons } from "./parks.ts";
158158 import { budgetLimit, budgetVerdict, cutOffBy, isOwnForge, NO_ACCOUNT_SAID, NO_KEY_SAID } from "./forge_budget.ts";
159159 import { boxesOf, landedBoxes } from "./box_pass.ts";
160160 import { reviewRange } from "./review_range.ts";
@@ -1309,6 +1309,9 @@ async function work(
13091309 .get(issue.id, run.id, run.createdAt)!.n > 0,
13101310 }
13111311 : {}),
1312 ...(brief.role === "builder" && issue.id !== repo.directionIssueId
1313 ? { earlierParks: parkReasons(app.db, repo, issue.id) }
1314 : {}),
13121315 // A planning run is given what the crew handed over (`parks.ts`),
13131316 // what holds the ready list when nothing on it can start (`held.ts`),
13141317 // 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>> = {
362362 "/settings/teams/:slug": "admin",
363363 "/settings/crew/:username": "admin",
364364 "/settings/crew/:username/delete": "admin",
365 "/settings/crew/try-again/:owner/:name/:number": "admin",
365366 "/settings/crew/model-review/:id/take": "admin",
366367 "/settings/crew/model-review/:id/decline": "admin",
367368 "/settings/origins/callback": "open",
src/http/settings_routes.ts +2
@@ -94,6 +94,7 @@ import {
9494 agentDelete,
9595 modelReviewDecline,
9696 modelReviewTake,
97 tryAgainPost,
9798 agentPage,
9899 agentUpdate,
99100 crewPage,
@@ -316,6 +317,7 @@ export function settingsRoutes(router: Router<Handler>, knownPatterns: () => rea
316317 router.add("GET", "/settings/crew/:username", agentPage);
317318 router.add("POST", "/settings/crew/:username", agentUpdate);
318319 router.add("POST", "/settings/crew/:username/delete", agentDelete);
320 router.add("POST", "/settings/crew/try-again/:owner/:name/:number", tryAgainPost);
319321 router.add("POST", "/settings/crew/model-review/:id/take", modelReviewTake);
320322 router.add("POST", "/settings/crew/model-review/:id/decline", modelReviewDecline);
321323 // Where an OAuth authorization comes back to. Deliberately a plain route
src/tui/screens/crewhandover.ts +24 −2
@@ -1,9 +1,12 @@
11 /**
22 * The crew's hand-overs in the terminal, a forge admin's: the issues it
33 * 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.
57 */
68
9 import { tryAgain } from "../../actions/try_again.ts";
710 import { getRepo } from "../../core/repos.ts";
811 import { crewHandover } from "../../crew/handed.ts";
912 import type { Ctx, Screen } from "../app/app.ts";
@@ -51,9 +54,24 @@ export function crewHandoverScreen(f: Session, ctx: Ctx): Screen {
5154 const repo = owner === undefined || name === undefined ? undefined : getRepo(f.db, owner, name);
5255 if (repo !== undefined) ctx.push(issueScreen(f, ctx, local(f, repo), p.number));
5356 };
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 };
5467 return {
5568 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 ],
5775 render(area, s) {
5876 const c = ctx.theme.c;
5977 const [head, list] = split(area, "v", [len(1), fill(1)]);
@@ -71,6 +89,10 @@ export function crewHandoverScreen(f: Session, ctx: Ctx): Screen {
7189 open();
7290 return true;
7391 }
92 if (e.key === "t") {
93 retry();
94 return true;
95 }
7496 if (e.key === "r") {
7597 read();
7698 rows = build();
src/web/pages/crew.ts +22
@@ -48,6 +48,7 @@ import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
4848 import { listTeams, type Team } from "../../core/teams.ts";
4949 import { isHostedChild } from "../../spawn/hosted-mark.ts";
5050 import { crewFor } from "../../crew/dispatch.ts";
51 import { tryAgain } from "../../actions/try_again.ts";
5152 import { crewHandover, type CrewHandover } from "../../crew/handed.ts";
5253 import { MODELS } from "../../crew/models.ts";
5354 import type { Ctx } from "../../http/context.ts";
@@ -810,6 +811,7 @@ function handedCard(parked: CrewHandover["parked"]): Html {
810811 <th>Issue</th>
811812 <th>Last parked</th>
812813 <th>Why</th>
814 <th></th>
813815 </tr>
814816 </thead>
815817 <tbody>
@@ -823,6 +825,11 @@ function handedCard(parked: CrewHandover["parked"]): Html {
823825 <td>
824826 ${r.reasons.map((why) => html`<div class="small">${why}</div>`)}
825827 </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>
826833 </tr>`,
827834 )}
828835 </tbody>
@@ -1148,6 +1155,21 @@ export async function agentDelete(ctx: Ctx): Promise<Response> {
11481155 });
11491156 }
11501157
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
11511173 // POST /settings/crew/model-review/:id/take and /decline
11521174 export async function modelReviewDecide(ctx: Ctx, take: boolean): Promise<Response> {
11531175 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";
66 import type { Session } from "../src/tui/session.ts";
77 import { createForgeApp } from "../src/tui/forge.ts";
88 import { headlessTerminal } from "../src/tui/term/terminal.ts";
9 import { parkedCount } from "../src/crew/autonomy.ts";
910 import { engine, tick, type Engine } from "./helpers/parks-engine.ts";
1011 import { quietLogs } from "./helpers/globals.ts";
1112 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
126127 expect(t.text()).not.toContain("Restore drill: passed");
127128 t.app.stop();
128129 });
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs