fix(pm): os-verify-lock --status reports whether each recorded process still exists - #18149
Conversation
…ss still exists `--status` already knew, for its own grant logic, whether the process behind the holder record and behind every ticket is still there — and both paths then deleted the record and printed nothing about it. So the one surface a seat reads could say "lock is free · queue empty" while the run it was waiting on had already died, and reaching that truth took three instruments plus a control pid typed by hand. `--status` now ends with a liveness reading: one line per holder and per queued ticket saying whether its pid still exists, and whether it is still the same process by the start time the script already keys tickets on. The snapshot is taken BEFORE `holder_line` and `queue_live` run, because those two reap what they judge. The reading carries its own control in the same output — this process must read alive, a child this call reaped must read dead — and refuses with exit 3, printing no reading at all, when either leg fails: every holder, waiter and parked line is decided by that same probe. `OS_VERIFY_LOCK_SIM_LIVENESS` exists only so the self-test can drive both failure directions. Every pre-existing `--status` line is byte-identical; the reading is appended. Claude-Session: https://claude.ai/code/session_01DAcomhvR9kKizeYgg89Vo8 Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewHead: ① derived judgments (seat-measured on the fetched head
② semver: unchanged — ③ boundary flags: the Implemented-by: Verdict: PASS — path face clean ⇒ this seat lands: ready through 维护者速读(终稿)验证锁的 Generated by Claude Code |
Fixes #17966
os-verify-lock.shalready knew, for its own grant logic, whether the process behind the holder record and behind every queued ticket still exists — it keys tickets on pid plus the/procstart time. Both paths that decide it (holder_line,queue_live) then delete the record and print nothing about it. That is correct for ordering — a dead ticket must not wedge the queue — and it is exactly why the reading a seat needs was never on the page: by the time--statusprints, the evidence that a run died holding this lock has been reaped silently, and the surface readsstate: lock is free/queue: empty, which a reader takes as "nothing is blocked" rather than "the thing you think is blocked already died".This PR is the card's shape 1: a liveness reading a seat can take in one command.
What changed
--statusnow prints, after every pre-existing line:instrument:line proving the prober itself works, thenThe snapshot of the records is taken before
holder_lineandqueue_liverun, because those two reap what they judge. Nothing in the new path prunes, grants or decides; it reads and it reports.--statuswas extended rather than given a new flag, and the output contract was measured before deciding.git grep -n -- '--status' -- scripts .claude .githubfinds two consumers outside the script:.claude/agents/os-dev.md:103— prose, "prints the holder and the queue"..claude/skills/pm-dispatch/SKILL.md:436— arrival depth = the count ofqueue N:lines plus 1, withstate:holder lines andparkedlines explicitly not counted.Neither pins the shape beyond those spellings, so lines may be added and none may be reshaped. A case in the suite asserts those two spellings still appear, and every new line carries a prefix (
instrument:,liveness,refusing:) that no existing reader counts.The reading carries its own control
A prober broken in the alive direction reports every corpse as running — an instrument answering confidently, wrongly, and in the reassuring direction, which is the shape this card is about. Both directions are therefore proved in the same output, against pids whose answer is known before the test runs: this process must read alive, and a child this call started and reaped must read dead (a pid can be reused between the reap and the check, so the dead leg retries before concluding).
If either leg fails,
--statusprints no reading at all — not a reading with a caveat, and not merely no verdicts. Every holder, waiter and parked line it prints is decided by that samepid_alive, so an unlit prober invalidates the whole listing; the command refuses with exit 3, names the leg that failed, and prints nothing else.OS_VERIFY_LOCK_SIM_LIVENESS=alive|deadexists solely so the suite can drive both failure directions — the one failure this control is built to catch cannot be produced by arranging real processes, and a control nothing has ever been seen to fail is not a control. It is documented atpid_alive, and every invocation announces it on stderr so it cannot be set quietly.Acceptance
A free lock — the control line, and no verdicts because there is nothing to judge. ⚠ The absence is printed, not left to be inferred: that is the exact shape the card measured (harness "running", lock free, queue empty, process already dead).
A fixture with a live holder, a live waiter, a dead waiter, a reused pid and a parked slot — every pre-existing line unchanged, the reading appended:
Byte-identity of the pre-existing lines. The pre-change script (
git show origin/main:scripts/pm/os-verify-lock.sh) and this one were run against the same fixture state, both outputs truncated at the new block (sed '/^instrument: /,$d'), and diffed:diffempty, exit 0, no normalisation at all.diffempty, exit 0, with one declared normalisation —held|waiting|kept N sdurations, which differ between two runs taken seconds apart by construction and are not affected by this diff.git diff --statagainst the merge base names only the script (its self-test lives in the same file):Tests
bash scripts/pm/os-verify-lock.sh --self-test— exit 0, 243 cases pass, 0 fail (the CI stepVerify-lock entry-point self-testinlint.ymlruns exactly this command). 14 new cases, written in the suite's existing shape and driving--statusas a child process against a private lock of their own, because what is owed is the surface a seat runs — cases that called the helpers in-process would pin the predicates and leave the output unpinned, which is the state this card is about.Ablation — both new claims proved able to fail
Run from the committed fix, each mutation proved on disk (injected-marker count plus a
git hash-objectblob change against theHEADblob), each restore leggit checkout HEAD -- scripts/pm/os-verify-lock.shproved by an emptygit diff HEAD, under atrap ... EXIT INT TERMwith absolute paths. Both legs restored clean; the working tree is atHEAD.A — the snapshot is taken before the pruning paths. Moved
liveness_snapshotto the foot ofmode_status. Suite exit 1, 2 cases red, exactly the ones about records that are reaped as they are judged:B — a failed control refuses. Made the dead leg's failure return success instead. Suite exit 1, 3 cases red:
⚠ Worth recording from B:
and names its instrument as not litstayed green under the mutation — theinstrument: NOT LITline was still printed, beside a full set of verdicts. The line is not the mechanism; the refusal is. A suite that asserted only the wording would have passed the broken build.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack(no paths) derived 26 families. All 26 run in the foreground, each exit code captured before any pipe, all exit 0; reconciled with--ran:The list was re-derived after a fresh
git fetch origin main(base moveda90a9f267to739ab526d; no upstream commit touched this file) and came back identical. Eight artifact-roster families whose roster sits in a directory this path is in were also run. Six exit 0:check-published-list-mirrors,check-published-list-mirrors --self-test,check:console-injection,check:dts-closureexcepted,check:engine-double-contract,check:i18n-stale-fill,check:pm-label-desc-cap.⛔ NOT MEASURED:
check:dts-closureandcheck:published-readme-exportsboth exit 3 (PREREQUISITE NOT MET — they need a fullpnpm build's dist closure). That is not a pass and not a finding. Neither gate reads a.shfile at all and neither roster names this script, so nothing in this diff can move them; they are left to CI, which builds.Deliberately not in this PR
The card's shapes 2 and 3 — the rule line in
SKILL.mdand dispatch briefs, and a seat-side sweep over in-flight dispatches — are a later PR.SKILL.mdis serial behind PR #18128, and it is a different layer (rules prose, four-piece) from this one (a script with its own self-test). Splitting them keeps this one landable now and keeps the prose half from queueing behind a script review. The lock's mutual-exclusion semantics, ticket ordering, ledger format and boots format are untouched: this is a reading only.Acceptance notes
Observed while working here, out of scope, not filed (each would need a carrier that will actually touch the file):
noted, not filed:the CI step comment in.github/workflows/lint.ymldescribing this suite says "48 cases"; the suite ran 228 before this PR and 243 after. Pre-existing prose drift, no mechanism reads the number. Carrier: the next PR that edits theVerify-lock entry-point self-teststep.noted, not filed:the reaping that hid this reading is also invisible to the waiter's progress output and to--report— only--statusgained the disclosure. Carrier: none identified; the seat-facing surface is--status, which is where the card asked for it.noted, not filed:skip-changesetapplies —scripts/pm/**publishes nothing (no packagefiles[]ships it, it is repo tooling). The label is applied on this PR.Generated by Claude Code
Generated by Claude Code