Repository navigation
feat(pm): declared unlocked mode where the verify lock has no flock - #11538
Merged
Merged
Conversation
The shared verification lock is declared Linux-only (maintainer ruling, 2026-08-22). A host with no usable `flock` no longer refuses at exit 99: it runs the command in a DECLARED UNLOCKED mode, writes the degradation into the VERDICT line so it is visible and auditable, and prints the disclosure its PR body must carry — the wording the two unlocked-verification precedents wrote by hand, promoted to the official format. Nothing is created on that path: no lockfile, no holder record, no queue ticket, no fd — so `kill -9` still leaves nothing to reap, which is the invariant a hand-rolled lockfile would have given up. The trigger is the FUNCTIONAL flock probe, never the platform name, so a Linux container without util-linux degrades the same way and a brew-equipped macOS takes the locked path. The probe file's creation is checked apart from the lock taken on it: an unusable temp dir stays a refusal rather than reading as "no flock" and silently dropping serialization for every agent in the container. Where flock works, the locked path is unchanged — the acquisition loops, the ordering layer, the budget and every verdict are byte-for-byte what they were. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
os-steve
marked this pull request as ready for review
August 24, 2026 09:52
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #10611
The shared verification lock is now declared Linux-only. A host with no usable
flockno longer refuses at exit 99 — it runs the command in a declared unlockedmode, writes the degradation into the
VERDICTline, and prints the disclosure itsPR body must carry.
The ruling this implements
Maintainer ruling recorded 2026-08-22 (「接受所有」 batch, comment
5377362474),verbatim:
Card options 1 (
brewprerequisite), 2 (mkdirlockfile) and 3 (perlflock(2)shim)are ruled against and are not built here. Option 3 exists only inside the reversal
clause, which fires on a maintainer decision.
Premise re-check, against today's
mainThe card is dated 2026-08-21 and describes PR #10609's state. Re-read at
daacc107: therefusal path is still shaped as described — the functional
flockprobe inpreflightadds its finding to
PREFLIGHT_PROBLEMS, andmode_runturns any non-emptyPREFLIGHT_PROBLEMSintoVERDICT lock-unusable (exit 99) · never acquired · refused before waiting. The card's premise holds.What changed
preflightsplits one finding out of the refusal set.PREFLIGHT_PROBLEMSkeeps itsmeaning — every entry is a refusal — and the flock finding moves to its own
PREFLIGHT_NO_FLOCK/PREFLIGHT_NO_FLOCK_NOTEpair. Summing the two into one verdict isprecisely what would hide that they lead to opposite outcomes.
mode_rungains one branch, placed on purpose: after the filter preflight (acommand that would measure nothing is refused whether or not there is a lock to spend on
it — the two checks are independent and both still apply) and before the budget, the
queue and the fd, none of which mean anything without the primitive.
run_unlockedruns the command with no lock, prints aVERDICT command-exit N · UNLOCKED (declared) · … the shared verify lock was NEVER taken and NOTHING was serialized · ran Ns, and passes the command's own exit code through.--statusreports the degraded host as degraded rather than as unusable, and stopsanswering
lock is free— a line it can only produce by failing to take a lock it has noprimitive for.
One documentation line, in
.claude/skills/pm-dispatch/references/true-green.md, theper-repo how-to-run index whose own discipline is one line per fact.
The three things most likely to go wrong, and what pins each
1. The
kill -9-releases invariantThe unlocked path creates nothing: no lock file, no holder record, no queue ticket, no
fd 9. There is nothing to reap, so a killed process leaves the host exactly as it found
it, and no sibling can read stale state and conclude the lock is held. That is the
property the rejected
mkdiroption would have given up, so it is pinned rather thanasserted — four self-test cases, three after an ordinary exit and one after a real
kill -9mid-run:2. Branch on the primitive, never on the platform
The trigger is the functional
flockprobe failing —unameis never consulted, so aLinux container without util-linux degrades the same way and a macOS host with brew's
flocktakes the ordinary locked path.The probe grew one refinement that is the whole guard against the worst regression here.
The probe file's creation is now checked apart from the lock taken on it. A probe file
that cannot be created says nothing whatever about
flock— the temp dir is unusable,which is also where the lock file lives — and stays a refusal. Merged into one finding, a
full or read-only
/tmpon an ordinary Linux host would have read as "no flock" andsilently dropped serialization for every agent in the container. Both halves are pinned:
3. The locked path is unaffected — measured, not asserted
Both scripts were driven through the same seven-part locked-path suite on this host (which
has
flock from util-linux 2.39.3): uncontended acquisition, exit-code passthrough,contention with a real holder and a waiter that times out,
--statuswith a live holder,--statusonce free,--show-budgetdefault and clamped, and a usage error. Transcriptsnormalized only for pids/durations/paths (
s/[0-9][0-9]*/N/g) and diffed:git show origin/main:scripts/pm/os-verify-lock.sh→ beforescripts/pm/os-verify-lock.sh→ afterdiffreports exactly one hunk: the four lines added to the--help/usage textdescribing the new mode. Every
ACQUIRED,VERDICT command-exit,VERDICT queue-timeout,state:,queue:andbudget:line is byte-for-byte identical:The full
--self-testis the second half of that evidence: it drives the real acquisitionpath under contention (FIFO ordering of three staggered waiters, legacy free-hand
flockcoexistence in both directions, the frozen-clock backstop, the ordinary full-budget
timeout) and passes 88/88 at this branch's head.
Reverse verification
Both legs mutate the committed file under a
trap '<restore>' EXIT INT TERM, confirm themutation landed on disk by grep-counting the removed text and the injected marker
before running anything, and restore to a byte-identical tree (
diff -qagainst apristine copy). No rebuild leg applies: the subject is a shell script executed directly
by
bash, with nodist/between the edit and the run, so the mutation reaches themeasured code by editing the file and nothing else — stated because a missing rebuild is
the usual way an ablation reads green while measuring the wrong tree.
PREFLIGHT_NO_FLOCK=1→pf_add "ABLATION-refuse-on-no-flock"(marker count 1→0 / 0→1)an unusable flock RUNS the command unlocked instead of refusingand--status calls a no-flock host unlocked rather than unusable: > "$probe"creation guard deleted, markerABLATION-merged-probeinjected (1→0 / 0→1)a host problem that is NOT the flock one still exits 99andand does NOT degrade to unlockedThe direction is plain "goes red" on both legs, which is what was predicted before running
them.
The exit-99 refusal has no other callers
Grepped for every reference to this wrapper in the repo. The only consumers are
.github/workflows/lint.yml(which runs--self-test, updated here),scripts/check-pnpm-filter-targets.mjsandscripts/pnpm-filter-targets.mjs(which namethe file as a rule-owning path, not its exit codes), and two prose docs. Nothing branches
on exit 99 from a missing
flock. The code is not repurposed either: an unlocked runpasses the command's own exit code through, so 99 keeps meaning "never got a turn" and
never doubles as "this host has no lock" — the
.claude/agents/os-dev.mdstatement99 仍 专指「没排到」stays accurate as written, which is why the one doc line went elsewhere.Note also that
AGENTS.md(961/961) and.claude/agents/os-dev.md(405/405) sit at zeroheadroom under
check:pm-skill-ratchet, whose ceilings may only be raised with amaintainer ruling quoted in the raising PR.
references/true-green.mdcarries no ceiling(verified against the
CEILINGSmap, not recalled), which is where the one line landed.Verification
Everything below ran at
ec001e60, the final commit, on Linux with a workingflock.bash scripts/pm/os-verify-lock.sh --self-test→ exit 0 ·✓ os-verify-lock self-test: all cases pass.(88 cases; 21 of them new or rewritten)node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no paths passed —the script derives the change set from the merge base itself). It named 11 families;
all 11 ran green, plus 5 more this diff visibly touches. Exit codes captured before any
pipe; each line below is the gate's own printed verdict, not a bare
$?:check:cross-package-test-inputsOK: 14 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.check:doc-authoring✓ doc authoring guard: 389 files clean — no bare metadata literals.check:doc-formula-expressions✓ …: 22 record-scoped formula example(s) across 420 files / 1449 TS blocks judged cleancheck:entry-guard✓ check:entry-guard: 139 scripts/ file(s) — every entry guard goes through invoked-as.mjscheck:parse-guard✓ check:parse-guard: 138 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.check:pm-governed-merges✓ check-governed-merges --self-test: 129 assertions …check:pm-skill-id-lint✓ check-skill-id-lint: 21 file(s) clean (pattern /#[0-9]{3,}/g).check:pnpm-filter-targets✓ check:pnpm-filter-targets: 120/148 --filter occurrence(s) across 25 file(s) resolve …check:skill-frame-sync✓ check-skill-frame-sync: 4 copies of the decision frame are structurally isomorphic across 3 filescheck-ci-filter-parity.mjsOK: all 89 declared cross-package glob(s) (77 unique) are covered …check-cross-package-test-inputs.mjscheck:nul-bytescheck-nul-bytes: OK (scanned 6459 text file(s) -- 6459 tracked, 0 untracked-not-ignored; skipped 5 binary; no raw ASCII control bytes).check:pm-skill-ratchet✓ check-skill-line-ratchet: CLAUDE.md is 86 lines (ceiling 86; headroom 0).check:pm-dispatch-gates✓ dispatch-gates self-test: 557 cases pass.check:pm-label-desc-cap✓ check:pm-label-desc-cap: 21 label descriptions … all ≤100 characterscheck:where-matcher✓ where-matcher conformance holds: 290 matcher(s) discovered …The last five are not in the derived list. Four are the families
--residueflags assilent-because-artifact-roster with the roster sitting in a directory one of these pathsis in — a verdict that "reads as a clearance and is not" — so they were run rather than
inferred;
check:nul-bytesis run on any edit.Declared narrowing — repo-wide
pnpm lintwas not run, and this is a measurement ratherthan a skip. Three pieces of evidence, all read from the tooling rather than assumed:
files:glob ineslint.config.mjsnames extensions
cjs cts js jsx mjs mts ts tsx. Neithershnormdappears.--format json.npx eslint --no-inline-config --format jsonover bothchanged paths → exit 0, 2 result objects, 0 errors, each carrying the single message
File ignored because no matching configuration was supplied.config's own comment at line 328 reads
(no parserOptions.project, no typed @typescript-eslint rules)— so no untouched file's judgment can move because of a diffthat adds no TypeScript.
pnpm linttherefore has zero files to say anything about here. CI runs the farm anyway.No changeset, and the
skip-changesetlabel is applied: the diff is a repo-internalscripts/pm/shell script plus an internal.claude/doc, so nothing is published and nopackage version moves. Line delta on the internal skills surface:
true-green.md32 → 34(+2); the published
skills/catalog is untouched.Scope
No other card is addressed here. #10303 remains open and stays
pm:blockedon this oneuntil it lands; it was not touched. The reversal clause's perl
flock(2)shim is notbuilt.
Generated by Claude Code
Generated by Claude Code