79493879ae
Closes Stream H Task 7 (H7). Prevents two Claude sessions on the same
workspace from concurrently mutating files — addresses the cross-session
worktree collisions seen on 28.05/29.05 (deploy branch hijack + push
non-fast-forward incidents).
Architecture:
- Pure module tools/parallel-session-lock.mjs with injectable I/O
(readLock/writeLock/deleteLock) so unit tests cover all branches without
touching the real filesystem. Exports acquire(), refresh(), release(),
computeWorkspaceHash(), LOCK_DEFAULT_TTL_MS (5 minutes).
- Lock record schema (schema_version=1): {session_id, pid, acquired_at, ttl_ms}.
Stored at ~/.claude/runtime/session-lock-<workspaceHash>.json (production
binding handled in deferred batch). Workspace hash is MD5 first-12 hex of
the resolved workspace path.
- Acquisition semantics: stale (past TTL) → take over; same-session → idempotent
re-acquire; other-session fresh → block. refresh() is same-session only
(never steals). release() is same-session only (never deletes other's lock).
- Wrapper tools/enforce-parallel-session-lock.mjs exports decide(acquireResult,
sessionId) → {block, reason?}. Fail-open if acquireResult is missing
(internal-error safety net — avoids the Stream G Task 8 self-lockout
pattern). Block message names the other holder's pid for human triage
("parallel session lock held by <other> (pid N) — wait or close that
session first").
Defensive design:
- main() is a no-op (exit 0) until settings.json registration AND a Stop-hook
release pathway are wired together in the batched activation step. Activating
this hook before release-on-Stop would lock the user out of their own
session on first abnormal exit.
Regression: vitest tools 1763/1763 GREEN (was 1748; +10 pure-module tests
under "parallel-session-lock pure module (Stream H Task 7)" and
"computeWorkspaceHash (Stream H Task 7)" describe blocks; +5 wrapper-decide
tests under "enforce-parallel-session-lock wrapper (Stream H Task 7)").
DEFERRED: .claude/settings.json registration (PreToolUse matcher
"Edit|Write|MultiEdit|NotebookEdit|Bash", block-mode, timeout 3000ms);
Stop-hook release wiring; PostToolUse refresh-on-success wiring.
Batched at end of Phase H-α/H-β.
Stream H Task 7 of 11. Plan: docs/superpowers/plans/2026-05-30-router-gate-v4-stream-H.md
48 lines
2.0 KiB
JavaScript
48 lines
2.0 KiB
JavaScript
#!/usr/bin/env node
|
||
/**
|
||
* enforce-parallel-session-lock — PreToolUse wrapper around the pure
|
||
* parallel-session-lock module (router-gate v4 Stream H Task 7).
|
||
*
|
||
* Prevents two Claude sessions on the same workspace from concurrently
|
||
* mutating files. When session B tries a mutating tool while session A
|
||
* holds a fresh (non-stale) lock, B is blocked with a message naming A's
|
||
* pid for human triage.
|
||
*
|
||
* Activation: settings.json registration is deferred to Phase H-α/H-β
|
||
* batch step. main() is a no-op (exit 0) until then.
|
||
*/
|
||
import { acquire, release, refresh, computeWorkspaceHash } from './parallel-session-lock.mjs';
|
||
|
||
/**
|
||
* Pure decision: given an acquire() result, decide block/allow.
|
||
*
|
||
* @param {object} args
|
||
* @param {object|null|undefined} args.acquireResult - from parallel-session-lock.acquire()
|
||
* @param {string} args.sessionId - current session id
|
||
* @returns {{block: boolean, reason?: string}}
|
||
*/
|
||
export function decide({ acquireResult, sessionId }) {
|
||
// Fail-open if no acquire result (treat as internal error — never lockout).
|
||
if (!acquireResult || typeof acquireResult !== 'object') return { block: false };
|
||
if (acquireResult.acquired) return { block: false };
|
||
const holder = acquireResult.holder || {};
|
||
return {
|
||
block: true,
|
||
reason: `parallel session lock held by ${holder.session_id || 'unknown'} (pid ${holder.pid || '?'}) — wait or close that session first`,
|
||
};
|
||
}
|
||
|
||
async function main() {
|
||
// No-op until settings.json registration + Stop-hook release wiring lands
|
||
// in the deferred Phase H-α/H-β batch step. Activating this hook before
|
||
// the release pathway is wired would lock the user out of their own
|
||
// session on first abnormal exit.
|
||
let input = '';
|
||
for await (const chunk of process.stdin) input += chunk;
|
||
process.exit(0);
|
||
}
|
||
|
||
if (import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}` || (process.argv[1] || '').endsWith('enforce-parallel-session-lock.mjs')) {
|
||
main().catch(() => process.exit(0));
|
||
}
|