Public: anyone can read this; the crew can change it.

A stalled issue frees itself: a builder that left it without a pull lets it go after one tick, and a twice-stalled issue goes to the planner with the reason - no person needed #4074

Merged opened this in answer to

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

Merged

mason/forge:mason/4073-a-stalled-issue-frees-itself-a-builder-t → main 1 commit fork

  • Merged. mason/4073-a-stalled-issue-frees-itself-a-builder-t was merged into main as 46ed73c.

Review

Sign in to review this change.
7 files changed +422 −15
README.md +4 −1
@@ -1277,7 +1277,10 @@ bar, and the terminal's Flow command prints it as a line per app
12771277 held are stalled, capped or parked twice, the planner is owed one pass with
12781278 that breakdown in its brief, to re-plan them into smaller, fresh issues; a
12791279 full plate and its own unanswered decline do not hold that pass, and the
1280 same breakdown does not ask it twice.
1280 same breakdown does not ask it twice. A stalled issue does not wait for that
1281 pass: the next tick parks it with who stalled it and why, so another builder
1282 picks it, and a second stall hands it to the planner past its day's cap
1283 (`crew/stalls.ts`, `unstuck=N` on the tick line).
12811284
12821285 The forge asks the host for one more binary: `claude`, on `PATH` or at
12831286 `FORGE_CLAUDE`, authenticated as the operator (an `ANTHROPIC_API_KEY` in the
docs/PLANNING.md +8 −3
@@ -469,9 +469,14 @@ closes #12 when it lands, as the person who landed it; that is what frees
469469 the map, on any repository, autonomous or not. An open pull that closes an
470470 issue means the issue is proposed, neither ready nor done, and the tick
471471 leaves it alone. An issue a builder ran on and left without a pull is
472 stalled: the clock never picks it again, because running it again blind is
473 the loop autonomy refuses. A person's comment on it is a feedback run like
474 any other. On every tick, green pulls that have not landed — a check that
472 stalled: running it again blind is the loop autonomy refuses, so the next
473 tick parks it in the crew's name — a note with whose run stalled it and the
474 run's last summary, the assignee cleared — and another builder may pick it;
475 the one that stalled it leaves it for the hour unless a person says what to
476 try. A second stall strikes it out and hands it to the planner with both
477 reasons, past the direction's day's planning cap: unsticking is not
478 planning. The tick line counts them as `unstuck=N` (`src/crew/stalls.ts`).
479 A person's comment on it is a feedback run like any other. On every tick, green pulls that have not landed — a check that
475480 passed before autonomy was on — land, so nothing green waits on chance.
476481
477482 **What holds it.** The ceiling is dollars of agent spend per UTC day; at
src/crew/autonomy.ts +75 −6
@@ -97,6 +97,7 @@ import { BREAKER_NOTE, breakerTripped } from "./breaker.ts";
9797 import { given, pendingReplans } from "../core/limb_replan.ts";
9898 import { handedOverPulls, SKIP_SUMMARY, skipsReplan } from "./guard.ts";
9999 import { atRunCap, type Held, heldBy, heldWords, replannable } from "./held.ts";
100 import { NO_PULL, shunnedBy, stallReason, unstickOwed } from "./stalls.ts";
100101 import { BY_CREW, DONE_LIKE } from "../core/done_on_main.ts";
101102 import { ALREADY_NOTE, alreadyDoneOids } from "./already.ts";
102103 import { interruptionOf } from "./interrupted.ts";
@@ -382,9 +383,16 @@ export async function driveRepo(
382383 for (const r of active)
383384 if (r.actorId !== null)
384385 inFlight.set(r.actorId, (inFlight.get(r.actorId) ?? 0) + 1);
385 const next = await nextOf(app, repo, ready);
386 // A builder that stalled an issue within the hour leaves it to the
387 // others; one no builder may take now is skipped, not waited on (#4073).
388 const crewFor = (issue: Issue) => {
389 const shunned = shunnedBy(app.db, repo, issue.id, now);
390 return crew.filter((c) => c.role !== "builder" || !shunned.has(c.agent.user.id));
391 };
392 const pickable = ready.filter((r) => crewFor(r.issue).some((c) => c.role === "builder"));
393 const next = await nextOf(app, repo, pickable);
386394 if (next !== undefined) {
387 const routed = await routeIssue(app, repo, next.issue, crew, inFlight);
395 const routed = await routeIssue(app, repo, next.issue, crewFor(next.issue), inFlight);
388396 if (!routed) return "paused";
389397 const builder = routed.builder;
390398 // Assigning is the event that starts a run. An issue already carrying
@@ -449,6 +457,31 @@ export function freeParked(app: App, repo: Repo, by: User, held: Held): number {
449457 return freed;
450458 }
451459
460 /**
461 * A ready issue a builder ran on and left without a pull, nobody having
462 * spoken since, is parked by the next tick rather than held for a person
463 * (#4073): the note says whose run stalled it and the run's last summary,
464 * the assignee is cleared, and any other builder may pick it. That note is
465 * a strike (`parks.ts`), so a second stall hands it to the planner. A run
466 * still in flight is not a stall yet. Returns how many were freed.
467 */
468 export function freeStalled(app: App, repo: Repo, by: User, held: Held): number {
469 let freed = 0;
470 for (const issue of held.by.stalled) {
471 const last = app.db
472 .query<{ state: string; summary: string; actor: string | null }, [string]>(
473 `SELECT state, summary, actor_id AS actor FROM run
474 WHERE issue_id = ?1 AND kind = 'agent' ORDER BY created_at DESC LIMIT 1`,
475 )
476 .get(issue.id);
477 if (!last || last.state === "queued" || last.state === "running") continue;
478 const who = last.actor === null ? undefined : getUserById(app.db, last.actor)?.username;
479 if (parkAs(app, by, repo, issue, stallReason(who, last.summary || last.state), "unstuck"))
480 freed++;
481 }
482 return freed;
483 }
484
452485 /**
453486 * The ready issues a builder may start now: not the direction, and held by
454487 * none of `held.ts`'s filters. The tick log counts these beside the raw
@@ -626,7 +659,8 @@ async function planIfThin(
626659 const news = owed.length + unstuck.length + limbsOwed.length;
627660 // The direction is the planner's standing issue, not one issue retried:
628661 // it is held to its own day's share, never the builders' cap (#2143).
629 if (atRunCap(app, repo, direction))
662 // Except for an issue struck out by a stall: unsticking is not planning (#4073).
663 if (atRunCap(app, repo, direction) && !unstickOwed(app.db, repo, seen))
630664 return notPlanned(repo, "parked: the direction is at its day's planning cap");
631665 // An issue the crew handed over is owed its pass whatever the hold: the
632666 // planner splitting it is how work starts landing again.
@@ -860,10 +894,23 @@ export function parkIssue(
860894 ): boolean {
861895 if (repo.autonomy !== "autonomous" || repo.autonomyBy === null) return false;
862896 const by = getUserById(app.db, repo.autonomyBy);
863 if (!by || issue.state !== "open") return false;
897 if (!by) return false;
898 return parkAs(app, by, repo, issue, why, "parked");
899 }
900
901 /** Park `issue` in `by`'s name: the strike note, the assignee cleared, and `said` in the log. */
902 function parkAs(
903 app: App,
904 by: User,
905 repo: Repo,
906 issue: Issue,
907 why: string,
908 said: string,
909 ): boolean {
910 if (issue.state !== "open") return false;
864911 if (issue.assigneeId !== null) assignIssue(app, by, repo, issue, null);
865912 addComment(app, by, repo, issue, strikeNote(app.db, repo, issue, why));
866 log.info("parked", {
913 log.info(said, {
867914 repo: `${repo.ownerName}/${repo.name}`,
868915 issue: issue.number,
869916 why,
@@ -925,7 +972,7 @@ export function settleAgentRun(
925972 app,
926973 repo,
927974 issue,
928 `the run ended without a pull: ${run.summary || run.state}`,
975 `the ${NO_PULL}: ${run.summary || run.state}`,
929976 );
930977 }
931978
@@ -1181,6 +1228,7 @@ export async function autonomyTick(
11811228 // Before the picks: a pull a reviewer answered is finished before new
11821229 // work takes the slots it needs (#2172).
11831230 const requested = await moveRequested(app, current, freeSlots(app, current, now), now);
1231 const unstuck = unstickNow(app, current, now);
11841232 const did = await driveRepo(app, current, now);
11851233 // Rules only, no run: an issue noted already done on the base closes.
11861234 const triaged = await liveTriage(app, current, now).catch((error) => {
@@ -1214,6 +1262,8 @@ export async function autonomyTick(
12141262 // The standing reserve the planner keeps: startable/target (#3237).
12151263 reserve: reserveWords(reserveOf(held.startable.length, status.slots)),
12161264 held: heldWords(held),
1265 // Stalled issues this tick parked and freed for another builder (#4073).
1266 unstuck,
12171267 active: status.active,
12181268 slots: status.slots,
12191269 // Approved pulls being brought up to date, and those still waiting (#3682).
@@ -1261,6 +1311,25 @@ export async function autonomyTick(
12611311 }
12621312 }
12631313
1314 /**
1315 * The stalled issues freed this tick (`freeStalled`): none while the crew
1316 * is off or paused, or nobody stands behind it.
1317 */
1318 function unstickNow(app: App, repo: Repo, now: Date): number {
1319 if (repo.autonomy === "off" || repo.autonomyBy === null) return 0;
1320 if (autonomyStatus(app, repo, now).paused !== null) return 0;
1321 const by = getUserById(app.db, repo.autonomyBy);
1322 if (!by) return 0;
1323 const held = heldBy(
1324 app,
1325 repo,
1326 readyNow(repoGraph(app.db, repo.id, { sealed: true })),
1327 listCrewRoles(app.db, repo.id),
1328 activeRuns(app, repo.id),
1329 );
1330 return held.by.stalled.length === 0 ? 0 : freeStalled(app, repo, by, held);
1331 }
1332
12641333 /**
12651334 * Which ready issue next. The rule takes the top of the ready list (the
12661335 * critical path); the decider, when there is one, chooses among the first
src/crew/dispatch.ts +22 −1
@@ -60,6 +60,7 @@ import { issueOfBranch, type RunReason } from "./brief.ts";
6060 import { detectClaude } from "./claude.ts";
6161 import { followDivergence, settleAll, settleOnPush } from "./converge.ts";
6262 import { breakerTripped } from "./breaker.ts";
63 import { unstickOwed } from "./stalls.ts";
6364 import {
6465 CAP_NOTE,
6566 capNoted,
@@ -279,6 +280,22 @@ function handOver(
279280 });
280281 }
281282
283 /**
284 * Whether a run on the direction is the planner's pass owed for an issue a
285 * stall struck out since its last pass: that pass starts past the day's
286 * planning cap, since unsticking is not planning (`stalls.ts`).
287 */
288 function unstickDue(app: App, repo: Repo, issue: Issue): boolean {
289 if (issue.id !== repo.directionIssueId) return false;
290 const last = app.db
291 .query<{ at: string }, [string]>(
292 `SELECT created_at AS at FROM run WHERE issue_id = ?1 AND kind = 'agent'
293 ORDER BY created_at DESC LIMIT 1`,
294 )
295 .get(issue.id);
296 return unstickOwed(app.db, repo, last?.at ?? "");
297 }
298
282299 /** Queue a run about an issue for an agent, unless one is already waiting. */
283300 export function queueAgentRun(
284301 app: App,
@@ -330,7 +347,11 @@ export function queueAgentRun(
330347 // a pause or a restart is the same start, never a new one. The direction
331348 // issue has its own, larger share (`runCapOf`).
332349 const cap = runCapOf(repo, issue, app.config);
333 if (again === undefined && capReached(app.db, repo, issue, cap)) {
350 if (
351 again === undefined &&
352 capReached(app.db, repo, issue, cap) &&
353 !unstickDue(app, repo, issue)
354 ) {
334355 if (
335356 !capNoted(app.db, repo, issue) &&
336357 parkForBreaker(app, repo, issue, agent, CAP_NOTE)
src/crew/held.ts +6 −4
@@ -15,7 +15,8 @@
1515 * - `done_on_main`: the crew found it done on the base branch and waits for
1616 * a person to close it (`done_on_main.ts`); nothing is left to plan.
1717 * - `stalled`: a builder ran on it and left without a pull, and nobody
18 * who directs the crew has spoken since.
18 * who directs the crew has spoken since. The next tick parks it in the
19 * crew's name, which frees it (`stalls.ts`).
1920 * - `run_cap`: it has used its runs for the day (`guard.ts`).
2021 * - `parked_twice`: the crew parked it twice; it is the planner's (`parks.ts`).
2122 * - `red_fix`: a fix for a red main already covers it (`red_fix.ts`).
@@ -66,9 +67,10 @@ export interface Held {
6667
6768 /**
6869 * An issue a builder already ran on and left without a pull — declined, or
69 * cut short — is not picked again by the clock: running it again blind is
70 * the loop autonomy refuses. It waits for a person's word, which is a
71 * feedback run like any other.
70 * cut short — is not picked again blind: running it again unchanged is the
71 * loop autonomy refuses. A person's word lifts it, and so does the park the
72 * next tick writes with who stalled it and why (`stalls.ts`); two such
73 * parks hand it to the planner.
7274 */
7375 export function stalled(app: App, repo: Repo, issue: Issue): boolean {
7476 const last = app.db
src/crew/stalls.ts added +84
@@ -0,0 +1,84 @@
1 /**
2 * A stall: a builder's run on an issue ended without a pull — declined, or
3 * nothing pushed — and was not cut short by the provider or a restart
4 * (#4073). The clock does not wait for a person to lift one:
5 *
6 * - The next tick parks it (`freeStalled` in `autonomy.ts`): a note with
7 * who stalled it and the run's last summary, and the assignee cleared, so
8 * any builder may pick it.
9 * - The builder that stalled it does not pick it again within the hour,
10 * unless a person has said what to try since (`shunnedBy`): another
11 * builder tries it fresh.
12 * - A second stall strikes it out like any second park (`parks.ts`) and it
13 * goes to the planner with every reason — past the direction's day's
14 * planning cap, since unsticking is not planning (`unstickOwed`).
15 */
16
17 import type { Database } from "bun:sqlite";
18 import { BY_CREW, DONE_LIKE, PARK_LIKE } from "../core/done_on_main.ts";
19 import type { Repo } from "../core/repos.ts";
20 import { getUserById } from "../core/users.ts";
21 import { speaksForCrew } from "./direct.ts";
22 import { interruptionOf } from "./interrupted.ts";
23 import { parkedTwice } from "./parks.ts";
24
25 /** What every stall's park reason says, whoever writes it. */
26 export const NO_PULL = "run ended without a pull";
27
28 /** How long a builder that stalled an issue leaves it to the others. */
29 export const SHUN_MS = 60 * 60_000;
30
31 /** Whether a finished run left its issue without a pull: not interrupted, no pull opened or updated. */
32 export function leftWithoutPull(run: { readonly state: string; readonly summary: string }): boolean {
33 if (run.state !== "ok" && run.state !== "failed") return false;
34 if (/opened pull #|updated pull #/.test(run.summary)) return false;
35 return !(run.state === "failed" && interruptionOf(run.summary) !== undefined);
36 }
37
38 /** The park reason for a stall: who stalled it, and the run's last summary on one line. */
39 export function stallReason(who: string | undefined, summary: string): string {
40 const said = summary.replace(/\s+/g, " ").trim();
41 const cut = said.length > 300 ? `${said.slice(0, 299)}…` : said;
42 return `${who === undefined ? "the" : `@${who}'s`} ${NO_PULL}: ${cut || "no summary"}`;
43 }
44
45 /**
46 * The agents whose run on the issue stalled it within `SHUN_MS` of `now`,
47 * and nobody who directs the crew has said what to try since. The crew's
48 * own notes — a park, done on main — are not such a word.
49 */
50 export function shunnedBy(db: Database, repo: Repo, issueId: string, now: Date): Set<string> {
51 const since = new Date(now.getTime() - SHUN_MS).toISOString();
52 const stalls = db
53 .query<{ actor: string; at: string; state: string; summary: string }, [string, string]>(
54 `SELECT actor_id AS actor, COALESCE(finished_at, created_at) AS at, state, summary FROM run
55 WHERE issue_id = ?1 AND kind = 'agent' AND actor_id IS NOT NULL
56 AND COALESCE(finished_at, created_at) > ?2`,
57 )
58 .all(issueId, since)
59 .filter(leftWithoutPull);
60 if (stalls.length === 0) return new Set();
61 const words = db
62 .query<{ id: string; at: string }, [string, string]>(
63 `SELECT n.author_id AS id, n.created_at AS at FROM comment n
64 WHERE n.issue_id = ?1 AND n.created_at > ?2 AND n.origin_author IS NULL
65 AND n.body NOT LIKE '${PARK_LIKE}'
66 AND NOT (n.body LIKE '${DONE_LIKE}' AND ${BY_CREW})`,
67 )
68 .all(issueId, since)
69 .filter((w) => speaksForCrew(db, repo, getUserById(db, w.id)));
70 return new Set(
71 stalls.filter((r) => !words.some((w) => w.at > r.at)).map((r) => r.actor),
72 );
73 }
74
75 /**
76 * Whether the crew handed the planner an issue struck out by a stall since
77 * `since` (its last pass on the direction): that pass is owed whatever the
78 * direction's day's cap.
79 */
80 export function unstickOwed(db: Database, repo: Repo, since: string): boolean {
81 return parkedTwice(db, [repo]).some(
82 (issue) => issue.at > since && (issue.reasons.at(-1) ?? "").includes(NO_PULL),
83 );
84 }
test/crew-stalls.test.ts added +223
@@ -0,0 +1,223 @@
1 /**
2 * A stalled issue frees itself (#4073): a builder that left an issue
3 * without a pull has it parked by the next tick — a note with who and why,
4 * the assignee cleared — and another builder picks it; the builder that
5 * stalled it leaves it for the hour; a second stall hands it to the planner
6 * past the direction's day's planning cap; the tick line counts `unstuck`.
7 * The repositories are assisted, where nothing parks a stall when its run
8 * ends, so the tick is what frees it.
9 */
10 import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp, type App } from "../src/app.ts";
15 import { addToCrew, createAgent } from "../src/actions/crew.ts";
16 import { openIssue } from "../src/actions/issues.ts";
17 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
18 import { getAgentByName, setCrewRole } from "../src/core/agents.ts";
19 import { getIssueById, listComments, type Issue } from "../src/core/issues.ts";
20 import { getRepoById, type Repo } from "../src/core/repos.ts";
21 import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts";
22 import { createUser, getUserByName, type User } from "../src/core/users.ts";
23 import { autonomyTick, driveRepo, ensureDirectionIssue } from "../src/crew/autonomy.ts";
24 import { queueAgentRun } from "../src/crew/dispatch.ts";
25 import { heldNow, heldWords } from "../src/crew/held.ts";
26 import { PARK_NOTE, parkedTwice } from "../src/crew/parks.ts";
27 import { leftWithoutPull, NO_PULL, SHUN_MS, stallReason } from "../src/crew/stalls.ts";
28 import { serve, type Servers } from "../src/http/server.ts";
29
30 /** Planning passes the direction may start in a day, here: small enough to reach. */
31 const DIRECTION_CAP = 2;
32
33 let dataDir: string;
34 let app: App;
35 let servers: Servers;
36 let alice: User;
37 let made = 0;
38
39 async function crewed(builders: readonly string[]): Promise<Repo> {
40 const created = await createRepo(app, alice, { name: `stalls${++made}`, visibility: "private" });
41 if (!created.ok) throw new Error(String(created.error));
42 for (const name of ["atlas", ...builders]) {
43 if (getUserByName(app.db, name) === undefined) {
44 const agent = await createAgent(app, alice, { username: name });
45 if (!agent.ok) throw new Error(String(agent.error));
46 }
47 expect(addToCrew(app, alice, created.value, name).ok).toBe(true);
48 }
49 setCrewRole(app.db, created.value.id, getUserByName(app.db, "atlas")!.id, "planner");
50 const set = await updateRepoSettings(app, alice, created.value, {
51 autonomy: "assisted",
52 autonomyDirection: "Keep the engine small.",
53 autonomyCeilingUsd: 100,
54 autonomySlots: 2,
55 autonomyBy: alice.id,
56 });
57 if (!set.ok) throw new Error(JSON.stringify(set.error));
58 return getRepoById(app.db, created.value.id)!;
59 }
60
61 function issue(repo: Repo, title: string): Issue {
62 const opened = openIssue(app, alice, getRepoById(app.db, repo.id)!, { title });
63 if (!opened.ok) throw new Error(String(opened.error));
64 return opened.value;
65 }
66
67 /** `by`'s run on `issue` ends without a pull, the issue left assigned to them. */
68 function stall(issue: Issue, by: User, summary: string): void {
69 const active = listRuns(app.db, issue.repoId, { kind: "agent" }).filter(
70 (r) => r.issueId === issue.id && (r.state === "queued" || r.state === "running"),
71 );
72 const runs =
73 active.length > 0
74 ? active
75 : [createRun(app.db, { repoId: issue.repoId, kind: "agent", issueId: issue.id, actorId: by.id, reason: "assigned" })];
76 for (const run of runs) {
77 if (run.state === "queued") claimRun(app.db, run.id);
78 finishRun(app.db, run.id, { state: "failed", summary, costUsd: 0.1 });
79 }
80 app.db.query("UPDATE issue SET assignee_id = ?2 WHERE id = ?1").run(issue.id, by.id);
81 }
82
83 /** Ends whatever the planner has in flight on the direction, and takes it off the planner. */
84 function plannerDone(repo: Repo): void {
85 const direction = getRepoById(app.db, repo.id)!.directionIssueId;
86 if (direction === null) return;
87 for (const run of listRuns(app.db, repo.id, { kind: "agent" }))
88 if (run.issueId === direction && (run.state === "queued" || run.state === "running")) {
89 if (run.state === "queued") claimRun(app.db, run.id);
90 finishRun(app.db, run.id, { state: "ok", summary: "opened nothing", costUsd: 0.1 });
91 }
92 app.db.query("UPDATE issue SET assignee_id = NULL WHERE id = ?1").run(direction);
93 }
94
95 /** One tick, with the green sweep left out, and the tick line it said for `repo`. */
96 async function tick(repo: Repo, now = new Date()): Promise<string> {
97 const lines: string[] = [];
98 const spy = spyOn(console, "info").mockImplementation((line: unknown) => {
99 lines.push(String(line));
100 });
101 try {
102 await autonomyTick(app, now, async () => 0);
103 } finally {
104 spy.mockRestore();
105 }
106 return (
107 lines.find(
108 (l) => l.includes("[autonomy] tick") && l.includes(`repo=alice/${repo.name} `) && l.includes("did="),
109 ) ?? ""
110 );
111 }
112
113 beforeAll(async () => {
114 dataDir = await mkdtemp(join(tmpdir(), "forge-stalls-"));
115 app = createApp({ dataDir, port: 0, directionRunCap: DIRECTION_CAP });
116 servers = await serve(app);
117 const a = await createUser(app.db, {
118 origin: "seed",
119 trusted: true,
120 username: "alice",
121 email: "[email protected]",
122 password: "a-good-passphrase",
123 adminIfFirst: true,
124 });
125 if (!a.ok) throw new Error(a.error);
126 alice = a.value;
127 });
128
129 afterAll(async () => {
130 servers.stop();
131 app.close();
132 await rm(dataDir, { recursive: true, force: true });
133 });
134
135 describe("a stall", () => {
136 test("is a finished run with no pull, not one cut short or one that proposed", () => {
137 expect(leftWithoutPull({ state: "failed", summary: "declined: too big" })).toBe(true);
138 expect(leftWithoutPull({ state: "ok", summary: "no changes pushed" })).toBe(true);
139 expect(leftWithoutPull({ state: "ok", summary: "opened pull #4" })).toBe(false);
140 expect(leftWithoutPull({ state: "running", summary: "" })).toBe(false);
141 expect(stallReason("mason", "declined:\n\ntoo big")).toBe(`@mason's ${NO_PULL}: declined: too big`);
142 });
143 });
144
145 describe("a stalled issue frees itself", () => {
146 test("the next tick parks it with who and why, and another builder picks it; a second stall goes to the planner past its cap", async () => {
147 const repo = await crewed(["mason", "smith"]);
148 const mason = getUserByName(app.db, "mason")!;
149 const smith = getUserByName(app.db, "smith")!;
150 const netcode = issue(repo, "Netcode");
151 // The planner's day is spent: its passes on the direction are at the cap.
152 const direction = ensureDirectionIssue(app, alice, getRepoById(app.db, repo.id)!)!;
153 for (let n = 0; n < DIRECTION_CAP; n++) {
154 const run = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: direction.id, actorId: getUserByName(app.db, "atlas")!.id, reason: "assigned" });
155 claimRun(app.db, run.id);
156 finishRun(app.db, run.id, { state: "ok", summary: "opened #9", costUsd: 0.1 });
157 app.db.query("UPDATE run SET created_at = ?2 WHERE id = ?1").run(run.id, new Date(Date.now() - (90 - n) * 60_000).toISOString());
158 }
159
160 stall(netcode, mason, "declined: needs a protocol decision");
161 expect(heldWords(heldNow(app, getRepoById(app.db, repo.id)!))).toBe("stalled=1");
162
163 const first = await tick(repo);
164 expect(first).toContain("unstuck=1");
165 expect(first).toContain("did=picked");
166 const note = listComments(app.db, netcode.id).at(-1)!;
167 expect(note.body.startsWith(PARK_NOTE)).toBe(true);
168 expect(note.body).toContain(`@mason's ${NO_PULL}: declined: needs a protocol decision`);
169 // Another builder has it: the one that stalled it leaves it for the hour.
170 expect(getIssueById(app.db, netcode.id)!.assigneeId).toBe(smith.id);
171 // One stall is no reason to plan: the cap holds the planner.
172 expect(getIssueById(app.db, direction.id)!.assigneeId).toBeNull();
173
174 // The second builder stalls it too: the next tick hands it to the
175 // planner, whose day's cap does not stand in the way.
176 plannerDone(repo);
177 stall(netcode, smith, "declined: still no protocol");
178 const second = await tick(repo);
179 expect(second).toContain("unstuck=1");
180 expect(getIssueById(app.db, netcode.id)!.assigneeId).toBeNull();
181 const handed = parkedTwice(app.db, [getRepoById(app.db, repo.id)!]).find((p) => p.issueId === netcode.id);
182 expect(handed?.reasons).toEqual([
183 `@mason's ${NO_PULL}: declined: needs a protocol decision`,
184 `@smith's ${NO_PULL}: declined: still no protocol`,
185 ]);
186 const atlas = getUserByName(app.db, "atlas")!;
187 expect(getIssueById(app.db, direction.id)!.assigneeId).toBe(atlas.id);
188 // The assignment's run starts past the cap, and the direction is not parked for it.
189 const pass = queueAgentRun(
190 app,
191 getRepoById(app.db, repo.id)!,
192 getIssueById(app.db, direction.id)!,
193 getAgentByName(app.db, "atlas")!,
194 "assigned",
195 alice.id,
196 );
197 expect(pass?.actorId).toBe(atlas.id);
198 expect(listComments(app.db, direction.id).some((c) => c.body.startsWith(PARK_NOTE))).toBe(false);
199 plannerDone(repo);
200
201 // Freed once: the tick after has nothing more to unstick.
202 expect(await tick(repo)).toContain("unstuck=0");
203 });
204
205 test("the builder that stalled it does not pick it again within the hour", async () => {
206 const repo = await crewed(["mason"]);
207 const mason = getUserByName(app.db, "mason")!;
208 const netcode = issue(repo, "Netcode");
209 stall(netcode, mason, "declined: needs a protocol decision");
210
211 const line = await tick(repo);
212 expect(line).toContain("unstuck=1");
213 expect(line).not.toContain("did=picked");
214 expect(getIssueById(app.db, netcode.id)!.assigneeId).toBeNull();
215 // Ready and held by nothing: only its builder waits out the hour.
216 expect(heldNow(app, getRepoById(app.db, repo.id)!).startable.map((r) => r.issue.id)).toContain(netcode.id);
217 plannerDone(repo);
218
219 const later = new Date(Date.now() + SHUN_MS + 60_000);
220 expect(await driveRepo(app, getRepoById(app.db, repo.id)!, later)).toBe("picked");
221 expect(getIssueById(app.db, netcode.id)!.assigneeId).toBe(mason.id);
222 });
223 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs