Skip to content

feat(pm): declared unlocked mode where the verify lock has no flock - #11538

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-10611-macos-declared-unlocked-mode
Aug 24, 2026
Merged

os-steve merged 1 commit into
mainfrom
claude/issue-10611-macos-declared-unlocked-mode

Conversation

@claude

@claude claude Bot commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #10611

The shared verification lock is now declared Linux-only. 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, and prints the disclosure its
PR body must carry.

The ruling this implements

Maintainer ruling recorded 2026-08-22 (「接受所有」 batch, comment 5377362474),
verbatim:

Ruled: Option B (the card body's option 4) — the shared verification lock is
declared Linux-only. macOS gets an explicit, declared unlocked mode instead of
a refusal: the degradation is written into the VERDICT line so it is visible and
auditable, and the disclosure format the two unlocked-verification precedents (#10219,
#10221) used becomes the official format, plus the one-line documentation change.
Attached reversal clause: this ruling is to be revisited if macOS becomes a required
agent host for the fleet — at that point the perl flock(2) shim direction (card
option 3) is the standing preference.

Card options 1 (brew prerequisite), 2 (mkdir lockfile) and 3 (perl flock(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 main

The card is dated 2026-08-21 and describes PR #10609's state. Re-read at daacc107: the
refusal path is still shaped as described — the functional flock probe in preflight
adds its finding to PREFLIGHT_PROBLEMS, and mode_run turns any non-empty
PREFLIGHT_PROBLEMS into VERDICT lock-unusable (exit 99) · never acquired · refused before waiting. The card's premise holds.

What changed

preflight splits one finding out of the refusal set. PREFLIGHT_PROBLEMS keeps its
meaning — every entry is a refusal — and the flock finding moves to its own
PREFLIGHT_NO_FLOCK / PREFLIGHT_NO_FLOCK_NOTE pair. Summing the two into one verdict is
precisely what would hide that they lead to opposite outcomes.

mode_run gains one branch, placed on purpose: after the filter preflight (a
command 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_unlocked runs the command with no lock, prints a VERDICT 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.

--status reports the degraded host as degraded rather than as unusable, and stops
answering lock is free — a line it can only produce by failing to take a lock it has no
primitive for.

One documentation line, in .claude/skills/pm-dispatch/references/true-green.md, the
per-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 invariant

The 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 mkdir option would have given up, so it is pinned rather than
asserted — four self-test cases, three after an ordinary exit and one after a real
kill -9 mid-run:

✓ an unlocked run leaves no lock file
✓ no holder record
✓ no queue ticket directory
✓ and a kill -9 mid-run leaves nothing behind to reap either

2. Branch on the primitive, never on the platform

The trigger is the functional flock probe failing — uname is never consulted, so a
Linux container without util-linux degrades the same way and a macOS host with brew's
flock takes 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 /tmp on an ordinary Linux host would have read as "no flock" and
silently dropped serialization for every agent in the container. Both halves are pinned:

✓ a host problem that is NOT the flock one still exits 99
✓ and says lock-unusable, not queue-timeout
✓ and does not run the command
✓ and does NOT degrade to unlocked — flock was never the thing that failed
✓ a host WITH a working flock never enters unlocked mode

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, --status with a live holder,
--status once free, --show-budget default and clamped, and a usage error. Transcripts
normalized only for pids/durations/paths (s/[0-9][0-9]*/N/g) and diffed:

  • git show origin/main:scripts/pm/os-verify-lock.sh → before
  • this branch's scripts/pm/os-verify-lock.sh → after

diff reports exactly one hunk: the four lines added to the --help/usage text
describing the new mode. Every ACQUIRED, VERDICT command-exit, VERDICT queue-timeout, state:, queue: and budget: line is byte-for-byte identical:

### N contention: holder + waiter that times out
os-verify-lock: VERDICT queue-timeout (exit N) · never acquired · waited Ns · holder pid N, held Ns — sleep N

The full --self-test is the second half of that evidence: it drives the real acquisition
path under contention (FIFO ordering of three staggered waiters, legacy free-hand flock
coexistence 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 the
mutation landed on disk by grep-counting the removed text and the injected marker
before running anything, and restore to a byte-identical tree (diff -q against a
pristine copy). No rebuild leg applies: the subject is a shell script executed directly
by bash, with no dist/ between the edit and the run, so the mutation reaches the
measured 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.

leg mutation (confirmed on disk) result
revert the ruling PREFLIGHT_NO_FLOCK=1 → pf_add "ABLATION-refuse-on-no-flock" (marker count 1→0 / 0→1) 8 cases red, incl. an unusable flock RUNS the command unlocked instead of refusing and --status calls a no-flock host unlocked rather than unusable
merge the probe checks back the : > "$probe" creation guard deleted, marker ABLATION-merged-probe injected (1→0 / 0→1) 5 cases red, incl. a host problem that is NOT the flock one still exits 99 and and does NOT degrade to unlocked

The 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.mjs and scripts/pnpm-filter-targets.mjs (which name
the 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 run
passes 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.md statement 99 仍 专指「没排到」 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 zero
headroom under check:pm-skill-ratchet, whose ceilings may only be raised with a
maintainer ruling quoted in the raising PR. references/true-green.md carries no ceiling
(verified against the CEILINGS map, not recalled), which is where the one line landed.

Verification

Everything below ran at ec001e60, the final commit, on Linux with a working flock.

  • 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)
  • Gate union re-derived at the final commit with
    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 $?:
gate exit its own verdict line
check:cross-package-test-inputs 0 OK: 14 package(s) read outside themselves, all declared, and turbo.json hashes every declared glob.
check:doc-authoring 0 ✓ doc authoring guard: 389 files clean — no bare metadata literals.
check:doc-formula-expressions 0 ✓ …: 22 record-scoped formula example(s) across 420 files / 1449 TS blocks judged clean
check:entry-guard 0 ✓ check:entry-guard: 139 scripts/ file(s) — every entry guard goes through invoked-as.mjs
check:parse-guard 0 ✓ check:parse-guard: 138 scripts/ file(s) — every TypeScript parse goes through ts-parse.mjs.
check:pm-governed-merges 0 ✓ check-governed-merges --self-test: 129 assertions …
check:pm-skill-id-lint 0 ✓ check-skill-id-lint: 21 file(s) clean (pattern /#[0-9]{3,}/g).
check:pnpm-filter-targets 0 ✓ check:pnpm-filter-targets: 120/148 --filter occurrence(s) across 25 file(s) resolve …
check:skill-frame-sync 0 ✓ check-skill-frame-sync: 4 copies of the decision frame are structurally isomorphic across 3 files
check-ci-filter-parity.mjs 0 OK: all 89 declared cross-package glob(s) (77 unique) are covered …
check-cross-package-test-inputs.mjs 0 same script as the first row, run standalone
check:nul-bytes 0 check-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 0 ✓ check-skill-line-ratchet: CLAUDE.md is 86 lines (ceiling 86; headroom 0).
check:pm-dispatch-gates 0 ✓ dispatch-gates self-test: 557 cases pass.
check:pm-label-desc-cap 0 ✓ check:pm-label-desc-cap: 21 label descriptions … all ≤100 characters
check:where-matcher 0 ✓ where-matcher conformance holds: 290 matcher(s) discovered …

The last five are not in the derived list. Four are the families --residue flags as
silent-because-artifact-roster with the roster sitting in a directory one of these paths
is in — a verdict that "reads as a clearance and is not" — so they were run rather than
inferred; check:nul-bytes is run on any edit.

Declared narrowing — repo-wide pnpm lint was not run, and this is a measurement rather
than a skip.
Three pieces of evidence, all read from the tooling rather than assumed:

  1. Population, from eslint's own config. Every files: glob in eslint.config.mjs
    names extensions cjs cts js jsx mjs mts ts tsx. Neither sh nor md appears.
  2. Count, from --format json. npx eslint --no-inline-config --format json over both
    changed paths → exit 0, 2 result objects, 0 errors, each carrying the single message
    File ignored because no matching configuration was supplied.
  3. Invariance for untouched files. Type-aware linting is off by construction — the
    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 diff
    that adds no TypeScript.

pnpm lint therefore has zero files to say anything about here. CI runs the farm anyway.

No changeset, and the skip-changeset label is applied: the diff is a repo-internal
scripts/pm/ shell script plus an internal .claude/ doc, so nothing is published and no
package version moves. Line delta on the internal skills surface: true-green.md 32 → 34
(+2); the published skills/ catalog is untouched.

Scope

No other card is addressed here. #10303 remains open and stays pm:blocked on this one
until it lands; it was not touched. The reversal clause's perl flock(2) shim is not
built.

Generated by Claude Code


Generated by Claude Code

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
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 24, 2026
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation labels Aug 24, 2026
@os-steve
os-steve requested a review from os-zhuang August 24, 2026 02:52
@os-steve
os-steve marked this pull request as ready for review August 24, 2026 09:52
@os-steve
os-steve added this pull request to the merge queue Aug 24, 2026
Merged via the queue into main with commit 353f4e8 Aug 24, 2026
35 checks passed
@os-steve
os-steve deleted the claude/issue-10611-macos-declared-unlocked-mode branch August 24, 2026 10:00
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

os-verify-lock.sh now refuses loudly on stock macOS instead of spinning — but it still cannot take the lock there, because macOS ships no flock

2 participants