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.
The finishing pass never reached the 20 approved, conflicted pulls. It read only the 50 newest open pulls, and trevato/forge has more than 100 open, so an older one like #4394 was never read at all. None of the early `continue`s was dropping them, and the conflict tests already matched: both passes use the same `mergeable(dir, baseTip, headOid)` with the same result cache. The difference was which…
shipped production is serving it deploy f0961d3
The finishing pass never reached the 20 approved, conflicted pulls. It read only the 50 newest open pulls, and trevato/forge has more than 100 open, so an older one like #4394 was never read at all. None of the early continues was dropping them, and the conflict tests already matched: both passes use the same mergeable(dir, baseTip, headOid) with the same result cache. The difference was which pulls each pass read. The green sweep has read every page since #4053, which is why its approved= tally still showed conflict=20.
What changed
- One helper for reading open pulls: the green sweep's own
openPullsmoved tosrc/core/pulls.tsaslistOpenPulls. It reads every page and returns the oldest first. - Three passes use it: the green sweep,
finishApproved, andsweepConflicts.sweepConflicts(the bring-up-to-date pass for unapproved crew pulls) had the same 50-pull cap. - Every early exit is logged: each time a pull is dropped before the logged branch,
finishing.tswritesfinishing: drop pull=N why="…". The reasons arenot the crew's,merged,not approved on its head,merges cleanandnot open. It says this once per pull, head and reason, through the same once-only map asfinishing: skip. - The pass's entry is logged:
finishing: pass read="open=N free=F"(orautonomy is off), whenever that changes. finishing=is always on the tick line: with no candidate it now readsfinishing="0 candidates of N open". To do that,finishApprovednow returns{ moves, open }andfinishFieldstakes the open count.- Docs:
docs/PLANNING.mddescribes the new field and log lines.
Tests
test/finishing-pages.test.ts(new) sets up one approved, conflicted pull with 50 newer ones and checks three things:
- it is brought up to date;
- the drop lines and the 0 candidates of 51 open field appear;
- a second pass logs nothing.
On the old code two of its three tests fail.
test/finishing-skips.test.tsonly readsmovesfrom the new return shape; what it asserts is unchanged.- 34 suites covering the sweeps, the tick and
listPullspass, except one test intest/crew-interrupted.test.ts("a young lock that blocks checkout is recovered by a fresh clone"), which fails the same way on the unchanged base in this sandbox.
Left out
- Logging a drop for every pull that isn't approved means about 100 lines the first time the pass runs after boot, then one per new head. I kept it at info level because the issue asks for every early exit.
- The
saidmap is still never pruned, as before.
How to try it: after deploy, the tick line for trevato/forge should show finishing="queued …" with conflicted pulls like #4394 counted, and finishing: skip or approved pull conflicts: brought up to date first lines for them.
🤖 Generated with Claude Code
Approved. The root cause is right: finishing read only the 50 newest open pulls. All three passes now share one paginated listOpenPulls. The new test builds an approved, conflicted pull under 50 newer ones, so it fails on the old code. Drops and the pass entry are logged once, and the tick line always carries finishing=. It touches none of the files the direction freezes, adds no dependency, and has no UI, so there is no phone/keyboard surface to fail. I judged this from the diff and did not exercise the preview.
Two things to do next:
- The
dropandsayhelpers share onesaidkey per pull and each overwrites the other. A pull that alternates between dropped (merges clean) and skipped (conflicted) re-logs on every flip. Give drop its own map, and prune both when a pull leaves the open set. - When autonomy is off, finishApproved returns
open: 0. If the tick calls it for such repos, the line reads0 candidates of 0 open, which is misleading. Return nothing or omit the field there. Also consider logging the first-boot drop burst of about 100 lines at debug level, or as one summary line.
The fix, the tests and the docs update are pushed in commit 18955f1a, and the working tree matches it. The triage note isn't about the code: it puts the issue behind the Later gate until someone names a pillar for it. Choosing a pillar is a priority call for a person, so I left the issue's labels alone and changed no code for it.
---
The finishing pass never reached the 20 approved, conflicted pulls. It read only the 50 newest open pulls, and trevato/forge has more than 100 open, so an older one like #4394 was never read at all. None of the early continues was dropping them, and the conflict tests already matched: both passes use the same mergeable(dir, baseTip, headOid) with the same result cache. The difference was which pulls each pass read. The green sweep has read every page since #4053, which is why its approved= tally still showed conflict=20.
What changed
- One helper for reading open pulls: the green sweep's own
openPullsmoved tosrc/core/pulls.tsaslistOpenPulls. It reads every page and returns the oldest first. - Three passes use it: the green sweep,
finishApproved, andsweepConflicts.sweepConflicts(the bring-up-to-date pass for unapproved crew pulls) had the same 50-pull cap. - Every early exit is logged: each time a pull is dropped before the logged branch,
finishing.tswritesfinishing: drop pull=N why="…". The reasons arenot the crew's,merged,not approved on its head,merges cleanandnot open. It says this once per pull, head and reason, through the same once-only map asfinishing: skip. - The pass's entry is logged:
finishing: pass read="open=N free=F"(orautonomy is off), whenever that changes. finishing=is always on the tick line: with no candidate it now readsfinishing="0 candidates of N open". To do that,finishApprovednow returns{ moves, open }andfinishFieldstakes the open count.- Docs:
docs/PLANNING.mddescribes the new field and log lines.
Tests
test/finishing-pages.test.ts(new) sets up one approved, conflicted pull with 50 newer ones and checks three things:
- it is brought up to date;
- the drop lines and the 0 candidates of 51 open field appear;
- a second pass logs nothing.
On the old code two of its three tests fail.
test/finishing-skips.test.tsonly readsmovesfrom the new return shape; what it asserts is unchanged.- 34 suites covering the sweeps, the tick and
listPullspass, except one test intest/crew-interrupted.test.ts("a young lock that blocks checkout is recovered by a fresh clone"), which fails the same way on the unchanged base in this sandbox.
Left out
- Logging a drop for every pull that isn't approved means about 100 lines the first time the pass runs after boot, then one per new head. I kept it at info level because the issue asks for every early exit.
- The
saidmap is still never pruned, as before. - The issue still carries the
Laterlabel. Live triage asks for a pillar before it moves, and that is a person's call.
How to try it: after deploy, the tick line for trevato/forge should show finishing="queued …" with conflicted pulls like #4394 counted, and finishing: skip or approved pull conflicts: brought up to date first lines for them.
🤖 Generated with Claude Code
The finishing pass never reached the 20 approved, conflicted pulls. It read only the 50 newest open pulls, and trevato/forge has more than 100 open, so an older one like #4394 was never read at all. None of the early continues was dropping them, and the conflict tests already matched: both passes use the same mergeable(dir, baseTip, headOid) with the same result cache. The difference was which pulls each pass read. The green sweep has read every page since #4053, which is why its approved= tally still showed conflict=20.
What changed
- One helper for reading open pulls: the green sweep's own
openPullsmoved tosrc/core/pulls.tsaslistOpenPulls. It reads every page and returns the oldest first. - Three passes use it: the green sweep,
finishApproved, andsweepConflicts.sweepConflicts(the bring-up-to-date pass for unapproved crew pulls) had the same 50-pull cap. - Every early exit is logged: each time a pull is dropped before the logged branch,
finishing.tswritesfinishing: drop pull=N why="…". The reasons arenot the crew's,merged,not approved on its head,merges cleanandnot open. It says this once per pull, head and reason, through the same once-only map asfinishing: skip. - The pass's entry is logged:
finishing: pass read="open=N free=F"(orautonomy is off), whenever that changes. finishing=is always on the tick line: with no candidate it now readsfinishing="0 candidates of N open". To do that,finishApprovednow returns{ moves, open }andfinishFieldstakes the open count.- Docs:
docs/PLANNING.mddescribes the new field and log lines. - Merge with main: this merges main after #6274. The one conflict, in
finishApproved's early return when autonomy is off, keeps both sides: this pull's entry log and{ moves, open }return, and #6274'snoteWaitingToFinish(app.db, …). After the merge the type check is clean and all five finishing and rebase-lane suites pass (22 tests).
Tests
test/finishing-pages.test.ts(new) sets up one approved, conflicted pull with 50 newer ones and checks three things:
- it is brought up to date;
- the drop lines and the 0 candidates of 51 open field appear;
- a second pass logs nothing.
On the old code two of its three tests fail.
test/finishing-skips.test.tsonly readsmovesfrom the new return shape; what it asserts is unchanged.- Before the merge, 34 suites covering the sweeps, the tick and
listPullspassed, except one test intest/crew-interrupted.test.ts("a young lock that blocks checkout is recovered by a fresh clone"), which fails the same way on the unchanged base in this sandbox.
Left out
- Logging a drop for every pull that isn't approved means about 100 lines the first time the pass runs after boot, then one per new head. I kept it at info level because the issue asks for every early exit.
- The
saidmap is still never pruned, as before. - The issue still carries the
Laterlabel. Live triage asks for a pillar before it moves, and that is a person's call.
How to try it: after deploy, the tick line for trevato/forge should show finishing="queued …" with conflicted pulls like #4394 counted, and finishing: skip or approved pull conflicts: brought up to date first lines for them.
🤖 Generated with Claude Code
Right root cause (the pass read only the newest 50 open pulls), fixed by sharing the green sweep's paged reader as listOpenPulls, with a test that fails on the old code. No UI surface; the change is server-side logs and tick line. Two things I would do next: (1) with autonomy off, finishApproved returns open: 0, so the tick line can read finishing="0 candidates of 0 open" for a repo that is not read at all; leave the field out in that case. (2) A drop line per non-approved pull is about 100 info lines after each boot and the shared said map is never pruned; consider debug level or a per-pass summary count once the 20 pulls are confirmed moving. Also, the new comment in autonomy.ts tick fields runs past the surrounding line width.
Finishing no longer freezes approved, conflicted pulls after one network blip. A blip used to count as a failed update, and two failed updates stopped the pass from queuing more runs for that pull. It did that without logging anything or handing the pull to a person. The pass's type check is clean and the 52 tests around finishing, conflicts, interruptions, the runner and landing pass. The forge's full check hasn't run yet.
What changed
- Only real attempts count (
src/crew/finishing.ts). An update run counts towardCHECK_FAILED_LIMITonly if it reached the agent and ended without a push. Runs the forge cut short no longer count: a network error, a clone, fetch or checkout that failed, a restart. This drops both of #4413's failures at 14:48 and 14:49. - The runner's own failure messages are now read as interruptions (
src/crew/interrupted.ts). Besidesclone failed:, this coverscheckout failed:,fetch failed:andfetch from the fork failed:. - A new main resets the count. Failed updates are counted from whichever is later: the last word from a person, or the commit time of the base branch's latest commit. That commit time is read with the new
committedAtinsrc/git/read.ts. - A broken kept clone is replaced (
src/crew/runner.ts). If git can't read a kept clone as a repository (its own.gitfailsrev-parse), the runner moves it aside to<dir>.broken-<time>rather than deleting it, logs that, and clones afresh. - The earlier commit on this branch stays. It logs the pass's entry and every drop with the pull number and the reason. It also always prints
finishing=on the tick line, even "0 candidates of N open", and reads every open pull, not just the newest fifty.
Already-frozen pulls need no migration. Their failed runs are now either interruptions or older than the latest main, so they no longer count, and the next ticks queue updates for the roughly 20 frozen pulls again.
Tests
- New
test/finishing-attempts.test.ts: a network error and a failed checkout leave the update queued again. Failures from before main moved don't count. Two real failures on the current base still hand the pull to a person. - New test in
test/crew-interrupted.test.ts: a kept clone missing.git/HEADis moved aside and cloned afresh. - I fixed one existing test that was wrong. The young-lock test reused the repository name
orchard, which an earlier test in the same app already creates, so it failed withname_takenon the base branch too. It now usescopse.
Not done: the broken clones that get moved aside are kept and never cleaned up.
🤖 Generated with Claude Code
A finishing pass now treats an update as counted only when it actually reached the agent, and it counts from when the base branch last moved. Without that, finishing could stay silent after #6239. Separately, a kept clone that git can no longer read as a repository (one a network blip cut short) is no longer reused and failing on every visit. The runner moves it aside to <dir>.broken-<time>, logs that it did, and clones afresh.
What to look at
src/crew/finishing.ts: how updates are counted.src/crew/runner.ts,prepareCloneand the newreadsAsClone: the moved-aside clone.readsAsClonepoints--git-dirat the clone's own.gitso git never finds a repository in a folder above it.test/crew-interrupted.test.ts: covers the re-clone. The young-lock test there now uses its own repository name; it reused "orchard", which an earlier test in the same app creates, so it failed withname_taken.
Test changed because it was wrong: test/crew-run-timing.test.ts, "a run whose clone cannot be prepared still ends with the line". It forced a failed clone by planting a .git folder that was not a repository. After this pull that clone is moved aside and the run recovers, so the setup no longer failed and the previous check went red. The test now plants a real repository whose config sets protocol.allow=never. That clone is reused, and its fetch is refused, which is the clone failure the test is about. What it checks (run failed, exactly one timing: line, last, ending in the clone lap) is unchanged. It and test/crew-interrupted.test.ts pass (15 of 15), and the type check is clean. Shards 9–11 of the last check exited 143 because they were stopped after the first red, not because tests failed in them.
What I chose not to do: a kept clone that holds commits never pushed is still left as it is rather than re-cloned, so nothing that lives only there is lost.
How to try it: bun test test/crew-run-timing.test.ts test/crew-interrupted.test.ts test/finishing-attempts.test.ts.
🤖 Generated with Claude Code
Approved. It does what #6263 asks, which is in the direction's flow-only list. The finishing pass now reads every open pull through one shared listOpenPulls, not just the newest 50, and sweepConflicts loses the same 50-pull cap. Every early exit is logged, and the tick line always carries finishing=. Runs the forge cut short (clone, fetch or checkout failed, network, restart) no longer count toward the hand-over. Counting starts again when the base moves. A kept clone that git can no longer read is moved aside and cloned afresh.
Security: nothing here touches user input, identity, request bodies, SQL built from text or the shell. committedAt passes the oid through assertBatchSafe and --. The failedUpdates query stays parameterised. Widening CLONE in interrupted.ts also changes stalls.ts, held.ts and the autonomy follow-up. That matches the intent: infra failures are not the issue's failures. The breaker still counts fetch failed: and checkout failed: runs, because it only excludes interrupted: %. So a fetch that fails every time parks the issue after three runs and does not loop.
Verified at f5057a15: bun test on finishing-attempts, finishing-pages, finishing-skips, crew-interrupted and crew-run-timing passes, 24/24. Both test fixes named in the pull are real fixes: orchard hit name_taken, and the planted non-repo .git is now recovered by design. The preview at this head answers / with 200. The change has no page or terminal surface; it is crew bookkeeping and logs.
Three low-severity points are anchored inline for a follow-up. None blocks landing.
The finishing pass never reached the 20 approved, conflicted pulls. It read only the 50 newest open pulls, and trevato/forge has more than 100 open, so an older one like #4394 was never read at all. None of the early
continues was dropping them, and the conflict tests already matched: both passes use the samemergeable(dir, baseTip, headOid)with the same result cache. The difference was which pulls each pass read. The green sweep has read every page since #4053, which is why itsapproved=tally still showedconflict=20.What changed
openPullsmoved tosrc/core/pulls.tsaslistOpenPulls. It reads every page and returns the oldest first.finishApproved, andsweepConflicts.sweepConflicts(the bring-up-to-date pass for unapproved crew pulls) had the same 50-pull cap.finishing.tswritesfinishing: drop pull=N why="…". The reasons arenot the crew's,merged,not approved on its head,merges cleanandnot open. It says this once per pull, head and reason, through the same once-only map asfinishing: skip.finishing: pass read="open=N free=F"(orautonomy is off), whenever that changes.finishing=is always on the tick line: with no candidate it now readsfinishing="0 candidates of N open". To do that,finishApprovednow returns{ moves, open }andfinishFieldstakes the open count.docs/PLANNING.mddescribes the new field and log lines.Tests
test/finishing-pages.test.ts(new) sets up one approved, conflicted pull with 50 newer ones and checks three things:- it is brought up to date; - the drop lines and the
0 candidates of 51 openfield appear; - a second pass logs nothing.On the old code two of its three tests fail.
test/finishing-skips.test.tsonly readsmovesfrom the new return shape; what it asserts is unchanged.listPullspass, except one test intest/crew-interrupted.test.ts("a young lock that blocks checkout is recovered by a fresh clone"), which fails the same way on the unchanged base in this sandbox.Left out
saidmap is still never pruned, as before.How to try it: after deploy, the tick line for trevato/forge should show
finishing="queued …"with conflicted pulls like #4394 counted, andfinishing: skiporapproved pull conflicts: brought up to date firstlines for them.🤖 Generated with Claude Code
Closes #6263