feat(m2): WorktreeMutex + crucible cleanup CLI#8
Open
suzuke wants to merge 3 commits into
Open
Conversation
The worktree-level concurrency primitive that future parallel BFTS
workers will use to claim a worktree without stepping on each other.
For v1.0 single-process mode this is a near-free no-op; the value is
the invariant ("one attempt per worktree at any time") plus crash
recovery via the cleanup command.
`crucible/locking.py` (~280 LOC):
- `WorktreeMutex(workspace, timeout=30.0)` context manager. Lock file
at `<workspace>/logs/locks/<id>.lock` — platform-owned, NOT
configurable (reviewer round 1 Q1 — don't rely on user files.hidden).
- **flock IS the authority, sentinel JSON is metadata only** (reviewer
round 1 PRIMARY concern). The kernel `flock(LOCK_EX|LOCK_NB)` decides
ownership; the sentinel is overwritten under flock for diagnostics.
Never unlink/recreate based on sentinel content — that creates
split-brain (process A holds inode X, B unlinks and locks new inode
Y, both think they own).
- `LockOwner` records `pid`, `host`, `process_create_time` (best-effort
via psutil if available) — defends against PID reuse for cleanup
classification ONLY, never to bypass a failed flock acquisition.
- Windows: matches TrialLedger v1.0 stance — `acquire()` raises clearly;
not a silent no-op.
- `classify_worktree()` + `safe_cleanup_lock()` for the cleanup CLI:
classify is metadata-driven (live / stale / orphan / no-lock-file),
but actual deletion requires `try_acquire_for_cleanup` succeeding
non-blocking — flock is still authoritative.
Orchestrator integration:
- `_run_loop_serial()` wraps the entire loop in a single mutex acquire
with 5s timeout. If another process already holds the worktree, log
a clear error and exit cleanly. On Windows, the import raises and we
fall through to unsafe single-process mode (matches ledger).
CLI:
- `crucible cleanup [--apply] [--yes]` with reviewer round 1 safety
contract:
- dry-run by default; `--apply` actually deletes; `--yes` skips
confirmation prompt
- even with `--apply`, attempts non-blocking flock first; busy
worktrees are skipped (reported separately from cleaned)
- discovery is narrow: only inspects the project's own worktree for
now, not future M3 parallel-worker layouts
Tests (17 new, all passing):
- Acquire / release / reacquire round-trip (4)
- Sentinel metadata: written under flock, overwritten on next acquire
without unlink (2)
- **CRITICAL split-brain prevention** (`test_no_split_brain_stale_
sentinel_with_live_holder`): subprocess holds flock + writes
stale-LOOKING sentinel; main process MUST get WorktreeLocked, MUST
NOT unlink/recreate, sentinel content unchanged after attempt
- Subprocess contention timeout with owner metadata in error (1)
- Cleanup classification: no-lock / live / orphan / stale (4)
- Cleanup safety: busy skip / unheld acquire (2)
- is_owner_alive: dead pid / self / cross-host conservative (3)
Full suite: 2625 passed / 4 skipped (was 2608 in PR 13; +17 from new
lock tests), 0 regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…(F1+F2+F3+F4)
Reviewer round 2 REJECTED PR 14 with 4 blocking findings. All addressed.
F1 — cleanup MUST NOT unlink lock file under flock:
- Original `cleanup --apply` called `lock_path.unlink()` after acquiring
flock, which violates the module's own contract ("flock IS authority,
never unlink/recreate"). Reviewer verified the inode-split attack:
process B holds old inode while polling, cleanup unlinks the path,
process C creates a new file and locks the new inode, B's release
leaves both with "valid" flocks on different inodes.
- Fix: cleanup now only refreshes sentinel metadata under flock; the
lock file path is NEVER unlinked. The acquire path already overwrites
the sentinel, so the held context manager's release leaves the file
with up-to-date metadata. New regression test
`test_cleanup_does_not_unlink_lock_file` asserts this.
F2 — CLI scope vs help text mismatch:
- Original help said "delete/prune orphan worktrees" but code only
unlinked the lock pathname. Worktree directories were never touched.
- Fix: command renamed in scope. New help: "Inspect / refresh stale
worktree lock metadata." Flag changed from `--apply` to `--refresh`
reflecting actual behavior. Worktree-directory pruning is M3 scope
once the parallel-worker layout exists.
F3 — Lock file was agent-readable:
- Original location `<workspace>/logs/locks/<id>.lock` was inside the
workspace. Claude Code's Read/Glob/Grep hooks default-allow any non-
hidden path; the lock was reachable. Reviewer's claim that it was
"platform-owned, not exposed to agent" was incorrect.
- Fix: lock files now live at `{tempdir}/crucible-locks/<sha>.lock`
(outside ANY workspace, unreachable by smolagents `_path_acl`'s
absolute-path rejection AND by Claude Code's workspace-scoped hooks).
Lock-id is the SHA-256 of the resolved workspace path (32-hex prefix
for unique-enough identity). New helper `lock_dir()` respects
`CRUCIBLE_LOCK_DIR` env var for testing / op coordination.
- New regression: `test_lock_path_is_outside_workspace` asserts the
lock path is NOT a descendant of the workspace tree.
F4 — Orchestrator silently swallowed Windows RuntimeError:
- Original code did `except RuntimeError: mutex = None` then proceeded
without lock — the exact silent no-op I claimed I wouldn't do.
- Fix: explicit Windows branch logs a clear warning ("WorktreeMutex
unsupported on Windows in v1.0 — running without cross-process lock
enforcement") and proceeds. POSIX path is unchanged: WorktreeLocked
cleanly aborts the run, no swallowed exceptions.
Tests:
- 19 lock tests (was 17; +2 for F1 and F3 regressions)
- Full suite: 2627 passed / 4 skipped (was 2625), 0 regressions
- Subprocess tests now use `CRUCIBLE_LOCK_DIR` env var for isolation
so subprocess helpers see the same lock directory as the parent.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewer round 3 still REJECTED on a config hole in F3:
`CRUCIBLE_LOCK_DIR` was accepted verbatim, so a misconfigured (or
hostile) value pointing back into the workspace would re-expose the
lock file to agent tools.
Fix:
- New `WorktreeLockConfigError` exception.
- `WorktreeMutex.__init__` resolves the lock path and validates it
is NOT inside the workspace tree. Violation raises the new error
with a clear "Unset or correct CRUCIBLE_LOCK_DIR" message.
- `lock_dir()` always resolves the env override to absolute up-front,
so a relative `CRUCIBLE_LOCK_DIR` can't be steered by cwd.
- Helper `_is_inside(path, ancestor)` for the containment check
(works on Python 3.10+ without depending on `Path.is_relative_to`).
Tests (+5):
- `test_env_override_inside_workspace_rejected` — direct workspace path
- `test_env_override_inside_workspace_subdir_rejected` — nested path
- `test_env_override_outside_workspace_ok` — legitimate ops case
- `test_env_override_relative_pointing_into_workspace_rejected` —
relative env value resolved with cwd inside workspace
- `test_default_tempdir_placement_passes_containment` — sanity for
the no-override path
Reviewer's repro that was failing:
`CRUCIBLE_LOCK_DIR=<workspace>/locks` -> WorktreeMutex(workspace)
Before: succeeded, lock at `<workspace>/locks/<sha>.lock`
After: raises WorktreeLockConfigError("inside workspace")
Tests: 24 lock tests (was 19; +5 round-3 regressions). Full suite:
2632 passed / 4 skipped (was 2627), 0 regressions.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
19 tasks
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.
Summary
Stacked on #7 (M2 PR 13 smolagents). Final M2 PR. Adds the worktree-level concurrency primitive (
WorktreeMutex) that future parallel BFTS workers will use to claim a worktree without stepping on each other. For v1.0 single-process mode this is a near-free no-op; the value is the invariant ("one attempt per worktree at any time", per spec §4) plus crash recovery viacrucible cleanup.Also closes the gap noted in spec §11 INV-4: TrialLedger's per-write
fcntl.flockis fine for one process, but cross-process worktree contention had no story until now.What's new
src/crucible/locking.py(new, ~330 LOC)Design contract (reviewer round 1 PRIMARY):
flock(LOCK_EX | LOCK_NB)IS the authority. The JSON sentinel file is metadata only — useful for diagnostics ("who holds it?") and cleanup classification, but NEVER used to bypass a failed acquisition.{tempdir}/crucible-locks/{sha256(resolved_workspace_path)[:32]}.lock, unreachable by smolagents_path_acl(rejects absolute paths) and Claude Code's workspace-scoped Read/Glob/Grep hooks.WorktreeMutex.__init__validates the resolved lock path is NOT inside the workspace tree —WorktreeLockConfigErrorwith actionable message ifCRUCIBLE_LOCK_DIRis misconfigured into the workspace.API:
WorktreeMutex(workspace, *, timeout=30.0)context manager withacquire()/release()/heldproperty.WorktreeLockedraised on contention timeout, carries owner metadata for diagnostics.LockOwnerrecordspid/host/process_create_time(best-effort via psutil) for PID-reuse defence.is_owner_alive()/classify_worktree()/safe_cleanup_lock()for the cleanup CLI — metadata-driven classification but actual deletion gated on non-blocking flock acquisition.crucible cleanupCLIInspect / refresh stale worktree lock metadata for the current project:
Reviewer round 2 F1 / F2 safety contract:
--refreshdoes NOT unlink the lock file. The acquired flock has already overwritten the sentinel with current owner metadata; release leaves the file with up-to-date content. Unlinking would re-introduce inode-split (process B holds old inode, cleanup unlinks path, C creates new file → both have "valid" flocks on different inodes).Orchestrator integration
_run_loop_serial()wraps the entire loop in a single mutex acquire (5s timeout, fail-loud on contention). Single-process mode pays effectively zero overhead; future parallel workers each pin their own worktree.Windows: explicit branch logs a clear warning (
WorktreeMutex unsupported on Windows in v1.0 — running without cross-process lock enforcement. Concurrent crucible runs against the same workspace will race; use one process at a time.) and proceeds. POSIX path is unchanged.Reviewer trail
<workspace>/logs/locks/...was agent-readable via Claude Code Read hooks. F4: orchestrator silently swallowed Windows RuntimeError.CRUCIBLE_LOCK_DIRaccepted verbatim, so an env value pointing into the workspace re-exposed the lock.WorktreeMutex.__init__containment-checks resolved lock path against workspace, raisesWorktreeLockConfigErroron violation. Reviewer ran 4-case repro: absolute-into-workspace, nested-into-workspace, legit-external (passes), relative-via-cwd-into-workspace — all behave correctly.Stats
feat/m2-ledger-lock(b7a6fe2initial +a6be393round-2 fix +6f36a1dround-3 F3 follow-up)test_locking.py(was 17 round-2; +7 across rounds)Test plan
Acquire / release / sentinel mechanics
CRITICAL split-brain prevention (reviewer R1 primary)
test_no_split_brain_stale_sentinel_with_live_holder: subprocess holds flock + writes "PID 99999999 / 2020-01-01" sentinel; main process MUST getWorktreeLocked, MUST NOT unlink, sentinel content asserted unchanged after attemptSubprocess contention
Cleanup safety (reviewer R2 F1+F2)
test_cleanup_does_not_unlink_lock_file: pre-write stale sentinel → safe_cleanup_lock acquires & releases → file STILL exists, sentinel pid is current runner not 99999999Lock-outside-workspace invariant (reviewer R2 F3 + R3 F3 follow-up)
test_lock_path_is_outside_workspace: default placement is NOT a descendant of workspacetest_env_override_inside_workspace_rejected:CRUCIBLE_LOCK_DIR=<ws>/locks→WorktreeLockConfigErrortest_env_override_inside_workspace_subdir_rejected: nested workspace path rejectedtest_env_override_outside_workspace_ok: legitimate external override workstest_env_override_relative_pointing_into_workspace_rejected: relative env value resolved against cwd-inside-workspace → rejectedtest_default_tempdir_placement_passes_containment: no-override path is outside workspaceWindows (reviewer R2 F4)
WorktreeMutex.acquire()raisesRuntimeErroron Windows (mirrors TrialLedger v1.0 stance)Cleanup classification
no-lock-file/live/orphan(malformed sentinel) /stale(dead PID, same host)is_owner_alive: dead pid / self / cross-host conservativeKnown limitations / non-blockers
is_owner_alivereturns conservative-true on cross-host PIDs. v1 assumes single-host operation; distributed locking via Redis/etcd is out of scope.psutil: when psutil is unavailable,is_owner_alivefalls back to PID-only matching — small window for false positives, acceptable for cleanup classification (acquire path doesn't trust this anyway).Closing M2
This is the LAST M2 PR. With #4 (doom-loop pruning), #5 (compare mode), #6 (HMAC seal), #7 (smolagents backend), and now #8, the M2 spec deliverables are complete:
+ doom-loop pruning+ comparison+ HMAC upgrade+ smolagents adapter+ concurrency lock🤖 Generated with Claude Code