Skip to content

fix(scripts/pm): os-regen-merge marks the hand-off record and refuses a re-entrant rerun - #19447

Merged
os-steve merged 3 commits into
mainfrom
claude/issue-19392-regen-merge-rerun-not-reentrant
Sep 20, 2026
Merged

os-steve merged 3 commits into
mainfrom
claude/issue-19392-regen-merge-rerun-not-reentrant

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes #19392

Clause-②: no

The defect

scripts/pm/os-regen-merge.sh marks its record done at exactly one place, after step 3's commit has
succeeded. Step 3's refusal is a designed outcome — step 3's commit is an ordinary commit and the
os-regen pre-commit hook's refuse-stale path fires on it — and it exits before that write, while its
own 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_classify then gated rerun on containmentgit merge-base --is-ancestor RECORDED_BRANCH_TIP HEAD — which every later branch commit satisfies too. The next run therefore re-entered rerun, pinned
the 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 record
before 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. rerun asks 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 orphan and stale already do.

3. A sixth classification, advanced, with its own refusal. A record still reading pending whose
branch 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_stale and rr_refuse_orphan use. After
this 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

phase meaning HEAD still AT the recorded merge HEAD past it
pending step 1 recorded, step 2 not discharged rerun (unchanged) advanced — refused (new)
handoff step 2 discharged in the index, commit owed to the operator (new) rerun — that index was dropped, so step 2 is owed again discharged: settled / plain
done step 2 discharged and committed by the script settled / plain (unchanged) settled / plain (unchanged)

Why handoff and not done — the card offered either. Marking done reads as discharged in every
tree, including the one where the operator drops the staged index (git reset --hard) instead of
completing the commit. There step 2 is not discharged, and a done record would report "already
discharged" 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 advanced refusal.

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-commit hook printing the two
lines the header quotes from the real one). Same fixture script, two scripts under test: origin/main
23f1de0 and this branch's head. FLOWX is the operator's regeneration.

step origin/main 23f1de0 this branch
run 1 — the MIXED conflict exit 1, record phase=pending exit 1, record phase=pending
operator resolves and commits the merge
run 2 — the rerun, step 3's commit refused by the hook exit 1, record still phase=pending exit 1, record phase=handoff
operator does what that refusal says: clear the hook, regenerate, git add -A && git commit FLOWX in HEAD = 1 FLOWX in HEAD = 1
run 3 — the card's run exit 0, 1 × RERUN, 1 × TAKING main's side, FLOWX = 0 exit 0, 0 × RERUN, 0 × TAKING main's side, 1 × "already discharged", FLOWX = 1
same tree, record forced back to phase=pending n/a exit 1, the advanced refusal with the by-hand step 2, FLOWX = 1

Repro 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 plain arm):

leg both scripts
repro 2 — main moves the routed path again after a regeneration commit exit 0, 0 × RERUN, 1 × TAKING main's side, FLOWX = 0
control — main's next commit leaves the routed artifact alone exit 0, 1 × KEEPING the branch's bytes, FLOWX = 1

The 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 and
step 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:

  • Real need, measured. Repro 2 is step 2's designed both-sides arm, and the script does not lie
    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.
  • Long-term soundness. Every non-zero exit in this script today is a REFUSAL that stops the sequence
    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.
  • Making it harder for an AI to get wrong. The measured hazard on this very card is an operator or
    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.
  • Not spreading scope at the startup stage. No new exit-code vocabulary for one arm of one step.

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

  • Net +92 / -2 = +90 lines in scripts/pm/os-regen-merge.sh, exactly the dispatch's figure. The
    last ten lines were paid for by compressing prose this PR itself added (no pre-existing line was
    re-wrapped to buy room).
  • Scope held to the claim's file surface: one file, no merge driver, no ledger, no workflow.
  • skip-changeset: nothing under any package's files[] moves — scripts/pm/** is on AGENTS.md's fast
    lane and publishes nothing.
  • noted, not filed — the self-test in this file has neither of the two shapes AGENTS.md's
    "Writing a --self-test" section requires (a pinned battery-name floor, and a module-level handshake
    flag the dispatch refuses on). It counts st_fail and returns. That is pre-existing, orthogonal to
    this 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

…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>
@os-steve os-steve added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 20, 2026 — with Claude
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>

Copy link
Copy Markdown
Collaborator Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: aa5a2b71e32a6d200fe9c44dd16d0a2ffd148580

① Derived judgments

  • Published accept set / public surface: none moves — one PM landing tool (scripts/pm/os-regen-merge.sh, +93 / −3, net +90 against the ≤ +90 budget, spent exactly); ⛔ not the merge driver, ⛔ not the ledger, no workflow. check-governed-merges --pr 19447: NOT governed — ordinary queue landing. Judged correct.
  • The fix, read line by line: (1) rr_head_is_recorded_merge — HEAD IS the recorded merge iff HEAD^1 = the recorded pre-merge branch tip and HEAD^2 = the recorded main tip; rr_classify now enters rerun only while that holds, and a pending record whose branch has moved PAST its merge classifies advanced and is REFUSED by rr_refuse_advanced with the record printed and the by-hand step 2 against the RECORDED base — the same posture orphan / stale already take. (2) Step 3's refused commit writes the record handoff BEFORE exiting and prints, in the same breath, that step 2 is discharged in the index, that the sequence resumes at step 4, and ⛔ that re-running the script would redo step 2; the hand-off assertion's exit marks handoff too; rr_read_record accepts the phase. The dev's refinement over the seat's done-or-pending-handoff reading is correct and accepted: a done mark would read as discharged even where the operator DROPPED the staged index, so a distinct handoff phase plus the equality gate is what closes repro 1, and the body says so rather than leaving the seat's assumption standing. Repro 2 (step 2's designed both-sides arm after a regeneration commit) is deliberately not landed — see ③. Judged correct.
  • Evidence, seat-run on this head in a detached worktree: bash scripts/pm/os-regen-merge.sh --self-test exit 0 (127 cases; 122 on origin/main, the five new composite cases additive); merge-tree vs origin/main 57ceb9d: 0 conflicts; CI on aa5a2b7: 31 check runs, 1 still running, 0 red. The dev's reproduction is the card's own: repro 1 replayed on pristine 23f1de0 (run 3 exit 0, RERUN, TAKING main's side, FLOWX 1 → 0) and on this head (exit 0, no rerun, 「already discharged」, FLOWX 1 → 1); a pending record forced past its merge → exit 1 with the advanced refusal; repro 2 and the control byte-identical before and after (intended — the plain arm is untouched); self-test case 11 pins the whole sequence and 11b is the discriminating mutation (the containment gate restored by the same \Q..\E replacement the file's other mutation cases use → run 3 re-enters, exits 0, FLOWX gone). The 24-family derived union 24 / 24 exit 0 with --ran reconciled (a DERIVED zero), check:pm-dispatch-gates exit 0; two roster families NOT MEASURED (no dist), declared as such.

② Semver level

None — nothing published; skip-changeset on the PR is the correct declaration. Judged correct.

③ Boundary flags

One open_question, the seat rules it on the four axes as the dev recommends: repro 2's half — A (land neither half now) with B (a loud per-path notice when the discarded branch side post-dates the recorded merge, exit unchanged) as its own follow-up, ⛔ not C (a non-zero exit on a COMPLETED run would give exit 1 a third meaning in a file where every non-zero is a refusal, and would tempt the retry loop this PR closes). The seat files B as a p3 card if a second instance of repro 2's route is measured; today the incident behind this card is repro 1. All four mechanism assumptions confirmed (the case band at :2067 rather than :2074 — the seat's pointer slip, measured). Deviations read and accepted (gates run unlocked — no package build to serialise, the lock's own text excludes check:*; two roster families NOT MEASURED without artifacts; the PR body's +92 / −2 written once before the last commit, the net identical). Two noted, not filed observations with carriers (the file's --self-test lacks the battery floor and handshake flag AGENTS.md's section requires — pre-existing, the self-test census owns it; the origin/main pin window before step 1's merge — unreproduced, and this PR's equality gate makes that run refuse rather than redo).

Implemented-by: claude/issue-19392-regen-merge-rerun-not-reentrant
Reviewed-by: session_017ETYWqMQD4qMtZzAGovWNi

VERDICT: PASS


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/s skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants