Files
portal/tools/enforce-parallel-session-lock.mjs
T
Дмитрий 79493879ae feat(router-gate-v4): Stream H Task 7 — parallel-session-lock pure module + PreToolUse wrapper (deferred activation)
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
2026-05-30 11:34:44 +03:00

48 lines
2.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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));
}