Skip to content

feat: detect and reap stale locks (lock_heartbeat, lock_reap) - #3

Open
warnes wants to merge 2 commits into
luohoa97:mainfrom
Warnes-Innovations:add-stale-lock-detection
Open

feat: detect and reap stale locks (lock_heartbeat, lock_reap)#3
warnes wants to merge 2 commits into
luohoa97:mainfrom
Warnes-Innovations:add-stale-lock-detection

Conversation

@warnes

@warnes warnes commented Jul 30, 2026

Copy link
Copy Markdown

Adds dead-agent detection: lock_query/lock_check_conflict now compute a stale flag (and staleForSeconds) on every active lock, and two new tools let an agent act on it explicitly:

  • lock_heartbeat — bumps only a lock's updated timestamp, for long stretches of work that aren't naturally hitting lock_update often enough to keep the lock from reading as abandoned.
  • lock_reap — finishes (same mechanism as lock_finish) every currently-stale active lock, or one specific lock_id. Refuses (LockNotStaleError) to reap a lock_id that isn't actually stale, so it can't be used to force-finish someone else's live work. Supports dry_run.

Staleness is a pure function of now and each lock's existing updated field — no new frontmatter, no separate heartbeat/pid field. Computed fresh on every read, never mutated as a side effect of reading (same principle as the rest of this project — see "No database, no in-memory cache" in the README). Threshold resolves from an explicit stale_minutes argument, then AGENT_LOCKS_STALE_MINUTES (read fresh every call, never cached, matching resolveLocksRoot's own philosophy), then a 60-minute default.

Deliberately does not use a PID-liveness signal, even though that was the original design idea I started from: a PID check means something different across this tool's two interfaces. An MCP server subprocess plausibly represents "is this session still connected" for its whole lifetime, but a CLI invocation's process exits within milliseconds of creating a lock, by design, while the claimed work may continue for hours — trusting PID liveness would flag every CLI-created lock as abandoned almost immediately. A pure timestamp-since-last-activity check means the same thing regardless of which interface touched the lock. README's new "Staleness detection" section has the full reasoning, plus a note on what a PID-aware follow-up could look like for the long-running-MCP-server case specifically.

30 new/updated tests (10 new in timestamp.test.ts for the new parseTimestamp — the inverse of formatTimestamp, needed for staleness math — 20 new in staleness.test.ts, 1 new e2e round trip, plus the tool-count assertion updated from 5 to 7). All pass, typecheck clean.

One real bug I hit and fixed in my own tests, worth flagging: lock timestamps have whole-second precision (see #4 — filed separately, since it's a pre-existing design property, not something this PR introduces), so a naive short-sleep test is flaky — truncation alone can show up to ~1000ms of apparent staleness with zero real inactivity. Tests now sleep past a full second and use thresholds calibrated to sit comfortably above that noise floor; staleness.test.ts documents the reasoning inline.

This branch doesn't touch the CLI — src/cli.ts doesn't exist here since it branched before #2 merged. Exposing agent-locks heartbeat/agent-locks reap is a natural small follow-up once both land; kept separate here so each PR stays independently reviewable regardless of merge order.

Pre-existing note: pnpm test on macOS will show 1 unrelated failure in git.test.ts (a symlink-canonicalization mismatch in resolveLocksRoot, not touched by this change) — already has a fix up in #1.

warnes and others added 2 commits July 29, 2026 20:32
Not upstream-bound — this fork uses a worktree-per-PR workflow that the
original repo doesn't need.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds dead-agent detection: lock_query/lock_check_conflict now compute a
stale flag (and staleForSeconds) on every active lock, and two new tools
let an agent act on it explicitly:

- lock_heartbeat: bumps only a lock's `updated` timestamp, for long
  stretches of work that aren't naturally hitting lock_update often
  enough to keep the lock from reading as abandoned.
- lock_reap: finishes (same mechanism as lock_finish) every currently-
  stale active lock, or one specific lock_id. Refuses (LockNotStaleError)
  to reap a lock_id that isn't actually stale, so it can't be used to
  force-finish someone else's live work. Supports dry_run.

Staleness is a pure function of `now` and each lock's existing `updated`
field (no new frontmatter, no separate heartbeat/pid field) — computed
fresh on every read, never mutated as a side effect of reading. Threshold
resolves from an explicit stale_minutes argument, then
AGENT_LOCKS_STALE_MINUTES (read fresh every call, never cached, matching
resolveLocksRoot's own philosophy), then a 60-minute default.

Deliberately reuses `updated` rather than adding a PID-liveness signal:
a PID check means something different across this tool's two interfaces
(an MCP server subprocess plausibly represents "is this session still
connected" for its lifetime; a CLI invocation's process exits within
milliseconds of creating a lock, by design, while the claimed work may
continue for hours) — trusting PID liveness would flag every CLI-created
lock as abandoned almost immediately. A pure timestamp-since-activity
check means the same thing regardless of which interface touched the
lock. README's new "Staleness detection" section has the full reasoning,
including a note on what a PID-aware follow-up could look like.

30 new/updated tests (10 new in the reused timestamp.test.ts for
parseTimestamp, 20 new in staleness.test.ts, 1 new e2e round trip, plus
the tool-count assertion updated from 5 to 7) — all pass, typecheck
clean. README documents both new tools and the staleness design.

This branch does not touch the CLI (src/cli.ts doesn't exist here — it
branched before #2 merged); exposing lock_heartbeat/lock_reap as
`agent-locks heartbeat`/`agent-locks reap` is a natural small follow-up
once both this and #2 have landed, kept separate here to keep each PR
independently reviewable regardless of merge order.

Pre-existing note: `pnpm test` on macOS will show 1 unrelated failure in
git.test.ts (same as #2 — not touched by this change, already fixed in #1).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant