fix(runner): stop a review going stale after you open it, and close three smaller holes - #41
Conversation
Five defects found while writing docs/lifecycle.md, all with regression tests that fail without the fix. fix(runner): keep the comments you wrote, whatever the run does (#37) The runner dropped every comment to `comments: []` before calling Claude and put the human ones back only on the success path — so a failed run took the user's own writing with it, permanently, and a successful one restored the versions it had read minutes earlier over the top of anything edited since. Now they are re-anchored onto the new diff *before* the run starts, so they are never off disk and are correctly anchored while it is in flight; and the result is folded onto what the artifact says now (`mergeRunResult`) rather than written over it. A comment edited mid-run keeps your version, one added is kept, one deleted stays deleted. Same discipline `mergeConcurrentEdits` already applied to a chat turn, for the same reason. fix(runner): compare against the head the AI read, not the one it mentions (#35) Opening a draft refreshes it, which moves `pr.headSha` onto the new head so the comments stay anchored — nothing is re-read. The freshness guard compared against that field, so merely looking at a draft after a push convinced it the draft was current and the poll never re-reviewed that PR again. Runs now record `run.reviewedSha` and the guard reads that; artifacts without one fall back. fix(server): refuse to send a draft a run is rewriting (#36) Send guarded only on `artifact.sent`, and the run then wrote `sent: null` back over the record, so the "already sent" guard would wave a second submission through. The clobbering is fixed above; both send paths now also refuse with a 409 while a run is in flight — the cockpit on the status and this process's own claim, `cerber send` on the status, which is all another terminal can see. fix(server): only settle statuses through the status endpoint (#39) PATCH accepted any ArtifactStatus and wrote it through, so it would set `status: "sent"` with no `sent` record — a row claiming a review reached GitHub that no honest path produces. It takes `reviewed` and `skipped` now. fix(cli): reconcile before anything polls, and never over a live run (#38) `serve` started the daemon (which polls at once) before awaiting reconciliation. Reconciliation is hoisted ahead of it, and also skips runs this process owns (`inUse`, the pattern `evictOldCheckouts` already uses) — ordering is a promise about wiring, this holds whatever order they start in. docs: `--no-source` is not a cap, and `failed` is retried automatically (#40) Also drops `carryOverComments`, which nothing called once the runner stopped carrying only on success; its behaviours are pinned on `mergeRunResult` instead. Closes #35, closes #36, closes #37, closes #38, closes #39, closes #40 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
🤖
|
🤖
|
| gate | result |
|---|---|
pnpm typecheck |
clean |
pnpm test |
455 passed / 32 files (was 433) |
pnpm build |
clean |
Every one of the 22 new tests was confirmed to fail without its fix by reverting each fix and re-running, not by assuming. Counts are in the PR body.
Two judgement calls the reviewer should weigh
carryOverCommentsis deleted. Nothing called it once the runner stopped carrying only on success. Leaving it would have been worse than dead code: it is named inCLAUDE.mdas the mechanism protecting human work, so a future reader would have taken it for the live guard. Its four behaviours are re-pinned onmergeRunResult.- serve starts the daemon before it reconciles leftover running artifacts #38 got more than its issue asked for. The issue proposed reordering startup. I did that and gave
reconcileRunninganinUsepredicate so it skips runs this process owns. Ordering alone is a promise about wiring and cannot be tested at the seam; the predicate holds whatever order things start in, and matches the patternevictOldCheckoutsalready uses. Say if you would rather have only the reorder.
Not in scope
#33 and #18 are still open. Both predate this work and neither was part of what #34 turned up.
Worth knowing
I found a gap in my own fix while reading the diff back: the failure path would have overwritten a skipped you set mid-run with failed — the same class of bug as #37, one path over. Fixed with the shared userOwnsStatus predicate and its own test before commit.
Reverses the #37 half of the previous commit at the maintainer's decision: losing draft comments across a re-review is acceptable, so cerber does not spend code protecting them. Dropped rather than half-kept. Carrying on success and losing on failure — the behaviour that shipped before this branch — costs the code and still loses the work, and it is the shape that made the bug confusing enough to need a test to prove. So the comments now go when the run starts and do not come back either way, and `docs/lifecycle.md` says so where a reader would otherwise assume otherwise, with advice to send or copy first. `mergeRunResult` stays, minus the comment merge: it is what keeps a send from being erased by a run that finishes after it (#36's other half, since reviewPr does not check for an in-flight send) and what stops a completing run reopening a `reviewed` or `skipped`. Those are decisions, not draft work. Tests invert with the behaviour: three now pin that a re-review replaces the comments on both the success and failure paths, so restoring carry-over is a deliberate act with a failing test rather than a quiet drift from the docs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
🤖 Scope changed after the maintainer decided against fixing #37, so two labels move with it.
On the linkage: the PR body no longer says |
|
🤖 Driving this PR: diagnose → classify → chain → report. Invoked with The lock is released on every exit path, including failure. |
|
🤖
The lock is retained at the end of this step, not released — the chain continues. |
Found by reviewing the PR against the code it describes — the same exercise that produced the branch, applied to the branch. - `cerber send` was said to refuse "with a 409". A CLI has no HTTP status; it prints the reason and exits non-zero. The cockpit is the one answering 409. - §6 listed what a successful run persists without `run.reviewedSha`, which is the field the whole freshness fix turns on. - §6 said a failure writes `failed`, which this branch made conditional: a send or a settle that landed while the run worked stands, with the error recorded beside it. - §4's numbered guard still said "head SHA unchanged", the phrasing the bug came from, two lines above the paragraph explaining why it is not that. Also gives the CLI's send refusal a recovery hint: "wait for it to finish" is unactionable when the run died mid-flight, so it now names re-running or restarting `cerber serve` as the way to clear a stale status. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jtomaszewski
left a comment
There was a problem hiding this comment.
🔍 Code Review
Verdict: approve — no blockers, no majors. Four minor/nit findings, all fixed in-loop in 0307042 because each was drift introduced by this branch itself rather than pre-existing work.
Submitted as a comment, not an approval: GitHub refuses APPROVE from a PR's own author, so the verdict below is the review's real conclusion and the review state is the closest thing the API allows. A second pair of eyes is still worth having on mergeRunResult.
✅ Validation gate
All three commands from .ai/agentic.config.json, in order:
| command | result |
|---|---|
pnpm typecheck |
clean |
pnpm test |
451 passed / 32 files |
pnpm build |
clean |
🧭 Scope and correctness
The diff does what the five linked issues describe, and the two that shared a root cause are fixed by one change rather than two patches. mergeRunResult is the centre of it and reads correctly: the run owns the draft, the user owns the decisions, and userOwnsStatus is applied on both the success and failure paths — a settle made mid-run is not reopened by a run that then errors, which is the kind of asymmetry this class of bug usually leaves behind.
Two design points I checked rather than assumed:
refreshis reset tonullby a successful run. Correct, not a leak: the artifact is now at the run's own diff, so a record of a previous re-anchor would describe a state that no longer exists.fresh.prwins overcurrent.pr. Safe, becausePOST /refreshreturns early on arunningartifact, sopr/diff/commentscannot desynchronise mid-run.
🐛 Findings
All four fixed in 0307042.
- minor —
docs/lifecycle.md:138— claimedcerber sendrefuses "with a409". A CLI has no HTTP status; it prints the reason and exits non-zero. Only the cockpit answers 409. - minor —
docs/lifecycle.md§6.3 — listed what a successful run persists withoutrun.reviewedSha, the field the entire freshness fix turns on. - minor —
docs/lifecycle.md§6.4 — said a failure writesfailed; this branch made that conditional onuserOwnsStatus. - nit —
src/cli/index.ts:183— "wait for it to finish" is unactionable when the run died mid-flight. Now names re-running or restartingcerber serveas the way to clear a stale status.
These matter more than their severity suggests: this document's stated contract is that every rule names the file it lives in so that code changes which outdate it are findable. A reference doc that drifts on the same branch that changed the code is the failure mode it exists to prevent.
🧪 Test coverage
Strong where it counts. Every new test was verified to fail without its fix by reverting each fix and re-running — reverting the reviewedSha guard fails its test, reverting the two server guards fails 4 of 7 guard tests. That is the difference between tests that pin behaviour and tests that merely pass.
One honest gap, recorded rather than fixed: src/cli/index.ts has no tests — neither the new send-refusal nor the serve reconcile ordering is covered. That matches the repository, which has no CLI test file at all, so adding the harness is out of scope for this PR. The reconcileRunning({ inUse }) half of the ordering fix is covered in src/core/state.test.ts, which is the half that carries the actual safety.
⚠️ Worth knowing
inFlight blocks Send on a stale running status, which is a new way to be stuck if a detached run dies without its catch handler firing. The recovery path exists — re-review resets the status, as does restarting serve — and the CLI message now says so. Rare enough not to hold the PR.
🔁 Backward compatibility
No breaks. run.reviewedSha is nullable with a default, so older artifacts load unchanged and fall back to the previous comparison. The PATCH allowlist is a deliberate narrowing; the cockpit only ever sent the two statuses it still accepts.
|
🤖 Autofix ran and fixed four minor/nit findings in Pipeline label moved Lock retained — chain continues. |
🤖
|
| step | outcome |
|---|---|
| diagnose | Matrix rows 2, 5, 9 matched. Rows 1, 3, 4, 6, 7, 10 skipped — reasons below. |
om-auto-fix-pr 41 --max-iterations 3 |
Base already current (merge-base == origin/main), so the mandatory base-merge was a no-op. One loop iteration; no CI stabilization needed. |
om-auto-review-pr 41 --autofix |
Verdict: approve. Four minor/nit findings, all fixed in-loop in 0307042. No blockers, no majors, so nothing was deferred and no follow-up issue was filed. |
om-approve-merge-pr 41 |
Stopped at step 3 — see below. |
What the review found
All four were drift this branch introduced into its own work, which is why they were fixed here rather than filed:
docs/lifecycle.mdclaimedcerber sendrefuses "with a409" — a CLI has no HTTP status; it prints and exits non-zero.- §6 listed what a successful run persists without
run.reviewedSha, the field the entire freshness fix turns on. - §6 said a failure writes
failed; this branch made that conditional onuserOwnsStatus. - The CLI's send refusal said "wait for it to finish", which is unactionable if the run died — it now names the recovery.
A reference doc drifting on the same branch that changed the code is precisely the failure it exists to prevent, so these mattered more than their severity suggests.
Gates
- CI: green on
0307042—testpassed,releaseskipped by design. No wait was needed, so theci.maxWaitMinutesbudget (20) is untouched.mainis not branch-protected, so there are no required checks; every reported check was treated as required. - QA:
needs-qais unmet and no evidence exists.qaGateisfalse, so it is advisory rather than blocking — recorded here rather than waved through. The diff touches noweb/file, so matrix row 7 (UI QA) never fired: there is no screen to capture. - Hard blocks: none — no
qa-failed,do-not-merge,blocked, or activeqa.
Why the merge did not happen
om-approve-merge-pr step 3 could not submit the approving review: GitHub refuses APPROVE from a PR's own author (Can not approve your own pull request). The skill's documented behaviour there is to report and ask rather than proceed straight to merge, and this is one of the gated human-decision cases the autopilot stops on.
That gate is worth honouring here rather than routing around, because merging would mean:
- landing this with no independent approval — the only review on it is mine, of my own code;
needs-qaunmet, with no QA evidence; and- an npm publish —
.github/workflowsrunssemantic-releaseon every green merge tomain, and these arefix:commits, so merging ships a patch release of@fullstackhouse/cerberto the public registry.
The PR is merge-ready on every mechanical signal (MERGEABLE / CLEAN). The decision left is a human one.
Labels are merge-queue, bug, needs-qa, priority-high, risk-medium; in-progress is released with this comment.
|
🎉 This PR is included in version 0.24.1 🎉 The release is available on: Your semantic-release bot 📦🚀 |
Closes #35
Closes #36
Closes #38
Closes #39
Closes #40
Also settles #37, closed as not planned — the decision is recorded there and explained under "The comment decision" below.
The problem
Open a drafted review, and cerber stops re-reviewing that PR. The author pushes, and the review you are looking at goes on describing code that has moved — for good. Nothing in the queue tells you it has gone stale, and no poll ever corrects it. This is the ordinary path, not a corner: you glance at a draft, decide it isn't urgent, and come back tomorrow to an opinion about yesterday's commits.
Three quieter faults sit alongside it. Sending a review while a run was in flight could post it to GitHub twice — a hole in the one rule the product is built on. The local API would record a review as sent to GitHub when nothing had been sent. And starting the server raced its own first poll, so two writers could land on the same leftover row after an unclean shutdown.
All four were found by verifying
docs/lifecycle.mdagainst the code it describes, in #34.The fix
The stale-review bug came from a field meaning two different things. The freshness guard asked "is this draft current?" by comparing the head commit recorded on the artifact against the PR's head — but opening a review refreshes it, which moves that field forward so the comments stay anchored to current code. Nothing is re-read at that moment. The guard was comparing the new head against itself.
Runs now record
run.reviewedSha— the commit the AI actually read — and the guard compares against that instead. Opening a review no longer counts as reviewing it.The double-send had its own cause: the runner replaced the artifact wholesale when it finished, putting
sent: nullback over a send that had landed meanwhile, after which the "already sent" guard waved a second submission through. The runner now folds its result onto what is on disk, and both send paths refuse while a run is in flight.Details
src/core/refresh.ts—mergeRunResultfolds a finished run onto the current artifact instead of overwriting it: the run owns the draft it regenerated, the user owns the decisions.userOwnsStatusis the shared predicate for that, applied on the success and failure paths, so a settle made mid-run is not reopened by a run that then errors.carryOverCommentsandhumanCommentsare removed — nothing carries comments across any more.src/runner/review.ts— recordsreviewedShaon success; the guard falls back to the old comparison when it is absent.src/server/index.ts— Send answers409while a run is in flight, checking the persisted status and this process's in-memory claim, because neither alone sees both a run in another terminal and one started here moments ago.PATCH /api/reviews/:keyaccepts onlyreviewedandskipped.src/cli/index.ts—cerber sendrefuses on arunningartifact, going on the status alone, which is all another terminal can see.servereconciles leftover runs before starting anything that polls.src/core/state.ts—reconcileRunningtakes aninUsepredicate and skips runs this process owns. The reorder above fixes the race by wiring; this fixes it by construction, whatever order things start in. It reuses the patternevictOldCheckoutsalready established.src/core/artifact.ts—run.reviewedSha, nullable with a default.The comment decision
#37 reported that a re-review destroys comments you wrote. It does — and after review that is now intended behaviour rather than a bug: a re-review regenerates the whole draft, your comments included, on success and on failure alike.
The alternative was carrying them across, which is what shipped before this branch: carry on success, lose on failure. That is the worst of the three options — it costs the code and still loses the work, and being right half the time is what made the bug hard enough to need a test to prove. Given that protecting the comments was not wanted, dropping the carry-over entirely is the honest version: one rule, true on every path.
docs/lifecycle.mdnow states it plainly, with the advice that follows — send or copy anything you want to keep before pressing re-review.Three tests pin it, so restoring carry-over would be a deliberate act with a failing test rather than a quiet drift from the docs.
Verification
pnpm typecheck,pnpm test(451 passed / 31 files, up from 433 measured on this branch point) andpnpm buildall clean; CI green.Every new test was confirmed to fail without its fix, by reverting each fix and re-running rather than assuming: reverting the
reviewedShaguard fails its test, and reverting the two server guards fails 4 of 7 tests inguards.test.ts.src/runner/review-merge.test.ts(new) drives a real run end to end withclaudestubbed, acting on the artifact mid-run as a user would — a send is not undone, a settle is not reopened on either the success or failure path, the recorded head is the one read, and the draft is replaced whichever way the run ends.src/server/guards.test.ts(new) covers both send refusals, separating the cross-process case from the in-process one, and the status allowlist.src/runner/review.test.ts— a draft opened after a push is still re-reviewed; one whose recorded review is of this very head is not.src/core/state.test.ts— reconciliation leaves a run this process owns alone.Not covered:
src/cli/index.tshas no tests, so neither the CLI's send refusal nor theservereconcile ordering is exercised. That matches the repository, which has no CLI test harness at all; adding one is out of scope here. The half of the ordering fix that carries the actual safety —reconcileRunning({ inUse })— is covered.Breaking changes
Nothing in the artifact contract:
run.reviewedShais nullable with a default, so artifacts written before it load unchanged and fall back to the previous comparison.Two behaviour changes worth naming:
PATCH /api/reviews/:keyrejects any status butreviewedandskippedwith a400. The cockpit only ever sent those two.Follow-ups
None outstanding from this PR. The review pass found four minor items and all were fixed in
0307042— three lines indocs/lifecycle.mdthat this branch's own changes had outdated, plus an unactionable error message in the CLI — so nothing was deferred to an issue.