Public: anyone can read this; the crew can change it.
A landed pull's builder run reads as done, not "In progress" or "interrupted: restart … cancelled" #12056
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/12049-a-landed-pull-s-builder-run-reads-as-don → main 4 commits fork 3 threads · 3 unresolved
6 files
changed +335
−1
src/checks/service.ts +7
| @@ -136,6 +136,7 @@ import { checkStage, type GateStage } from "./gate_reason.ts"; | ||
| 136 | 136 | import { extraGates, GATE_DEADLINE_MS, gateQueued, gateRunId, gatesInFlight, watchGates } from "./queue.ts"; |
| 137 | 137 | import type { JobOptions } from "../cluster/jobs.ts"; |
| 138 | 138 | import type { CheckDetail } from "../core/runs.ts"; |
| 139 | import { settleLandedRuns } from "../crew/interrupted.ts"; | |
| 139 | 140 | import { during } from "../perf/jobs.ts"; |
| 140 | 141 | |
| 141 | 142 | const log = createLog("checks"); |
| @@ -1739,6 +1740,12 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle { | ||
| 1739 | 1740 | inFlight.get(run.id)?.abort.abort(); |
| 1740 | 1741 | announceRun(app, run); |
| 1741 | 1742 | } |
| 1743 | // A builder run still in flight when its pull lands reads as done now (#12049). | |
| 1744 | if (event.kind === "pull.merged") | |
| 1745 | for (const id of settleLandedRuns(app.db, { issueId: pull.id, headBranch: pull.headBranch, repoId: repo.id, number })) { | |
| 1746 | const done = getRun(app.db, id); | |
| 1747 | if (done) announceRun(app, done); | |
| 1748 | } | |
| 1742 | 1749 | // A land moves the default branch without a push event: check its |
| 1743 | 1750 | // new head, or a red main stays red until someone pushes to it. The |
| 1744 | 1751 | // check settles first: a candidate's next step supersedes it. |
src/crew/dispatch.ts +10
| @@ -24,6 +24,7 @@ import { tmpdir } from "node:os"; | ||
| 24 | 24 | import { emailVerified } from "../core/verify.ts"; |
| 25 | 25 | import { rmSync } from "node:fs"; |
| 26 | 26 | import type { App } from "../app.ts"; |
| 27 | import { landedPullOf, settleLanded } from "./interrupted.ts"; | |
| 27 | 28 | import { transact } from "../kernel/db.ts"; |
| 28 | 29 | import { addComment } from "../actions/issues.ts"; |
| 29 | 30 | import { planAnotherRound } from "../actions/give_back.ts"; |
| @@ -1454,6 +1455,15 @@ function stoppedForMemory(app: App, run: Run): boolean { | ||
| 1454 | 1455 | * graceful stop for a restart carries the count over and starts at once. |
| 1455 | 1456 | */ |
| 1456 | 1457 | export function requeue(app: App, run: Run, why: string, interrupted: boolean, now = Date.now()): void { |
| 1458 | // A run whose pull landed is done, not interrupted, and is not queued again (#12049). | |
| 1459 | const landed = landedPullOf(app.db, run); | |
| 1460 | if (landed !== undefined) { | |
| 1461 | if (settleLanded(app.db, run.id, landed)) { | |
| 1462 | const done = getRun(app.db, run.id); | |
| 1463 | if (done) announceRun(app, done); | |
| 1464 | } | |
| 1465 | return; | |
| 1466 | } | |
| 1457 | 1467 | const interruptions = run.interruptions + (interrupted ? 1 : 0); |
| 1458 | 1468 | if (interrupted && interruptions > MAX_INTERRUPTIONS) { |
| 1459 | 1469 | finishRun(app.db, run.id, { state: "failed", summary: INTERRUPTED_OUT }); |
src/crew/interrupted.ts +61
| @@ -78,3 +78,64 @@ export function interruptionsOn( | ||
| 78 | 78 | ) |
| 79 | 79 | .all(issueId); |
| 80 | 80 | } |
| 81 | ||
| 82 | /** What a run reads as once its pull has landed: done, never failed, interrupted or in progress. */ | |
| 83 | export const landedSummary = (number: number): string => `Landed: pull #${number} is live`; | |
| 84 | ||
| 85 | /** | |
| 86 | * The number of the merged pull this run worked on — by its issue, else its | |
| 87 | * branch — or undefined while none has landed. | |
| 88 | */ | |
| 89 | export function landedPullOf( | |
| 90 | db: Database, | |
| 91 | run: { repoId: string; issueId: string | null; branch: string | null; createdAt: string }, | |
| 92 | ): number | undefined { | |
| 93 | const row = db | |
| 94 | .query<{ number: number }, [string, string | null, string | null, string]>( | |
| 95 | `SELECT issue.number AS number FROM pull JOIN issue ON issue.id = pull.issue_id | |
| 96 | WHERE pull.merged_at IS NOT NULL AND issue.repo_id = ?1 | |
| 97 | AND pull.merged_at >= ?4 | |
| 98 | AND (pull.issue_id = ?2 OR (?3 IS NOT NULL AND pull.head_branch = ?3)) | |
| 99 | ORDER BY pull.merged_at DESC LIMIT 1`, | |
| 100 | ) | |
| 101 | .get(run.repoId, run.issueId, run.branch, run.createdAt); | |
| 102 | return row?.number; | |
| 103 | } | |
| 104 | ||
| 105 | /** | |
| 106 | * Record a run whose pull landed as done: `ok`, "Landed: pull #N is live". | |
| 107 | * Only a run not finished, or one the landing or a restart ended as cancelled | |
| 108 | * or interrupted, is touched; a run that really failed keeps its words. | |
| 109 | * Returns whether a row changed. | |
| 110 | */ | |
| 111 | export function settleLanded(db: Database, runId: string, number: number): boolean { | |
| 112 | return ( | |
| 113 | db | |
| 114 | .query( | |
| 115 | `UPDATE run SET state = 'ok', summary = ?2, | |
| 116 | finished_at = COALESCE(finished_at, strftime('%Y-%m-%dT%H:%M:%fZ','now')) | |
| 117 | WHERE id = ?1 AND (state IN ('queued','running','cancelled') | |
| 118 | OR (state = 'failed' AND summary LIKE 'interrupted: %'))`, | |
| 119 | ) | |
| 120 | .run(runId, landedSummary(number)).changes > 0 | |
| 121 | ); | |
| 122 | } | |
| 123 | ||
| 124 | /** | |
| 125 | * A pull just landed: every agent run still queued or running on its issue | |
| 126 | * or its branch reads as done at once, not "In progress" until it ends. | |
| 127 | * Answers the runs it settled. | |
| 128 | */ | |
| 129 | export function settleLandedRuns( | |
| 130 | db: Database, | |
| 131 | pull: { issueId: string; headBranch: string; number: number; repoId: string }, | |
| 132 | ): string[] { | |
| 133 | const ids = db | |
| 134 | .query<{ id: string }, [string, string, string]>( | |
| 135 | `SELECT id FROM run WHERE repo_id = ?1 AND kind = 'agent' | |
| 136 | AND state IN ('queued','running') | |
| 137 | AND (issue_id = ?2 OR branch = ?3)`, | |
| 138 | ) | |
| 139 | .all(pull.repoId, pull.issueId, pull.headBranch); | |
| 140 | return ids.filter(({ id }) => settleLanded(db, id, pull.number)).map(({ id }) => id); | |
| 141 | } |
src/crew/runner.ts +13 −1
| @@ -150,7 +150,7 @@ import { | ||
| 150 | 150 | whenText, |
| 151 | 151 | } from "./limits.ts"; |
| 152 | 152 | import { clearStaleLocks, lockedOut } from "./locks.ts"; |
| 153 | import { interruptedSummary } from "./interrupted.ts"; | |
| 153 | import { interruptedSummary, interruptionOf, landedPullOf, landedSummary, settleLanded } from "./interrupted.ts"; | |
| 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"; |
| @@ -552,6 +552,18 @@ async function runClaimed( | ||
| 552 | 552 | summary: outcome.summary, |
| 553 | 553 | }; |
| 554 | 554 | } |
| 555 | // A run whose pull landed is done, not interrupted: only a run the provider, | |
| 556 | // the network or a restart cut short is settled; a real outcome keeps its words (#12049). | |
| 557 | const landedNumber = | |
| 558 | outcome.state === "failed" && interruptionOf(outcome.summary) !== undefined | |
outdated
Run is still |
||
| 559 | ? landedPullOf(app.db, run) | |
| 560 | : undefined; | |
| 561 | if (landedNumber !== undefined && settleLanded(app.db, run.id, landedNumber)) { | |
| 562 | write(`done: ${landedSummary(landedNumber)}`); | |
| 563 | const landed = getRun(app.db, run.id); | |
| 564 | if (landed) announceRun(app, landed); | |
| 565 | return undefined; | |
| 566 | } | |
| 555 | 567 | // A run the provider or a restart cut short says so, and when it goes |
| 556 | 568 | // on: interrupted, never a park (`interrupted.ts`). |
| 557 | 569 | const summary = |
test/run-landed-finish.test.ts added +144
| @@ -0,0 +1,144 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 6 | import { openIssue } from "../src/actions/issues.ts"; | |
| 7 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createApp, type App } from "../src/app.ts"; | |
| 9 | import { createRun, getRun } from "../src/core/runs.ts"; | |
| 10 | import { createToken } from "../src/core/tokens.ts"; | |
| 11 | import { createUser, type User } from "../src/core/users.ts"; | |
| 12 | import type { Repo } from "../src/core/repos.ts"; | |
| 13 | import { landedSummary, settleLandedRuns } from "../src/crew/interrupted.ts"; | |
| 14 | import { executeAgentRun } from "../src/crew/runner.ts"; | |
| 15 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 16 | ||
| 17 | describe("a run that ends after its pull landed (#12049)", () => { | |
| 18 | let dataDir: string; | |
| 19 | let app: App; | |
| 20 | let servers: Servers; | |
| 21 | let alice: User; | |
| 22 | let bot: User; | |
| 23 | let repo: Repo; | |
| 24 | let n = 0; | |
| 25 | ||
| 26 | beforeAll(async () => { | |
| 27 | dataDir = await mkdtemp(join(tmpdir(), "forge-landed-finish-")); | |
| 28 | app = createApp({ dataDir, port: 0 }); | |
| 29 | servers = await serve(app); | |
| 30 | const a = await createUser(app.db, { | |
| 31 | origin: "seed", trusted: true, username: "alice", | |
| 32 | email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true, | |
| 33 | }); | |
| 34 | if (!a.ok) throw new Error(a.error); | |
| 35 | alice = a.value; | |
| 36 | const r = await createRepo(app, alice, { name: "land", visibility: "private" }); | |
| 37 | if (!r.ok) throw new Error(String(r.error)); | |
| 38 | repo = r.value; | |
| 39 | await commitToBranch(app, alice, repo, { message: "seed", files: [{ path: "README.md", content: "hi\n" }] }); | |
| 40 | const made = await createAgent(app, alice, { username: "bot", model: "", brief: "" }); | |
| 41 | if (!made.ok) throw new Error(String(made.error)); | |
| 42 | bot = made.value.user; | |
| 43 | addToCrew(app, alice, repo, "bot"); | |
| 44 | }, 30_000); | |
| 45 | ||
| 46 | afterAll(async () => { | |
| 47 | servers.stop(); | |
| 48 | app.close(); | |
| 49 | await rm(dataDir, { recursive: true, force: true }); | |
| 50 | }); | |
| 51 | ||
| 52 | /** A queued run, its pull merged after it began when `landed`. */ | |
| 53 | const setup = (landed: boolean) => { | |
| 54 | const issue = openIssue(app, alice, repo, { title: "Plant", body: "" }); | |
| 55 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 56 | const run = createRun(app.db, { | |
| 57 | repoId: repo.id, kind: "agent", issueId: issue.value.id, actorId: bot.id, reason: "assigned", | |
| 58 | }); | |
| 59 | const branch = `scout/${n++}`; | |
| 60 | app.db.query("UPDATE run SET branch = ?2 WHERE id = ?1").run(run.id, branch); | |
| 61 | let number = 0; | |
| 62 | let pullIssueId = ""; | |
| 63 | let headBranch = branch; | |
| 64 | if (landed) { | |
| 65 | const pull = openIssue(app, alice, repo, { title: "The pull", body: "" }); | |
| 66 | if (!pull.ok) throw new Error(String(pull.error)); | |
| 67 | number = pull.value.number; | |
| 68 | pullIssueId = pull.value.id; | |
| 69 | app.db.query("UPDATE issue SET kind = 'pull' WHERE id = ?1").run(pull.value.id); | |
| 70 | app.db | |
| 71 | .query( | |
| 72 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) | |
| 73 | VALUES (?1, 'main', ?2, 'a', 'b', ?3, ?4)`, | |
| 74 | ) | |
| 75 | .run(pull.value.id, headBranch, repo.id, new Date(Date.now() + 60_000).toISOString()); | |
| 76 | } | |
| 77 | return { run, number, pullIssueId, headBranch }; | |
| 78 | }; | |
| 79 | ||
| 80 | /** Run it against a stand-in `claude` that says `text` and exits 1. */ | |
| 81 | const finish = async (run: { id: string }, text: string) => { | |
| 82 | const bin = join(dataDir, `claude-${n++}`); | |
| 83 | await writeFile(bin, `#!/bin/sh\necho '${text}' >&2\necho '${text}'\nexit 1\n`); | |
| 84 | await chmod(bin, 0o755); | |
| 85 | await executeAgentRun(app, getRun(app.db, run.id)!, { | |
| 86 | forgeUrl: servers.url, localUrl: servers.url, binary: bin, | |
| 87 | concurrency: 1, budgetUsd: 1, runTimeoutMs: 20_000, | |
| 88 | }); | |
| 89 | return getRun(app.db, run.id)!; | |
| 90 | }; | |
| 91 | ||
| 92 | const mcpRuns = async (): Promise<any[]> => { | |
| 93 | const res = await fetch(`${servers.url}/mcp`, { | |
| 94 | method: "POST", | |
| 95 | headers: { | |
| 96 | "content-type": "application/json", | |
| 97 | accept: "application/json, text/event-stream", | |
| 98 | authorization: `Bearer ${token}`, | |
| 99 | }, | |
| 100 | body: JSON.stringify({ | |
| 101 | jsonrpc: "2.0", id: 1, method: "tools/call", | |
| 102 | params: { name: "runs", arguments: { repo: `alice/${repo.name}`, kind: "agent" } }, | |
| 103 | }), | |
| 104 | }); | |
| 105 | const body: any = await res.json(); | |
| 106 | return JSON.parse(body.result.content[0].text).runs; | |
| 107 | }; | |
| 108 | let token = ""; | |
| 109 | ||
| 110 | test("a run a restart cut short after its pull landed ends ok, Landed, in the runs list", async () => { | |
| 111 | token = createToken(app.db, alice.id, "rw", "rw").token; | |
| 112 | const { run, number } = setup(true); | |
| 113 | const after = await finish(run, "restart: the forge stopped"); | |
| 114 | expect(after.state).toBe("ok"); | |
| 115 | expect(after.summary).toBe(landedSummary(number)); | |
| 116 | expect(after.summary).toMatch(/^Landed: pull #\d+ is live$/); | |
| 117 | expect((await mcpRuns()).find((r) => r.id === run.id)?.state).toBe("ok"); | |
| 118 | }, 40_000); | |
| 119 | ||
| 120 | test("a real failure keeps its words even when the pull landed", async () => { | |
| 121 | const { run } = setup(true); | |
| 122 | const after = await finish(run, "API Error: 500 broke"); | |
| 123 | expect(after.state).toBe("failed"); | |
| 124 | expect(after.summary).toContain("API Error: 500 broke"); | |
| 125 | expect(after.summary).not.toMatch(/^Landed/); | |
| 126 | }, 40_000); | |
| 127 | ||
| 128 | test("the same cut-short run with no landed pull still reads interrupted", async () => { | |
| 129 | const { run } = setup(false); | |
| 130 | const after = await finish(run, "restart: the forge stopped"); | |
| 131 | expect(after.state).toBe("failed"); | |
| 132 | expect(after.summary).toMatch(/^interrupted: /); | |
| 133 | }, 40_000); | |
| 134 | ||
| 135 | test("a run still in flight reads as done the moment its pull lands", () => { | |
| 136 | const { run, number, pullIssueId, headBranch } = setup(false); | |
| 137 | app.db.query("UPDATE run SET state = 'running', started_at = ?2 WHERE id = ?1").run(run.id, new Date().toISOString()); | |
| 138 | const ids = settleLandedRuns(app.db, { issueId: pullIssueId, headBranch, number: 7, repoId: repo.id }); | |
| 139 | expect(ids).toEqual([run.id]); | |
| 140 | expect(getRun(app.db, run.id)!.state).toBe("ok"); | |
| 141 | expect(getRun(app.db, run.id)!.summary).toBe(landedSummary(7)); | |
| 142 | expect(number).toBe(0); | |
Non-blocking: this asserts a setup artifact (number is 0 because setup(false) opens no pull), not behaviour. Remove it. |
||
| 143 | }); | |
| 144 | }); |
test/run-landed-restart.test.ts added +100
| @@ -0,0 +1,100 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 2 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 3 | import { tmpdir } from "node:os"; | |
| 4 | import { join } from "node:path"; | |
| 5 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 6 | import { openIssue } from "../src/actions/issues.ts"; | |
| 7 | import { createRepo } from "../src/actions/repos.ts"; | |
| 8 | import { createApp, type App } from "../src/app.ts"; | |
| 9 | import { claimRun, createRun, getRun, listActive } from "../src/core/runs.ts"; | |
| 10 | import { createUser, type User } from "../src/core/users.ts"; | |
| 11 | import { requeue, requeueInterrupted } from "../src/crew/dispatch.ts"; | |
| 12 | import { landedSummary } from "../src/crew/interrupted.ts"; | |
| 13 | import { STALE_AFTER_MS } from "../src/runs/lease.ts"; | |
| 14 | ||
| 15 | describe("a run whose pull landed, then the forge restarts (#12049)", () => { | |
| 16 | let dataDir: string; | |
| 17 | let app: App; | |
| 18 | let alice: User; | |
| 19 | let bot: User; | |
| 20 | let n = 0; | |
| 21 | ||
| 22 | beforeAll(async () => { | |
| 23 | dataDir = await mkdtemp(join(tmpdir(), "forge-landed-restart-")); | |
| 24 | app = createApp({ dataDir, port: 0 }); | |
| 25 | const a = await createUser(app.db, { | |
| 26 | origin: "seed", trusted: true, username: "alice", | |
| 27 | email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true, | |
| 28 | }); | |
| 29 | if (!a.ok) throw new Error(a.error); | |
| 30 | alice = a.value; | |
| 31 | const made = await createAgent(app, alice, { username: "bot" }); | |
| 32 | if (!made.ok) throw new Error(String(made.error)); | |
| 33 | bot = made.value.user; | |
| 34 | }); | |
| 35 | ||
| 36 | afterAll(async () => { | |
| 37 | app.close(); | |
| 38 | await rm(dataDir, { recursive: true, force: true }); | |
| 39 | }); | |
| 40 | ||
| 41 | /** A running run whose lease lapsed; its pull is merged when `landed`. */ | |
| 42 | const setup = async (landed: boolean) => { | |
| 43 | const repo = await createRepo(app, alice, { name: `r${n++}`, visibility: "private" }); | |
| 44 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 45 | expect(addToCrew(app, alice, repo.value, "bot").ok).toBe(true); | |
| 46 | const issue = openIssue(app, alice, repo.value, { title: "Plant", body: "" }); | |
| 47 | if (!issue.ok) throw new Error(String(issue.error)); | |
| 48 | const run = createRun(app.db, { | |
| 49 | repoId: repo.value.id, kind: "agent", issueId: issue.value.id, actorId: bot.id, reason: "assigned", | |
| 50 | }); | |
| 51 | app.db.query("UPDATE run SET branch = ?2 WHERE id = ?1").run(run.id, `scout/${n}`); | |
| 52 | claimRun(app.db, run.id); | |
| 53 | app.db | |
| 54 | .query("UPDATE run SET heartbeat_at = ?2 WHERE id = ?1") | |
| 55 | .run(run.id, new Date(Date.now() - STALE_AFTER_MS - 1_000).toISOString()); | |
| 56 | let number = 0; | |
| 57 | if (landed) { | |
| 58 | const pull = openIssue(app, alice, repo.value, { title: "The pull", body: "" }); | |
| 59 | if (!pull.ok) throw new Error(String(pull.error)); | |
| 60 | number = pull.value.number; | |
| 61 | app.db.query("UPDATE issue SET kind = 'pull' WHERE id = ?1").run(pull.value.id); | |
| 62 | app.db | |
| 63 | .query( | |
| 64 | `INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) | |
| 65 | VALUES (?1, 'main', ?2, 'a', 'b', ?3, ?4)`, | |
| 66 | ) | |
| 67 | .run(pull.value.id, `scout/${n}`, repo.value.id, new Date(Date.now() + 1_000).toISOString()); | |
| 68 | } | |
| 69 | return { run, number, issueId: issue.value.id }; | |
| 70 | }; | |
| 71 | const waiting = (issueId: string) => listActive(app.db, "agent").filter((r) => r.issueId === issueId && r.state === "queued"); | |
| 72 | ||
| 73 | test("a lapsed lease after landing reads as done and queues nothing", async () => { | |
| 74 | const { run, number, issueId } = await setup(true); | |
| 75 | requeueInterrupted(app); | |
| 76 | const after = getRun(app.db, run.id)!; | |
| 77 | expect(after.state).toBe("ok"); | |
| 78 | expect(after.summary).toBe(landedSummary(number)); | |
| 79 | expect(after.summary).toMatch(/^Landed: pull #\d+ is live$/); | |
| 80 | expect(waiting(issueId)).toEqual([]); | |
| 81 | }); | |
| 82 | ||
| 83 | test("a run the restart already recorded interrupted is settled as landed", async () => { | |
| 84 | const { run, number, issueId } = await setup(true); | |
| 85 | app.db | |
| 86 | .query("UPDATE run SET state = 'failed', summary = 'interrupted: restart, resumes on the next pass — cancelled' WHERE id = ?1") | |
| 87 | .run(run.id); | |
| 88 | requeue(app, getRun(app.db, run.id)!, "stopped for a restart; queued again", false); | |
| 89 | expect(getRun(app.db, run.id)!.summary).toBe(landedSummary(number)); | |
| 90 | expect(getRun(app.db, run.id)!.state).toBe("ok"); | |
| 91 | expect(waiting(issueId)).toEqual([]); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a run really cut mid-work still resumes", async () => { | |
| 95 | const { run, issueId } = await setup(false); | |
| 96 | requeueInterrupted(app); | |
| 97 | expect(getRun(app.db, run.id)!.state).toBe("cancelled"); | |
| 98 | expect(waiting(issueId)).toHaveLength(1); | |
| 99 | }); | |
| 100 | }); |
new above
offline
Non-blocking: nothing tests this handler. Only settleLandedRuns is tested directly, so removing this call goes unnoticed.