Skip to content

fix(pm): os-verify-lock --status reports whether each recorded process still exists - #18149

Merged
claude[bot] merged 1 commit into
mainfrom
claude/issue-17966-verify-lock-liveness-reading
Sep 14, 2026
Merged

fix(pm): os-verify-lock --status reports whether each recorded process still exists#18149
claude[bot] merged 1 commit into
mainfrom
claude/issue-17966-verify-lock-liveness-reading

Conversation

@claude

@claude claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Fixes #17966

os-verify-lock.sh already 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 /proc start 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 --status prints, the evidence that a run died holding this lock has been reaped silently, and the surface reads state: 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

--status now prints, after every pre-existing line:

  • one instrument: line proving the prober itself works, then
  • one verdict line per holder and per queued ticket — the pid, whether it exists, whether it is still the same process by the recorded start time, and how long ago the record was written.

The snapshot of the records is taken before holder_line and queue_live run, because those two reap what they judge. Nothing in the new path prunes, grants or decides; it reads and it reports.

--status was extended rather than given a new flag, and the output contract was measured before deciding. git grep -n -- '--status' -- scripts .claude .github finds 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 of queue N: lines plus 1, with state: holder lines and parked lines 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, --status prints 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 same pid_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|dead exists 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 at pid_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).

state: lock is free
queue: empty (entry-point waiters only — a free-hand flock waiter takes no ticket)
ledger: ... (no records yet)
boots: ... (1 recorded) — `--report` reads them back with their controls
instrument: lit — own pid 6537 reads ALIVE, a child this call started and reaped (pid 6542) reads DEAD, and start times are readable here, so a reused pid is told apart from the original.
liveness: no records to judge — this lock has no holder and no tickets.
          ⚠ That is NOT evidence that whatever you are waiting on is alive. A
          process that never reached this lock leaves nothing here to judge,
          which is the exact shape #17966 measured: the harness said running,
          this command said free and empty, and the process was already dead.
          Read the process table next to a pid you KNOW is alive before you
          conclude anything about a run that left no record here.

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:

state: holder pid 8283, held 95s — pnpm --filter core test
queue 1: pid 8283 waiting 40s — live-waiter
parked 1: slot skills, place kept 300s (blocks nobody) — skills-slot
...
instrument: lit — own pid 8396 reads ALIVE, a child this call started and reaped (pid 8399) reads DEAD, and start times are readable here, so a reused pid is told apart from the original.
liveness holder: pid 8283 ALIVE and still the same process (start time matches the record) — holding 95s (1m35s), since 2026-09-14T06:21:48Z — pnpm --filter core test
liveness waiter ticket 00000000000000000001-8283: pid 8283 ALIVE and still the same process (start time matches the record) — waiting 40s, since 2026-09-14T06:22:43Z — live-waiter
liveness waiter ticket 00000000000000000002-999999: pid 999999 DOES NOT EXIST — nothing is waiting behind this ticket; the record was written 70s (1m10s) ago, at 2026-09-14T06:22:13Z, and the next queue scan prunes it, so this is the one reading you get — dead-waiter
liveness waiter ticket 00000000000000000003-r8283: pid 8283 EXISTS BUT IS NOT THE RECORDED PROCESS (start time 472121 recorded, 472114 now) — nothing is waiting behind this ticket and its pid has been reused; the next queue scan prunes it, so this is the one reading you get — reused-waiter
liveness parked 00000000000000000004-sskills: pid 0 — BY DESIGN no process to judge; a place kept, not a waiter, taken 300s (5m00s) ago, at 2026-09-14T06:18:23Z — skills-slot

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:

  • free lock, empty queue: diff empty, exit 0, no normalisation at all.
  • the rich fixture above: diff empty, exit 0, with one declared normalisation — held|waiting|kept N s durations, which differ between two runs taken seconds apart by construction and are not affected by this diff.

git diff --stat against the merge base names only the script (its self-test lives in the same file):

 scripts/pm/os-verify-lock.sh | 341 +++++++++++++++++++++++++++++++++-
 1 file changed, 338 insertions(+), 3 deletions(-)

Tests

bash scripts/pm/os-verify-lock.sh --self-testexit 0, 243 cases pass, 0 fail (the CI step Verify-lock entry-point self-test in lint.yml runs exactly this command). 14 new cases, written in the suite's existing shape and driving --status as 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.

  ✓ a lit liveness reading exits 0
  ✓ and says it is lit, on its own line, before any verdict
  ✓ a waiter whose process is really there reads ALIVE
  ✓ a holder whose process is gone reads DOES NOT EXIST — the one-line reading that was owed
  ✓ and a live pid that is NOT the recorded process is neither alive nor gone
  ✓ and the lines existing readers count are untouched
  ✓ a prober that reads every pid ALIVE refuses instead of reporting
  ✓ and names its instrument as not lit
  ✓ and prints no verdict at all — not one carrying a caveat
  ✓ and withholds the holder, queue and parked lines that same probe decides
  ✓ and the other direction — every pid reading DEAD — refuses too
  ✓ with nothing recorded it says there is nothing to judge
  ✓ and refuses to let that absence read as an all-clear
  ✓ and prints no verdict line, because there is nothing to judge

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-object blob change against the HEAD blob), each restore leg git checkout HEAD -- scripts/pm/os-verify-lock.sh proved by an empty git diff HEAD, under a trap ... EXIT INT TERM with absolute paths. Both legs restored clean; the working tree is at HEAD.

A — the snapshot is taken before the pruning paths. Moved liveness_snapshot to the foot of mode_status. Suite exit 1, 2 cases red, exactly the ones about records that are reaped as they are judged:

  ✗ a holder whose process is gone reads DOES NOT EXIST — the one-line reading that was owed
      want: 1 / got: 0
  ✗ and a live pid that is NOT the recorded process is neither alive nor gone
      want: 1 / got: 0

B — a failed control refuses. Made the dead leg's failure return success instead. Suite exit 1, 3 cases red:

  ✗ a prober that reads every pid ALIVE refuses instead of reporting   want: 3 / got: 0
  ✗ and prints no verdict at all — not one carrying a caveat           want: 0 / got: 1
  ✗ and withholds the holder, queue and parked lines that same probe decides   want: 0 / got: 2

⚠ Worth recording from B: and names its instrument as not lit stayed green under the mutation — the instrument: NOT LIT line 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:

✓ dispatch-gates --ran: 26 derived famil(ies) accounted for — 26 run,
  0 NOT-MEASURED (a DERIVED zero — all 26 recorded an exit code and none of them is 3).

The list was re-derived after a fresh git fetch origin main (base moved a90a9f267 to 739ab526d; 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-closure excepted, check:engine-double-contract, check:i18n-stale-fill, check:pm-label-desc-cap.

NOT MEASURED: check:dts-closure and check:published-readme-exports both exit 3 (PREREQUISITE NOT MET — they need a full pnpm build's dist closure). That is not a pass and not a finding. Neither gate reads a .sh file 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.md and dispatch briefs, and a seat-side sweep over in-flight dispatches — are a later PR. SKILL.md is 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.yml describing 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 the Verify-lock entry-point self-test step.
  • noted, not filed: the reaping that hid this reading is also invisible to the waiter's progress output and to --report — only --status gained the disclosure. Carrier: none identified; the seat-facing surface is --status, which is where the card asked for it.
  • noted, not filed: skip-changeset applies — scripts/pm/** publishes nothing (no package files[] ships it, it is repo tooling). The label is applied on this PR.

Generated by Claude Code


Generated by Claude Code

…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>
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 14, 2026
@claude

claude Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author
  • Served-tier: 2300/2300 CONTRACT_REVIEW_TIER — harness model stamp counted over this seat's own transcript (non-sidechain assistant messages a model served; <synthetic> harness notices excluded) at 2026-09-14T06:46Z and compared to the constant's value outside the repository; get_session external_metadata.last_served_model read equal to the constant at 2026-09-14T00:20Z.

Contract review

Head: a3d075e0 (PR #18149, card #17966) — reviewed at 2026-09-14T06:51Z by the skills seat at the contract-review tier. Path face clean (scripts/pm/os-verify-lock.sh only — non-governed PM tooling) ⇒ in-seat landing: ready + auto-merge after this record.

① derived judgments (seat-measured on the fetched head a3d075e0 against merge-base a90a9f26, ⛔ not taken from the report):

  1. Shape 1 only, as ruled: --status now ends with a liveness block — one instrument: control line, then one verdict line per holder and per queued ticket (pid exists or not; same process by /proc start time or a reused pid; age of the record). The snapshot is taken BEFORE the existing holder_line / queue_live paths, which silently delete every record they judge dead — the reason a run that died holding the lock left no trace on the surface a seat reads. The control proves both directions in one output (own pid alive, a reaped child dead) and on failure --status prints NO reading and exits 3. Shapes 2–3 deferred, as the claim ruled.
  2. Every pre-existing line is byte-identical: seat diff of --status on origin/main's script versus the head's, both truncated at the instrument: line, on the same free lock → empty; the state: / queue: spellings SKILL.md :436–:437 count are present and unchanged (the dev's rich fixture — live holder, live waiter, dead waiter, reused pid, parked slot — diffs empty too, with the one declared normalisation of held|waiting|kept Ns). The only consumers of --status output (os-dev.md :103 prose, SKILL.md :436–:437 counting queue N: lines) were measured, not recalled; every new line carries a prefix no reader counts.
  3. Pins: --self-test exit 0 on the head (seat-run; 243 cases, 228 before — 14 new, driving --status as a child process against a private lock); the dev's two ablations from the committed state (the snapshot moved after the reaping paths → 2 cases red; the control's dead leg forced to succeed → 3 cases red) restored to the HEAD blob — the pins bite, and the refusal, not the printed line, is the mechanism. OS_VERIFY_LOCK_SIM_LIVENESS=alive|dead exists only for the suite and is announced on stderr on every invocation.
  4. Gates: 26 derived / 26 run / 0 NOT-MEASURED / 0 UNRUN, all exit 0 (including check:bash32-floor), plus the path-scheduled CI job's own --self-test; two roster families (check:dts-closure, check:published-readme-exports) exit 3 without a full build — NOT MEASURED, and neither reads a .sh file; check:declared-population-live exit 0 on the head (seat-run). Checks on the head at 2026-09-14T06:51Z: 21 success / 11 skipped / 2 in progress / 0 red.
  5. Scope held: one file, +338/−3; skip-changeset is the declaration; Clause-②: no holds — no contract path; --pair 18149 on origin/main's reader → exit 0 at 2026-09-14T06:51Z before this record; 3 REST writes, 0 MCP calls per the report.

② semver: unchanged — scripts/pm/** publishes nothing.

③ boundary flags: the Verify-lock entry-point self-test step comment in lint.yml says 「48 cases」 while the suite runs 243 — prose drift nothing reads; the next PR editing that step carries it (no card). The dev's PR-body reading (a REST-created PR with a bare session-URL footer stores two footers ⇒ send none) joins the row #18145's dev named — one platform-readings row when the register is next touched. Shapes 2–3 of #17966 (the SKILL.md rule line; a seat-side sweep) stay on the card's successor behind PR #18128.

Implemented-by: claude/issue-17966-verify-lock-liveness-reading
Reviewed-by: session_01DAcomhvR9kKizeYgg89Vo8

Verdict: PASS — path face clean ⇒ this seat lands: ready through POST …/pulls/18149/ccr/ready_for_review with read-back, then PUT …/ccr/auto_merge; Fixes #17966 closes the card on landing.

维护者速读(终稿)

验证锁的 --status 现在多打一段"活性读数":先一行仪器自检(自己的 pid 必须读活、刚回收的子进程必须读死,自检不过就拒绝输出并退 3),再逐条报每个持有者和排队票的 pid 是否还在、是否同一进程。原来的每一行一个字节没变,SKILL.md 数队列深度的那两种行照旧。自测 228 → 243。脚本层,本席直接落地;卡上的规则行那一半等 PR #18128 之后。


Generated by Claude Code

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

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

1 participant