fix(ship): the runner trusts the review child's own record of its post — a review posted a second ago no longer reads as unposted, and GitHub is asked patiently only when the record is silent - #1013
Conversation
…t — a review posted a second ago no longer reads as unposted, and GitHub is asked patiently only when the record is silent
The review post-step ran after the reply and the seal that writes the run
record, so the record — and the finish event that wakes a plan runner within
a second of it — always predated the GitHub post. The runner's read-record
then asked GitHub's review list about a review posted a second earlier, read
false, and aborted a unit whose approval stood on the pull request ten
seconds later.
The post-step now runs inside the run loop, before the stream finishes, in
the coding post-step's position, and records how it ended: a landed post is a
`review_posted` event and `reviewPost: { posted: true, target, head, verdict }`
on the record; a skip or a failure is a `review_not_posted` note and
`reviewPost: { posted: false, reason }`. RunsService overlays the fact onto a
finished row like the verdict. read-record answers reviewPosted from that
fact — true at the reviewed head on the unit's pull request, false with the
recorded reason for a skip — and asks GitHub only when the record is silent,
then looks up to three times two seconds apart before answering false. The
merge step still re-verifies the approval at the head, so the pre-check may
be patient while that guard stays strict. The unit's aborted report carries
the child's reason and says how to continue in the runner's words.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
There was a problem hiding this comment.
LGTM: Correct fix for the review-posted race: the post-step moves inside the run loop so the record carries the post as a fact, the runner trusts the record and only asks GitHub patiently when it's silent — thoroughly tested and spec-updated in the same diff.
- [nit] F1 src/core/dispatch/runLoop.ts:723 — An unexpected throw from the post-step now fails the whole run (it runs inside the loop's try), unlike the old best-effort position
- [nit] F2 src/channels/adminCoordinator.ts:633 — read-record's patient loop can hold the step ~4s per call when GitHub stays silent — fine today, worth a note on the step's latency budget
Verdict: approve — the race fix is sound, the record-first design is well-guarded, and the tests cover every path (posted, skipped, GitHub-lagging, stale record, silent GitHub).
What the change does, verified:
runReviewPostStepmoves fromafterReply(after the seal/record) into the run loop, before the stream finishes — mirroring the coding post-step — so the finish record carriesreviewPost(review_postedevent /review_not_postednote).dispatcher.test.tsproves ordering (record:interrupted → post → record:completed).read-recordanswersreviewPostedfrom the child's record when it matches the unit's PR, head, and verdict (reviewPostedByRecord, usingsameCommit); a stale/mismatched record falls back to GitHub. With no record it asks GitHub up to 3 times, 2s apart (REVIEW_POSTED_CHECKS/REVIEW_POSTED_RECHECK_MS), silent → unknown, never a guess.ReviewPostis shape-checked on read (isReviewPostShape), the skip reason redacted at the record seam, and the ship coordinator's abort message now carries the recorded reason. Specs (agent-review item 18, http-ingress item 9, run-history, run-visibility, tracing) are updated in the same diff with validation rows bound to the new tests.
Findings (both nits, non-blocking):
- F1
src/core/dispatch/runLoop.ts:~723— the step now runs inside the loop'stry, so an unexpected throw (e.g. from an injectedpublish) would mark the run failed, where the oldafterReplyposition was after the record landed. In practice every risky call inside the step is caught (posttry/catch,.catch()on replies/fetch), so this is theoretical. - F2
src/channels/adminCoordinator.ts:~633— the patient loop can hold aread-recordcall ~4s (3 fetches + 2 sleeps) when GitHub stays silent; fine for the runner's cadence, just worth remembering if the step ever gets an HTTP timeout.
Test-guard dispositions: the four reply.test.ts check lines and the runsService.test.ts rename are allowed by same-diff spec changes; the coordinator.test.ts retitle ("an approve whose post did not land…") is a refactor with verification strengthened (the new title adds assertions on the recorded reason and re-issue wording) — intact. The reply.test.ts coverage removed there is re-proven end-to-end in dispatcher.test.ts and reviewRound.test.ts, per the deleted tests' own comment.
|
Review round 1 at 14b514c: LGTM with two nits. Gate is
Not merging: the live row on #835 (a re-issued plan whose review child's approval is read from its record within a second of the finish) is owed after deploy. |
There was a problem hiding this comment.
No verdict submitted — not approving.
Noted — nothing further from me. Both dispositions match my read: F1's "never throws" contract holds (every risky call inside the step is caught, and the coding post-step's identical placement is precedent), and F2's ~4s worst case is bounded and safe given the workflow step's retry posture. Round 1 stands as approved at 14b514c0; makes sense to hold the merge until the live row on #835 is in hand post-deploy.
A plan runner aborted a unit because it asked GitHub whether the review it had just been told about was posted — one second after the post, before GitHub's review list showed it. The review run now records its own post as a fact on its run record, the runner reads that fact, and GitHub is asked (patiently) only when the record has nothing to say.
What & why
Defect 6 of #992 (the runner's
[agent]rows on #835 stay owed until a plan carries units through review to merges the runner made). Live on #1004: the review child finished at 01:55:39Z, the finish event woke the runner at 01:55:40Z, GitHub stamped the LGTM at 01:55:41Z, andread-recordat 01:55:41–43Z asked GitHub's review list, readfalse, andsettleReviewaborted the unit — the approval could not be posted — while the approval stood on the pull request and auto-approve followed at 01:55:51Z.The root cause is structural, not a timing accident: the review post-step ran in
afterReply, after the seal indeliverAnswerthat writes the run record and fires the finish event. Every record a runner read therefore predated the GitHub post by design; the chunked waits from defect 1's fix only made the runner fast enough to notice.Three changes, each with red-first tests:
registry.finish, in the coding post-step's position. A landed post publishes areview_postedevent and rides the record asreviewPost: { posted: true, target, head, verdict }; a skip or failure publishes areview_not_postednote and rides asreviewPost: { posted: false, reason }.RunsServiceoverlays the fact onto a finished row from the store like the verdict, so a reader woken by the finish sees it.read-recordprefers the record. The fact answersreviewPosted: truewhen the child posted this verdict at the reviewed head to the unit's pull request, andfalsewithreviewPostReasonfor a recorded skip — GitHub never asked. Only a silent record (an older child, or a fact naming another head/verdict/pull request) asks GitHub, and then looks up to 3 times 2 s apart before answeringfalse.agent:ship… and include the PR URL). The old Re-run ship with the pull request URL sentence is gone.The merge step's guard is untouched: it still re-verifies the approving review at the head on GitHub before the squash. The pre-check can afford patience because that guard stays strict.
Tour
1.
read-recordasks the record first, GitHub only when it is silentThe seam that failed live. The child's own record decides when it can; the GitHub look happens only when the record says nothing, and it is the patient one. The answer gains
reviewPostReasonwhen the record recorded why nothing was posted.Look for: the fallback is entered only when
reviewPostedByRecordanswersundefined— a recordedfalseis final, never re-asked of GitHub.switchboard/src/channels/adminCoordinator.ts
Lines 727 to 747 in 14b514c
2. What the record is trusted to say
reviewPostedByRecordtrusts a posted fact only when it names the unit's pull request, the reviewed head and the same verdict kind — anything else falls through to GitHub. A recorded skip isfalsewith its reason.switchboard/src/channels/adminCoordinator.ts
Lines 691 to 704 in 14b514c
3. The patient GitHub look
Three looks, two seconds apart, stopping at the first
true. The last look's answer stands:falsewhen every look found nothing,undefined(absent from the answer, never a guess) when every look was silent.sleepis injectable so the tests record the pauses instead of waiting.switchboard/src/channels/adminCoordinator.ts
Lines 628 to 637 in 14b514c
switchboard/src/channels/adminCoordinator.ts
Lines 669 to 684 in 14b514c
4. The post-step moves into the run loop
The structural half. The step now runs after the canonical
answeris published and before thefinallyfinishes the stream — soreview_postedreaches the registry andreviewPostreachesregisterFinishRecord. Only a review run reaches it; every other run's stream is unchanged (the spanrun.review_post_stepjoins the streamed table as a finishing-up span, likerun.pr_post_step).Look for:
hardStopped: falseis not a lie — the gate above it excludes hard stops, so a hard-stopped review posts nothing and records nothing, as before.switchboard/src/core/dispatch/runLoop.ts
Lines 708 to 727 in 14b514c
5. The step records how it ended
Every exit of
runReviewPostSteppasses throughrecord: a landed post becomes thereview_postedevent with the pull request, the pinned head (the carried head after a rebase) and the verdict kind; every skip and failure becomes areview_not_postednote carrying the reason the outcome carries. Non-review and hard-stopped rounds publish nothing; without thepublishseam the outcome is simply returned.switchboard/src/core/reviewRound.ts
Lines 589 to 615 in 14b514c
6. The typed fact and its event
ReviewPostis the outcome type and the record field, with a shape check for read-back and redaction of the skip's reason (it may carry GitHub's own words).ReviewPostOutcomebecomes its alias, so the in-process ship loop's caller keeps compiling.switchboard/src/core/reviewVerdict.ts
Lines 262 to 272 in 14b514c
switchboard/src/core/runEvents.ts
Lines 449 to 469 in 14b514c
7. The machine's ending
The abort reason carries the child's recorded reason when there is one and states the fact; how to continue comes from
renderUnitReport's unit-aware re-issue line, so a plan unit reads the unit runs again when the plan is re-issued and a task unit reads theagent:ship+ PR URL form.switchboard/src/core/ship/coordinator.ts
Lines 724 to 741 in 14b514c
8. Tests — the record-first read and the patient fallback
GitHub shows no review; the record says posted →
true, zero GitHub fetches, zero sleeps. Then: the record says skipped →falsewith the reason, GitHub never asked even though it shows a matching review. Then the fallback: the list surfaces the review on the third look →trueafter 3 fetches and 2 pauses ofREVIEW_POSTED_RECHECK_MS; never shown →falseafter 3; silent → absent.switchboard/src/channels/adminCoordinator.test.ts
Lines 1282 to 1306 in 14b514c
switchboard/src/channels/adminCoordinator.test.ts
Lines 1376 to 1399 in 14b514c
9. Tests — the record is written after the post
End to end through
dispatch(): the store sees the start tombstone, then the GitHub post, then the finish record carryingreviewPost— the ordering that was impossible before this change.switchboard/src/core/dispatcher.test.ts
Lines 2015 to 2040 in 14b514c
10. Tests — the step's fact and the machine's words
switchboard/src/core/reviewRound.test.ts
Lines 589 to 599 in 14b514c
switchboard/src/core/ship/coordinator.test.ts
Lines 639 to 659 in 14b514c
11. Specs
agent-review.md gains item 18 (the post as a recorded fact) with two bound rows; run-history.md items 2 and 21 name
reviewPostand the rows bind the new tests; http-ingress.md item 9'sread-recordgains one sentence on the record-first answer and the patient fallback, and the row's criterion says the same; tracing.md item 18 moves the span from the log-only tail to therun.*list; run-visibility.md counts six side facts; live-view.md's parenthetical — the record carries no fact about whether a verdict reached GitHub — was made false by this change and now says the record carries it and the page does not read it yet.switchboard/docs/reference/specs/agent-review.md
Line 41 in 14b514c
12. Remaining changes
src/core/dispatch/reply.ts—afterReplyis the reflection pass only (sync now);ReplyDepslosespostReviewComment/fetchPrHead;activityLinegains thereview_postedcase.src/core/dispatch/run.ts—RunDepsgainspostReviewComment(the seam the run loop reads;CoreDepsshape unchanged).src/core/dispatcher.ts— passesrequestTextto the loop; the trimmedafterReplycall;RunOutcomedestructuring shrinks.src/core/dispatch/runLoop.ts—RunOutcomelosesverdict/digest/observedHead/carried(nothing outside the loop read them once the post-step moved in);RunLoopContext.requestText.src/core/dispatch/record.ts—assembleRunRecordandregisterFinishRecordcarryreviewPost, the skip's reason redacted there.src/core/runRecord.ts—RunRecord.reviewPost, validated byisReviewPostShape; the summary row (RunListItem) inherits it, so the state Worker'ssummary_jsoncarries it with no schema change.src/core/runsService.ts—RunView.reviewPost;storedArtifactsoverlays it.src/core/runEvents.ts— thereview_not_postednote kind beside the event.src/core/runEventLines.ts,src/core/runFriction.ts— the analyzer accepts the event and counts it as a side fact.src/core/trace/streamSpans.ts,src/core/trace/displayNames.ts—run.review_post_stepstreamed as finishing-up, "posting the review".src/core/coordinator/driver.ts— passesreviewPostReasonthrough to the machine.src/core/ship/coordinator.ts—ChildFacts.reviewPostReason.src/core/dispatch/reply.test.ts— theafterReplytests keep only the reflection pass (the post-step tests live in the run loop's end-to-end tests now);src/core/dispatch/runLoop.test.ts— ctx gainsrequestText.src/core/reviewVerdict.test.ts,src/core/runRecord.test.ts,src/core/dispatch/record.test.ts,src/core/runsService.test.ts— the shape, the record, the assembly and the overlay carryreviewPost.src/core/ship/reviewChild.test.ts— twotoEqual({ posted: true })becometoMatchObject(the outcome now carries the target, head and verdict); this file is on the parallel ship-loop removal's deletion list.docs/reference/specs/run-visibility.md,docs/reference/specs/live-view.md,docs/reference/specs/tracing.md,docs/reference/specs/run-history.md,docs/reference/specs/http-ingress.md— as in step 11.Decisions
registry.finish; delaying the seal would have left the fact as a record-only field with no stream event and no card/page witness. Moving the step also makes the two post-steps symmetric and retires the "deliberately after the registry finish" caveat that had only ever been a zero-behavior-change extraction constraint.RunsServiceoverlay, like the verdict) and survives event-budget truncation.pr_openedis event-only today, which is whyread-recordreads it from events; the verdict artifacts set the other precedent, and the runner's read follows that one.prNotepattern) would have changed a pinned Slack surface and several assertions for no gain; separate replies keep every existing note test valid.reviewPost: { posted: false, reason: "no PR post was intended" }would be noise; a hard-stopped review's abort is its record's story.maintoo (there was no fact to distrust); it guards the implementation against trusting a stale fact rather than pinning the defect, and is labelled as such below.reviewChild.test.tsis touched minimally. The in-process ship loop is being deleted in a parallel PR; two assertions loosen soverifyis green here without widening the conflict.runTimeline's default) and does not readreviewPost; live-view.md says so rather than pretending.Validation
Red first against
origin/main: 19 tests failed for the right reasons (missing exports, the old{ posted: true }outcome shape, the old ending text); no red log is attached — the green CI run on this PR is the receipt for each row below.review_postedwith the pull request, the pinned head (the carried head after a rebase) and the verdict; the outcome carries the same facts[unit]src/core/reviewRound.test.ts::runReviewPostStep (explicit AgentDef decides the post)::the post as a recorded fact (item 18)::*teston this PRreview_not_postednote with the reason; no pull request records the skip without a target; non-review and hard-stopped rounds publish nothing; nopublishseam → same outcometestreviewPostredacted and validates;isRunRecordaccepts posted and skipped shapes after a JSON round-trip and refuses malformed ones; the stored shape refuses a posted outcome without target/head and a skip without a reason[unit]src/core/dispatch/record.test.ts::…::carries the review post…,src/core/runRecord.test.ts::isRunRecord — the review's verdict and head…::accepts the review post…,src/core/reviewVerdict.test.ts::the stored review post — isReviewPostShape and its redaction::*testreviewPostfrom the store's summary row[unit]src/core/runsService.test.ts::RunsService.getRun::a finished run still in the registry carries verdict, reviewHead, reviewPost, dispositions and handoff…testread-recordanswersreviewPosted: truefrom the fact with zero GitHub calls; a recorded skip answersfalse+reviewPostReasonwith zero GitHub calls[unit]src/channels/adminCoordinator.test.ts::…::read-record answers reviewPosted from the child's recorded post…,::read-record answers a recorded skip…testREVIEW_POSTED_RECHECK_MSapart, firsttruewins; never shown →falseafter 3; silent GitHub → absent[unit]src/channels/adminCoordinator.test.ts::…::with no recorded post (an older child) GitHub is asked up to three times…testmaintoo, pins the fallback not the defect)[unit]src/channels/adminCoordinator.test.ts::…::a recorded post at another head, with another verdict or on another pull request is not trusted…testreviewPostand thereview_postedevent; the store receives the tombstone, then the post, then the finish record; a guard-refused post is recorded as a skip beside areview_not_postednote[unit]src/core/dispatcher.test.ts::review post-step::head-moved note (item 10)::the review run's record carries…,::the record is written AFTER the post…,::a post the reviewed-head guard refused is recorded as a skip…testagent:ship+ PR URL form[unit]src/core/ship/coordinator.test.ts::the unit pipeline — every ending…::an approve whose post did not land…,::an approve GitHub shows no post for…test[unit]src/core/dispatcher.test.ts::review post-step::*,::no gaps…::*testspecs:check(2703 proofs),specs:coverage --changed HEAD --test-guardclean;npm run verifygreen locally (368 files / 6790 tests, lint, format, typecheck, PR title check)[gate][agent]re-issue2026-09-14-001-feat-title-gate-in-the-child-contract-planafter deploy🤖 Generated with Claude Code