Files
portal/tools/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

93 lines
3.2 KiB
JavaScript

// tools/parallel-session-lock.mjs
/**
* Pure parallel-session lock — router-gate v4 Stream H Task 7.
*
* Prevents two Claude sessions on the same workspace from concurrently
* mutating files. Lock file lives at
* ~/.claude/runtime/session-lock-<workspaceHash>.json
* with TTL-based stale recovery (default 5 minutes).
*
* I/O is injected (readLock/writeLock/deleteLock) so this module stays pure
* and unit-testable. The wrapper hook (enforce-parallel-session-lock.mjs)
* binds real fs implementations.
*
* Lock format:
* { schema_version: 1, session_id, pid, acquired_at: <ms>, ttl_ms }
*/
import { createHash } from 'node:crypto';
export const LOCK_SCHEMA_VERSION = 1;
export const LOCK_DEFAULT_TTL_MS = 5 * 60 * 1000;
/** Derive a deterministic 12-char hex workspace hash from a path. */
export function computeWorkspaceHash(workspacePath) {
return createHash('md5').update(String(workspacePath || ''), 'utf-8').digest('hex').slice(0, 12);
}
function isStale(record, now) {
if (!record || typeof record !== 'object') return true;
const ttl = typeof record.ttl_ms === 'number' ? record.ttl_ms : LOCK_DEFAULT_TTL_MS;
return now - (record.acquired_at || 0) > ttl;
}
/**
* Try to acquire the lock for sessionId. Takes over stale or same-session locks.
*
* @param {object} args
* @param {string} args.sessionId
* @param {number} args.pid
* @param {string} args.workspaceHash
* @param {number} args.now - unix ms
* @param {function} args.readLock - () => record | null
* @param {function} args.writeLock - (record) => void
* @param {number} [args.ttlMs] - override default TTL
* @returns {{acquired: boolean, holder?: {session_id, pid, acquired_at}}}
*/
export function acquire({ sessionId, pid, workspaceHash, now, readLock, writeLock, ttlMs = LOCK_DEFAULT_TTL_MS }) {
const existing = readLock();
// Stale OR same-session → take over.
if (!existing || isStale(existing, now) || existing.session_id === sessionId) {
const record = {
schema_version: LOCK_SCHEMA_VERSION,
session_id: sessionId,
pid,
acquired_at: now,
ttl_ms: ttlMs,
};
writeLock(record);
return { acquired: true, holder: { session_id: sessionId, pid, acquired_at: now } };
}
// Fresh lock from another session — blocked.
return {
acquired: false,
holder: { session_id: existing.session_id, pid: existing.pid, acquired_at: existing.acquired_at },
};
}
/**
* Same-session refresh — bumps acquired_at if we still own the lock.
* Other-session refresh is a no-op (does not steal).
*
* @returns {{refreshed: boolean}}
*/
export function refresh({ sessionId, workspaceHash, now, readLock, writeLock, ttlMs = LOCK_DEFAULT_TTL_MS }) {
const existing = readLock();
if (!existing || existing.session_id !== sessionId) return { refreshed: false };
writeLock({
schema_version: LOCK_SCHEMA_VERSION,
session_id: sessionId,
pid: existing.pid,
acquired_at: now,
ttl_ms: ttlMs,
});
return { refreshed: true };
}
/**
* Release the lock if we own it; no-op otherwise.
*/
export function release({ sessionId, workspaceHash, readLock, deleteLock }) {
const existing = readLock();
if (existing && existing.session_id === sessionId) deleteLock();
}