Files
brain/tools/enforce-parallel-session-lock.mjs
T

113 lines
4.4 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, computeWorkspaceHash, isProcessAlive } from './parallel-session-lock.mjs';
import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { readStdin, parseEventJson, exitDecision, runtimeDir } from './enforce-hook-helpers.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`,
};
}
/**
* PreToolUse wiring: acquire (or same-session refresh / stale takeover) the lock,
* then decide block/allow. I/O injected for testability.
*
* @returns {{block: boolean, reason?: string}}
*/
export function runAcquireDecision({ event, now, pid, cwd, readLock, writeLock, isPidAlive = isProcessAlive }) {
const sessionId = event && event.session_id;
const workspaceHash = computeWorkspaceHash(cwd);
// 7.5 (дыра 5): pid-liveness — мёртвый держатель свежего лока перехватывается, не блокирует TTL.
const acquireResult = acquire({ sessionId, pid, workspaceHash, now, readLock, writeLock, isPidAlive });
return decide({ acquireResult, sessionId });
}
/**
* Stop wiring: release the lock if this session owns it (no-op otherwise).
*
* @returns {{released: boolean}}
*/
export function runReleaseAction({ event, cwd, readLock, deleteLock }) {
const sessionId = event && event.session_id;
const workspaceHash = computeWorkspaceHash(cwd);
release({ sessionId, workspaceHash, readLock, deleteLock });
return { released: true };
}
function lockPathFor(cwd) {
return join(runtimeDir(), `session-lock-${computeWorkspaceHash(cwd)}.json`);
}
function realReadLock(p) {
try { return JSON.parse(readFileSync(p, 'utf-8')); } catch { return null; }
}
function realWriteLock(p, rec) {
try { mkdirSync(dirname(p), { recursive: true }); writeFileSync(p, JSON.stringify(rec)); } catch { /* fail-open */ }
}
function realDeleteLock(p) {
try { unlinkSync(p); } catch { /* already gone */ }
}
async function main() {
// Live wiring (point 2, 2026-05-31). PreToolUse (mutating tool) → acquire/refresh
// the workspace lock; Stop (no tool_name) → release it. Fail-open on any error so
// a lock bug can NEVER wedge the user out of their own session.
try {
const event = parseEventJson(await readStdin());
const cwd = process.cwd();
const p = lockPathFor(cwd);
// Stop event carries no tool_name → release path.
if (!event.tool_name) {
runReleaseAction({ event, cwd, readLock: () => realReadLock(p), deleteLock: () => realDeleteLock(p) });
return exitDecision({ block: false });
}
// PreToolUse on a mutating tool → acquire/refresh, then block/allow.
const r = runAcquireDecision({
event,
now: Date.now(),
pid: process.pid,
cwd,
readLock: () => realReadLock(p),
writeLock: (rec) => realWriteLock(p, rec),
});
return exitDecision({ block: r.block, message: r.block ? `[parallel-session-lock] ${r.reason}` : undefined });
} catch {
return exitDecision({ block: false }); // fail-open — never lock out
}
}
if (import.meta.url === `file://${process.argv[1].replace(/\\/g, '/')}` || (process.argv[1] || '').endsWith('enforce-parallel-session-lock.mjs')) {
main().catch(() => process.exit(0));
}