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

Finish before starting: an approved pull with a conflict is brought up to date before any new issue is picked #3683

Merged opened this in answer to

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

Merged

mason/forge:mason/3682-finish-before-starting-an-approved-pull → main 1 commit fork

  • Merged. mason/3682-finish-before-starting-an-approved-pull was merged into main as 5162479.

Review

Sign in to review this change.
4 files changed +416 −3
docs/PLANNING.md +6 −1
@@ -420,7 +420,12 @@ asked, under **Settings → Autonomy**:
420420 person: a pull whose check stays red past its tries, or whose branch
421421 could not be brought up to date, is _parked_ — closed with the reason,
422422 its issue put back on the map unassigned with the same note — and the
423 crew moves on; the planner's next pass splits or rewrites a parked
423 crew moves on. An approved pull is finished before anything starts: a
424 slot freed goes first to an approved pull that no longer merges, then
425 to a pull with changes requested, then to a new issue, and one whose
426 two updates ended without a push is handed to the person who turned
427 autonomy on — assigned to them, not closed (`crew/finishing.ts`; the
428 tick line says `finishing=N` and `conflict="N waiting"`). The planner's next pass splits or rewrites a parked
424429 issue, and one parked twice is handed to the planner (below). The planner
425430 keeps a standing reserve of startable work — two issues per builder
426431 slot, never fewer than three — and is asked whenever fewer than that can
src/crew/autonomy.ts +13 −2
@@ -115,6 +115,7 @@ import {
115115 markReaders,
116116 } from "./direct.ts";
117117 import { crewOffInPreview } from "./preview.ts";
118 import { approvedOnHead, finishApproved, finishFields } from "./finishing.ts";
118119 import { moveRequested, tallyRequested, tallyWords } from "./requested.ts";
119120 import { reviewersOf, sendToReview, sweepReviews } from "./review.ts";
120121 import { lastWaits, noteWaits, tallyWaits, type Wait, waitWords } from "./waits.ts";
@@ -1139,6 +1140,9 @@ export async function autonomyTick(
11391140 if (repo.archivedAt !== null) continue;
11401141 try {
11411142 void startSweep(app, repo, sweep);
1143 // First of all: a slot freed goes to an approved pull that no longer
1144 // merges, before a changes-requested one or a new issue (#3682).
1145 const finishing = await finishApproved(app, repo, freeSlots(app, repo, now));
11421146 // Quick, and before the picks: a pull main moved under is brought up
11431147 // to date within this tick, not after a landing gate (#2039).
11441148 await sweepConflicts(app, repo).catch((error) =>
@@ -1185,6 +1189,8 @@ export async function autonomyTick(
11851189 held: heldWords(held),
11861190 active: status.active,
11871191 slots: status.slots,
1192 // Approved pulls being brought up to date, and those still waiting (#3682).
1193 ...finishFields(finishing),
11881194 ...(requested.length === 0
11891195 ? {}
11901196 : {
@@ -1443,7 +1449,8 @@ export function releaseConflicted(
14431449 * Every tick, each open crew pull that no longer merges gets a
14441450 * bring-up-to-date run (one in flight per pull). After two finished
14451451 * attempts, or 24 h since the first, it is closed and its issue is ready
1446 * again. Returns how many pulls were acted on.
1452 * again. An approved pull is not this sweep's but `finishApproved`'s.
1453 * Returns how many pulls were acted on.
14471454 */
14481455 export async function sweepConflicts(app: App, repo: Repo): Promise<number> {
14491456 if (repo.autonomy !== "autonomous") return 0;
@@ -1498,6 +1505,9 @@ export async function sweepConflicts(app: App, repo: Repo): Promise<number> {
14981505 continue;
14991506 }
15001507 const files = seen.files;
1508 // An approved pull is finished first, and handed to a person rather
1509 // than closed: the tick's finishing pass has it (`finishing.ts`).
1510 if (approvedOnHead(app.db, pull)) continue;
15011511 const issue = getIssueById(app.db, pull.id);
15021512 if (!issue || issue.state !== "open") continue;
15031513 if (activeRunFor(app.db, "agent", issue.id) !== undefined) continue;
@@ -1687,7 +1697,8 @@ async function admit(
16871697 if (!clean.ok) {
16881698 if (clean.error.kind === "conflict") {
16891699 conflicted.set(pull.id, between);
1690 bringUpToDate(app, repo, pull, clean.error.files);
1700 // An approved one waits for the tick's finishing pass and its slot.
1701 if (!approvedOnHead(app.db, pull)) bringUpToDate(app, repo, pull, clean.error.files);
16911702 }
16921703 return err("conflict");
16931704 }
src/crew/finishing.ts added +202
@@ -0,0 +1,202 @@
1 /**
2 * Finish before starting (#3682): an approved pull that no longer merges
3 * is nearly done work, and it is brought up to date before any new issue
4 * takes a builder slot.
5 *
6 * Every tick, first of the passes that spend slots, for each open crew
7 * pull approved on its head with no change requested that conflicts with
8 * its base:
9 *
10 * - With a run already in flight on it, it is finishing: nothing more.
11 * - With a slot free, a conflict run (the #2039 resolver brief: merge the
12 * base, resolve keeping both intents, re-run the check) is queued for
13 * its builder — the agent on it, else its author, else any builder —
14 * and it takes that slot.
15 * - After two such runs ended without a push (the head still carries the
16 * approval, so neither pushed), it is handed to a person: assigned to
17 * whoever turned autonomy on, so it is on their review list, with the
18 * conflict named. Never retried forever, and never closed: the work was
19 * approved.
20 *
21 * Changes requested come after (`requested.ts`), then new issues
22 * (`driveRepo`). The conflict sweep and the green sweep leave approved
23 * pulls to this pass (`approvedOnHead`).
24 */
25
26 import type { Database } from "bun:sqlite";
27 import type { App } from "../app.ts";
28 import { addComment, assignIssue } from "../actions/issues.ts";
29 import { getAgent, listCrewRoles, type Agent } from "../core/agents.ts";
30 import { getHold } from "../core/holds.ts";
31 import { getIssueById, type Issue } from "../core/issues.ts";
32 import { listPulls, pullDir, type Pull } from "../core/pulls.ts";
33 import type { Repo } from "../core/repos.ts";
34 import { reviewSummary } from "../core/reviews.ts";
35 import { activeRunFor } from "../core/runs.ts";
36 import { getUserById } from "../core/users.ts";
37 import { mergeable, resolveOid } from "../git/merge.ts";
38 import { createLog } from "../kernel/log.ts";
39 import { CHECK_FAILED_LIMIT, lastHumanWord, queueAgentRun } from "./dispatch.ts";
40 import { HANDOVER_NOTE } from "./guard.ts";
41 import { pickBuilder } from "./route.ts";
42
43 const log = createLog("crew");
44
45 /** What the pass did with one approved, conflicted pull. */
46 export type FinishMove =
47 | { readonly pull: number; readonly did: "queued update"; readonly builder: string }
48 | { readonly pull: number; readonly did: "in flight" }
49 | { readonly pull: number; readonly did: "handed to a person"; readonly to: string }
50 | { readonly pull: number; readonly did: "not moved"; readonly why: string };
51
52 /** Whether the pull's head carries an approval and no change requested: what lands once it merges. */
53 export function approvedOnHead(db: Database, pull: Pull): boolean {
54 const seen = reviewSummary(db, pull.id, pull.headOid);
55 return seen.approvals > 0 && seen.changesRequested === 0;
56 }
57
58 /** The tick line's two numbers: pulls being brought up to date, and the rest still waiting. */
59 export function finishFields(moves: readonly FinishMove[]): { finishing?: number; conflict?: string } {
60 const finishing = moves.filter((m) => m.did === "queued update" || m.did === "in flight").length;
61 const waiting = moves.length - finishing;
62 return {
63 ...(finishing === 0 ? {} : { finishing }),
64 ...(waiting === 0 ? {} : { conflict: `${waiting} waiting` }),
65 };
66 }
67
68 /** Pull id → the tips git last read, and the files in conflict (null: clean or nothing to add). */
69 const read = new Map<string, { key: string; files: string[] | null }>();
70
71 /** The files the pull conflicts on with its base; undefined when it merges. */
72 async function conflictOf(app: App, repo: Repo, pull: Pull): Promise<string[] | undefined> {
73 const dir = await pullDir(app.db, app.reposRoot, repo, pull);
74 const baseTip = await resolveOid(dir, `refs/heads/${pull.baseBranch}`);
75 if (baseTip === undefined) return undefined;
76 // Git is asked once per pair of tips; until one moves, the answer stands.
77 const key = `${pull.headOid}:${baseTip}`;
78 let seen = read.get(pull.id);
79 if (seen?.key !== key) {
80 // A clean merge is nothing to do here, and one that leaves the base's
81 // own tree is superseded, which the conflict sweep closes.
82 const reading = await mergeable(dir, baseTip, pull.headOid);
83 seen = { key, files: !reading.ok && reading.error.kind === "conflict" ? reading.error.files : null };
84 read.set(pull.id, seen);
85 }
86 return seen.files ?? undefined;
87 }
88
89 /**
90 * Bring every approved, conflicted crew pull in `repo` up to date, taking
91 * at most `free` builder slots. Returns what happened to each, oldest
92 * pull first.
93 */
94 export async function finishApproved(app: App, repo: Repo, free: number): Promise<FinishMove[]> {
95 if (repo.autonomy !== "autonomous" || repo.autonomyBy === null) return [];
96 const moves: FinishMove[] = [];
97 const builders = listCrewRoles(app.db, repo.id)
98 .filter((c) => c.role === "builder")
99 .map((c) => c.agent);
100 const open = listPulls(app.db, repo.id, { sealed: true, state: "open", limit: 50 });
101 const ids = new Set(open.map((p) => p.id));
102 for (const id of read.keys()) if (!ids.has(id)) read.delete(id);
103 let slots = free;
104 for (const pull of open.sort((a, b) => a.number - b.number)) {
105 if (!pull.authorIsAgent || pull.mergedAt !== null) continue;
106 if (getHold(app.db, pull.id) !== undefined) continue;
107 if (!approvedOnHead(app.db, pull)) continue;
108 const files = await conflictOf(app, repo, pull);
109 if (files === undefined) continue;
110 const issue = getIssueById(app.db, pull.id);
111 if (!issue || issue.state !== "open") continue;
112 const move = finishOne(app, repo, pull, issue, files, builders, slots);
113 if (move.did === "queued update") slots--;
114 moves.push(move);
115 say(repo, pull, move);
116 }
117 return moves;
118 }
119
120 function finishOne(
121 app: App,
122 repo: Repo,
123 pull: Pull,
124 issue: Issue,
125 files: readonly string[],
126 builders: readonly Agent[],
127 slots: number,
128 ): FinishMove {
129 const not = (why: string): FinishMove => ({ pull: pull.number, did: "not moved", why });
130 if (activeRunFor(app.db, "agent", pull.id) !== undefined) return { pull: pull.number, did: "in flight" };
131 // A person's name on it is a person holding it — after a hand-over too.
132 if (issue.assigneeId !== null && getAgent(app.db, issue.assigneeId) === undefined)
133 return not("a person has it");
134 const builder =
135 builders.find((b) => b.user.id === issue.assigneeId) ??
136 builders.find((b) => b.user.id === issue.authorId) ??
137 pickBuilder(issue, builders, new Map());
138 if (builder === undefined) return not("no builder on the crew");
139 if (failedUpdates(app, issue, builder) >= CHECK_FAILED_LIMIT) return handOver(app, repo, pull, issue, files);
140 if (slots <= 0) return not("waits for a builder slot");
141 const run = queueAgentRun(app, repo, issue, builder, "conflict", undefined, {
142 notBefore: new Date().toISOString(),
143 attempt: 0,
144 summary: `main moved: ${files.join(", ")}`,
145 });
146 if (run === undefined) return not(`${builder.user.username}'s run was not queued (ceiling or breaker)`);
147 return { pull: pull.number, did: "queued update", builder: builder.user.username };
148 }
149
150 /**
151 * Conflict runs that ended since a person last spoke on the pull. Each one
152 * left the approved head as it was, so none of them pushed.
153 */
154 function failedUpdates(app: App, issue: Issue, builder: Agent): number {
155 return app.db
156 .query<{ n: number }, [string, string]>(
157 `SELECT COUNT(*) AS n FROM run
158 WHERE kind = 'agent' AND reason = 'conflict' AND issue_id = ?1
159 AND created_at > ?2 AND state IN ('ok','failed')`,
160 )
161 .get(issue.id, lastHumanWord(app, issue, builder))!.n;
162 }
163
164 /** Two updates failed: the pull is the person's who turned autonomy on, with the conflict named. */
165 function handOver(app: App, repo: Repo, pull: Pull, issue: Issue, files: readonly string[]): FinishMove {
166 const not = (why: string): FinishMove => ({ pull: pull.number, did: "not moved", why });
167 const by = getUserById(app.db, repo.autonomyBy!);
168 if (by === undefined) return not("nobody to hand it to");
169 const assigned = assignIssue(app, by, repo, issue, by);
170 if (!assigned.ok) return not(`could not hand it to ${by.username}: ${assigned.error}`);
171 addComment(
172 app,
173 by,
174 repo,
175 issue,
176 `${HANDOVER_NOTE} this pull is approved, but ${CHECK_FAILED_LIMIT} runs to bring it up to date with ${pull.baseBranch} ended without a push, so no more start here. Conflicting files: ${files.join(", ")}. Merge ${pull.baseBranch} into it, or say what to try and assign it back to the crew. (The forge posts this under your name; you did not write it.)`,
177 );
178 log.info("approved pull handed to a person: could not be brought up to date", {
179 repo: `${repo.ownerName}/${repo.name}`,
180 pull: pull.number,
181 to: by.username,
182 });
183 return { pull: pull.number, did: "handed to a person", to: by.username };
184 }
185
186 /** The last why-not said for a pull, so a stall is said once until it changes. */
187 const said = new Map<string, string>();
188
189 function say(repo: Repo, pull: Pull, move: FinishMove): void {
190 const where = { repo: `${repo.ownerName}/${repo.name}`, pull: pull.number };
191 const key = `${repo.id}:${pull.number}`;
192 if (move.did === "queued update") {
193 said.delete(key);
194 log.info("approved pull conflicts: brought up to date first", { ...where, builder: move.builder });
195 return;
196 }
197 if (move.did !== "not moved") return;
198 const line = `${pull.headOid}:${move.why}`;
199 if (said.get(key) === line) return;
200 said.set(key, line);
201 log.info(`approved pull conflicts: not moved, ${move.why}`, where);
202 }
test/finishing.test.ts added +195
@@ -0,0 +1,195 @@
1 /**
2 * Finish before starting (#3682): with one slot, an approved pull that no
3 * longer merges is brought up to date before a ready issue is picked; two
4 * updates that end without a push hand it to a person, on their review
5 * list, instead of closing it or trying again.
6 */
7 import { afterAll, beforeAll, expect, spyOn, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { addToCrew, createAgent } from "../src/actions/crew.ts";
13 import { openIssue } from "../src/actions/issues.ts";
14 import { openPull } from "../src/actions/pulls.ts";
15 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
16 import { setCrewRole } from "../src/core/agents.ts";
17 import { getIssueById, listComments, type Issue } from "../src/core/issues.ts";
18 import type { Pull } from "../src/core/pulls.ts";
19 import { getRepoById, type Repo } from "../src/core/repos.ts";
20 import { submitReview } from "../src/core/reviews.ts";
21 import { claimRun, finishRun, listRuns } from "../src/core/runs.ts";
22 import { createUser, getUserByName, type User } from "../src/core/users.ts";
23 import { autonomyTick } from "../src/crew/autonomy.ts";
24 import { finishFields } from "../src/crew/finishing.ts";
25 import { HANDOVER_NOTE } from "../src/crew/guard.ts";
26 import { serve, type Servers } from "../src/http/server.ts";
27
28 let dataDir: string;
29 let app: App;
30 let servers: Servers;
31 let alice: User;
32 let mason: User;
33 let repo: Repo;
34 let pull: Pull;
35 let ready: Issue;
36 const fresh = () => getRepoById(app.db, repo.id)!;
37 const conflicts = () =>
38 listRuns(app.db, repo.id, { kind: "agent" }).filter((r) => r.issueId === pull.id && r.reason === "conflict");
39 const picked = () => listRuns(app.db, repo.id, { kind: "agent" }).filter((r) => r.issueId === ready.id);
40
41 /** One tick, with the green sweep left out, and the tick line it said. */
42 async function tick(): Promise<string> {
43 const lines: string[] = [];
44 const spy = spyOn(console, "info").mockImplementation((line: unknown) => {
45 lines.push(String(line));
46 });
47 try {
48 await autonomyTick(app, new Date(), async () => 0);
49 } finally {
50 spy.mockRestore();
51 }
52 return lines.find((l) => l.includes("[autonomy] tick") && l.includes("did=")) ?? "";
53 }
54
55 const fail = (id: string) => {
56 claimRun(app.db, id);
57 finishRun(app.db, id, { state: "failed", summary: "could not merge" });
58 };
59
60 beforeAll(async () => {
61 dataDir = await mkdtemp(join(tmpdir(), "forge-finishing-"));
62 app = createApp({ dataDir, port: 0 });
63 servers = await serve(app);
64 const a = await createUser(app.db, {
65 origin: "seed",
66 trusted: true,
67 username: "alice",
68 email: "[email protected]",
69 password: "a-good-passphrase",
70 adminIfFirst: true,
71 });
72 if (!a.ok) throw new Error(a.error);
73 alice = a.value;
74 const made = await createRepo(app, alice, { name: "engine", visibility: "private" });
75 if (!made.ok) throw new Error(String(made.error));
76 repo = made.value;
77 const seeded = await commitToBranch(app, alice, repo, {
78 message: "Start",
79 files: [{ path: "shared.ts", content: "export const shared = 0;\n" }],
80 });
81 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
82 for (const name of ["mason", "quill"]) {
83 const agent = await createAgent(app, alice, { username: name });
84 if (!agent.ok) throw new Error(String(agent.error));
85 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
86 }
87 mason = getUserByName(app.db, "mason")!;
88 const quill = getUserByName(app.db, "quill")!;
89 setCrewRole(app.db, repo.id, quill.id, "reviewer");
90
91 const branch = await commitToBranch(app, alice, repo, {
92 branch: "feature/shared",
93 base: "main",
94 message: "Shared, the branch's way",
95 files: [{ path: "shared.ts", content: "export const shared = 'branch';\n" }],
96 });
97 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
98 const opened = await openPull(app, mason, repo, {
99 title: "Shared the branch's way",
100 body: "",
101 baseBranch: "main",
102 headBranch: "feature/shared",
103 });
104 if (!opened.ok) throw new Error(String(opened.error));
105 pull = opened.value;
106 const approved = submitReview(app.db, {
107 issueId: pull.id,
108 authorId: quill.id,
109 state: "approved",
110 commitOid: pull.headOid,
111 });
112 if (!approved.ok) throw new Error(approved.error);
113 const moved = await commitToBranch(app, alice, repo, {
114 branch: "main",
115 message: "Shared, main's way",
116 files: [{ path: "shared.ts", content: "export const shared = 'main';\n" }],
117 });
118 if (!moved.ok) throw new Error(JSON.stringify(moved.error));
119 const issue = openIssue(app, alice, repo, { title: "Something new" });
120 if (!issue.ok) throw new Error(String(issue.error));
121 ready = issue.value;
122 await Bun.sleep(5);
123 app.db
124 .query("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2, autonomy_slots = 1 WHERE id = ?1")
125 .run(repo.id, alice.id);
126 });
127
128 afterAll(async () => {
129 servers.stop();
130 app.close();
131 await rm(dataDir, { recursive: true, force: true });
132 });
133
134 test("with one slot, the approved conflicted pull is brought up to date before the ready issue is picked", async () => {
135 const line = await tick();
136 expect(conflicts()).toHaveLength(1);
137 expect(conflicts()[0]!.actorId).toBe(mason.id);
138 expect(conflicts()[0]!.summary).toBe("main moved: shared.ts");
139 expect(picked()).toHaveLength(0);
140 expect(getIssueById(app.db, ready.id)!.assigneeId).toBeNull();
141 expect(line).toContain("did=full");
142 expect(line).toContain("finishing=1");
143 // In flight: the next tick queues no second, and still picks nothing.
144 await tick();
145 expect(conflicts()).toHaveLength(1);
146 expect(picked()).toHaveLength(0);
147 });
148
149 test("two updates that end without a push hand the pull to a person, open and on their review list", async () => {
150 fail(conflicts()[0]!.id);
151 await tick();
152 expect(conflicts()).toHaveLength(2);
153 expect(picked()).toHaveLength(0);
154
155 fail(conflicts().find((r) => r.state === "queued")!.id);
156 const line = await tick();
157 // Not closed by the conflict sweep, not tried a third time: a person's.
158 const after = getIssueById(app.db, pull.id)!;
159 expect(after.state).toBe("open");
160 expect(after.assigneeId).toBe(alice.id);
161 expect(conflicts()).toHaveLength(2);
162 const notes = listComments(app.db, pull.id).filter((c) => c.body.startsWith(HANDOVER_NOTE));
163 expect(notes).toHaveLength(1);
164 expect(notes[0]!.body).toContain("shared.ts");
165 expect(line).toContain('conflict="1 waiting"');
166 // The slot it no longer needs goes to the ready issue.
167 expect(getIssueById(app.db, ready.id)!.assigneeId).toBe(mason.id);
168
169 // Said once: another tick adds no second note and no run.
170 await tick();
171 expect(listComments(app.db, pull.id).filter((c) => c.body.startsWith(HANDOVER_NOTE))).toHaveLength(1);
172 expect(conflicts()).toHaveLength(2);
173
174 const signin = await fetch(`${servers.url}/signin`, {
175 method: "POST",
176 redirect: "manual",
177 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
178 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
179 });
180 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
181 const asks = await (await fetch(`${servers.url}/asks`, { headers: { cookie } })).text();
182 expect(asks).toContain("Shared the branch&#39;s way");
183 });
184
185 test("the tick line's fields: finishing counts what moves, conflict what waits", () => {
186 expect(finishFields([])).toEqual({});
187 expect(
188 finishFields([
189 { pull: 1, did: "queued update", builder: "mason" },
190 { pull: 2, did: "in flight" },
191 { pull: 3, did: "not moved", why: "waits for a builder slot" },
192 { pull: 4, did: "handed to a person", to: "alice" },
193 ]),
194 ).toEqual({ finishing: 2, conflict: "2 waiting" });
195 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs