fix(scripts/pm): os-regen-merge marks the hand-off record and refuses a re-entrant rerun - #19447
Conversation
…e-entering rerun Step 3's commit refusal exits before any record write, so the record stayed `pending` on the one path that finishes step 3 outside the script. `rr_classify` gated `rerun` on containment, which every later branch commit satisfies, so the next run redid step 2 against the pre-merge base and committed a revert of the operator's own regeneration — exit 0, nothing refused. Two closures: the refusal now marks the record `handoff` (step 2 discharged in the index, commit handed to the operator) and says re-running would redo step 2; and `rerun` now requires HEAD to BE the recorded merge, with a new `advanced` refusal for an unmarked record whose branch has moved past it. Claude-Session: https://claude.ai/code/session_017ETYWqMQD4qMtZzAGovWNi Co-authored-by: Claude <noreply@anthropic.com>
…self-test Case 11 replays the card's sequence on the rerun fixture — conflict, hand resolution, a refused step-3 commit, the by-hand hand-off commit — and pins that the record reads `handoff`, that the next run reports the sequence discharged without re-entering `rerun`, and that the regeneration (`FLOWX`) survives. It also pins the new refusal for a record nothing ever marked. Case 11b puts the containment gate back by literal replacement and replays run 3 in a copy of that tree: the run re-enters `rerun`, takes main's side back and step 3 commits the revert with exit 0 — the card, reproduced on demand. Claude-Session: https://claude.ai/code/session_017ETYWqMQD4qMtZzAGovWNi Co-authored-by: Claude <noreply@anthropic.com>
The uncommitted-regen-path refusal is the second exit that leaves step 2 discharged — committed, in that one's case — and it also left the record `pending`. With the rerun now gated on equality that would refuse the next run instead of redoing step 2, which is safe but points the operator at the wrong remedy. Marked `handoff`, the next clean-tree run reports the sequence discharged and resumes at step 4. Claude-Session: https://claude.ai/code/session_017ETYWqMQD4qMtZzAGovWNi Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: ① Derived judgments
② Semver levelNone — nothing published; ③ Boundary flagsOne Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #19392
Clause-②: no
The defect
scripts/pm/os-regen-merge.shmarks its recorddoneat exactly one place, after step 3's commit hassucceeded. Step 3's refusal is a designed outcome — step 3's commit is an ordinary commit and the
os-regenpre-commit hook'srefuse-stalepath fires on it — and it exits before that write, while itsown instruction finishes the commit outside the script. So the one path that hands step 3 to the
operator was the one path on which the record could never be marked.
rr_classifythen gatedrerunon containment —git merge-base --is-ancestor RECORDED_BRANCH_TIP HEAD— which every later branch commit satisfies too. The next run therefore re-enteredrerun, pinnedthe base back to the recorded pre-merge shas, redid step 2, and the branch bytes it discarded were the
operator's own regeneration commit, made one commit earlier on this script's own instruction. Step 3
committed that revert. Exit 0, nothing refused.
What lands
1. The hand-off path marks the record —
phase=handoff. Step 3's refusal now writes the recordbefore it exits: step 2 IS discharged in the index at that instant, and the commit is the operator's.
The refusal says so, and says in the same breath that re-running the script would redo step 2 over
whatever that hand-off commits.
2.
rerunasks equality, not containment. A new one-line predicate,rr_head_is_recorded_merge,asks whether HEAD IS the recorded merge — a merge commit whose first parent is the recorded pre-merge
tip and whose second is the main tip that was merged. Branch commits past that merge mean step 2 was
discharged by somebody, and the answer is a refusal, as
orphanandstalealready do.3. A sixth classification,
advanced, with its own refusal. A record still readingpendingwhosebranch has moved past the recorded merge is refused with the record printed and the by-hand step 2
computed off the recorded base — the same register
rr_refuse_staleandrr_refuse_orphanuse. Afterthis PR that state is reachable only for a record nothing ever marked (a run killed after step 1, or one
written before the marking existed); it is the safety net, not the ordinary path.
The record's phase vocabulary
pendingrerun(unchanged)advanced— refused (new)handoffrerun— that index was dropped, so step 2 is owed againsettled/plaindonesettled/plain(unchanged)settled/plain(unchanged)Why
handoffand notdone— the card offered either. Markingdonereads as discharged in everytree, including the one where the operator drops the staged index (
git reset --hard) instead ofcompleting the commit. There step 2 is not discharged, and a
donerecord would report "alreadydischarged" and exit 0 over a side the driver dropped — the same class of silent loss this card is about.
A distinct phase plus the equality gate answers both trees. One consequence, stated because the card
assumed otherwise: in this design hole 1 is not independently closing. The equality gate is what
closes repro 1; the marking is what makes the post-fix run exit 0 with "already discharged" instead of
the
advancedrefusal.Measurements
Repro 1 rebuilt as a standalone fixture (the card's shape: real merge-driver behaviour keyed per path —
CONFLICTS on the MIXED path, defers the other with exit 0 — plus a
pre-commithook printing the twolines the header quotes from the real one). Same fixture script, two scripts under test:
origin/main23f1de0and this branch's head.FLOWXis the operator's regeneration.origin/main23f1de0phase=pendingphase=pendingphase=pendingphase=handoffgit add -A && git commitFLOWXin HEAD = 1FLOWXin HEAD = 1RERUN, 1 ×TAKING main's side,FLOWX= 0RERUN, 0 ×TAKING main's side, 1 × "already discharged",FLOWX= 1phase=pendingadvancedrefusal with the by-hand step 2,FLOWX= 1Repro 2 and the card's control, also rebuilt standalone, run against both scripts — identical readings
before and after, which is the intended no-regression result (this PR does not touch the
plainarm):RERUN, 1 ×TAKING main's side,FLOWX= 0KEEPING the branch's bytes,FLOWX= 1The script's own self-test carries all of this as cases 11 and 11b: 122 cases before, 127 after, exit 0
(five new composite cases, thirteen readings). Case 11b is the discriminating mutation in the register
6b / 8b / 9b / 10b already use — it puts the containment gate back by literal replacement and replays run
3 in a copy of case 11's tree one commit earlier: the run re-enters
rerun, takes main's side back andstep 3 commits the revert with exit 0,
FLOWX= 0. The card, reproduced on demand.Repro 2 is NOT addressed here — recorded as an open question
The card's cheaper half for repro 2 is "a distinct per-path notice and a non-zero exit". The notice
stays inside this file's register; the non-zero exit does not, and the two were offered as one package:
about it — its notice already says step 4's regeneration re-derives the content, and it does. The
measured incident behind this card (the round-7 instance on PR fix(spec,objectql): declare the inert-JSON artifact and registry-record package body stages, and stop the record under-reporting functions #19373) is repro 1, not repro 2.
before it completes. A completed run — merge committed, step 4 printed — exiting non-zero is
indistinguishable, to anything reading only the code, from a refusal. That is a third meaning for
exit 1 and a contract muddle; a "completed but owing" signal needs its own code and a declared
vocabulary, which is bigger than this card.
agent who reacts to a non-zero by re-running the script — the loop this PR closes. An ambiguous exit
code makes that more likely; a loud per-path notice is the register the file already uses.
Secondary, and stated so the seat can weigh it: the PM's net-line budget for this card (net ≤ +90 lines,
spent exactly) could not hold the notice half plus the fixture that would pin it, so it is reported
rather than half-landed.
Acceptance notes
+92 / -2= +90 lines inscripts/pm/os-regen-merge.sh, exactly the dispatch's figure. Thelast ten lines were paid for by compressing prose this PR itself added (no pre-existing line was
re-wrapped to buy room).
skip-changeset: nothing under any package'sfiles[]moves —scripts/pm/**is on AGENTS.md's fastlane and publishes nothing.
"Writing a
--self-test" section requires (a pinned battery-name floor, and a module-level handshakeflag the dispatch refuses on). It counts
st_failand returns. That is pre-existing, orthogonal tothis card, and out of its net-line budget. Successor: whoever next touches this file's self-test, or a
sweep driven by
scripts/measure-self-test-floor.mjs.Generated by Claude Code