Skip to content

fix(devx): keep check-regen-pending's self-test gate stub out of the fixture index - #9323

Merged
os-project-manager merged 1 commit into
mainfrom
claude/issue-9258-regen-selftest-merge-abort
Aug 17, 2026
Merged

fix(devx): keep check-regen-pending's self-test gate stub out of the fixture index#9323
os-project-manager merged 1 commit into
mainfrom
claude/issue-9258-regen-selftest-merge-abort

Conversation

@os-project-manager

@os-project-manageros-project-manager commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

Fixes#9258

pnpm check:merge-driver runs node scripts/check-regen-pending.mjs --self-test, and its fixture crashed on ~40% of observed CI runs — 2 of 5 today, across 4 PRs, none of which touch this script:

error: Entry 'package.json' not uptodate. Cannot merge.
fatal: Could not reset index file to revision 'HEAD'.

Root cause, measured

The card offered two candidate mechanisms and asked for one to be established. They turn out to be two halves of the same thing, and measurement separates them cleanly.

1. The defect: the fixture's package.json is TRACKED. It is the harness's gate stub — runHook rewrites it to flip the stub check:spec-changes between exit 0 and exit 1 — and it is no part of the two-commit scenario under test. It became tracked by accident: the git add -A on the side2 branch sweeps it in. Probed directly: git log --diff-filter=A -- package.json names the side2 commit, and the stub is untracked through the whole first merge.

2. The intermittency: git compares mtime at one-second granularity.runHook is called between git merge --no-commit --no-ff side2 and git merge --abort, so it rewrites a tracked file just after the merge recorded that file's stat data in the index. The stub's content is byte-identical every time, so the file is only ever stat-dirty — and merge --abort is a reset --merge, whose verify_uptodate compares mtime in whole seconds. Land the rewrite in the same wall-clock second as the merge's index write and the entry reads clean; land it in the next second and the abort refuses to discard the file.

So it was never "the tree is sometimes dirty". The tree is dirty on every single run. What varies is only whether git can see it.

Deterministic reproduction

The card asked for this first, and it is the part that makes the rest checkable. All on this box, git 2.43.0:

scenariomerge --abort failed
origin/main script, as-is0/30
origin/main script, rewrite delayed past a second boundary5/5
isolated replay, rewrite +400ms4/10
isolated replay, rewrite +1100ms10/10
isolated replay, stub kept out of the index0/10 at every delay
this PR's script, rewrite delayed past a second boundary0/10
this PR's script, as-is0/30

The forced-failure runs reproduce the CI signature byte for byte, including the frame the issue quotes:

Error: Command failed: git merge --abort
error: Entry 'package.json' not uptodate. Cannot merge.
fatal: Could not reset index file to revision 'HEAD'.
at fixtureSelfTest (.../scripts/check-regen-pending.mjs:455:5)

The delay was injected by a PATH shim that pauses 1.1s after any git merge --no-commit, modelling a loaded runner. Real git underneath, unmodified; the +400ms row shows the same knob reproducing the observed CI rate.

The local box could never have found this by re-running: it passes 30/30 either way, exactly as the card warned. The variable is timing, and the shim is what supplies it.

The fix

Shape (c) of the three the card named: the stub never enters the index. One repo-local info/exclude line, written right after git init, so that everygit add in the fixture — the ones here today and the ones added later — leaves the stub alone. .git/info/exclude rather than a .gitignore, because a .gitignore would itself be a tracked file inside the merges under test: changing the scenario in order to protect the harness.

Shapes (a) and (b) are rejected on the measurement, not on taste:

  • (a) move the abort before the runHook that dirties the tree is unreachable. That runHookis the assertion — it has to run while MERGE_HEAD exists, which is the entire content of the case ("a SECOND merge cannot defer on top of an outstanding deferral").
  • (b) restore package.json to its committed content while a merge is in progress is a no-op. The content already is the committed content, byte for byte. Only the stat differs — so restoring content fixes nothing, and restoring the mtime would couple the harness to git's stat-comparison internals, which is a worse thing to depend on than the thing being fixed.

No retry, no try/catch, no || true anywhere near the abort — per the card, and because a swallowed abort failure is precisely the class of defect this script exists to prevent.

The regression guard

One new assertion, placed where the invariant is load-bearing:

 ✓ …with the gate stub still OUT of the index, so `merge --abort` cannot trip on it

Deliberately a state assertion rather than the symptom: with the stub in the index the crash is a coin flip, so the state is the only thing reportable. Ablated (exclude removed, assertion kept) it goes red 5/5 with no shim and no crash — an intermittent crash converted into a deterministic, self-explaining assertion failure. It covers every git add in the fixture, not only the one that broke.

Verification

Gate union re-derived with node scripts/pm/dispatch-gates.mjs from the git merge-base diff (not origin/main..HEAD), against the one changed path, and run at HEAD cba224879:

gateresult
pnpm check:merge-driver (derived)pass — ✓ check-regen-pending self-test passed.
pnpm check:nul-bytespass — 6067 files, no raw control bytes
pnpm exec eslint scripts/check-regen-pending.mjs --no-inline-configpass
pnpm check:required-contextspass

Self-test pass rate: 30/30 before, 30/30 after on this box, and 0/10 vs 5/5 under the adverse timing that is the actual bug.

The second item, stopped and reported

The card asked, if cheap, to make the failure surface say which gate broke — today a merge-driver crash reports as a failing check named ESLint.

It is not cheap, and the reason is in-repo. scripts/check-required-contexts.mjs pins the job name: literals as contract, and ESLint is a registered entry (authorized: '#5617 maintainer ruling 2026-08-07 — applied to the settings the same day'). A GitHub required status check is matched by check-run name, so renaming the job makes the old required context sit permanently pending, which wedges every PR and the merge queue. The corresponding Settings entry cannot be changed from an agent seat — that same file records GET /repos/objectstack-ai/objectstack/branches/main/protection answering 403 GitHub access is not enabled for this session, and I re-confirmed there is no gh on this runner either.

There is also no atomic ordering: repo-side rename and Settings-side rename cannot land together, so either sequence leaves a window where the gate is pending-forever or silently advisory — the second being #5617 verbatim. That needs a maintainer, so it is filed as #9325 rather than ridden in here — with the two-halves-cannot-land-atomically analysis, the registry pin, and three candidate shapes.

Changeset

None. The change is confined to scripts/, a dev-tooling self-test fixture; nothing published moves. Carrying the skip-changeset label instead.


Generated by Claude Code

…fixture index
Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/sskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@os-project-manager@claude