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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/4073-a-stalled-issue-frees-itself-a-builder-t → main 1 commit fork
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 | ||
| 1277 | 1277 | held are stalled, capped or parked twice, the planner is owed one pass with |
| 1278 | 1278 | that breakdown in its brief, to re-plan them into smaller, fresh issues; a |
| 1279 | 1279 | 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). | |
| 1281 | 1284 | |
| 1282 | 1285 | The forge asks the host for one more binary: `claude`, on `PATH` or at |
| 1283 | 1286 | `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 | ||
| 469 | 469 | the map, on any repository, autonomous or not. An open pull that closes an |
| 470 | 470 | issue means the issue is proposed, neither ready nor done, and the tick |
| 471 | 471 | 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 | |
| 475 | 480 | passed before autonomy was on — land, so nothing green waits on chance. |
| 476 | 481 | |
| 477 | 482 | **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"; | ||
| 97 | 97 | import { given, pendingReplans } from "../core/limb_replan.ts"; |
| 98 | 98 | import { handedOverPulls, SKIP_SUMMARY, skipsReplan } from "./guard.ts"; |
| 99 | 99 | import { atRunCap, type Held, heldBy, heldWords, replannable } from "./held.ts"; |
| 100 | import { NO_PULL, shunnedBy, stallReason, unstickOwed } from "./stalls.ts"; | |
| 100 | 101 | import { BY_CREW, DONE_LIKE } from "../core/done_on_main.ts"; |
| 101 | 102 | import { ALREADY_NOTE, alreadyDoneOids } from "./already.ts"; |
| 102 | 103 | import { interruptionOf } from "./interrupted.ts"; |
| @@ -382,9 +383,16 @@ export async function driveRepo( | ||
| 382 | 383 | for (const r of active) |
| 383 | 384 | if (r.actorId !== null) |
| 384 | 385 | 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); | |
| 386 | 394 | 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); | |
| 388 | 396 | if (!routed) return "paused"; |
| 389 | 397 | const builder = routed.builder; |
| 390 | 398 | // 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 { | ||
| 449 | 457 | return freed; |
| 450 | 458 | } |
| 451 | 459 | |
| 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 | ||
| 452 | 485 | /** |
| 453 | 486 | * The ready issues a builder may start now: not the direction, and held by |
| 454 | 487 | * none of `held.ts`'s filters. The tick log counts these beside the raw |
| @@ -626,7 +659,8 @@ async function planIfThin( | ||
| 626 | 659 | const news = owed.length + unstuck.length + limbsOwed.length; |
| 627 | 660 | // The direction is the planner's standing issue, not one issue retried: |
| 628 | 661 | // 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)) | |
| 630 | 664 | return notPlanned(repo, "parked: the direction is at its day's planning cap"); |
| 631 | 665 | // An issue the crew handed over is owed its pass whatever the hold: the |
| 632 | 666 | // planner splitting it is how work starts landing again. |
| @@ -860,10 +894,23 @@ export function parkIssue( | ||
| 860 | 894 | ): boolean { |
| 861 | 895 | if (repo.autonomy !== "autonomous" || repo.autonomyBy === null) return false; |
| 862 | 896 | 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; | |
| 864 | 911 | if (issue.assigneeId !== null) assignIssue(app, by, repo, issue, null); |
| 865 | 912 | addComment(app, by, repo, issue, strikeNote(app.db, repo, issue, why)); |
| 866 | log.info("parked", { | |
| 913 | log.info(said, { | |
| 867 | 914 | repo: `${repo.ownerName}/${repo.name}`, |
| 868 | 915 | issue: issue.number, |
| 869 | 916 | why, |
| @@ -925,7 +972,7 @@ export function settleAgentRun( | ||
| 925 | 972 | app, |
| 926 | 973 | repo, |
| 927 | 974 | issue, |
| 928 | `the run ended without a pull: ${run.summary || run.state}`, | |
| 975 | `the ${NO_PULL}: ${run.summary || run.state}`, | |
| 929 | 976 | ); |
| 930 | 977 | } |
| 931 | 978 | |
| @@ -1181,6 +1228,7 @@ export async function autonomyTick( | ||
| 1181 | 1228 | // Before the picks: a pull a reviewer answered is finished before new |
| 1182 | 1229 | // work takes the slots it needs (#2172). |
| 1183 | 1230 | const requested = await moveRequested(app, current, freeSlots(app, current, now), now); |
| 1231 | const unstuck = unstickNow(app, current, now); | |
| 1184 | 1232 | const did = await driveRepo(app, current, now); |
| 1185 | 1233 | // Rules only, no run: an issue noted already done on the base closes. |
| 1186 | 1234 | const triaged = await liveTriage(app, current, now).catch((error) => { |
| @@ -1214,6 +1262,8 @@ export async function autonomyTick( | ||
| 1214 | 1262 | // The standing reserve the planner keeps: startable/target (#3237). |
| 1215 | 1263 | reserve: reserveWords(reserveOf(held.startable.length, status.slots)), |
| 1216 | 1264 | held: heldWords(held), |
| 1265 | // Stalled issues this tick parked and freed for another builder (#4073). | |
| 1266 | unstuck, | |
| 1217 | 1267 | active: status.active, |
| 1218 | 1268 | slots: status.slots, |
| 1219 | 1269 | // Approved pulls being brought up to date, and those still waiting (#3682). |
| @@ -1261,6 +1311,25 @@ export async function autonomyTick( | ||
| 1261 | 1311 | } |
| 1262 | 1312 | } |
| 1263 | 1313 | |
| 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 | ||
| 1264 | 1333 | /** |
| 1265 | 1334 | * Which ready issue next. The rule takes the top of the ready list (the |
| 1266 | 1335 | * 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"; | ||
| 60 | 60 | import { detectClaude } from "./claude.ts"; |
| 61 | 61 | import { followDivergence, settleAll, settleOnPush } from "./converge.ts"; |
| 62 | 62 | import { breakerTripped } from "./breaker.ts"; |
| 63 | import { unstickOwed } from "./stalls.ts"; | |
| 63 | 64 | import { |
| 64 | 65 | CAP_NOTE, |
| 65 | 66 | capNoted, |
| @@ -279,6 +280,22 @@ function handOver( | ||
| 279 | 280 | }); |
| 280 | 281 | } |
| 281 | 282 | |
| 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 | ||
| 282 | 299 | /** Queue a run about an issue for an agent, unless one is already waiting. */ |
| 283 | 300 | export function queueAgentRun( |
| 284 | 301 | app: App, |
| @@ -330,7 +347,11 @@ export function queueAgentRun( | ||
| 330 | 347 | // a pause or a restart is the same start, never a new one. The direction |
| 331 | 348 | // issue has its own, larger share (`runCapOf`). |
| 332 | 349 | 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 | ) { | |
| 334 | 355 | if ( |
| 335 | 356 | !capNoted(app.db, repo, issue) && |
| 336 | 357 | parkForBreaker(app, repo, issue, agent, CAP_NOTE) |
src/crew/held.ts +6 −4
| @@ -15,7 +15,8 @@ | ||
| 15 | 15 | * - `done_on_main`: the crew found it done on the base branch and waits for |
| 16 | 16 | * a person to close it (`done_on_main.ts`); nothing is left to plan. |
| 17 | 17 | * - `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`). | |
| 19 | 20 | * - `run_cap`: it has used its runs for the day (`guard.ts`). |
| 20 | 21 | * - `parked_twice`: the crew parked it twice; it is the planner's (`parks.ts`). |
| 21 | 22 | * - `red_fix`: a fix for a red main already covers it (`red_fix.ts`). |
| @@ -66,9 +67,10 @@ export interface Held { | ||
| 66 | 67 | |
| 67 | 68 | /** |
| 68 | 69 | * 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. | |
| 72 | 74 | */ |
| 73 | 75 | export function stalled(app: App, repo: Repo, issue: Issue): boolean { |
| 74 | 76 | 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 | }); |
new above
offline