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

finishing still silent after #6239: the 20 conflicted pulls are dropped before the logged branch; log every early continue and make the conflict tests agree #6276

Merged opened this in answer to

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

Merged

mason/forge:mason/6263-finishing-still-silent-after-6239-the-20 → main 6 commits fork 5 threads · 5 unresolved

  • Merged. mason/6263-finishing-still-silent-after-6239-the-20 was merged into main as e51f87e.

Review

Sign in to review this change.
12 files changed +484 −74
docs/PLANNING.md +4 −1
@@ -430,7 +430,10 @@ asked, under **Settings → Autonomy**:
430430 two updates ended without a push is handed to the person who turned
431431 autonomy on — assigned to them, not closed (`crew/finishing.ts`; the
432432 tick line says `finishing="queued Q, in flight F, skipped S: <why>×k"`,
433 and each skipped pull is logged once per reason as `finishing: skip pull=N why="…"`). The planner's next pass splits or rewrites a parked
433 or `finishing="0 candidates of N open"`; each skipped pull is logged
434 once per reason as `finishing: skip pull=N why="…"`, and every open pull
435 that is not a candidate as `finishing: drop pull=N why="…"`; it reads every
436 open pull, not the newest fifty). The planner's next pass splits or rewrites a parked
434437 issue, and one parked twice is handed to the planner (below). The planner
435438 keeps a standing reserve of startable work — two issues per builder
436439 slot, never fewer than three — and is asked whenever fewer than that can
src/core/pulls.ts +24
@@ -33,6 +33,7 @@ import {
3333 getIssue,
3434 getIssueById,
3535 getIssuesByIds,
36 LIST_LIMIT_MAX,
3637 listIssues,
3738 setIssueState,
3839 type Issue,
@@ -531,6 +532,29 @@ export function listPulls(
531532 return joinMany(db, listIssues(db, repoId, { ...options, kind: "pull" }));
532533 }
533534
535 /**
536 * Every open pull of the repository, sealed ones too, oldest first. All of
537 * them, page by page: a pass that read only the newest page never saw an
538 * older pull — the green sweep's approved ones (#4053), the finishing
539 * pass's approved, conflicted ones (#6263).
540 */
541 export function listOpenPulls(db: Database, repoId: string): Pull[] {
542 const all: Pull[] = [];
543 let cursor: number | undefined;
544 for (;;) {
545 const page = listPulls(db, repoId, {
546 sealed: true,
547 state: "open",
548 limit: LIST_LIMIT_MAX,
549 ...(cursor === undefined ? {} : { cursor }),
550 });
551 all.push(...page);
552 if (page.length < LIST_LIMIT_MAX) break;
553 cursor = page.at(-1)!.number;
554 }
555 return all.sort((a, b) => a.number - b.number);
556 }
557
534558 /**
535559 * Attach the pull rows to a page of issues in one query.
536560 *
src/crew/autonomy.ts +7 −30
@@ -57,7 +57,6 @@ import { wallOf, wallSentence } from "../contract/policy.ts";
5757 import {
5858 getIssue,
5959 getIssueById,
60 LIST_LIMIT_MAX,
6160 listComments,
6261 MAX_TITLE,
6362 type Issue,
@@ -65,7 +64,7 @@ import {
6564 import {
6665 getPull,
6766 closesFromBody,
68 listPulls,
67 listOpenPulls,
6968 type MergePullError,
7069 type Pull,
7170 pullDir,
@@ -1110,7 +1109,7 @@ export async function sweepGreen(app: App, repo: Repo): Promise<number> {
11101109 const entries: Queued[] = [];
11111110 // Why each approved pull waits, for the tick line (#3055).
11121111 const waits: Wait[] = [];
1113 for (const pull of openPulls(app, repo)) {
1112 for (const pull of listOpenPulls(app.db, repo.id)) {
11141113 const admitted = await admit(app, repo, pull);
11151114 if (admitted.ok) entries.push(admitted.value);
11161115 const seen = reviewSummary(app.db, pull.id, pull.headOid);
@@ -1123,29 +1122,6 @@ export async function sweepGreen(app: App, repo: Repo): Promise<number> {
11231122 return landed.filter(Boolean).length;
11241123 }
11251124
1126 /**
1127 * Every open pull of the repository, oldest first. All of them, page by
1128 * page: a sweep that read only the newest page never queued an approved
1129 * pull older than it, which then joined the line alone, on its own event,
1130 * one gate at a time (#4053).
1131 */
1132 function openPulls(app: App, repo: Repo): Pull[] {
1133 const all: Pull[] = [];
1134 let cursor: number | undefined;
1135 for (;;) {
1136 const page = listPulls(app.db, repo.id, {
1137 sealed: true,
1138 state: "open",
1139 limit: LIST_LIMIT_MAX,
1140 ...(cursor === undefined ? {} : { cursor }),
1141 });
1142 all.push(...page);
1143 if (page.length < LIST_LIMIT_MAX) break;
1144 cursor = page.at(-1)!.number;
1145 }
1146 return all.sort((a, b) => a.number - b.number);
1147 }
1148
11491125 /** The sweep each repository has in flight, by repository id. */
11501126 const sweeping = new Map<string, Promise<number>>();
11511127
@@ -1289,9 +1265,9 @@ export async function autonomyTick(
12891265 active: status.active,
12901266 slots: status.slots,
12911267 // Approved conflicted pulls: queued, in flight, and why each other is
1292 // skipped (#3682, #6237); and the lane for rebases: conflicted N,
1268 // skipped (#3682, #6237), or "0 candidates of N open" (#6263); and the lane for rebases: conflicted N,
12931269 // rebasing M, room R (#6140).
1294 ...finishFields(finishing, readConflictLane(app.db)),
1270 ...finishFields(finishing.moves, readConflictLane(app.db), finishing.open),
12951271 ...(requested.length === 0
12961272 ? {}
12971273 : {
@@ -1627,8 +1603,9 @@ export async function sweepConflicts(app: App, repo: Repo): Promise<number> {
16271603 cleanAt.delete(id);
16281604 asked.delete(id);
16291605 }
1630 const open = listPulls(app.db, repo.id, { sealed: true, state: "open", limit: 50 });
1631 for (const pull of open.sort((a, b) => a.number - b.number)) {
1606 // Every open pull, not the newest page: an old one main moved under is
1607 // the crew's too (#6263).
1608 for (const pull of listOpenPulls(app.db, repo.id)) {
16321609 if (!pull.authorIsAgent || pull.mergedAt !== null) continue;
16331610 if (getHold(app.db, pull.id) !== undefined) continue;
16341611 const dir = await pullDir(app.db, app.reposRoot, repo, pull);
src/crew/finishing.ts +105 −28
@@ -30,6 +30,11 @@
3030 * Each one not brought up to date is logged once per reason as
3131 * `finishing: skip pull=N why="…"`, and the tick line tallies them:
3232 * `finishing="queued Q, in flight F, skipped S: <why>×k, …"` (#6237).
33 * Every open pull is read, not the newest page, and one that is not a
34 * candidate at all is logged once per reason too, as
35 * `finishing: drop pull=N why="…"`; the pass's entry is logged when what
36 * it reads changes, and with no candidate the tick line still says
37 * `finishing="0 candidates of N open"` (#6263).
3338 *
3439 * Changes requested come after (`requested.ts`), then new issues
3540 * (`driveRepo`). The conflict sweep and the green sweep leave approved
@@ -42,17 +47,19 @@ import { addComment, assignIssue } from "../actions/issues.ts";
4247 import { getAgent, listCrewRoles, type Agent } from "../core/agents.ts";
4348 import { getHold } from "../core/holds.ts";
4449 import { getIssueById, type Issue } from "../core/issues.ts";
45 import { listPulls, pullDir, type Pull } from "../core/pulls.ts";
50 import { listOpenPulls, pullDir, type Pull } from "../core/pulls.ts";
4651 import type { Repo } from "../core/repos.ts";
4752 import { reviewSummary } from "../core/reviews.ts";
4853 import { activeRunFor, listActive } from "../core/runs.ts";
4954 import { getUserById } from "../core/users.ts";
5055 import { mergeable, resolveOid } from "../git/merge.ts";
56 import { committedAt } from "../git/read.ts";
5157 import { createLog } from "../kernel/log.ts";
5258 import { noteWaitingToFinish, rebaseLaneSays } from "../flow/conflicted.ts";
5359 import { type ConflictLane, readConflictRoom } from "./conflict_room.ts";
5460 import { CHECK_FAILED_LIMIT, lastHumanWord, queueAgentRun } from "./dispatch.ts";
5561 import { HANDOVER_NOTE } from "./guard.ts";
62 import { interruptionOf } from "./interrupted.ts";
5663 import { pickBuilder } from "./route.ts";
5764 import { NO_BUILDER_ON_CREW, WAITS_FOR_BUILDER_SLOT } from "./words.ts";
5865
@@ -73,6 +80,12 @@ export type FinishMove =
7380
7481 const notMoved = (pull: Pull, why: string): FinishMove => ({ pull: pull.number, did: "not moved", why });
7582
83 /** One finishing pass: what it did with each candidate, and how many open pulls it read. */
84 export interface FinishPass {
85 readonly moves: FinishMove[];
86 readonly open: number;
87 }
88
7689 /** Whether the pull's head carries an approval and no change requested: what lands once it merges. */
7790 export function approvedOnHead(db: Database, pull: Pull): boolean {
7891 const seen = reviewSummary(db, pull.id, pull.headOid);
@@ -96,6 +109,7 @@ function skipWhy(move: FinishMove): string | undefined {
96109 export function finishFields(
97110 moves: readonly FinishMove[],
98111 lane?: ConflictLane,
112 open?: number,
99113 ): { finishing?: string; lane?: string } {
100114 const queued = moves.filter((m) => m.did === "queued update").length;
101115 const inFlight = moves.filter((m) => m.did === "in flight").length;
@@ -112,17 +126,28 @@ export function finishFields(
112126 : rebaseLaneSays({ conflicted: moves.length, rebasing: lane.runs + lane.checks, room: lane.room });
outdated

With autonomy off finishApproved returns open: 0, so this prints "0 candidates of 0 open" for a repo that was never read. Return open undefined (or skip the field) in that case.

113127 return {
114128 ...(moves.length === 0
115 ? {}
129 ? open === undefined
130 ? {}
131 : { finishing: `0 candidates of ${open} open` }
116132 : { finishing: `queued ${queued}, in flight ${inFlight}, skipped ${skipped}${skipped === 0 ? "" : `: ${reasons}`}` }),
117133 ...(words === null ? {} : { lane: words }),
118134 };
119135 }
120136
121 /** Pull id → the tips git last read, and the files in conflict (null: clean or nothing to add). */
122 const read = new Map<string, { key: string; files: string[] | null }>();
137 /**
138 * Pull id → the tips git last read, the files in conflict (null: clean or
139 * nothing to add), and when the base tip was committed.
140 */
141 const read = new Map<string, { key: string; files: string[] | null; movedAt?: string }>();
123142
124 /** The files the pull conflicts on with its base; undefined when it merges. */
125 async function conflictOf(app: App, repo: Repo, pull: Pull): Promise<string[] | undefined> {
143 /** A pull's conflict with its base: the files, and when the base last moved. */
144 interface Conflict {
145 readonly files: string[];
146 readonly movedAt?: string;
147 }
148
149 /** The pull's conflict with its base; undefined when it merges. */
150 async function conflictOf(app: App, repo: Repo, pull: Pull): Promise<Conflict | undefined> {
126151 const dir = await pullDir(app.db, app.reposRoot, repo, pull);
127152 const baseTip = await resolveOid(dir, `refs/heads/${pull.baseBranch}`);
128153 if (baseTip === undefined) return undefined;
@@ -133,10 +158,13 @@ async function conflictOf(app: App, repo: Repo, pull: Pull): Promise<string[] |
133158 // A clean merge is nothing to do here, and one that leaves the base's
134159 // own tree is superseded, which the conflict sweep closes.
135160 const reading = await mergeable(dir, baseTip, pull.headOid);
136 seen = { key, files: !reading.ok && reading.error.kind === "conflict" ? reading.error.files : null };
161 const files = !reading.ok && reading.error.kind === "conflict" ? reading.error.files : null;
162 // A new base is a new chance: failed updates count from when it moved (#6263).
163 const movedAt = files === null ? undefined : await committedAt(dir, baseTip);
164 seen = { key, files, ...(movedAt === undefined ? {} : { movedAt }) };
137165 read.set(pull.id, seen);
138166 }
139 return seen.files ?? undefined;
167 return seen.files === null ? undefined : { files: seen.files, ...(seen.movedAt === undefined ? {} : { movedAt: seen.movedAt }) };
140168 }
141169
142170 /**
@@ -144,16 +172,20 @@ async function conflictOf(app: App, repo: Repo, pull: Pull): Promise<string[] |
144172 * at most `free` builder slots. Returns what happened to each, oldest
145173 * pull first.
146174 */
147 export async function finishApproved(app: App, repo: Repo, free: number): Promise<FinishMove[]> {
175 export async function finishApproved(app: App, repo: Repo, free: number): Promise<FinishPass> {
148176 if (repo.autonomy !== "autonomous" || repo.autonomyBy === null) {
177 enter(repo, "autonomy is off");
149178 noteWaitingToFinish(app.db, repo.id, []);
150 return [];
179 return { moves: [], open: 0 };
151180 }
152181 const moves: FinishMove[] = [];
153182 const builders = listCrewRoles(app.db, repo.id)
154183 .filter((c) => c.role === "builder")
155184 .map((c) => c.agent);
156 const open = listPulls(app.db, repo.id, { sealed: true, state: "open", limit: 50 });
185 // Every open pull, oldest first: the newest page alone left the old,
186 // approved, conflicted ones unread and unsaid (#6263).
187 const open = listOpenPulls(app.db, repo.id);
188 enter(repo, `open=${open.length} free=${free}`);
157189 const ids = new Set(open.map((p) => p.id));
158190 for (const id of read.keys()) if (!ids.has(id)) read.delete(id);
159191 let slots = free;
@@ -161,20 +193,38 @@ export async function finishApproved(app: App, repo: Repo, free: number): Promis
161193 const load = new Map<string, number>();
162194 for (const run of listActive(app.db, "agent"))
163195 if (run.actorId !== null) load.set(run.actorId, (load.get(run.actorId) ?? 0) + 1);
164 for (const pull of open.sort((a, b) => a.number - b.number)) {
165 if (!pull.authorIsAgent || pull.mergedAt !== null) continue;
166 if (!approvedOnHead(app.db, pull)) continue;
167 const files = await conflictOf(app, repo, pull);
168 if (files === undefined) continue;
196 for (const pull of open) {
197 // Not a candidate: said once per pull per reason, so a pull the pass
198 // never reaches is never a mystery (#6263).
199 if (!pull.authorIsAgent) {
200 drop(repo, pull, "not the crew's");
201 continue;
202 }
203 if (pull.mergedAt !== null) {
204 drop(repo, pull, "merged");
205 continue;
206 }
207 if (!approvedOnHead(app.db, pull)) {
208 drop(repo, pull, "not approved on its head");
209 continue;
210 }
211 const conflict = await conflictOf(app, repo, pull);
212 if (conflict === undefined) {
213 drop(repo, pull, "merges clean");
214 continue;
215 }
216 const issue = getIssueById(app.db, pull.id);
217 if (!issue || issue.state !== "open") {
218 drop(repo, pull, "not open");
219 continue;
220 }
169221 // From here every pull is approved and conflicted: one not brought up
170222 // to date says why, on its own line and on the tick line (#6237).
171 const issue = getIssueById(app.db, pull.id);
172 if (!issue || issue.state !== "open") continue;
173223 const hold = getHold(app.db, pull.id);
174224 const move =
175225 hold !== undefined
176226 ? notMoved(pull, `held by ${hold.heldByName}`)
177 : finishOne(app, repo, pull, issue, files, builders, load, slots, room);
227 : finishOne(app, repo, pull, issue, conflict, builders, load, slots, room);
178228 if (move.did === "queued update") {
179229 slots--;
180230 room--;
@@ -195,7 +245,7 @@ export async function finishApproved(app: App, repo: Repo, free: number): Promis
195245 public: repo.visibility === "public",
196246 })),
197247 );
198 return moves;
248 return { moves, open: open.length };
199249 }
200250
201251 function finishOne(
@@ -203,7 +253,7 @@ function finishOne(
203253 repo: Repo,
204254 pull: Pull,
205255 issue: Issue,
206 files: readonly string[],
256 { files, movedAt }: Conflict,
207257 builders: readonly Agent[],
208258 load: ReadonlyMap<string, number>,
209259 slots: number,
@@ -218,7 +268,7 @@ function finishOne(
218268 if (own === undefined) return not(NO_BUILDER_ON_CREW);
219269 // Two failed updates are counted against the pull's own builder, whoever ran them.
220270 const builder = (slots > 0 && room > 0 ? lentBuilder(own, builders, load) : undefined) ?? own;
221 if (failedUpdates(app, issue, own) >= CHECK_FAILED_LIMIT) return handOver(app, repo, pull, issue, files);
271 if (failedUpdates(app, issue, own, movedAt) >= CHECK_FAILED_LIMIT) return handOver(app, repo, pull, issue, files);
222272 if (slots <= 0) return not(WAITS_FOR_BUILDER_SLOT);
223273 if (room <= 0) return not("waits for room in the check lane");
224274 const run = queueAgentRun(app, repo, issue, builder, "conflict", undefined, {
@@ -270,17 +320,26 @@ export function lentBuilder(
270320 }
271321
272322 /**
273 * Conflict runs that ended since a person last spoke on the pull. Each one
274 * left the approved head as it was, so none of them pushed.
323 * Conflict runs that ended since a person last spoke on the pull and since
324 * its base last moved. Each one reached the agent and left the approved
325 * head as it was, so none of them pushed. A run the forge cut short — a
326 * network error, a clone or checkout that failed, a restart — never reached
327 * the agent and is no attempt (`interrupted.ts`); a new base is a new
328 * chance (#6263).
275329 */
276 function failedUpdates(app: App, issue: Issue, builder: Agent): number {
330 function failedUpdates(app: App, issue: Issue, builder: Agent, movedAt?: string): number {
331 const spoke = lastHumanWord(app, issue, builder);
332 const since = movedAt !== undefined && movedAt > spoke ? movedAt : spoke;

Low: movedAt is the base tip's committer date. That date is whatever the committing clock said, not when main moved. A fast-forward to an older commit gives no reset, and a future-skewed commit stops any failure counting until that time. The breaker still bounds spend. A sturdier signal is the time the forge saw the base tip change: the reflog, or recording it when the conflict cache key changes. Not blocking.

333 // Read row by row, so a run recorded before its words were known as an
334 // interruption (a bare `checkout failed: …`) is not counted either.
277335 return app.db
278 .query<{ n: number }, [string, string]>(
279 `SELECT COUNT(*) AS n FROM run
336 .query<{ summary: string | null }, [string, string]>(
337 `SELECT summary FROM run
280338 WHERE kind = 'agent' AND reason = 'conflict' AND issue_id = ?1
281339 AND created_at > ?2 AND state IN ('ok','failed')`,
282340 )
283 .get(issue.id, lastHumanWord(app, issue, builder))!.n;
341 .all(issue.id, since)
342 .filter(({ summary }) => !summary?.startsWith("skipped: ") && interruptionOf(summary ?? "") === undefined).length;
284343 }
285344
286345 /** Two updates failed: the pull is the person's who turned autonomy on, with the conflict named. */
@@ -308,6 +367,24 @@ function handOver(app: App, repo: Repo, pull: Pull, issue: Issue, files: readonl
308367 /** The last why-not said for a pull, so a stall is said once until it changes. */
309368 const said = new Map<string, string>();
310369
370 /** What each repository's pass last said it read, so its entry is said when that changes. */
371 const entered = new Map<string, string>();
372
373 function enter(repo: Repo, what: string): void {
374 if (entered.get(repo.id) === what) return;
375 entered.set(repo.id, what);
376 log.info("finishing: pass", { repo: `${repo.ownerName}/${repo.name}`, read: what });
377 }
378
379 /** A pull that is not an approved, conflicted crew pull: said once per head and reason. */
380 function drop(repo: Repo, pull: Pull, why: string): void {
381 const key = `${repo.id}:${pull.number}`;
382 const line = `${pull.headOid}:${why}`;
383 if (said.get(key) === line) return;

Low: drop and say share the one said key per pull, so each overwrites the other. A pull that flips between merges clean and conflicted re-logs on every flip. Neither map is pruned when a pull leaves the open set, unlike read. Fix: give drop its own map, and delete both maps' entries in the loop that already prunes read for ids no longer open.

384 said.set(key, line);
385 log.info("finishing: drop", { pull: pull.number, why, repo: `${repo.ownerName}/${repo.name}` });
386 }
387
311388 function say(repo: Repo, pull: Pull, move: FinishMove): void {
312389 const where = `${repo.ownerName}/${repo.name}`;
313390 const key = `${repo.id}:${pull.number}`;
src/crew/interrupted.ts +3 −2
@@ -21,8 +21,9 @@ const PROVIDER =
2121 const NETWORK = new RegExp(`${NETWORK_ERROR.source}|could not reach|unreachable`, "i");
2222 const RESTART =
2323 /^cancelled$|interrupted by a restart|stopped for a restart|the forge stopped/i;
24 // The runner's own words when the forge could not clone or check out.
25 const CLONE = /^clone failed:/i;
24 // The runner's own words when the forge could not clone, fetch or check
25 // out: the run never reached the agent (#6263).
26 const CLONE = /^(?:clone|checkout|fetch(?: from the fork)?) failed:/i;
2627
2728 /**
2829 * Why a run that ended without a pull was cut short, read from its summary
src/crew/runner.ts +18 −1
@@ -12,7 +12,7 @@
1212 */
1313
1414 import { capacityEnv } from "../core/capacity.ts";
15 import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
15 import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
1616 import { join } from "node:path";
1717 import type { App } from "../app.ts";
1818 import { addComment } from "../actions/issues.ts";
@@ -1700,6 +1700,15 @@ export async function forkFor(
17001700 return ok(made.value);
17011701 }
17021702
1703 /** Whether `dir` is empty, or a work tree git can read: its `.git` resolves. */
1704 async function readsAsClone(dir: string, env: Record<string, string>): Promise<boolean> {
1705 if (readdirSync(dir).length === 0) return true;
1706 if (!existsSync(join(dir, ".git"))) return false;
1707 // Its own `.git`, named: never a repository above it that git would find.
1708 const top = await runGit(["--git-dir", join(dir, ".git"), "rev-parse", "--git-dir"], { cwd: dir, env });
1709 return top.ok;
1710 }
1711
17031712 /**
17041713 * A clone on `branch`, matching what the forge holds. The clone is the
17051714 * triangle git was built for: it fetches from the repository and pushes to
@@ -1731,6 +1740,14 @@ async function prepareClone(
17311740 const agentUrl = `${options.forgeUrl}/${repo.ownerName}/${repo.name}.git`;
17321741 const agentForkUrl = `${options.forgeUrl}/${fork.ownerName}/${fork.name}.git`;
17331742 if (signal.aborted) return { ok: false, error: "cancelled" };
1743 // A kept clone git no longer reads as one — a clone a network blip cut
1744 // short — is moved aside, never reused, and cloned afresh (#6263).
1745 if (!fresh && existsSync(dir) && !(await readsAsClone(dir, env))) {
1746 const aside = `${dir}.broken-${Date.now()}`;

Low: every broken clone moved aside to <dir>.broken-<time> stays under the data dir forever. A flaky network repeats this per pull, so disk use grows without bound. Fix: have the existing workdir sweep remove .broken- older than a day, or keep only the newest one per workdir. The pull already lists this as not done; open an issue for it if it is not fixed here.

1747 renameSync(dir, aside);
1748 write(`the kept clone is not a git repository; moved aside to ${aside}, cloning afresh`);
1749 return prepareClone(app, options, repo, fork, branch, dir, env, write, signal, false, true);
1750 }
17341751 if (!existsSync(join(dir, ".git"))) {
17351752 mkdirSync(dir, { recursive: true });
17361753 write(`cloning ${repo.ownerName}/${repo.name}`);
src/git/read.ts +9
@@ -492,6 +492,15 @@ export interface LogOptions {
492492 path?: string;
493493 }
494494
495 /** When `oid` was committed, as an ISO time; undefined when git cannot say. */
496 export async function committedAt(dir: string, oid: string): Promise<string | undefined> {
497 assertBatchSafe(oid);
498 const result = await runGitRaw(["show", "-s", "--format=%cI", oid, "--"], { cwd: dir });
499 if (!result.ok) return undefined;
500 const when = new Date(new TextDecoder().decode(result.value).trim());
501 return Number.isNaN(when.getTime()) ? undefined : when.toISOString();
502 }
503
495504 export async function commitLog(
496505 dir: string,
497506 rev: string,
test/crew-interrupted.test.ts +15 −3
@@ -1,8 +1,8 @@
11 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import { chmodSync, existsSync, utimesSync, writeFileSync } from "node:fs";
2 import { chmodSync, existsSync, readdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
33 import { mkdtemp, rm } from "node:fs/promises";
44 import { tmpdir } from "node:os";
5 import { join } from "node:path";
5 import { basename, dirname, join } from "node:path";
66 import { addToCrew, createAgent } from "../src/actions/crew.ts";
77 import { addComment, assignIssue, openIssue } from "../src/actions/issues.ts";
88 import { listComments } from "../src/core/issues.ts";
@@ -401,7 +401,7 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Done.","to
401401 }, 60_000);
402402
403403 test("a young lock that blocks checkout is recovered by a fresh clone", async () => {
404 const { repo, clone } = await keptClone("orchard");
404 const { repo, clone } = await keptClone("copse");
405405 const lock = join(clone, ".git", "index.lock");
406406 writeFileSync(lock, "");
407407 const next = await followUp(repo);
@@ -409,6 +409,18 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Done.","to
409409 expect(existsSync(lock)).toBe(false);
410410 }, 60_000);
411411
412 test("a kept clone git no longer reads is moved aside and cloned afresh (#6263)", async () => {
413 const { repo, clone } = await keptClone("hedgerow");
414 // What a clone cut short by a network blip leaves: `.git` without its HEAD.
415 rmSync(join(clone, ".git", "HEAD"));
416 const next = await followUp(repo);
417 expect(next.summary ?? "").not.toContain("not a git repository");
418 expect(next.state).toBe("ok");
419 expect(existsSync(join(clone, ".git", "HEAD"))).toBe(true);
420 const parent = dirname(clone);
421 expect(readdirSync(parent).some((name) => name.startsWith(`${basename(clone)}.broken-`))).toBe(true);
422 }, 60_000);
423
412424 test("a young lock is not worked around when the clone holds unpushed commits", async () => {
413425 const { repo, clone } = await keptClone("paddock");
414426 writeFileSync(join(clone, "LOST.md"), "unpushed\n");
test/crew-run-timing.test.ts +9 −8
@@ -92,14 +92,15 @@ describe("an agent run's timing line", () => {
9292 const { run, lines, timing } = await runOn("Prune the roses", (number) => {
9393 const agent = getAgent(standIn.app.db, standIn.bot.id);
9494 if (!agent) throw new Error("no agent");
95 // A `.git` that is no repository: the fetch of a kept clone fails.
96 mkdirSync(
97 join(
98 workdirFor(standIn.app.config.dataDir, agent, standIn.repo, number),
99 ".git",
100 ),
101 { recursive: true },
102 );
95 // A kept clone that reads as a repository but refuses every
96 // transport: it is reused, and its fetch fails. (A `.git` that is no
97 // repository is moved aside and cloned afresh, #6263.)
98 const dir = workdirFor(standIn.app.config.dataDir, agent, standIn.repo, number);
99 mkdirSync(dir, { recursive: true });
100 for (const args of [["init", "--quiet"], ["config", "protocol.allow", "never"]]) {
101 const done = Bun.spawnSync(["git", ...args], { cwd: dir });
102 if (done.exitCode !== 0) throw new Error(done.stderr.toString());
103 }
103104 });
104105 expect(run.state).toBe("failed");
105106 expect(timing).toHaveLength(1);
test/finishing-attempts.test.ts added +149
@@ -0,0 +1,149 @@
1 /**
2 * What counts toward the two failed updates that hand an approved,
3 * conflicted pull to a person (#6263): only a run that reached the agent
4 * and ended without a push. A run the forge cut short — a network error, a
5 * checkout that failed — is no attempt, and a new base is a new chance.
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 { openPull } from "../src/actions/pulls.ts";
14 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { setCrewRole } from "../src/core/agents.ts";
16 import type { Pull } from "../src/core/pulls.ts";
17 import { getRepoById, type Repo } from "../src/core/repos.ts";
18 import { submitReview } from "../src/core/reviews.ts";
19 import { claimRun, finishRun, listRuns } from "../src/core/runs.ts";
20 import { createUser, getUserByName, type User } from "../src/core/users.ts";
21 import { finishApproved, type FinishMove } from "../src/crew/finishing.ts";
22 import { interruptionOf } from "../src/crew/interrupted.ts";
23
24 let dataDir: string;
25 let app: App;
26 let alice: User;
27 let repo: Repo;
28 let pull: Pull;
29
30 async function pass(): Promise<FinishMove[]> {
31 const spy = spyOn(console, "info").mockImplementation(() => {});
32 try {
33 return (await finishApproved(app, getRepoById(app.db, repo.id)!, 1)).moves;
34 } finally {
35 spy.mockRestore();
36 }
37 }
38
39 /** The queued update on the pull, ended with `summary`. */
40 function end(summary: string): void {
41 const run = listRuns(app.db, repo.id, { kind: "agent" }).find(
42 (r) => r.issueId === pull.id && r.state === "queued",
43 );
44 expect(run).toBeDefined();
45 claimRun(app.db, run!.id);
46 finishRun(app.db, run!.id, { state: "failed", summary });
47 }
48
49 beforeAll(async () => {
50 dataDir = await mkdtemp(join(tmpdir(), "forge-finishing-attempts-"));
51 app = createApp({ dataDir, port: 0 });
52 const a = await createUser(app.db, {
53 origin: "seed",
54 trusted: true,
55 username: "alice",
56 email: "[email protected]",
57 password: "a-good-passphrase",
58 adminIfFirst: true,
59 });
60 if (!a.ok) throw new Error(a.error);
61 alice = a.value;
62 const made = await createRepo(app, alice, { name: "engine", visibility: "private" });
63 if (!made.ok) throw new Error(String(made.error));
64 repo = made.value;
65 const seeded = await commitToBranch(app, alice, repo, {
66 message: "Start",
67 files: [{ path: "shared.ts", content: "export const shared = 0;\n" }],
68 });
69 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
70 for (const name of ["mason", "quill"]) {
71 const agent = await createAgent(app, alice, { username: name });
72 if (!agent.ok) throw new Error(String(agent.error));
73 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
74 }
75 const quill = getUserByName(app.db, "quill")!;
76 setCrewRole(app.db, repo.id, quill.id, "reviewer");
77 const branch = await commitToBranch(app, alice, repo, {
78 branch: "feature/1",
79 base: "main",
80 message: "Shared, the branch's way",
81 files: [{ path: "shared.ts", content: "export const shared = 1;\n" }],
82 });
83 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
84 const opened = await openPull(app, getUserByName(app.db, "mason")!, repo, {
85 title: "Shared the branch's way",
86 body: "",
87 baseBranch: "main",
88 headBranch: "feature/1",
89 });
90 if (!opened.ok) throw new Error(String(opened.error));
91 pull = opened.value;
92 const approved = submitReview(app.db, {
93 issueId: pull.id,
94 authorId: quill.id,
95 state: "approved",
96 commitOid: pull.headOid,
97 });
98 if (!approved.ok) throw new Error(approved.error);
99 const moved = await commitToBranch(app, alice, repo, {
100 branch: "main",
101 message: "Shared, main's way",
102 files: [{ path: "shared.ts", content: "export const shared = 'main';\n" }],
103 });
104 if (!moved.ok) throw new Error(JSON.stringify(moved.error));
105 app.db
106 .query("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2, autonomy_slots = 1 WHERE id = ?1")
107 .run(repo.id, alice.id);
108 }, 30_000);
109
110 afterAll(async () => {
111 app.close();
112 await rm(dataDir, { recursive: true, force: true });
113 });
114
115 test("the runner's own clone, fetch and checkout failures are interruptions", () => {
116 expect(interruptionOf("checkout failed: fatal: not a git repository")).toBe("clone");
117 expect(interruptionOf("fetch failed: fatal: unable to access")).toBe("clone");
118 expect(interruptionOf("fetch from the fork failed: fatal: x")).toBe("clone");
119 expect(interruptionOf("declined: the checkout failed: is not mine")).toBe(undefined);
120 });
121
122 test("a network error and a failed checkout are no attempt: the update is queued again", async () => {
123 expect((await pass())[0]).toMatchObject({ did: "queued update", builder: "mason" });
124 end("interrupted: network error, resumes on the next pass — the run failed: TypeError: fetch failed");
125 expect((await pass())[0]).toMatchObject({ did: "queued update" });
126 end("checkout failed: fatal: not a git repository");
127 expect((await pass())[0]).toMatchObject({ did: "queued update" });
128 end("could not merge");
129 });
130
131 test("a new base is a new chance: failed updates before main moved do not count", async () => {
132 // The runs above were hours before main moves again, as the frozen pulls'
133 // were — and outside the breaker's window (`breaker.ts`).
134 app.db
135 .query("UPDATE run SET created_at = ?2 WHERE issue_id = ?1")
136 .run(pull.id, new Date(Date.now() - 2 * 60 * 60_000).toISOString());
137 const moved = await commitToBranch(app, alice, repo, {
138 branch: "main",
139 message: "Shared, main's way again",
140 files: [{ path: "shared.ts", content: "export const shared = 'main, again';\n" }],
141 });
142 if (!moved.ok) throw new Error(JSON.stringify(moved.error));
143 expect((await pass())[0]).toMatchObject({ did: "queued update" });
144 end("could not merge");
145 expect((await pass())[0]).toMatchObject({ did: "queued update" });
146 end("could not merge");
147 // Two that reached the agent on this base: a person's now.
148 expect((await pass())[0]).toEqual({ pull: pull.number, did: "handed to a person", to: "alice" });
149 });
test/finishing-pages.test.ts added +140
@@ -0,0 +1,140 @@
1 /**
2 * The finishing pass reads every open pull, not the newest page (#6263):
3 * an approved, conflicted pull older than fifty newer ones is still brought
4 * up to date. Its entry and every pull it drops are logged once per reason,
5 * and with no candidate the tick line says `0 candidates of N open`.
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 { openPull } from "../src/actions/pulls.ts";
14 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
15 import { setCrewRole } from "../src/core/agents.ts";
16 import type { Pull } from "../src/core/pulls.ts";
17 import { getRepoById, type Repo } from "../src/core/repos.ts";
18 import { submitReview } from "../src/core/reviews.ts";
19 import { createUser, getUserByName, type User } from "../src/core/users.ts";
20 import { finishApproved, finishFields, type FinishPass } from "../src/crew/finishing.ts";
21
22 const NEWER = 50;
23
24 let dataDir: string;
25 let app: App;
26 let alice: User;
27 let repo: Repo;
28 let oldest: Pull;
29
30 /** One finishing pass with `free` slots, and the lines it logged about its pulls. */
31 async function pass(free: number): Promise<{ pass: FinishPass; lines: string[] }> {
32 const lines: string[] = [];
33 const spy = spyOn(console, "info").mockImplementation((line: unknown) => {
34 lines.push(String(line));
35 });
36 try {
37 const done = await finishApproved(app, getRepoById(app.db, repo.id)!, free);
38 return { pass: done, lines: lines.filter((l) => l.includes("finishing: ") || l.includes("approved pull conflicts")) };
39 } finally {
40 spy.mockRestore();
41 }
42 }
43
44 async function pullOn(n: number, content: string): Promise<Pull> {
45 const branch = await commitToBranch(app, alice, repo, {
46 branch: `feature/${n}`,
47 base: "main",
48 message: `Branch ${n}`,
49 files: [{ path: `file-${n}.ts`, content }],
50 });
51 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
52 const opened = await openPull(app, getUserByName(app.db, "mason")!, repo, {
53 title: `Branch ${n}`,
54 body: "",
55 baseBranch: "main",
56 headBranch: `feature/${n}`,
57 });
58 if (!opened.ok) throw new Error(String(opened.error));
59 return opened.value;
60 }
61
62 beforeAll(async () => {
63 dataDir = await mkdtemp(join(tmpdir(), "forge-finishing-pages-"));
64 app = createApp({ dataDir, port: 0 });
65 const a = await createUser(app.db, {
66 origin: "seed",
67 trusted: true,
68 username: "alice",
69 email: "[email protected]",
70 password: "a-good-passphrase",
71 adminIfFirst: true,
72 });
73 if (!a.ok) throw new Error(a.error);
74 alice = a.value;
75 const made = await createRepo(app, alice, { name: "engine", visibility: "private" });
76 if (!made.ok) throw new Error(String(made.error));
77 repo = made.value;
78 const seeded = await commitToBranch(app, alice, repo, {
79 message: "Start",
80 files: [{ path: "file-0.ts", content: "export const zero = 0;\n" }],
81 });
82 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
83 for (const name of ["mason", "quill"]) {
84 const agent = await createAgent(app, alice, { username: name });
85 if (!agent.ok) throw new Error(String(agent.error));
86 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
87 }
88 setCrewRole(app.db, repo.id, getUserByName(app.db, "quill")!.id, "reviewer");
89 // The oldest pull edits the file main is about to change; fifty newer
90 // ones each add their own file and merge clean.
91 oldest = await pullOn(0, "export const zero = 'branch';\n");
92 for (let n = 1; n <= NEWER; n++) await pullOn(n, `export const n = ${n};\n`);
93 const approved = submitReview(app.db, {
94 issueId: oldest.id,
95 authorId: getUserByName(app.db, "quill")!.id,
96 state: "approved",
97 commitOid: oldest.headOid,
98 });
99 if (!approved.ok) throw new Error(approved.error);
100 app.db
101 .query("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2, autonomy_slots = 1 WHERE id = ?1")
102 .run(repo.id, alice.id);
103 }, 60_000);
104
105 afterAll(async () => {
106 app.close();
107 await rm(dataDir, { recursive: true, force: true });
108 });
109
110 test("with no candidate the tick line still says how many open pulls were read", async () => {
111 const { pass: done, lines } = await pass(0);
112 expect(done.moves).toEqual([]);
113 expect(done.open).toBe(NEWER + 1);
114 expect(finishFields(done.moves, undefined, done.open).finishing).toBe(`0 candidates of ${NEWER + 1} open`);
115 expect(lines.some((l) => l.includes("finishing: pass") && l.includes(`open=${NEWER + 1} free=0`))).toBe(true);
116 // Every pull the pass drops is said, with the filter that dropped it.
117 const drops = lines.filter((l) => l.includes("finishing: drop"));
118 expect(drops).toHaveLength(NEWER + 1);
119 expect(drops.find((l) => l.includes(`pull=${oldest.number} `))).toContain('why="merges clean"');
120 expect(drops.find((l) => l.includes(`pull=${NEWER + 1} `))).toContain('why="not approved on its head"');
121 });
122
123 test("said once per pull and reason: the same pass again logs nothing", async () => {
124 const { lines } = await pass(0);
125 expect(lines).toEqual([]);
126 });
127
128 test("an approved, conflicted pull older than fifty newer ones is brought up to date", async () => {
129 const moved = await commitToBranch(app, alice, repo, {
130 branch: "main",
131 message: "Zero, main's way",
132 files: [{ path: "file-0.ts", content: "export const zero = 'main';\n" }],
133 });
134 if (!moved.ok) throw new Error(JSON.stringify(moved.error));
135 const { pass: done, lines } = await pass(1);
136 expect(done.moves).toEqual([{ pull: oldest.number, did: "queued update", builder: "mason" }]);
137 expect(finishFields(done.moves, undefined, done.open).finishing).toBe("queued 1, in flight 0, skipped 0");
138 expect(lines.some((l) => l.includes("finishing: pass") && l.includes("free=1"))).toBe(true);
139 expect(lines.some((l) => l.includes("approved pull conflicts: brought up to date first"))).toBe(true);
140 });
test/finishing-skips.test.ts +1 −1
@@ -37,7 +37,7 @@ async function pass(free: number): Promise<{ moves: FinishMove[]; skips: string[
3737 lines.push(String(line));
3838 });
3939 try {
40 const moves = await finishApproved(app, getRepoById(app.db, repo.id)!, free);
40 const { moves } = await finishApproved(app, getRepoById(app.db, repo.id)!, free);
4141 return { moves, skips: lines.filter((l) => l.includes("finishing: skip")) };
4242 } finally {
4343 spy.mockRestore();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs