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

A conflicted pull never sits: brought up to date within the hour, or closed with its issue back on the map #2050

Merged opened this in answer to

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

Merged

scout/forge:scout/2039-a-conflicted-pull-never-sits-brought-up → main 4 commits fork 9 threads · 9 unresolved

  • Merged. scout/2039-a-conflicted-pull-never-sits-brought-up was merged into main as 402a488.

Review

Sign in to review this change.
8 files changed +526 −6
README.md +1 −1
@@ -574,7 +574,7 @@ from before this record say nothing).
574574 mechanical rather than advisory.
575575
576576 `docs/FLOW.md` is where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h.
577 Where the whole forge waits is a forge admin's: `/flow` shows each stage from ready to live with what waits, the oldest wait and its capacity, under one sentence naming the bottleneck, and beside each what it let out in the last hour with a 24-hour sparkline, under a line of landings and the median ask→live — every count links to the items it counts. A stalled step is a card on the home (Studio mode) for anyone who can write to the repository: an open pull whose check was never asked for, or is red, past its stage's allowance, with its stage, how long it has been stuck, and one button — Run check or Re-run — that asks for a check at the head and returns to the home. The terminal's **Stalled steps** command (palette) lists the same steps, live, and Enter or `r` does the same re-run; both read `flow/stalled.ts` and act through `flow/unblock.ts`, and neither shows anything to someone who cannot write. The terminal's Flow command and the MCP `flow` tool read the same: the sentence, the landings line, and each stage's count out in the last hour and in 24 h.
577 Where the whole forge waits is a forge admin's: `/flow` shows each stage from ready to live with what waits, the oldest wait and its capacity, under one sentence naming the bottleneck, and beside each what it let out in the last hour with a 24-hour sparkline, under a line of landings and the median ask→live — every count links to the items it counts. A stalled step is a card on the home (Studio mode) for anyone who can write to the repository: an open pull whose check was never asked for, or is red, past its stage's allowance, with its stage, how long it has been stuck, and one button — Run check or Re-run — that asks for a check at the head and returns to the home. The terminal's **Stalled steps** command (palette) lists the same steps, live, and Enter or `r` does the same re-run; both read `flow/stalled.ts` and act through `flow/unblock.ts`, and neither shows anything to someone who cannot write. A conflicted pull never sits: every tick the crew's open pulls that no longer merge get one bring-up-to-date run each (one in flight per pull); after 2 failed tries in the same conflict, or 24 h since the first, the pull is closed with the conflicting files named and its issue goes back to ready, unassigned, with no strike against the builder. Conflicted pulls are listed oldest first on `/flow`. The terminal's Flow command and the MCP `flow` tool read the same: the sentence, the landings line, and each stage's count out in the last hour and in 24 h.
578578
579579 `/resilience` is a forge admin's too (a guest is sent to sign in, anyone else gets 404): each protection in `docs/THREATS.md` as one line, green, red, untested or "no test yet", judged by the test file it cites in the last full gate of the forge's own repository (`FORGE_REPO`; other repositories' gates never touch it). The terminal's **Resilience** command (palette, admins) lists the same lines.
580580
scripts/gate.ts +1
@@ -155,6 +155,7 @@ export const GATE: readonly string[] = [
155155 "test/parked-close.test.ts",
156156 "test/autonomy.test.ts",
157157 "test/autonomy_sweep.test.ts",
158 "test/conflict_sweep.test.ts",
158159 "test/studio.test.ts",
159160 "test/studio-words.test.ts",
160161 "test/converge-settled.test.ts",
src/crew/autonomy.ts +173 −4
@@ -1085,6 +1085,14 @@ export async function autonomyTick(
10851085 for (const repo of listAutonomousRepos(app.db)) {
10861086 try {
10871087 void startSweep(app, repo, sweep);
1088 // Quick, and before the picks: a pull main moved under is brought up
1089 // to date within this tick, not after a landing gate (#2039).
1090 await sweepConflicts(app, repo).catch((error) =>

Awaiting a git merge check for every open crew pull on every tick delays the builder picks. Cache by (headOid, baseTip) or bound it.

1091 log.error("conflict sweep failed", {
1092 repo: `${repo.ownerName}/${repo.name}`,
1093 error: String(error),
1094 }),
1095 );
10881096 const current = getRepoById(app.db, repo.id) ?? repo;
10891097 const did = await driveRepo(app, current, now);
10901098 // Every tick says what it did and why, so an app that sits still is
@@ -1218,6 +1226,163 @@ function mergeWhy(error: AccessError | MergePullError): string {
12181226 }
12191227 }
12201228
1229 /** Pull id → the tips git last read, and the files in conflict (null: clean). */
1230 const checked = new Map<
outdated

checked and cleanAt never forget a pull. Delete a pull's entries once it is closed, merged or released, or rebuild both from the open pulls each sweep.

1231 string,
1232 { head: string; key: string; files: string[] | null }
1233 >();
1234 /** Pull id → when the sweep last saw it merge cleanly. */
1235 const cleanAt = new Map<string, string>();
1236
1237 /**
1238 * Where this conflict began counting: a person's last word, or the last
1239 * time the pull was seen clean. After a restart nothing is remembered, so a
1240 * run that ended well is taken as the end of the episode before it. A
1241 * finished run in the episode that left the pull conflicted is a failed
1242 * attempt, whatever state it ended in.
1243 */
1244 function conflictEpisodeStart(app: App, issue: Issue, pullId: string): string {
1245 const agent = agentFor(app, issue);
1246 let start = agent ? lastHumanWord(app, issue, agent) : issue.createdAt;
1247 const clean = cleanAt.get(pullId);
1248 if (clean !== undefined) {
1249 if (clean > start) start = clean;
1250 } else {
1251 const ended = app.db
1252 .query<{ at: string | null }, [string]>(
1253 `SELECT MAX(finished_at) AS at FROM run
1254 WHERE kind = 'agent' AND reason = 'conflict' AND issue_id = ?1 AND state = 'ok'`,
1255 )
1256 .get(pullId)?.at;
1257 if (ended != null && ended > start) start = ended;
1258 }
1259 return start;
1260 }
1261
1262 /** How long a conflicted pull may wait to be brought up to date. */
1263 const CONFLICT_LIMIT_MS = 24 * 3_600_000;
1264
1265 /**
1266 * Close a pull the crew could not bring up to date, and put its issues back
1267 * on the map unassigned with the conflict named. Unlike `parkPull` it is
1268 * main that moved, not the builder that failed: the note is not a park, so
1269 * it is never a strike against the issue or the builder.
1270 */
1271 export function releaseConflicted(
1272 app: App,
1273 repo: Repo,
1274 pull: Pull,
1275 why: string,
1276 files: readonly string[],
1277 ): boolean {
1278 if (repo.autonomy !== "autonomous" || repo.autonomyBy === null) return false;
1279 if (getHold(app.db, pull.id) !== undefined) return false;
1280 const by = getUserById(app.db, repo.autonomyBy);
1281 const issue = getIssueById(app.db, pull.id);
1282 if (!by || !issue || issue.state !== "open") return false;
1283 const named = files.length > 0 ? ` Conflicting files: ${files.join(", ")}.` : "";
1284 addComment(app, by, repo, issue, `Closed by the crew: ${why}.${named}`);
1285 const closed = setIssueState(app, by, repo, issue, "closed", {
1286 reason: "parked",
1287 });
1288 if (!closed.ok) return false;
1289 for (const number of closesFromBody(pull.body)) {
1290 const about = getIssue(app.db, repo.id, number);
1291 if (!about || about.state !== "open") continue;
1292 if (about.assigneeId !== null) assignIssue(app, by, repo, about, null);
1293 addComment(
1294 app,
1295 by,
1296 repo,
1297 about,
outdated

Counts all conflict runs ever, including succeeded ones and earlier episodes. Filter to state='failed' and created_at after the last clean state or human word.

1298 `Ready again: pull #${pull.number} was closed because main moved under it and it could not be brought up to date.${named}`,
1299 );
1300 }
1301 log.info("conflicted pull released", {
1302 repo: `${repo.ownerName}/${repo.name}`,
1303 pull: pull.number,
1304 why,
outdated

aged measures from the first run ever, not from when this conflict began, so an old pull conflicted again is closed at once.

1305 });
1306 return true;
1307 }
1308
1309 /**
1310 * Every tick, each open crew pull that no longer merges gets a
1311 * bring-up-to-date run (one in flight per pull). After two finished
1312 * attempts, or 24 h since the first, it is closed and its issue is ready
1313 * again. Returns how many pulls were acted on.
1314 */
1315 export async function sweepConflicts(app: App, repo: Repo): Promise<number> {
1316 if (repo.autonomy !== "autonomous") return 0;
1317 let acted = 0;
1318 // Forget pulls that are no longer open: closed, merged or released.
1319 for (const id of new Set([...checked.keys(), ...cleanAt.keys()]))
1320 if (getIssueById(app.db, id)?.state !== "open") {
1321 checked.delete(id);
1322 cleanAt.delete(id);
1323 }
1324 for (const pull of listPulls(app.db, repo.id, { sealed: true, state: "open", limit: 50 })) {
1325 if (!pull.authorIsAgent || pull.mergedAt !== null) continue;
1326 if (getHold(app.db, pull.id) !== undefined) continue;
1327 const dir = await pullDir(app.db, app.reposRoot, repo, pull);
1328 const baseTip = await resolveOid(dir, `refs/heads/${pull.baseBranch}`);
1329 if (baseTip === undefined) continue;
1330 // Git is asked once per pair of tips; until one moves, the answer stands.
1331 const key = `${pull.headOid}:${baseTip}`;
1332 let seen = checked.get(pull.id);
1333 // The head moved since the last read: the builder pushed (or a person
1334 // did), so what was tried before belongs to an earlier episode.
1335 if (seen !== undefined && seen.head !== pull.headOid)
1336 cleanAt.set(pull.id, new Date().toISOString());
1337 if (seen?.key !== key) {
1338 const reading = await mergeable(dir, baseTip, pull.headOid);
1339 seen = {
1340 head: pull.headOid,
1341 key,
1342 files: reading.ok
1343 ? null
1344 : reading.error.kind === "conflict"
1345 ? reading.error.files
1346 : null,
1347 };
1348 checked.set(pull.id, seen);
1349 }
1350 if (seen.files === null) {
outdated

An ok run counts as failed whenever the pull is still conflicted at the next sweep, even when that run pushed a merge and main moved again afterwards. Start a new episode when the head differs from the head in checked's key (the builder pushed), so only attempts that left the head unchanged count.

1351 // Merges cleanly: whatever came before was a different episode.
1352 cleanAt.set(pull.id, new Date().toISOString());
1353 continue;
outdated

The 24 h close has no test. Backdate the first conflict run and assert the pull closes with the 24-hours reason and its issue is freed.

1354 }
1355 const files = seen.files;
1356 const issue = getIssueById(app.db, pull.id);
1357 if (!issue || issue.state !== "open") continue;
1358 if (activeRunFor(app.db, "agent", issue.id) !== undefined) continue;
1359 const since = conflictEpisodeStart(app, issue, pull.id);
1360 const runs = app.db
1361 .query<{ n: number; first: string | null }, [string, string]>(
1362 `SELECT COUNT(*) AS n, MIN(created_at) AS first FROM run
1363 WHERE kind = 'agent' AND reason = 'conflict' AND issue_id = ?1
1364 AND created_at > ?2 AND state IN ('ok','failed')`,
1365 )
1366 .get(pull.id, since);
1367 const aged =
1368 runs?.first != null &&
1369 Date.now() - Date.parse(runs.first) >= CONFLICT_LIMIT_MS;
1370 if ((runs?.n ?? 0) >= CHECK_FAILED_LIMIT || aged) {
1371 const why = aged
1372 ? "main moved under it and the branch was not brought up to date within 24 hours"
1373 : `main moved under it and ${runs?.n} attempts to bring the branch up to date failed`;
1374 if (releaseConflicted(app, repo, pull, why, files)) {
1375 acted++;
1376 checked.delete(pull.id);
1377 cleanAt.delete(pull.id);
1378 }
1379 continue;
1380 }
1381 if (bringUpToDate(app, repo, pull, files, since) !== undefined) acted++;
1382 }
1383 return acted;
1384 }
1385
12211386 /**
12221387 * Queue one run for the pull's agent to merge main into its branch. One
12231388 * waiting is enough, and the automatic tries since a person last spoke
@@ -1229,6 +1394,7 @@ export function bringUpToDate(
12291394 repo: Repo,
12301395 pull: Pull,
12311396 files: readonly string[],
1397 episodeStart = "",
12321398 ): string | undefined {
12331399 const issue = getIssueById(app.db, pull.id);
12341400 if (!issue || issue.state !== "open") return undefined;
@@ -1239,15 +1405,18 @@ export function bringUpToDate(
12391405 const tries = automaticRunsSince(
12401406 app.db,
12411407 issue.id,
1242 lastHumanWord(app, issue, agent),
1408 episodeStart > lastHumanWord(app, issue, agent)
1409 ? episodeStart
1410 : lastHumanWord(app, issue, agent),
12431411 );
12441412 if (tries >= CHECK_FAILED_LIMIT) {
1245 // Autonomous: parked, never waiting. Assisted: the person's word.
1246 parkPull(
1413 // Autonomous: closed, never waiting. Assisted: the person's word.
1414 releaseConflicted(
12471415 app,
12481416 repo,
12491417 pull,
1250 `main moved under it ${tries} times and the branch could not be brought up to date (${files.join(", ")})`,
1418 `main moved under it ${tries} times and the branch could not be brought up to date`,
1419 files,
12511420 );
12521421 return undefined;
12531422 }
src/flow/conflicted.ts added +48
@@ -0,0 +1,48 @@
1 /**
2 * Pulls that main moved out from under (#2039): open pulls with a
3 * bring-up-to-date run queued, running or failed, by how long since the
4 * crew was first asked to bring each one up to date. Read from the runs
5 * alone, so /flow, the terminal and MCP say the same without asking git.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import { span } from "./pipeline.ts";
10
11 export interface ConflictedPull {
12 readonly repo: string;
13 readonly number: number;
14 /** When the crew was first asked to bring it up to date. */
15 readonly since: string;
16 }
17
18 /** Oldest first. */
19 export function conflictedPulls(db: Database): ConflictedPull[] {
20 return db
21 .query<ConflictedPull, []>(
22 `SELECT o.username || '/' || r.name AS repo, i.number AS number,
23 MIN(u.created_at) AS since

Minor: since is the first conflict run ever made for the pull, not the first one in the current episode. A pull that was brought up to date last week and is conflicted again today shows on /flow as a week old, and sorts ahead of pulls that have really waited longer. sweepConflicts already counts from the start of the current episode, so read the age the same way. One way: take the MIN only over runs created after the latest ok conflict run.

24 FROM run u JOIN issue i ON i.id = u.issue_id
25 JOIN pull p ON p.issue_id = i.id JOIN repo r ON r.id = i.repo_id JOIN user o ON o.id = r.owner_id
26 WHERE u.kind = 'agent' AND u.reason = 'conflict' AND i.state = 'open'
27 AND p.merged_at IS NULL
28 AND (SELECT state FROM run n WHERE n.issue_id = i.id AND n.kind = 'agent'
29 AND n.reason = 'conflict' ORDER BY n.created_at DESC, n.id DESC LIMIT 1)
30 IN ('queued','running','failed')
31 GROUP BY i.id ORDER BY since`,
32 )
33 .all();
34 }
35
36 /** One sentence for /flow, or null when none is conflicted. */
37 export function conflictedSays(
38 pulls: readonly ConflictedPull[],
39 now = Date.now(),
40 ): string | null {
41 if (pulls.length === 0) return null;
42 const list = pulls
43 .slice(0, 5)
44 .map((p) => `${p.repo}#${p.number} ${span(now - Date.parse(p.since))}`)
45 .join(", ");
46 const more = pulls.length > 5 ? `, and ${pulls.length - 5} more` : "";
47 return `${pulls.length} conflicted ${pulls.length === 1 ? "pull" : "pulls"}, oldest first: ${list}${more}.`;
48 }
src/http/mcp.ts +7 −1
@@ -21,6 +21,7 @@ import { voteVariant } from "../actions/variants.ts";
2121 import { mayView, mayViewId, sealedScope } from "../core/sealed.ts";
2222 import { listAccess } from "../actions/access_log.ts";
2323 import { changeCapacity, listCapacity } from "../actions/capacity.ts";
24 import { conflictedPulls, conflictedSays } from "../flow/conflicted.ts";
2425 import { readPipeline, verdict } from "../flow/pipeline.ts";
2526 import { readThroughput, stageOut, throughputSays } from "../flow/throughput.ts";
2627 import { changeLimit, listLimits } from "../actions/limits.ts";
@@ -2047,7 +2048,7 @@ const TOOLS: readonly Tool[] = [
20472048 {
20482049 name: "flow",
20492050 description:
2050 "The forge's pipeline, for a forge admin: ready, building, check queue, checking, review, approved, landed and live, each with how many wait, how many are being worked, when the oldest waiting one entered (oldestWaiting) and what limits it (capacity), and one sentence (says) naming the stage whose oldest has waited longest — the bottleneck. Beside it, throughput: per stage how many left it in the last hour (lastHour), in 24 hours (day) and in each of the last 24 hours (hours, 0 the last), the median ask→live in ms over the last 24 hours, and its own sentence (says). Read-only; the knobs are the `capacity` tool. The same as /flow.",
2051 "The forge's pipeline, for a forge admin: ready, building, check queue, checking, review, approved, landed and live, each with how many wait, how many are being worked, when the oldest waiting one entered (oldestWaiting) and what limits it (capacity), and one sentence (says) naming the stage whose oldest has waited longest — the bottleneck. Beside it, throughput: per stage how many left it in the last hour (lastHour), in 24 hours (day) and in each of the last 24 hours (hours, 0 the last), the median ask→live in ms over the last 24 hours, and its own sentence (says). Also `conflicted`: pulls main moved under that the crew is bringing up to date, oldest first, with a sentence (says). Read-only; the knobs are the `capacity` tool. The same as /flow.",
20512052 inputSchema: { type: "object", properties: {} },
20522053 writes: false,
20532054 run(ctx, user) {
@@ -2056,8 +2057,13 @@ const TOOLS: readonly Tool[] = [
20562057 const now = new Date();
20572058 const stages = readPipeline(ctx.app.db, now);
20582059 const flow = readThroughput(ctx.app.db, stages.map((s) => s.key), now);
2060 const conflicted = conflictedPulls(ctx.app.db);
20592061 return good({
20602062 says: verdict(stages, now.getTime()),
2063 conflicted: {
outdated

conflictedPulls runs twice here. Read it once into a const. Also, no test asserts this field.

2064 says: conflictedSays(conflicted, now.getTime()),
2065 pulls: conflicted,
2066 },
20612067 stages,
20622068 throughput: {
20632069 says: throughputSays(flow),
src/tui/screens/flow.ts +3
@@ -7,6 +7,7 @@
77 */
88
99 import type { Database } from "bun:sqlite";
10 import { conflictedPulls, conflictedSays } from "../../flow/conflicted.ts";
1011 import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts";
1112 import { readThroughput, stageOut, throughputSays } from "../../flow/throughput.ts";
1213 import type { Ctx, Screen } from "../app/app.ts";
@@ -21,6 +22,7 @@ export function flowLines(db: Database, now = new Date()): string[] {
2122 const stages = readPipeline(db, now);
2223 const flow = readThroughput(db, stages.map((s) => s.key), now);
2324 const inner = WIDTH - 6;
25 const conflicted = conflictedSays(conflictedPulls(db), now.getTime());
2426 return [
2527 ...wrap(verdict(stages, now.getTime()), inner),
2628 "",
@@ -31,6 +33,7 @@ export function flowLines(db: Database, now = new Date()): string[] {
3133 (s) =>
3234 `${pad(STAGE_LABEL[s.key], 12)} ${pad(String(s.lastHour), 9, "right")} ${pad(String(s.day), 5, "right")} ${s.label}`,
3335 ),
36 ...(conflicted === null ? [] : ["", ...wrap(conflicted, inner)]),
3437 ];
3538 }
3639
src/web/pages/flow.ts +3
@@ -14,6 +14,7 @@
1414 * items it counted under the table.
1515 */
1616
17 import { conflictedPulls, conflictedSays } from "../../flow/conflicted.ts";
1718 import {
1819 bottleneck,
1920 readPipeline,
@@ -99,6 +100,7 @@ export function flowPage(ctx: Ctx): Response {
99100 const flow = readThroughput(ctx.app.db, stages.map((s) => s.key), new Date(now));
100101 const out = new Map(flow.stages.map((t) => [t.key, t]));
101102 const landed = out.get("approved");
103 const conflicted = conflictedSays(conflictedPulls(ctx.app.db), now);
102104 const listed = asked(ctx.url);
103105 const shown = listed === null ? undefined : out.get(listed.key);
104106 const body = html`<div class="page-head">
@@ -114,6 +116,7 @@ export function flowPage(ctx: Ctx): Response {
114116 <p id="bottleneck" data-stage="${worst?.key ?? ""}">
115117 <strong>${verdict(stages, now)}</strong>
116118 </p>
119 ${conflicted === null ? "" : html`<p id="conflicted">${conflicted}</p>`}

Untested: add an assertion that /flow for an admin shows #conflicted with the pull, and does not show it when none is conflicted.

Nit: every other count on /flow links to the items it counts, but repo#number here is plain text. Linking each pull (/:owner/:name/pulls/:n) would let an admin look deeper without leaving the page.

117120 <p id="throughput">
118121 landed
119122 <a href="${outHref("approved", 0)}" data-landed-hour>${String(landed?.hours[0] ?? 0)}</a>
test/conflict_sweep.test.ts added +290
@@ -0,0 +1,290 @@
1 /**
2 * A conflicted pull never sits (#2039): a landing makes a crew pull
3 * conflicted, the next tick queues one bring-up-to-date run, and a pull
4 * that is still conflicted after two attempts is closed with its issue
5 * ready again — no strike.
6 */
7 import { afterAll, beforeAll, expect, 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 { getIssueById } from "../src/core/issues.ts";
17 import { getRepoById, type Repo } from "../src/core/repos.ts";
18 import { claimRun, finishRun, listRuns } from "../src/core/runs.ts";
19 import { createUser, getUserByName, type User } from "../src/core/users.ts";
20 import { parkedCount } from "../src/crew/parks.ts";
21 import { createToken } from "../src/core/tokens.ts";
22 import { serve, type Servers } from "../src/http/server.ts";
23 import { sweepConflicts } from "../src/crew/autonomy.ts";
24 import { conflictedPulls, conflictedSays } from "../src/flow/conflicted.ts";
25 import { flowLines } from "../src/tui/screens/flow.ts";
26
27 let dataDir: string;
28 let app: App;
29 let servers: Servers;
30 let alice: User;
31 let repo: Repo;
32 const fresh = () => getRepoById(app.db, repo.id)!;
33
34 /** /flow as the admin, and the MCP flow tool's `conflicted` field. */
35 async function flowDoors() {
36 const signin = await fetch(`${servers.url}/signin`, {
37 method: "POST",
38 redirect: "manual",
39 headers: {
40 "content-type": "application/x-www-form-urlencoded",
41 origin: servers.url,
42 },
43 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
44 });
45 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
46 const page = await (await fetch(`${servers.url}/flow`, { headers: { cookie } })).text();
47 const res = await fetch(`${servers.url}/mcp`, {
48 method: "POST",
49 headers: {
50 authorization: `Bearer ${createToken(app.db, alice.id, "t", "ro").token}`,
51 "content-type": "application/json",
52 accept: "application/json, text/event-stream",
53 },
54 body: JSON.stringify({
55 jsonrpc: "2.0",
56 id: 1,
57 method: "tools/call",
58 params: { name: "flow", arguments: {} },
59 }),
60 });
61 const body = (await res.json()) as { result: { content: { text: string }[] } };
62 const read = JSON.parse(body.result.content[0]!.text) as {
63 conflicted: { says: string | null; pulls: { number: number }[] };
64 };
65 return { page, mcp: read.conflicted };
66 }
67
68 beforeAll(async () => {
69 dataDir = await mkdtemp(join(tmpdir(), "forge-conflicts-"));
70 app = createApp({ dataDir, port: 0 });
71 servers = await serve(app);
72 const a = await createUser(app.db, {
73 trusted: true,
74 username: "alice",
75 email: "[email protected]",
76 password: "a-good-passphrase",
77 adminIfFirst: true,
78 });
79 if (!a.ok) throw new Error(a.error);
80 alice = a.value;
81 const made = await createRepo(app, alice, { name: "engine", visibility: "private" });
82 if (!made.ok) throw new Error(String(made.error));
83 repo = made.value;
84 const seeded = await commitToBranch(app, alice, repo, {
85 message: "Start",
86 files: [{ path: "shared.ts", content: "export const shared = 0;\n" }],
87 });
88 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
89 const agent = await createAgent(app, alice, { username: "mason" });
90 if (!agent.ok) throw new Error(String(agent.error));
91 expect(addToCrew(app, alice, repo, "mason").ok).toBe(true);
92 app.db
93 .query(
94 "UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2 WHERE id = ?1",
95 )
96 .run(repo.id, alice.id);
97 });
98
99 afterAll(async () => {
100 servers.stop();
101 app.db.close();
102 await rm(dataDir, { recursive: true, force: true });
103 });
104
105 test("a pull a landing made conflicted is brought up to date within a tick; failing twice closes it and frees its issue", async () => {
106 const mason = getUserByName(app.db, "mason")!;
107 const ask = openIssue(app, alice, repo, { title: "Shared" });
108 if (!ask.ok) throw new Error(String(ask.error));
109 const branch = await commitToBranch(app, alice, repo, {
110 branch: "feature/shared",
111 base: "main",
112 message: "Shared, the branch's way",
113 files: [{ path: "shared.ts", content: "export const shared = 'branch';\n" }],
114 });
115 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
116 const opened = await openPull(app, mason, repo, {
117 title: "Shared",
118 body: `Closes #${ask.value.number}`,
119 baseBranch: "main",
120 headBranch: "feature/shared",
121 });
122 if (!opened.ok) throw new Error(String(opened.error));
123 const pull = opened.value;
124
125 // Clean so far: nothing is asked of anyone.
126 expect(await sweepConflicts(app, fresh())).toBe(0);
127
128 // A landing on main makes it conflicted.
129 const moved = await commitToBranch(app, alice, repo, {
130 branch: "main",
131 message: "Shared, main's way",
132 files: [{ path: "shared.ts", content: "export const shared = 'main';\n" }],
133 });
134 if (!moved.ok) throw new Error(JSON.stringify(moved.error));
135
136 const conflicts = () =>
137 listRuns(app.db, repo.id, { kind: "agent" }).filter(
138 (r) => r.issueId === pull.id && r.reason === "conflict",
139 );
140 expect(await sweepConflicts(app, fresh())).toBe(1);
141 expect(conflicts().length).toBe(1);
142 // One in flight per pull: another tick queues no second.
143 expect(await sweepConflicts(app, fresh())).toBe(0);
144 expect(conflicts().length).toBe(1);
145 expect(conflictedPulls(app.db).map((p) => p.number)).toEqual([pull.number]);
146 expect(conflictedSays(conflictedPulls(app.db))).toContain(`engine#${pull.number}`);
147 expect(flowLines(app.db).join("\n")).toContain("1 conflicted pull");
148 const doors = await flowDoors();
149 expect(doors.page).toContain('id="conflicted"');
150 expect(doors.page).toContain(`engine#${pull.number}`);
151 expect(doors.mcp.pulls.map((p) => p.number)).toEqual([pull.number]);
152 expect(doors.mcp.says).toContain(`engine#${pull.number}`);
153
154 // The run ends without pushing: the second attempt is queued.
155 const first = conflicts()[0]!;
156 claimRun(app.db, first.id);
157 finishRun(app.db, first.id, { state: "failed", summary: "could not merge" });
158 expect(await sweepConflicts(app, fresh())).toBe(1);
159 expect(conflicts().length).toBe(2);
160 expect(getIssueById(app.db, pull.id)!.state).toBe("open");
161
162 // The second ends the same way: the pull closes, the issue is ready.
163 const second = conflicts().find((r) => r.id !== first.id)!;
164 claimRun(app.db, second.id);
165 finishRun(app.db, second.id, { state: "failed", summary: "could not merge" });
166 expect(await sweepConflicts(app, fresh())).toBe(1);
167 expect(getIssueById(app.db, pull.id)!.state).toBe("closed");
168 const freed = getIssueById(app.db, ask.value.id)!;
169 expect(freed.state).toBe("open");
170 expect(freed.assigneeId).toBeNull();
171 // The reason is named on the issue, and it is no strike.
172 const { listComments } = await import("../src/core/issues.ts");
173 const said = listComments(app.db, freed.id).map((c) => c.body).join("\n");
174 expect(said).toContain("shared.ts");
175 expect(parkedCount(app.db, fresh(), freed.id)).toBe(0);
176 expect(conflictedPulls(app.db)).toEqual([]);
177 const after = await flowDoors();
178 expect(after.page).not.toContain('id="conflicted"');
179 expect(after.mcp).toEqual({ says: null, pulls: [] });
180 });
181
182 test("a pull brought up to date before is not closed for it: only the current conflict counts", async () => {
183 const mason = getUserByName(app.db, "mason")!;
184 const ask = openIssue(app, alice, repo, { title: "Other" });
185 if (!ask.ok) throw new Error(String(ask.error));
186 const write = async (branch: string, value: string, base?: string) => {
187 const r = await commitToBranch(app, alice, repo, {
188 branch,
189 ...(base ? { base } : {}),
190 message: `other ${value}`,
191 files: [{ path: "other.ts", content: `export const other = '${value}';\n` }],
192 });
193 if (!r.ok) throw new Error(JSON.stringify(r.error));
194 };
195 await write("feature/other", "branch", "main");
196 const opened = await openPull(app, mason, repo, {
197 title: "Other",
198 body: `Closes #${ask.value.number}`,
199 baseBranch: "main",
200 headBranch: "feature/other",
201 });
202 if (!opened.ok) throw new Error(String(opened.error));
203 const pull = opened.value;
204 const conflicts = () =>
205 listRuns(app.db, repo.id, { kind: "agent" }).filter(
206 (r) => r.issueId === pull.id && r.reason === "conflict",
207 );
208 // Main and the branch each write other.ts differently, twice over: each
209 // time the crew's run brings the branch level, and the pull merges again.
210 for (let round = 1; round <= 3; round++) {
211 await write("main", `main ${round}`);
212 expect(await sweepConflicts(app, fresh())).toBe(1);
213 expect(conflicts().length).toBe(round);
214 const run = conflicts()[0]!;
215 claimRun(app.db, run.id);
216 finishRun(app.db, run.id, { state: "ok", summary: "merged main" });
217 await write("feature/other", `main ${round}`);
218 expect(await sweepConflicts(app, fresh())).toBe(0);
219 expect(getIssueById(app.db, pull.id)!.state).toBe("open");
220 }
221 });
222
223 /** A fresh crew pull on its own file, and a way to move either side of it. */
224 async function pullOn(file: string) {
225 const mason = getUserByName(app.db, "mason")!;
226 const ask = openIssue(app, alice, repo, { title: file });
227 if (!ask.ok) throw new Error(String(ask.error));
228 const write = async (branch: string, value: string, base?: string) => {
229 const r = await commitToBranch(app, alice, repo, {
230 branch,
231 ...(base ? { base } : {}),
232 message: `${file} ${value}`,
233 files: [{ path: file, content: `export const v = '${value}';\n` }],
234 });
235 if (!r.ok) throw new Error(JSON.stringify(r.error));
236 };
237 await write(`feature/${file}`, "branch", "main");
238 const opened = await openPull(app, mason, repo, {
239 title: file,
240 body: `Closes #${ask.value.number}`,
241 baseBranch: "main",
242 headBranch: `feature/${file}`,
243 });
244 if (!opened.ok) throw new Error(String(opened.error));
245 const runs = () =>
246 listRuns(app.db, repo.id, { kind: "agent" }).filter(
247 (r) => r.issueId === opened.value.id && r.reason === "conflict",
248 );
249 return { pull: opened.value, ask: ask.value, write, runs };
250 }
251
252 test("an ok run that pushed is not a failed attempt when main moves again before the next tick", async () => {
253 const { pull, write, runs } = await pullOn("pushed.ts");
254 // The sweep has seen it clean once, so that is where its episodes begin.
255 expect(await sweepConflicts(app, fresh())).toBe(0);
256 // Three times the builder merges main and pushes, and main moves again
257 // before any tick has seen the pull clean.
258 for (let round = 1; round <= 3; round++) {
259 await write("main", `main ${round}`);
260 expect(await sweepConflicts(app, fresh())).toBe(1);
261 expect(runs().length).toBe(round);
262 const run = runs()[0]!;
263 claimRun(app.db, run.id);
264 finishRun(app.db, run.id, { state: "ok", summary: "merged main and pushed" });
265 await write("feature/pushed.ts", `main ${round}`);
266 expect(getIssueById(app.db, pull.id)!.state).toBe("open");
267 }
268 });
269
270 test("a pull still conflicted 24 hours after the first attempt is closed, and its issue freed", async () => {
271 const { pull, ask, write, runs } = await pullOn("aged.ts");
272 await write("main", "main 1");
273 expect(await sweepConflicts(app, fresh())).toBe(1);
274 const only = runs()[0]!;
275 claimRun(app.db, only.id);
276 finishRun(app.db, only.id, { state: "failed", summary: "could not merge" });
277 const old = new Date(Date.now() - 25 * 3_600_000).toISOString();
278 app.db.query("UPDATE run SET created_at = ?2 WHERE id = ?1").run(only.id, old);
279 // The pull itself is older still, as one that sat for a day is.
280 const older = new Date(Date.now() - 26 * 3_600_000).toISOString();
281 app.db.query("UPDATE issue SET created_at = ?2 WHERE id = ?1").run(pull.id, older);
282 expect(await sweepConflicts(app, fresh())).toBe(1);
283 expect(getIssueById(app.db, pull.id)!.state).toBe("closed");
284 const freed = getIssueById(app.db, ask.id)!;
285 expect(freed.state).toBe("open");
286 expect(freed.assigneeId).toBeNull();
287 const { listComments } = await import("../src/core/issues.ts");
288 const said = listComments(app.db, pull.id).map((c) => c.body).join("\n");
289 expect(said).toContain("within 24 hours");
290 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs