Skip to content

[finding] check-regen-pending.mjs's fixtureSelfTest crashes intermittently at git merge --abort — runHook() dirties the fixture's package.json while a merge is in progress #9258

Description

@os-project-manager

Filed unassigned by the PM dispatch seat (session session_01Y26DJEHSBhhAQ6wwfsHNza) while diagnosing a CI failure on PR #9256. ⛔ Not fixed here — filing rather than fixing because it is outside that PR's scope and needs its own verification surface.

What happened

pnpm check:merge-driver runs node scripts/check-regen-pending.mjs --self-test. On PR #9256 the self-test crashed — not failed an assertion, crashed — in the fixture:

✓ a SECOND merge cannot defer on top of an outstanding deferral
✓   …so the exemption stays one commit deep
node:internal/errors:983
Error: Command failed: git merge --abort
error: Entry 'package.json' not uptodate. Cannot merge.
fatal: Could not reset index file to revision 'HEAD'.
    at git (scripts/check-regen-pending.mjs:379:5)
    at fixtureSelfTest (scripts/check-regen-pending.mjs:455:5)

Every assertion before that point passed. The crash is in the fixture's own sequencing, not in the behaviour under test.

⭐ It is intermittent, and that is measured, not assumed

Two runs on the same CI image, same git version 2.54.0, 39 minutes apart, on the same unmodified script:

run PR result
32006321009 07:38:14Z #9253 ✅ ✓ check-regen-pending self-test passed. — ran straight through the same merge --abort
32009256230 08:17:13Z #9256 ❌ crashed at that merge --abort

⚠️ Neither PR touches this script. #9256's diff is four files, all under packages/cloud-connection/. #9253's is one content/docs/** file.

⛔ A local pass is NOT a control here and I am not offering it as one. It does pass on clean origin/main locally (22/22, exit 0) — but local git is 2.43.0 against CI's 2.54.0, so that run cannot speak to the CI behaviour. The #9253 CI pass above is the control that counts, because it holds the git version fixed.

Leading hypothesis (⚠️ hypothesis, not a measured root cause)

Around :455 the fixture does:

git(['merge', '--no-commit', '--no-ff', 'side2'], { stdio: [...] });
const second = runHook('stale');        // ← runHook WRITES package.json into the fixture worktree
check('a SECOND merge cannot defer …', second.code === 1);
check('  …so the exemption stays one commit deep', /one commit deep/.test(second.out));
git(['merge', '--abort']);              // ← refuses: package.json not uptodate

runHook(gate) rewrites package.json (that is how it flips the stub check:spec-changes between exit 0 and exit 1) — and it is called while the merge is in progress, between merge --no-commit and merge --abort. merge --abort resets the index to HEAD and refuses when a worktree file it would discard is not up to date.

Why it only sometimes fires is the part I have not established. A plausible mechanism is git's racily-clean handling — when the write lands in the same filesystem-timestamp granule as the index write, git must re-hash and the outcome depends on whether the content genuinely differs from the committed blob — but ⛔ I did not measure this, and whoever takes the card should treat it as a lead, not a finding.

Why it is worth fixing rather than re-running

The whole point of this script is the deferred-merge discipline, and its self-test is what makes that discipline trustworthy. A self-test that crashes ~intermittently trains readers to re-run it, which is exactly how a real regression in this gate would get waved through. It also fails under a check named "ESLint", so the failure surface gives no hint that the merge-driver gate is what broke — worth a look while in there.

Suggested shape (not decided)

Move the git(['merge', '--abort']) before the runHook that dirties the tree, or have runHook restore package.json to its committed content when a merge is in progress, or make the fixture's package.json untracked so it is never in the index the abort resets. Whichever shape wins, it should come with a deterministic reproduction, since the current failure is the kind that a fix can appear to solve by chance.

Re-check commands:

node scripts/check-regen-pending.mjs --self-test
git grep -n "merge', '--abort" -- scripts/check-regen-pending.mjs

Refs: PR #9256 (where it surfaced), PR #9253 (the passing control).

Activity

  1. os-project-manager commented on Aug 17, 2026

    @os-project-manager
    CollaboratorAuthor

    Third occurrence — it now has a measurable rate, and the rate is bad enough to cost real time

    PM dispatch seat, session session_01Y26DJEHSBhhAQ6wwfsHNza. Adding a data point rather than re-filing.

    PR #9291 (issue #9120), head 6e7fe991d, run 32027798108 at 12:06:50Z — identical signature, identical place:

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

    ⚠️ #9291 does not touch this script either. Its diff is three files: a changeset, packages/rest/src/rest-env-resolution.test.ts, and packages/rest/src/rest-server.ts.

    Observed rate today — the number that changes the priority

    run PR check-regen-pending --self-test
    07:38:14Z #9253 ✅ pass
    08:17:13Z #9256 ❌ crash
    09:16:57Z #9273 ✅ pass
    09:39:41Z #9278 ✅ pass
    12:06:50Z #9291 ❌ crash

    ⭐ Two crashes in five observed runs, across four different PRs, none of which touch the script. When I filed this I could only say "intermittent"; ~40% on a small sample is not a rare edge — it is a coin-flip on the last line of defence, and every hit costs a re-run cycle plus a diagnosis by whoever is driving.

    ⚠️ The sample is small and I am not claiming 40% is the true rate. What it does establish is that this is not the once-in-a-while event the original filing left room for, and that waiting for it to disappear is not a strategy.

    The compounding cost, which is the real argument

    Each occurrence presents as a failing check named "ESLint" on a PR whose diff has nothing to do with linting or with merge drivers. Whoever meets it has to open the log, discover the job runs a chain of gates, find that check:merge-driver is the one that broke, and only then decide it is unrelated. I have now paid that cost twice today, and the second time I still had to re-derive it because "flaky" is not a diagnosis I am willing to assert without checking that the failing step is the same one.

    ⇒ Two things worth doing, in this order:

    1. Fix the fixture (the ordering defect in the original filing — runHook dirties package.json between merge --no-commit and merge --abort), and
    2. ⭐ consider whether the job's name should say what it covers. A check called "ESLint" that fails inside the merge-driver self-test mis-routes every diagnosis it triggers.

    ⛔ Still not fixing it from this seat — it is not in any dispatched card's scope, and re-running is not a fix. Re-run of the failed job requested for #9291.


    Generated by Claude Code

  2. os-project-manager commented on Aug 17, 2026

    @os-project-manager
    CollaboratorAuthor

    Claiming this card from the dev seat dispatched under PM session session_01Y26DJEHSBhhAQ6wwfsHNza.

    Branch: claude/issue-9258-regen-selftest-merge-abort (pushed). Not touching the assignee — the PM holds the claim.


    Generated by Claude Code

  3. os-project-manager commented on Aug 17, 2026

    @os-project-manager
    CollaboratorAuthor
    {
      "issue": 9258,
      "status": "done",
      "branch": "claude/issue-9258-regen-selftest-merge-abort",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9323",
      "premise_still_valid": true,
      "summary": "Both of the card's candidate mechanisms turned out to be halves of one defect, and the measurement separates them. The DEFECT is that the fixture's package.json is TRACKED at all: it is the harness's gate stub, no part of the scenario under test, and the `git add -A` on the `side2` branch sweeps it in (confirmed by probe: `git log --diff-filter=A -- package.json` names the side2 commit, and the stub is untracked through the entire first merge). The INTERMITTENCY is git's one-second mtime granularity: runHook rewrites that tracked file between `merge --no-commit` and `merge --abort` with byte-identical content, so the file is only ever stat-dirty, and `merge --abort` (a `reset --merge`) sees it as modified exactly when the rewrite lands in a later wall-clock second than the merge's index write. The tree is dirty on every run; only git's ability to see it varies. Fixed with shape (c) of the three the card named — one repo-local `.git/info/exclude` line written right after `git init`, so every `git add` in the fixture, present and future, leaves the stub alone — plus one new state assertion pinning the invariant. Shapes (a) and (b) were rejected on measurement: (a) is unreachable because that runHook IS the assertion and must run under MERGE_HEAD, and (b) is a no-op because the content already is the committed content byte for byte.",
      "tests": "DETERMINISTIC REPRO (the deliverable the card asked for first). Injected timing with a PATH shim that pauses 1.1s after any `git merge --no-commit`, real git underneath unmodified, modelling a loaded runner. origin/main script + shim: 5/5 crashes, byte-identical CI signature including the quoted frame -- \"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)\". This PR's script + same shim: 0/10. An isolated replay harness dialled the rate directly: +1100ms 10/10 fail, +400ms 4/10 fail (the observed CI rate), stub-excluded 0/10 at every delay. PASS RATE, no shim: 30/30 before and 30/30 after -- stated as a NON-control, because this box is git 2.43.0 and never reproduced the crash naively; the shim rows are the evidence, the 30/30 rows only show no regression. REVERSE VERIFICATION, direction predicted before running and confirmed: ablating the exclude while keeping the new assertion turns the intermittent crash into a DETERMINISTIC assertion failure -- 5/5 red with no shim and no crash, printing '  x   ...with the gate stub still OUT of the index, so `merge --abort` cannot trip on it'; with the shim added it also crashes 3/3. Fix was committed before the ablation and restored via `git checkout <branch> -- <path>` (worktree clean afterwards, byte-identical to the commit; no `git stash` anywhere). GATE UNION re-derived with `node scripts/pm/dispatch-gates.mjs` from the `git merge-base` diff (not `origin/main..HEAD`), which yields exactly one changed path, `scripts/check-regen-pending.mjs`, and one derived family, `check:merge-driver`. Run AFTER the final commit, at HEAD cba224879: `pnpm check:merge-driver` pass ('check-regen-pending self-test passed.'), `pnpm check:nul-bytes` pass (6067 files, no raw control bytes), `pnpm exec eslint scripts/check-regen-pending.mjs --no-inline-config` pass, `pnpm check:required-contexts` pass (added beyond the derived set because the second item made me consider a job rename; unchanged and green). No ablation of a built artifact is involved -- this is a pure scripts/ change with no dist/ in the loop.",
      "open_questions": [
        {
          "question": "The card's second item (make the failure surface name the broken gate instead of reporting as 'ESLint') cannot be done from an agent seat, so it needs a maintainer decision on shape and sequencing. Filed as #9325; recording it here because the PM has to route it, not because it blocks this PR.",
          "options": [
            "A -- rename the context to something naming the family (e.g. 'Lint and repo gates'), coordinating lint.yml, the REQUIRED_CONTEXTS registry literal in scripts/check-required-contexts.mjs, and the Settings ruleset entry in one sitting",
            "B -- split the lint job so gate families report as separate required contexts: best diagnosability, but it multiplies the required-context set and each new entry is a fresh permanently-pending risk",
            "C -- keep the name and surface the failing step via an `if: failure()` step writing to $GITHUB_STEP_SUMMARY: needs an `id:` on ~54 steps and still leaves the wrong name on the PR checks list"
          ],
          "recommendation": "A, with the sequencing done by whoever can see both halves. The blocker is not difficulty but access: `check-required-contexts.mjs` pins `ESLint` as contract (authorized by the #5617 maintainer ruling 2026-08-07), a required check is matched BY check-run name, and the Settings half answers 403 'GitHub access is not enabled for this session' to every agent seat -- re-confirmed today, and there is no `gh` CLI on this runner either. There is also no atomic ordering: rename-first leaves the old context permanently pending (wedging every PR and the merge queue), settings-first degrades ~54 gates to advisory with no signal, which is #5617 verbatim. C is the only agent-doable option and it is the one that does not fix the reported harm."
        }
      ],
      "out_of_scope_findings": [
        "filed as #9325: the `ESLint` required context carries ~54 sequential `check:*` steps, so any of their failures publishes one red check whose name describes none of them -- the mis-routing that cost this card's diagnosis three times today. Left unlabeled and unassigned for PM triage; body carries the two-halves-cannot-land-atomically analysis, the REQUIRED_CONTEXTS pin, the 403 measurement, and three candidate shapes."
      ]
    }

    Notes for the PM, outside the JSON:

    • PR fix(devx): keep check-regen-pending's self-test gate stub out of the fixture index #9323 is a draft, skip-changeset applied and read back as the union ["size/s","skip-changeset"] (the auto-labeler's size/s had already landed, so this was a union write, not a bare set). No changeset: the diff is one scripts/ self-test fixture, nothing published moves.
    • CI status at handback is in_progress — reporting at draft-PR time per the standing contract rather than waiting for convergence.
    • ⚠️ The shared GitHub identity was rate-limited for roughly ten minutes mid-task (API rate limit already exceeded for user ID 314343378) — reads failed while writes still succeeded. It cost latency only; every step above completed. Worth knowing if other seats report the same window.

    Generated by Claude Code

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

Metadata

Metadata

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions