// 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-.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: , 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; } /** * 7.5 (дыра 5): жив ли процесс-держатель. process.kill(pid, 0) не убивает — лишь проверяет * существование: ESRCH → мёртв; EPERM → существует (нет прав) → жив; иначе → жив. Невалидный * pid → мёртв. Реальная реализация для обёртки; в acquire инъектируется (чистота/тестируемость). */ export function isProcessAlive(pid) { if (typeof pid !== 'number' || !Number.isInteger(pid) || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch (e) { return e && e.code === 'EPERM'; } } /** * 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, isPidAlive }) { const existing = readLock(); // 7.5 (дыра 5): держатель свежего лока, чей процесс МЁРТВ, не должен блокировать на весь TTL. // Перехватываем, если isPidAlive предоставлен и holder.pid не жив. Без isPidAlive — старое // поведение (backward-compat): перехват только по stale/same-session. const holderDead = !!existing && typeof isPidAlive === 'function' && !isPidAlive(existing.pid); // Stale OR same-session OR dead-holder → take over. if (!existing || isStale(existing, now) || existing.session_id === sessionId || holderDead) { 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(); }