Skip to content

Commit 8731510

Browse files
fix(tooling): check:type-check-debt's closure build takes the shared verify lock, and a call from inside a hold runs NESTED (#23486)
Fixes #23470 Clause-②: no ## What this changes `pnpm check:type-check-debt` runs `--re-measure`, which rebuilds the ledgered packages' dependency closure (`refreshBuiltClosure`: a full workspace `turbo run build --filter=./packages/* --filter=./packages/*/*`). It ran that build directly, outside the shared verify lock, so it rewrote `dist/` under a dev's locked test battery in the same tree. That is the card's measured false red: `MODULE_NOT_FOUND` on `@objectstack/spec/*`, three re-runs. The rebuild itself stays (it came from #8271). What changes is the lock contract around it. - **`scripts/check-type-check-coverage.mjs`.** The closure build now runs through `scripts/pm/os-verify-lock.sh -- TURBO run build ...`. The gate reads the lock's `VERDICT` line, never the bare exit code. There are three refusals, all exit 3 (PREREQUISITE NOT MET): - The build's own failure. The text is unchanged. - A turn the lock never gave (`queue-timeout`, `lock-unusable`, a usage or filter refusal). Nothing was built and nothing was measured. The remedy names `OS_VERIFY_LOCK_SLOT` and wrapping the whole gate in the lock. - A wrapper that printed no verdict, or a verdict that disagrees with its exit code. The lock's own lines go to the caller on every outcome. The gate's tsc reads after the build stay unlocked. One header paragraph (LOCKED, under BUILT CLOSURE) says why the rebuild takes the lock. - **`scripts/pm/os-verify-lock.sh`.** Nested calls. The wrapped command gets `OS_VERIFY_LOCK_HELD_BY=HOLDERPID:LOCKFILE`. A call carrying it runs its command at once, inside that hold, with `NESTED` on its VERDICT line. That happens only when all three hold: the marker names this lock file, the holder record names that pid, and that pid is an ancestor of the call. Anything less queues as before, so a wrong "no" costs the old bounded wait and never an unlocked run. A nested run writes no ticket, no holder record and no ledger row: the outer call holds the lock and records the hold. ## Why the nesting check sits in the entry point and not in the gate Measured on `origin/main` at `cb9841aa2`, on a private lock file: - The lock exports no marker. The child's environment named only the two knobs the caller had set. - It closes fd 9 in the child (fd count 0). - A second call from inside a hold queued behind its own ancestor for its whole budget: `VERDICT queue-timeout (exit 99)` after a 6s budget, which is 540s by default. So with the gate taking the lock itself, a dev who runs `bash scripts/pm/os-verify-lock.sh -c 'pnpm check:type-check-debt'` would wait 540s and then be refused. To decide "this process tree already holds the lock", you need the lock file path, the holder record and a liveness rule. All three are the lock script's own state, and reading them from the gate would make a second owner of a private format. So the smallest lock-script change that makes nesting decidable (exporting the marker) comes with the check, placed beside the state it reads. That placement also fixes the same self-deadlock for every other caller that nests. After the change, the same nested call runs in 0.15s and prints `VERDICT command-exit 3 · NESTED inside the hold of pid ...`. The ledger gets one row, the outer call's. End to end at `e69ee947f`: - **M1: `bash scripts/pm/os-verify-lock.sh -c 'pnpm check:type-check-debt'`.** Outer `ACQUIRED after 0s`. The gate's build printed `VERDICT command-exit 0 · NESTED inside the hold of pid 13338`, then `--re-measure: OK`. Exit 0, outer hold 15s. - **M2: plain `pnpm check:type-check-debt`.** The gate's build printed `queued: 0 ahead of you`, `ACQUIRED after 0s`, `VERDICT command-exit 0 · held the lock 0s · waited 0s`, then `--re-measure: OK`. Exit 0. ## CI The `lint.yml` job `typecheck-debt` (`Type Check · debt ledger`, ubuntu-latest) runs `pnpm check:type-check-debt` in step `re-measure-the-type-check-debt-test-debt`, after its own closure builds. `ubuntu-latest` ships util-linux `flock`, and nothing else holds `/tmp/os-heavy-verify.lock` on a runner. So the gate takes the lock at once: measured locally on a fresh private lock, one entry-point call takes 0.13s with `waited 0s`. A host with no usable `flock` runs the lock's declared unlocked mode. The gate reads `UNLOCKED (declared)` as a build that ran, and passes the disclosure through. No CI step waits on a lock nobody holds. The lock-script change also schedules CI's `verify-lock` family (`bash scripts/pm/os-verify-lock.sh --self-test`). No `lint.yml` sentence about this gate turned false. At `:6072`-`:6126`, the gate still runs "this exact command with these exact filters", now under the lock. The `unbuiltClosure()` refusal still comes first. The NODE_OPTIONS heap pairing is untouched. ## Pin and ablation **Gate self-test.** New battery `verify-lock cases (#23470)`, 19 cases: - 11 VERDICT-reader rows. - 2 wiring pins: `refreshBuiltClosure` spawns nothing itself, and runs turbo through `runUnderVerifyLock`. - 6 real-lock cases on a private lock file in a temp dir: - A stand-in build that writes a marker file must not run while another locked run holds the lock. This is the card's pin. - The stand-in must pass 0 and 7 out through the verdict. - Run in the gate's own argv shape from inside a hold, it must run NESTED. Whether the host can lock at all is asked of the entry point directly, so a wrapper that stopped locking cannot pass itself off as a host without `flock`. **Lock self-test.** Four new nested cases: the marker shape, a nested call runs at once and passes its exit code out with a NESTED verdict, only the outer call writes a ledger row, and a hold on another lock file is not nesting. One forged-marker case: a marker naming the live holder from outside its process tree queues and exits 99. Ablations, each through `scripts/ablation-replace.mjs`: the anchor hit, the blob changed, and the restore was proven (blob == HEAD, `git diff HEAD` empty). No build or `dist/` is involved; both suites run from source. | # | mutation | suite | result | |---|---|---|---| | A1 | `refreshBuiltClosure` spawns turbo directly (the old shape) | gate self-test | red, 2 failures: the wiring pins | | A2 | `runUnderVerifyLock` bypasses the entry point | gate self-test | red, 5 failures, including "the build RAN while another locked run held the lock" | | A3 | lock's nested recognition disabled | gate / lock self-test | red 2 / red 3 (the inner call exits 99 instead of 5, no NESTED, 2 ledger rows) | | A4 | lock's ancestry clause deleted | lock self-test | red 1 (the forged marker nests: exit 0 instead of 99) | The first A2 run went red only through the host probe, which then read through the ablated wrapper. That run did not exercise the "build ran while held" pin. Commit `e69ee947f` makes the probe independent of the wrapper, and the re-run fired the pin directly. ## Local verification, at HEAD `e69ee947f` - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 31 commands. All 31 were run with exit codes recorded, all 0. `--ran` printed: `31 derived, 31 run, 0 NOT-MEASURED, 0 UNRUN`, with the zero derived from the recorded codes. - `pnpm check:type-check-coverage` passed with 19 verify-lock cases. The self-test went from 0.4s to 2.1s; 1s of that is the contention case's queue budget. - `pnpm check:type-check-debt` exited 0 in both forms (M1 and M2 above). The closure was first built under the lock, cold: 74 tasks, 9m20s, `VERDICT command-exit 0`. - `pnpm check:pm-dispatch-gates` exited 0 (fast tier, 1841 cases). - `bash scripts/pm/os-verify-lock.sh --self-test` passed all cases (249 checks, 107s) at `9b48f035a`. The lock script is byte-identical at HEAD. - `bash scripts/ci/select-gate-families.selftest.sh` passed: 59 cases. How each ran: the closure build and M1 went through `scripts/pm/os-verify-lock.sh -c`. M2 ran unlocked on purpose, because the gate's own locking is what it measures. The `check:*` gates and the two self-tests ran unlocked. Both self-tests use private lock files, and the lock's own self-test is about locking. ## Acceptance notes - **The mirror direction is not addressed (inference, not measured).** The gate's tsc reads after the build still run unlocked, so a sibling's locked build could rewrite `dist/` under this gate's measurement. Owner: none. Noted, not filed. - **A plain caller can now wait.** `pnpm check:type-check-debt` run unlocked can now wait up to the lock's 540s budget before its build. The refusal names a slot and the whole-gate wrap as remedies. The os-dev role file's sentence that `check:*` gates do not go through the lock stays true of the caller. It is not edited here (`.claude/**` is off this card's surface). - **The battery-roster floor now pins the real count.** `SELF_TEST_BATTERY_FLOOR` read 62 against 64 declared batteries. It now pins the 65 declared. - **Changeset.** Both files are root `scripts/`, in the private root package, and no package `files[]` ships them, so this PR takes `skip-changeset` and no changeset file. --- _Generated by [Claude Code](https://claude.ai/code/session_012VGBku7tEi8DX2uvi1gtXk)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0d3ea29 commit 8731510

2 files changed

Lines changed: 455 additions & 10 deletions

File tree

0 commit comments

Comments
 (0)