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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/6263-finishing-still-silent-after-6239-the-20 → main 6 commits fork 5 threads · 5 unresolved
docs/PLANNING.md +4 −1
| @@ -430,7 +430,10 @@ asked, under **Settings → Autonomy**: | ||
| 430 | 430 | two updates ended without a push is handed to the person who turned |
| 431 | 431 | autonomy on — assigned to them, not closed (`crew/finishing.ts`; the |
| 432 | 432 | 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 | |
| 434 | 437 | issue, and one parked twice is handed to the planner (below). The planner |
| 435 | 438 | keeps a standing reserve of startable work — two issues per builder |
| 436 | 439 | slot, never fewer than three — and is asked whenever fewer than that can |
src/core/pulls.ts +24
| @@ -33,6 +33,7 @@ import { | ||
| 33 | 33 | getIssue, |
| 34 | 34 | getIssueById, |
| 35 | 35 | getIssuesByIds, |
| 36 | LIST_LIMIT_MAX, | |
| 36 | 37 | listIssues, |
| 37 | 38 | setIssueState, |
| 38 | 39 | type Issue, |
| @@ -531,6 +532,29 @@ export function listPulls( | ||
| 531 | 532 | return joinMany(db, listIssues(db, repoId, { ...options, kind: "pull" })); |
| 532 | 533 | } |
| 533 | 534 | |
| 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 | ||
| 534 | 558 | /** |
| 535 | 559 | * Attach the pull rows to a page of issues in one query. |
| 536 | 560 | * |
src/crew/autonomy.ts +7 −30
| @@ -57,7 +57,6 @@ import { wallOf, wallSentence } from "../contract/policy.ts"; | ||
| 57 | 57 | import { |
| 58 | 58 | getIssue, |
| 59 | 59 | getIssueById, |
| 60 | LIST_LIMIT_MAX, | |
| 61 | 60 | listComments, |
| 62 | 61 | MAX_TITLE, |
| 63 | 62 | type Issue, |
| @@ -65,7 +64,7 @@ import { | ||
| 65 | 64 | import { |
| 66 | 65 | getPull, |
| 67 | 66 | closesFromBody, |
| 68 | listPulls, | |
| 67 | listOpenPulls, | |
| 69 | 68 | type MergePullError, |
| 70 | 69 | type Pull, |
| 71 | 70 | pullDir, |
| @@ -1110,7 +1109,7 @@ export async function sweepGreen(app: App, repo: Repo): Promise<number> { | ||
| 1110 | 1109 | const entries: Queued[] = []; |
| 1111 | 1110 | // Why each approved pull waits, for the tick line (#3055). |
| 1112 | 1111 | const waits: Wait[] = []; |
| 1113 | for (const pull of openPulls(app, repo)) { | |
| 1112 | for (const pull of listOpenPulls(app.db, repo.id)) { | |
| 1114 | 1113 | const admitted = await admit(app, repo, pull); |
| 1115 | 1114 | if (admitted.ok) entries.push(admitted.value); |
| 1116 | 1115 | const seen = reviewSummary(app.db, pull.id, pull.headOid); |
| @@ -1123,29 +1122,6 @@ export async function sweepGreen(app: App, repo: Repo): Promise<number> { | ||
| 1123 | 1122 | return landed.filter(Boolean).length; |
| 1124 | 1123 | } |
| 1125 | 1124 | |
| 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 | ||
| 1149 | 1125 | /** The sweep each repository has in flight, by repository id. */ |
| 1150 | 1126 | const sweeping = new Map<string, Promise<number>>(); |
| 1151 | 1127 | |
| @@ -1289,9 +1265,9 @@ export async function autonomyTick( | ||
| 1289 | 1265 | active: status.active, |
| 1290 | 1266 | slots: status.slots, |
| 1291 | 1267 | // 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, | |
| 1293 | 1269 | // rebasing M, room R (#6140). |
| 1294 | ...finishFields(finishing, readConflictLane(app.db)), | |
| 1270 | ...finishFields(finishing.moves, readConflictLane(app.db), finishing.open), | |
| 1295 | 1271 | ...(requested.length === 0 |
| 1296 | 1272 | ? {} |
| 1297 | 1273 | : { |
| @@ -1627,8 +1603,9 @@ export async function sweepConflicts(app: App, repo: Repo): Promise<number> { | ||
| 1627 | 1603 | cleanAt.delete(id); |
| 1628 | 1604 | asked.delete(id); |
| 1629 | 1605 | } |
| 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)) { | |
| 1632 | 1609 | if (!pull.authorIsAgent || pull.mergedAt !== null) continue; |
| 1633 | 1610 | if (getHold(app.db, pull.id) !== undefined) continue; |
| 1634 | 1611 | const dir = await pullDir(app.db, app.reposRoot, repo, pull); |
src/crew/finishing.ts +105 −28
| @@ -30,6 +30,11 @@ | ||
| 30 | 30 | * Each one not brought up to date is logged once per reason as |
| 31 | 31 | * `finishing: skip pull=N why="…"`, and the tick line tallies them: |
| 32 | 32 | * `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). | |
| 33 | 38 | * |
| 34 | 39 | * Changes requested come after (`requested.ts`), then new issues |
| 35 | 40 | * (`driveRepo`). The conflict sweep and the green sweep leave approved |
| @@ -42,17 +47,19 @@ import { addComment, assignIssue } from "../actions/issues.ts"; | ||
| 42 | 47 | import { getAgent, listCrewRoles, type Agent } from "../core/agents.ts"; |
| 43 | 48 | import { getHold } from "../core/holds.ts"; |
| 44 | 49 | 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"; | |
| 46 | 51 | import type { Repo } from "../core/repos.ts"; |
| 47 | 52 | import { reviewSummary } from "../core/reviews.ts"; |
| 48 | 53 | import { activeRunFor, listActive } from "../core/runs.ts"; |
| 49 | 54 | import { getUserById } from "../core/users.ts"; |
| 50 | 55 | import { mergeable, resolveOid } from "../git/merge.ts"; |
| 56 | import { committedAt } from "../git/read.ts"; | |
| 51 | 57 | import { createLog } from "../kernel/log.ts"; |
| 52 | 58 | import { noteWaitingToFinish, rebaseLaneSays } from "../flow/conflicted.ts"; |
| 53 | 59 | import { type ConflictLane, readConflictRoom } from "./conflict_room.ts"; |
| 54 | 60 | import { CHECK_FAILED_LIMIT, lastHumanWord, queueAgentRun } from "./dispatch.ts"; |
| 55 | 61 | import { HANDOVER_NOTE } from "./guard.ts"; |
| 62 | import { interruptionOf } from "./interrupted.ts"; | |
| 56 | 63 | import { pickBuilder } from "./route.ts"; |
| 57 | 64 | import { NO_BUILDER_ON_CREW, WAITS_FOR_BUILDER_SLOT } from "./words.ts"; |
| 58 | 65 | |
| @@ -73,6 +80,12 @@ export type FinishMove = | ||
| 73 | 80 | |
| 74 | 81 | const notMoved = (pull: Pull, why: string): FinishMove => ({ pull: pull.number, did: "not moved", why }); |
| 75 | 82 | |
| 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 | ||
| 76 | 89 | /** Whether the pull's head carries an approval and no change requested: what lands once it merges. */ |
| 77 | 90 | export function approvedOnHead(db: Database, pull: Pull): boolean { |
| 78 | 91 | const seen = reviewSummary(db, pull.id, pull.headOid); |
| @@ -96,6 +109,7 @@ function skipWhy(move: FinishMove): string | undefined { | ||
| 96 | 109 | export function finishFields( |
| 97 | 110 | moves: readonly FinishMove[], |
| 98 | 111 | lane?: ConflictLane, |
| 112 | open?: number, | |
| 99 | 113 | ): { finishing?: string; lane?: string } { |
| 100 | 114 | const queued = moves.filter((m) => m.did === "queued update").length; |
| 101 | 115 | const inFlight = moves.filter((m) => m.did === "in flight").length; |
| @@ -112,17 +126,28 @@ export function finishFields( | ||
| 112 | 126 | : rebaseLaneSays({ conflicted: moves.length, rebasing: lane.runs + lane.checks, room: lane.room }); |
| 113 | 127 | return { |
| 114 | 128 | ...(moves.length === 0 |
| 115 | ? {} | |
| 129 | ? open === undefined | |
| 130 | ? {} | |
| 131 | : { finishing: `0 candidates of ${open} open` } | |
| 116 | 132 | : { finishing: `queued ${queued}, in flight ${inFlight}, skipped ${skipped}${skipped === 0 ? "" : `: ${reasons}`}` }), |
| 117 | 133 | ...(words === null ? {} : { lane: words }), |
| 118 | 134 | }; |
| 119 | 135 | } |
| 120 | 136 | |
| 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 }>(); | |
| 123 | 142 | |
| 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> { | |
| 126 | 151 | const dir = await pullDir(app.db, app.reposRoot, repo, pull); |
| 127 | 152 | const baseTip = await resolveOid(dir, `refs/heads/${pull.baseBranch}`); |
| 128 | 153 | if (baseTip === undefined) return undefined; |
| @@ -133,10 +158,13 @@ async function conflictOf(app: App, repo: Repo, pull: Pull): Promise<string[] | | ||
| 133 | 158 | // A clean merge is nothing to do here, and one that leaves the base's |
| 134 | 159 | // own tree is superseded, which the conflict sweep closes. |
| 135 | 160 | 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 }) }; | |
| 137 | 165 | read.set(pull.id, seen); |
| 138 | 166 | } |
| 139 | return seen.files ?? undefined; | |
| 167 | return seen.files === null ? undefined : { files: seen.files, ...(seen.movedAt === undefined ? {} : { movedAt: seen.movedAt }) }; | |
| 140 | 168 | } |
| 141 | 169 | |
| 142 | 170 | /** |
| @@ -144,16 +172,20 @@ async function conflictOf(app: App, repo: Repo, pull: Pull): Promise<string[] | | ||
| 144 | 172 | * at most `free` builder slots. Returns what happened to each, oldest |
| 145 | 173 | * pull first. |
| 146 | 174 | */ |
| 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> { | |
| 148 | 176 | if (repo.autonomy !== "autonomous" || repo.autonomyBy === null) { |
| 177 | enter(repo, "autonomy is off"); | |
| 149 | 178 | noteWaitingToFinish(app.db, repo.id, []); |
| 150 | return []; | |
| 179 | return { moves: [], open: 0 }; | |
| 151 | 180 | } |
| 152 | 181 | const moves: FinishMove[] = []; |
| 153 | 182 | const builders = listCrewRoles(app.db, repo.id) |
| 154 | 183 | .filter((c) => c.role === "builder") |
| 155 | 184 | .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}`); | |
| 157 | 189 | const ids = new Set(open.map((p) => p.id)); |
| 158 | 190 | for (const id of read.keys()) if (!ids.has(id)) read.delete(id); |
| 159 | 191 | let slots = free; |
| @@ -161,20 +193,38 @@ export async function finishApproved(app: App, repo: Repo, free: number): Promis | ||
| 161 | 193 | const load = new Map<string, number>(); |
| 162 | 194 | for (const run of listActive(app.db, "agent")) |
| 163 | 195 | 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 | } | |
| 169 | 221 | // From here every pull is approved and conflicted: one not brought up |
| 170 | 222 | // 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; | |
| 173 | 223 | const hold = getHold(app.db, pull.id); |
| 174 | 224 | const move = |
| 175 | 225 | hold !== undefined |
| 176 | 226 | ? 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); | |
| 178 | 228 | if (move.did === "queued update") { |
| 179 | 229 | slots--; |
| 180 | 230 | room--; |
| @@ -195,7 +245,7 @@ export async function finishApproved(app: App, repo: Repo, free: number): Promis | ||
| 195 | 245 | public: repo.visibility === "public", |
| 196 | 246 | })), |
| 197 | 247 | ); |
| 198 | return moves; | |
| 248 | return { moves, open: open.length }; | |
| 199 | 249 | } |
| 200 | 250 | |
| 201 | 251 | function finishOne( |
| @@ -203,7 +253,7 @@ function finishOne( | ||
| 203 | 253 | repo: Repo, |
| 204 | 254 | pull: Pull, |
| 205 | 255 | issue: Issue, |
| 206 | files: readonly string[], | |
| 256 | { files, movedAt }: Conflict, | |
| 207 | 257 | builders: readonly Agent[], |
| 208 | 258 | load: ReadonlyMap<string, number>, |
| 209 | 259 | slots: number, |
| @@ -218,7 +268,7 @@ function finishOne( | ||
| 218 | 268 | if (own === undefined) return not(NO_BUILDER_ON_CREW); |
| 219 | 269 | // Two failed updates are counted against the pull's own builder, whoever ran them. |
| 220 | 270 | 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); | |
| 222 | 272 | if (slots <= 0) return not(WAITS_FOR_BUILDER_SLOT); |
| 223 | 273 | if (room <= 0) return not("waits for room in the check lane"); |
| 224 | 274 | const run = queueAgentRun(app, repo, issue, builder, "conflict", undefined, { |
| @@ -270,17 +320,26 @@ export function lentBuilder( | ||
| 270 | 320 | } |
| 271 | 321 | |
| 272 | 322 | /** |
| 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). | |
| 275 | 329 | */ |
| 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: |
||
| 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. | |
| 277 | 335 | 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 | |
| 280 | 338 | WHERE kind = 'agent' AND reason = 'conflict' AND issue_id = ?1 |
| 281 | 339 | AND created_at > ?2 AND state IN ('ok','failed')`, |
| 282 | 340 | ) |
| 283 | .get(issue.id, lastHumanWord(app, issue, builder))!.n; | |
| 341 | .all(issue.id, since) | |
| 342 | .filter(({ summary }) => !summary?.startsWith("skipped: ") && interruptionOf(summary ?? "") === undefined).length; | |
| 284 | 343 | } |
| 285 | 344 | |
| 286 | 345 | /** 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 | ||
| 308 | 367 | /** The last why-not said for a pull, so a stall is said once until it changes. */ |
| 309 | 368 | const said = new Map<string, string>(); |
| 310 | 369 | |
| 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: |
||
| 384 | said.set(key, line); | |
| 385 | log.info("finishing: drop", { pull: pull.number, why, repo: `${repo.ownerName}/${repo.name}` }); | |
| 386 | } | |
| 387 | ||
| 311 | 388 | function say(repo: Repo, pull: Pull, move: FinishMove): void { |
| 312 | 389 | const where = `${repo.ownerName}/${repo.name}`; |
| 313 | 390 | const key = `${repo.id}:${pull.number}`; |
src/crew/interrupted.ts +3 −2
| @@ -21,8 +21,9 @@ const PROVIDER = | ||
| 21 | 21 | const NETWORK = new RegExp(`${NETWORK_ERROR.source}|could not reach|unreachable`, "i"); |
| 22 | 22 | const RESTART = |
| 23 | 23 | /^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; | |
| 26 | 27 | |
| 27 | 28 | /** |
| 28 | 29 | * 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 @@ | ||
| 12 | 12 | */ |
| 13 | 13 | |
| 14 | 14 | 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"; | |
| 16 | 16 | import { join } from "node:path"; |
| 17 | 17 | import type { App } from "../app.ts"; |
| 18 | 18 | import { addComment } from "../actions/issues.ts"; |
| @@ -1700,6 +1700,15 @@ export async function forkFor( | ||
| 1700 | 1700 | return ok(made.value); |
| 1701 | 1701 | } |
| 1702 | 1702 | |
| 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 | ||
| 1703 | 1712 | /** |
| 1704 | 1713 | * A clone on `branch`, matching what the forge holds. The clone is the |
| 1705 | 1714 | * triangle git was built for: it fetches from the repository and pushes to |
| @@ -1731,6 +1740,14 @@ async function prepareClone( | ||
| 1731 | 1740 | const agentUrl = `${options.forgeUrl}/${repo.ownerName}/${repo.name}.git`; |
| 1732 | 1741 | const agentForkUrl = `${options.forgeUrl}/${fork.ownerName}/${fork.name}.git`; |
| 1733 | 1742 | 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 |
||
| 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 | } | |
| 1734 | 1751 | if (!existsSync(join(dir, ".git"))) { |
| 1735 | 1752 | mkdirSync(dir, { recursive: true }); |
| 1736 | 1753 | write(`cloning ${repo.ownerName}/${repo.name}`); |
src/git/read.ts +9
| @@ -492,6 +492,15 @@ export interface LogOptions { | ||
| 492 | 492 | path?: string; |
| 493 | 493 | } |
| 494 | 494 | |
| 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 | ||
| 495 | 504 | export async function commitLog( |
| 496 | 505 | dir: string, |
| 497 | 506 | rev: string, |
test/crew-interrupted.test.ts +15 −3
| @@ -1,8 +1,8 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 3 | import { mkdtemp, rm } from "node:fs/promises"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | import { join } from "node:path"; | |
| 5 | import { basename, dirname, join } from "node:path"; | |
| 6 | 6 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 7 | 7 | import { addComment, assignIssue, openIssue } from "../src/actions/issues.ts"; |
| 8 | 8 | import { listComments } from "../src/core/issues.ts"; |
| @@ -401,7 +401,7 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Done.","to | ||
| 401 | 401 | }, 60_000); |
| 402 | 402 | |
| 403 | 403 | 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"); | |
| 405 | 405 | const lock = join(clone, ".git", "index.lock"); |
| 406 | 406 | writeFileSync(lock, ""); |
| 407 | 407 | const next = await followUp(repo); |
| @@ -409,6 +409,18 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Done.","to | ||
| 409 | 409 | expect(existsSync(lock)).toBe(false); |
| 410 | 410 | }, 60_000); |
| 411 | 411 | |
| 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 | ||
| 412 | 424 | test("a young lock is not worked around when the clone holds unpushed commits", async () => { |
| 413 | 425 | const { repo, clone } = await keptClone("paddock"); |
| 414 | 426 | 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", () => { | ||
| 92 | 92 | const { run, lines, timing } = await runOn("Prune the roses", (number) => { |
| 93 | 93 | const agent = getAgent(standIn.app.db, standIn.bot.id); |
| 94 | 94 | 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 | } | |
| 103 | 104 | }); |
| 104 | 105 | expect(run.state).toBe("failed"); |
| 105 | 106 | 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[ | ||
| 37 | 37 | lines.push(String(line)); |
| 38 | 38 | }); |
| 39 | 39 | 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); | |
| 41 | 41 | return { moves, skips: lines.filter((l) => l.includes("finishing: skip")) }; |
| 42 | 42 | } finally { |
| 43 | 43 | spy.mockRestore(); |
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.