Skip to content

feat(approvals): atomic claim to fix concurrent-exec race (v0.3.1)#7

Merged
amittell merged 1 commit intomainfrom
feat/concurrent-approval-lock
Apr 21, 2026
Merged

feat(approvals): atomic claim to fix concurrent-exec race (v0.3.1)#7
amittell merged 1 commit intomainfrom
feat/concurrent-approval-lock

Conversation

@amittell
Copy link
Copy Markdown
Owner

Summary

Two concurrent agentcli exec calls on the same gated task could both observe the same pending approval and both consume it, allowing both to execute from one grant. This PR adds claimApproval: an atomic find-then-consume primitive wrapped in an fs-lockfile. enforceApprovalGate in src/exec.js now uses claimApproval in place of the separate findValidApproval + consumeApproval pair. Concurrent claims serialize to exactly one winner per grant.

Cuts v0.3.1 (pure bug fix, no CLI or manifest schema changes).

Design

  • openSync(lockPath, 'wx') for atomic lock creation (works on macOS/Linux/Windows)
  • Atomics.wait on a SharedArrayBuffer for sync backoff during contention — no new npm deps, no busy-waiting, no child processes
  • Stale locks (older than 30s) are treated as abandoned (crashed holder) and broken
  • approval_lock_timeout (default 5s contention window) surfaces genuine deadlocks
  • Signature verification still runs after the atomic claim; a bad signature refuses execution and the consumed grant is audited

Test plan

  • npm run lint clean
  • npm test green — 663/663 (was 659 + 4 new concurrency tests)
  • Worker-thread test (N=8 parallel claims on one grant) — exactly 1 winner, 7 null returns, 0 errors
  • Two pending grants + two parallel claims — both succeed with distinct grants
  • Stale lock (hour-old mtime) is broken, claim proceeds
  • Fresh lock held past timeout raises approval_lock_timeout with correct error code
  • CI lint-test passes

Files

  • src/approvals.jswithApprovalsLock helper, claimApproval exported, updated module-level comment
  • src/exec.jsenforceApprovalGate rewired to claimApproval, import list adjusted
  • src/index.js — barrel export of claimApproval
  • src/describe.jsapprove command summary mentions serialization
  • test/approvals.test.js — 4 new concurrency tests
  • package.json — 0.3.0 → 0.3.1
  • CHANGELOG.md — new 0.3.1 section

Two concurrent agentcli exec calls on the same gated task could both
observe the same pending grant and both append consume events, allowing
both to execute from one approval. Fixed by introducing claimApproval,
a single atomic primitive that performs find-then-consume under an
fs-lockfile.

Lock implementation (no new deps):
- openSync(lockPath, 'wx') for atomic creation
- Atomics.wait on SharedArrayBuffer for sync backoff during contention
- Stale locks (older than 30s) are treated as abandoned and broken
- approval_lock_timeout (default 5s) surfaces genuine contention

enforceApprovalGate in src/exec.js now calls claimApproval instead of
findValidApproval + consumeApproval separately. Signature verification
still runs after the atomic claim; a bad signature refuses execution
with approval_signature_invalid (the consumed grant is audited).

Tests added (4):
- N=8 worker threads racing on one grant → exactly 1 winner
- 2 pending grants + 2 concurrent claims → both succeed, distinct grants
- Stale lock (hour-old mtime) is broken and claim proceeds
- Fresh lock held past timeout raises approval_lock_timeout

claimApproval added to the barrel export. Previously-exported
consumeApproval remains for callers that consume without claiming;
approvals.js module comment updated to describe the new atomicity.

Release cut to 0.3.1 (bug fix, no schema or CLI surface changes).
@amittell amittell merged commit 7065b68 into main Apr 21, 2026
1 check passed
@amittell amittell deleted the feat/concurrent-approval-lock branch April 21, 2026 22:05
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