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

Merged opened this

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

Merged

scout/forge:scout/12049-a-landed-pull-s-builder-run-reads-as-don → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/12049-a-landed-pull-s-builder-run-reads-as-don was merged into main as 40d9178.

Review

Sign in to review this change.
6 files changed +335 −1
src/checks/service.ts +7
@@ -136,6 +136,7 @@ import { checkStage, type GateStage } from "./gate_reason.ts";
136136 import { extraGates, GATE_DEADLINE_MS, gateQueued, gateRunId, gatesInFlight, watchGates } from "./queue.ts";
137137 import type { JobOptions } from "../cluster/jobs.ts";
138138 import type { CheckDetail } from "../core/runs.ts";
139 import { settleLandedRuns } from "../crew/interrupted.ts";
139140 import { during } from "../perf/jobs.ts";
140141
141142 const log = createLog("checks");
@@ -1739,6 +1740,12 @@ export function startChecks(app: App, options: ChecksOptions): ChecksHandle {
17391740 inFlight.get(run.id)?.abort.abort();
17401741 announceRun(app, run);
17411742 }
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);

Non-blocking: nothing tests this handler. Only settleLandedRuns is tested directly, so removing this call goes unnoticed.

1747 if (done) announceRun(app, done);
1748 }
17421749 // A land moves the default branch without a push event: check its
17431750 // new head, or a red main stays red until someone pushes to it. The
17441751 // check settles first: a candidate's next step supersedes it.
src/crew/dispatch.ts +10
@@ -24,6 +24,7 @@ import { tmpdir } from "node:os";
2424 import { emailVerified } from "../core/verify.ts";
2525 import { rmSync } from "node:fs";
2626 import type { App } from "../app.ts";
27 import { landedPullOf, settleLanded } from "./interrupted.ts";
2728 import { transact } from "../kernel/db.ts";
2829 import { addComment } from "../actions/issues.ts";
2930 import { planAnotherRound } from "../actions/give_back.ts";
@@ -1454,6 +1455,15 @@ function stoppedForMemory(app: App, run: Run): boolean {
14541455 * graceful stop for a restart carries the count over and starts at once.
14551456 */
14561457 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 }
14571467 const interruptions = run.interruptions + (interrupted ? 1 : 0);
14581468 if (interrupted && interruptions > MAX_INTERRUPTIONS) {
14591469 finishRun(app.db, run.id, { state: "failed", summary: INTERRUPTED_OUT });
src/crew/interrupted.ts +61
@@ -78,3 +78,64 @@ export function interruptionsOn(
7878 )
7979 .all(issueId);
8080 }
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 {
150150 whenText,
151151 } from "./limits.ts";
152152 import { clearStaleLocks, lockedOut } from "./locks.ts";
153 import { interruptedSummary } from "./interrupted.ts";
153 import { interruptedSummary, interruptionOf, landedPullOf, landedSummary, settleLanded } from "./interrupted.ts";
154154 import { laterGate } from "./later.ts";
155155 import { MERGES_CLEAN_NOW, mergesCleanNow } from "./clean_now.ts";
156156 import { handedLimbs } from "./handed_limbs.ts";
@@ -552,6 +552,18 @@ async function runClaimed(
552552 summary: outcome.summary,
553553 };
554554 }
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 running here, so settleLanded overwrites a real failed outcome with ok/Landed. Restrict to interrupted/cancelled outcomes, and test it. Also move this block above the comment it split.

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 }
555567 // A run the provider or a restart cut short says so, and when it goes
556568 // on: interrupted, never a park (`interrupted.ts`).
557569 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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs