Files
portal/tools/enforce-parallel-session-lock.mjs
T
Дмитрий 1f9b51bc39 feat(router-gate-v4): parallel-session-lock live main() — acquire on PreToolUse + release on Stop (point 2)
The Stream H wrapper shipped a deliberate no-op main() — the lock did nothing.
This wires it live: PreToolUse on a mutating tool acquires/refreshes the
workspace lock (blocks only when a DIFFERENT session holds a fresh, non-stale
lock); the Stop event releases it. Fail-open on any error so a lock bug can
never wedge the user out of their own session.

- runAcquireDecision({event,now,pid,cwd,readLock,writeLock}) — compose
  acquire() + decide().
- runReleaseAction({event,cwd,readLock,deleteLock}) — release() if this
  session owns the lock, no-op otherwise.
- live main(): branches on tool_name (present → acquire/refresh; absent/Stop
  → release); real fs binding via runtimeDir()/session-lock-<workspaceHash>.json.

Activation registers BOTH the PreToolUse (acquire) AND the Stop (release)
entries — the Stop wiring is mandatory; without it the lock is never released
and the next abnormal exit would lock the user out. Script:
.scratch/activate-point2-hooks.ps1 (also registers safe-baseline-metering +
runtime-write-deny per the point-2 plan).

Plan: docs/superpowers/plans/2026-05-30-router-gate-v4-stream-H.md Task 7.
Regression: parallel-session-lock 12/12 GREEN; full tools suite 1958 passed | 2 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 11:06:52 +03:00

112 lines
4.2 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 } 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 }) {
const sessionId = event && event.session_id;
const workspaceHash = computeWorkspaceHash(cwd);
const acquireResult = acquire({ sessionId, pid, workspaceHash, now, readLock, writeLock });
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));
}