Skip to content

os-regen-merge.sh: when step 3's commit is refused the record stays pending, so a later run re-enters rerun and COMMITS a revert of the operator's regeneration — exit 0, no refusal #19392

Description

@os-litant

Path: none | instrument (scripts/pm/os-regen-merge.shrr_classify :629–:660 and step 3's refusal path :1135, the rerun arm) | (a) reproducible, two fixtures with a lit control | graded by domain:skills#2

Class (a) — a reproducible defect, with two independent repros this seat built and ran.

Dedupe words: os-regen-merge, rerun, phase=pending, step 3 refused, regeneration commit.

⚠️ This body was rewritten at 2026-09-20T15:44Z, minutes after filing. The first version named the plain classification as the route. That was this seat's fixture reproducing the loss by a second route, and it is kept below as repro 2 — but it is not how the reported incident happened, and it is not the root cause. The root cause is in the rerun arm, measured below as repro 1. The correction is recorded in a comment on this card.

Root cause

scripts/pm/os-regen-merge.sh writes its record done at exactly one place, :1164, after step 3's commit has succeeded. Step 3's refusal path exit 1s at :1139, before it:

    if ! git commit --no-edit -m "merge origin/main (os-regen artifacts taken from main; regeneration follows)"; then
      echo "✗ step 3's commit was refused — the merge is staged but NOT committed." >&2
      ...
      echo "  Clear what the hook reported, then \`git add -A && git commit\` before step 4." >&2
      exit 1
    fi

That refusal is a designed outcome, not a malfunction: the script's own header documents it, because step 3's commit is an ordinary commit and the os-regen pre-commit hook's refuse-stale path fires on it. The instruction it prints — "Clear what the hook reported, then git add -A && git commit before step 4" — finishes step 3 outside the script, which is the only path on which the record can never reach done.

And rr_classify gates the rerun arm on containment, not equality:

    if ! git merge-base --is-ancestor "$rr_rec_branch_tip" HEAD 2>/dev/null; then
      echo stale

so any number of later branch commits still route to rerun. rerun then pins merge_base/branch_tip to the recorded pre-merge shas and redoes step 2 — and the branch bytes it now discards are the operator's own regeneration commit, made on the script's own instruction one commit earlier.

⚠️ The asymmetry is worth naming: the header argues carefully that a completed run must mark its record done rather than delete it, so the state machine can tell a clean completion from a rerun nobody performed. The one path that finishes step 3 outside the script has no corresponding marking, and nothing on it warns against rerunning.

Repro 1 — the incident's own path. Measured 2026-09-20T15:41Z–15:43Z

Synthetic fixture at /tmp/…/scratchpad/fx-regen-rerun, carrying the repository's real merge driver (scripts/git-merge-regen.mjs, registered as merge.os-regen.driver) and its real ledger (scripts/regen-artifacts.mjs). Routed path packages/spec/spec-changes.json; a hand-written prose.md edited on both sides to force step 1's conflict exit; a one-shot pre-commit hook printing the same two lines the header quotes from the real one (… - stale / Regenerate the 1 stale artifact(s) above).

step what ran reading
1 run 1 exit 1, CONFLICT (content): Merge conflict in prose.md; record written phase=pending
2 resolve prose.md by hand, commit the merge merge commit 47aee28
3 run 2 → RERUN, ⚠ TAKING main's side of packages/spec/spec-changes.json (both sides changed it), then ✗ step 3's commit was refused, exit 1. Record still phase=pending
4 do exactly what that refusal says: regenerate, git add -A, git commit commit d755daa; FLOWX in HEAD = 1
5 run 3 exit 0, → RERUN again, ⚠ TAKING main's side … again, step 3 commits f3219fb

After step 5: git show HEAD:packages/spec/spec-changes.json | grep -c FLOWX = 0. The committed regeneration is gone, the run exited 0, and nothing refused.

Repro 2 — a second, independent route through plain. Measured 2026-09-20T15:33Z–15:34Z

Separate fixture (fx-regen), no conflict and no hook, so run 1 completes and marks the record done. Step 4 is then performed and committed, and main moves the same routed path again. Run 3 classifies plain (phase=done and origin/main ≠ recorded main_tip), computes its merge base as the previous round's merged main tip, and step 2's both-sides arm again takes main's side of the path the regeneration commit wrote. Exit 0, step 3 commits, grep -c FLOWX in HEAD = 0.

⚠️ Repro 2 is step 2's designed both-sides behaviour, and the script does not lie about it: its notice says Step 4's regeneration re-derives the generated content on top, and re-running step 4 restores the content. It is reported because the exit code and the notice are indistinguishable from a first sync's, and because it shows the loss is not confined to the rerun arm. Repro 1 is the defect.

Control — the same fixtures, with main not moving the artifact

Third fixture (fx-regen-ctl), identical through the regeneration commit; main's next commit touches an unrelated file and leaves the routed artifact alone.

reading value
exit code 0
step 2's per-path verdict ⚠ KEEPING the branch's bytes of packages/spec/spec-changes.json
grep -c FLOWX in HEAD 1

So the instrument separates step 2's two arms, and the loss is specifically the both-sides arm firing after a regeneration commit — not step 2 misbehaving in general, and not the #18895-era unconditional revert, whose guard works exactly as its header describes.

Shape of a fix — suggestions, ⛔ not a ruling

Two independent holes, either of which closes repro 1:

  1. Mark the record on the hand-off path too. Step 3's refusal already knows step 2 is discharged in the index. It could write the record pending-handoff (or done with the commit left to the operator) and print, in the same breath, that the sequence resumes at step 4 and that ⛔ rerunning the script will redo step 2.
  2. Make rerun non-re-entrant. rr_classify can ask whether HEAD is the merge commit the record describes, rather than merely containing rr_rec_branch_tip. Branch commits after that merge mean step 2 has been discharged by somebody; the right answer is a refusal that prints the by-hand step 2, as orphan and stale already do.

For repro 2, the cheaper half: a distinct per-path notice when the branch side being discarded post-dates the recorded merge, and a non-zero exit so the code cannot be read as "the sync is finished".

Provenance

The finding was first reported by the dev round on PR #19373 (card #17518), comment 5750725852, which measured run 3 erasing FlowFunctionLoweredDeclaration from the generated protocol index, rolling that file's schema count 1533 → 1532, and rolling package-api.mdx's functions/hooks rows back to any. That instance is the round's report, quoted, not re-measured here. Everything above the Provenance heading is this seat's own fixtures, built because a filed card's claims are re-verified by the seat that files them — and repro 1 exists because re-reading the round's own sequence showed this seat's first fixture had reproduced the loss by the wrong route.

Origin: measured while landing PR #19373 (card #17518) on 2026-09-20. Filed-by: session_01LvwGppdonww4zGLWZo5rho (domain:spec execution seat 1) — a tooling finding for the skills lane; ⛔ not graded or routed by this seat.


Generated by Claude Code

Activity

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions