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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/2039-a-conflicted-pull-never-sits-brought-up → main 4 commits fork 9 threads · 9 unresolved
README.md +1 −1
| @@ -574,7 +574,7 @@ from before this record say nothing). | ||
| 574 | 574 | mechanical rather than advisory. |
| 575 | 575 | |
| 576 | 576 | `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. | |
| 578 | 578 | |
| 579 | 579 | `/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. |
| 580 | 580 |
scripts/gate.ts +1
| @@ -155,6 +155,7 @@ export const GATE: readonly string[] = [ | ||
| 155 | 155 | "test/parked-close.test.ts", |
| 156 | 156 | "test/autonomy.test.ts", |
| 157 | 157 | "test/autonomy_sweep.test.ts", |
| 158 | "test/conflict_sweep.test.ts", | |
| 158 | 159 | "test/studio.test.ts", |
| 159 | 160 | "test/studio-words.test.ts", |
| 160 | 161 | "test/converge-settled.test.ts", |
src/crew/autonomy.ts +173 −4
| @@ -1085,6 +1085,14 @@ export async function autonomyTick( | ||
| 1085 | 1085 | for (const repo of listAutonomousRepos(app.db)) { |
| 1086 | 1086 | try { |
| 1087 | 1087 | 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) => | |
| 1091 | log.error("conflict sweep failed", { | |
| 1092 | repo: `${repo.ownerName}/${repo.name}`, | |
| 1093 | error: String(error), | |
| 1094 | }), | |
| 1095 | ); | |
| 1088 | 1096 | const current = getRepoById(app.db, repo.id) ?? repo; |
| 1089 | 1097 | const did = await driveRepo(app, current, now); |
| 1090 | 1098 | // 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 { | ||
| 1218 | 1226 | } |
| 1219 | 1227 | } |
| 1220 | 1228 | |
| 1229 | /** Pull id → the tips git last read, and the files in conflict (null: clean). */ | |
| 1230 | const checked = new Map< | |
outdated
|
||
| 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
|
||
| 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 |
||
| 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 | ||
| 1221 | 1386 | /** |
| 1222 | 1387 | * Queue one run for the pull's agent to merge main into its branch. One |
| 1223 | 1388 | * waiting is enough, and the automatic tries since a person last spoke |
| @@ -1229,6 +1394,7 @@ export function bringUpToDate( | ||
| 1229 | 1394 | repo: Repo, |
| 1230 | 1395 | pull: Pull, |
| 1231 | 1396 | files: readonly string[], |
| 1397 | episodeStart = "", | |
| 1232 | 1398 | ): string | undefined { |
| 1233 | 1399 | const issue = getIssueById(app.db, pull.id); |
| 1234 | 1400 | if (!issue || issue.state !== "open") return undefined; |
| @@ -1239,15 +1405,18 @@ export function bringUpToDate( | ||
| 1239 | 1405 | const tries = automaticRunsSince( |
| 1240 | 1406 | app.db, |
| 1241 | 1407 | issue.id, |
| 1242 | lastHumanWord(app, issue, agent), | |
| 1408 | episodeStart > lastHumanWord(app, issue, agent) | |
| 1409 | ? episodeStart | |
| 1410 | : lastHumanWord(app, issue, agent), | |
| 1243 | 1411 | ); |
| 1244 | 1412 | 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( | |
| 1247 | 1415 | app, |
| 1248 | 1416 | repo, |
| 1249 | 1417 | 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, | |
| 1251 | 1420 | ); |
| 1252 | 1421 | return undefined; |
| 1253 | 1422 | } |
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: |
||
| 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"; | ||
| 21 | 21 | import { mayView, mayViewId, sealedScope } from "../core/sealed.ts"; |
| 22 | 22 | import { listAccess } from "../actions/access_log.ts"; |
| 23 | 23 | import { changeCapacity, listCapacity } from "../actions/capacity.ts"; |
| 24 | import { conflictedPulls, conflictedSays } from "../flow/conflicted.ts"; | |
| 24 | 25 | import { readPipeline, verdict } from "../flow/pipeline.ts"; |
| 25 | 26 | import { readThroughput, stageOut, throughputSays } from "../flow/throughput.ts"; |
| 26 | 27 | import { changeLimit, listLimits } from "../actions/limits.ts"; |
| @@ -2047,7 +2048,7 @@ const TOOLS: readonly Tool[] = [ | ||
| 2047 | 2048 | { |
| 2048 | 2049 | name: "flow", |
| 2049 | 2050 | 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.", | |
| 2051 | 2052 | inputSchema: { type: "object", properties: {} }, |
| 2052 | 2053 | writes: false, |
| 2053 | 2054 | run(ctx, user) { |
| @@ -2056,8 +2057,13 @@ const TOOLS: readonly Tool[] = [ | ||
| 2056 | 2057 | const now = new Date(); |
| 2057 | 2058 | const stages = readPipeline(ctx.app.db, now); |
| 2058 | 2059 | const flow = readThroughput(ctx.app.db, stages.map((s) => s.key), now); |
| 2060 | const conflicted = conflictedPulls(ctx.app.db); | |
| 2059 | 2061 | return good({ |
| 2060 | 2062 | says: verdict(stages, now.getTime()), |
| 2063 | conflicted: { | |
outdated
|
||
| 2064 | says: conflictedSays(conflicted, now.getTime()), | |
| 2065 | pulls: conflicted, | |
| 2066 | }, | |
| 2061 | 2067 | stages, |
| 2062 | 2068 | throughput: { |
| 2063 | 2069 | says: throughputSays(flow), |
src/tui/screens/flow.ts +3
| @@ -7,6 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | import type { Database } from "bun:sqlite"; |
| 10 | import { conflictedPulls, conflictedSays } from "../../flow/conflicted.ts"; | |
| 10 | 11 | import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts"; |
| 11 | 12 | import { readThroughput, stageOut, throughputSays } from "../../flow/throughput.ts"; |
| 12 | 13 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -21,6 +22,7 @@ export function flowLines(db: Database, now = new Date()): string[] { | ||
| 21 | 22 | const stages = readPipeline(db, now); |
| 22 | 23 | const flow = readThroughput(db, stages.map((s) => s.key), now); |
| 23 | 24 | const inner = WIDTH - 6; |
| 25 | const conflicted = conflictedSays(conflictedPulls(db), now.getTime()); | |
| 24 | 26 | return [ |
| 25 | 27 | ...wrap(verdict(stages, now.getTime()), inner), |
| 26 | 28 | "", |
| @@ -31,6 +33,7 @@ export function flowLines(db: Database, now = new Date()): string[] { | ||
| 31 | 33 | (s) => |
| 32 | 34 | `${pad(STAGE_LABEL[s.key], 12)} ${pad(String(s.lastHour), 9, "right")} ${pad(String(s.day), 5, "right")} ${s.label}`, |
| 33 | 35 | ), |
| 36 | ...(conflicted === null ? [] : ["", ...wrap(conflicted, inner)]), | |
| 34 | 37 | ]; |
| 35 | 38 | } |
| 36 | 39 |
src/web/pages/flow.ts +3
| @@ -14,6 +14,7 @@ | ||
| 14 | 14 | * items it counted under the table. |
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | import { conflictedPulls, conflictedSays } from "../../flow/conflicted.ts"; | |
| 17 | 18 | import { |
| 18 | 19 | bottleneck, |
| 19 | 20 | readPipeline, |
| @@ -99,6 +100,7 @@ export function flowPage(ctx: Ctx): Response { | ||
| 99 | 100 | const flow = readThroughput(ctx.app.db, stages.map((s) => s.key), new Date(now)); |
| 100 | 101 | const out = new Map(flow.stages.map((t) => [t.key, t])); |
| 101 | 102 | const landed = out.get("approved"); |
| 103 | const conflicted = conflictedSays(conflictedPulls(ctx.app.db), now); | |
| 102 | 104 | const listed = asked(ctx.url); |
| 103 | 105 | const shown = listed === null ? undefined : out.get(listed.key); |
| 104 | 106 | const body = html`<div class="page-head"> |
| @@ -114,6 +116,7 @@ export function flowPage(ctx: Ctx): Response { | ||
| 114 | 116 | <p id="bottleneck" data-stage="${worst?.key ?? ""}"> |
| 115 | 117 | <strong>${verdict(stages, now)}</strong> |
| 116 | 118 | </p> |
| 119 | ${conflicted === null ? "" : html`<p id="conflicted">${conflicted}</p>`} | |
Untested: add an assertion that Nit: every other count on /flow links to the items it counts, but |
||
| 117 | 120 | <p id="throughput"> |
| 118 | 121 | landed |
| 119 | 122 | <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 | }); |
Awaiting a git merge check for every open crew pull on every tick delays the builder picks. Cache by (headOid, baseTip) or bound it.