6577c04a1f
Closes the remaining parallel-session-lock remarks on top of the keying fix
(7a469dc9), with NO weakening of same-worktree serialization:
- D: the block message now identifies the holder by its STABLE session_id and
marks the recorded pid as transient ("may change between attempts"). Chasing
the pid is what led to closing the wrong session. Decision logic is unchanged
(text only) — existing /pid N/ triage assertion still holds.
- B: pruneStaleLocks() best-effort deletes leaked lock files that are ALREADY
stale by the shared isStale() definition (now exported from the pure module —
single source of truth). Active within-TTL locks are never touched, so the
serialization guarantee is not weakened. Wired into the PreToolUse branch of
main(), wrapped so hygiene can never break the gate (fail-open).
- C (no code): release-on-SessionEnd needs only a settings.json registration
(owner action) — the existing !tool_name branch already releases. Documented
in the plan. Until then, leaked locks self-heal via B + the 5-min TTL takeover.
TDD: RED -> GREEN per behavior. tools-vitest 2014 passed / 2 skipped.
Backlog items B/C/D; plan docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
93 lines
3.2 KiB
JavaScript
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);
|
|
}
|
|
|
|
export 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();
|
|
}
|