fix(core): reclaim stale metadata locks and report contention as busy - #150
Merged
Merged
Conversation
A .workit/metadata.lock left by a dead process bricked the store: every mutation returned recovery_required and nothing could clear it. Contention between live writers (timeoutMs 0, no retries) was also mapped to recovery_required, which the bootstrap treats as a stop condition. - Reclaim a lock whose owner is provably gone: dead pid, a pid reused by a different process (start time differs), a foreign-host lock past a 10 min TTL, or an unreadable lock past 30 s. Abandoned reclaim guards past 30 s are cleared as well. - Retry a live holder for up to 2 s, then return the new retryable `busy` code. Losing a reclaim race retries instead of failing. - Genuine data-integrity failures (corrupt or foreign records) and the explicit state.recover protocol keep returning recovery_required. - `workit doctor` gains a workspace_lock check; `workit doctor --fix-lock` clears a stale lock and abandoned guard, never a live one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Review findings on the lock reclaim change: - Lock identity now includes the Linux pid-namespace inode and boot id, folded into `host` (`host#pidns:bootid`) so older strict schemas still parse it. A container sharing the hostname, another boot, or a legacy lock is treated as foreign (TTL rules only) instead of being checked against this host's process table. - `doctor --fix-lock` clears under the same `.reclaim` guard writers take, skips when a reclaim is in progress, and re-verifies the bytes just before removal, so it can no longer delete a lock a writer just took. - The wait budget is 250 ms by default (in-process hosts) and 2 s in the CLI process; a lost reclaim race retries within the remaining budget. - /proc stat start time is parsed after the last ")"; macOS/FreeBSD use `ps -o lstart=`. `doctor --fix-lock --force` (with --yes or a TTY confirmation) is the explicit escape hatch; `--fix-lock` honours WORKFLOW_WORKSPACE_ROOT like task commands. - Bootstrap: a revision_conflict on a call without expectedRevision is retryable contention. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iable ones - A lock from the same host and pid namespace but another boot id is stale at once: its owner died with the previous boot. - doctor's workspace_lock warns when an unverifiable lock (other host, pid namespace, or an older Workit's plain-hostname lock) has blocked writes for over 30 s, with the exact `workit doctor --fix-lock --force --yes` command. - Regression test: a plain-hostname lock naming live pid 1 with a mismatched start time (a container's lock) stays busy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ks carry namespace identity On macOS and Windows there is no pid-namespace identity, so a plain-hostname lock is the host's own format and pid checks apply. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Resolve task-store.ts: keep main's task index; the metadata lock schema now lives in store-lock.ts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d replace Under multi-process contention Windows briefly refuses to rename over, or open, a file another process is reading or renaming (EPERM/EACCES/EBUSY). That surfaced as storage_error (and could surface as recovery_required on read). Retry for about a second before reporting the error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
BrainerVirus
added a commit
that referenced
this pull request
Oct 3, 2026
Ports #150's `workit doctor --fix-lock [--force [--yes]]` from the old index.tsx dispatcher into verbs/doctor.ts. The CLI's 2 s default lock timeout moves into the router's process setup (installDiagnostics). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Contributor
|
🎉 This PR is included in version 2.2.0 🎉 The release is available on:
Your semantic-release bot 📦🚀 |
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.
What
.workit/metadata.lockis now reclaimed automatically when its owner is provably gone:/procstart time differs),An abandoned
metadata.lock.reclaimguard older than 30 s is also cleared.When a live process holds the lock, a write retries with jittered backoff for up to 2 s. After that it returns a new retryable error code,
busy, neverrecovery_required. A writer that loses a reclaim race retries instead of failing.recovery_requiredstill comes back for real state damage: corrupt or foreign records, an invalid workspace binding. The explicitstate.recoverprotocol keeps its fail-closed lock semantics.workit doctorhas a newworkspace_lockcheck. It warns on a stale lock or an abandoned guard.workit doctor --fix-lockclears both and never removes a live or unverifiable lock.The bootstrap now tells agents to retry
busyand points them to--fix-lock. README, AGENTS.md and CHANGELOG are updated.New module:
packages/workit-core/src/core/store-lock.ts. It holds the lock schema and parser (moved out oftask-store.ts), the owner classifier, and the inspect/clear helpers thatdoctoruses.Why
The architecture audit (§6 #1–2, §7.2 #1) found two problems:
shouldReclaim: () => falseandstaleRecovery: "fail-closed", nothing could clear the lock, and AGENTS.md forbids deleting it by hand.timeoutMs: 0, retries: 0) was reported asrecovery_required, which the bootstrap treats as a stop condition.Given/When/Then covered (
test/workit-core/store-lock.test.ts,test/workit-cli/doctor.test.ts)okorbusy: under full-suite load one run saw 117 ok and 3 busy, so the follow-up commit d6ece39 acceptsbusythere.workit doctorruns, Then it warns and names--fix-lockworkit doctor --fix-lockruns, Then both are clearedworkit doctor --fix-lockruns, Then the lock is keptExisting tests updated, none deleted:
task-store.test.ts: the "leftover locks stay inspectable" test asserted the old bricking behavior. It now asserts that reads stay inspectable and that the write reclaims the expired foreign lock.reliability-report.test.ts: doctor check counts go from 20 to 21 for the new check.install-scripts.test.ts: the stub copiesstore-lock.ts, whichdoctor.tsnow imports.Verification (measured, Node 24.20.0, Bun 1.3.14, in the worktree)
Red before the fix: 8 of the 9 new store-lock tests failed. The in-process stress test returned 76/120
recovery_required.Green after: 9/9 store-lock tests, 23/23
task-store, 6/6 CLI doctor, 4/4 reliability-report, 17/17 install-scripts.End to end through the built CLI (
node packages/workit-cli/dist/index.js task progress, 3 processes × 40 calls):recovery_requiredrevision_conflictorigin/mainbun run lint✅,bun run format:check✅,bun run typecheck✅,bun run knip✅ (only the existing configuration hints),bun run build✅.Full
bun test(run on d636352): 1583 pass / 1 fail. The one failure,AR-14: negative fixtures never leak raw git usage…intest/workit-core/doctor.test.ts, already fails onorigin/main. ItsrepoRootresolves to the parent of the checkout, so it runsbun testacross sibling worktrees. I ran it on anorigin/mainworktree and it failed there too. It is not touched here.Review fixes (commit 61ffbc9)
An independent adversarial review (kill -9 stress, containers) found these. All fixed:
hostis nowhostname#<pid-ns inode>:<boot_id>on Linux. It is folded into the existing string, so older strict schemas still parse it. A lock from a different pid namespace or boot, or one written by an older Workit, is treated as foreign: TTL rules only, and its pid is never checked against this host's process table. Test: a pid 1 lock with a foreign namespace suffix returnsbusywhile fresh and is reclaimed past the TTL. A legacy plain-hostname lock is also handled by TTL only.doctor --fix-lockrace. Clearing now holds the same.reclaimguard that writers take. It skips when a fresh guard exists and re-checks the bytes just before unlinking. I ported the reviewer'sdoctor-race.tsas a test (doctor preempted 400 ms before the rm while writer W reclaims): the critical sections of the two writers never overlap.setDefaultLockTimeout. A retry after a lost reclaim race uses the remaining budget instead of starting a new one./proc/<pid>/statstart time is parsed after the last), so a process name with spaces or parentheses no longer shifts it (unit test). macOS and FreeBSD useps -o lstart=.doctor --fix-lock --forceprints the holder and refuses without--yesor a TTY confirmation.--fix-lockand the doctor lock check honourWORKFLOW_WORKSPACE_ROOT, like task commands (CLI tests).revision_conflicton a call withoutexpectedRevisionis retryable: re-read and retry. Revisions are still checked against the pre-lock read; a bounded engine retry is left for a follow-up slice.Measured after the fixes (Node 24.20.0):
orchestrate.sh: 4 workers for 30 s, 118kill -9s, plus aclearStaleMetadataLockloop (1.18M calls, 28 stale locks cleared). Results: 831 ok, 186busy, 66revision_conflict, 0 critical-section violations, 0recovery_required, and the final write succeeded.doctor-race.ts, with the lock written in the new identity format: the lock still existed while the doctor held the guard, W returned retryablebusy, and X wrote afterwards with no overlap. The reviewer's original script writes a legacy plain-hostname lock, which is now foreign and TTL-only, so the doctor correctly leaves it alone.bun test: 1590 pass / 1 fail (AR-14, which also fails on main).Re-review fixes (commit 33d8426)
hostname()lock naming live pid 1 with a mismatched start time (a container's lock written before namespace identity) staysbusyand is not reclaimed. Measured: this test fails on d6ece39 and passes now.boot_idis stale at once, with no 10-minute wait after a crash plus reboot. Measured: the test fails on d6ece39 and 61ffbc9 and passes now.workspace_lockwarns when an unverifiable lock (another host, another pid namespace, or a legacy lock) has blocked writes for more than 30 s, and gives the fixworkit doctor --fix-lock --force --yes. A fresh lock of the same kind still passes. Measured: the test fails on 61ffbc9 and passes now.origin/mainto resolve atask-store.tsconflict with the task index from perf(opencode): task index and cached, capture-free per-turn context #149. Main's index code is kept, and the lock schema stays instore-lock.ts. After the merge:bun run test1204/0,test:packaging247/0, lint, format:check, typecheck and knip pass, and CI is 15/15 green.Known limits / follow-ups
revision_conflictstill shows up under contention when the caller omitsexpectedRevision. The engine reads the revision before it takes the lock, so another writer can win in between. This conflict is retryable and is notrecovery_required. Fixing it means resolving revisions under the lock in the engine, which is out of scope here./procon Linux andps -o lstart=on macOS/FreeBSD; elsewhere reclaim relies on pid liveness alone.doctor --fix-lock --force --yesis the explicit escape hatch.🤖 Generated with Claude Code