Files
portal/tools/parallel-session-lock.test.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

106 lines
4.2 KiB
JavaScript

// tools/parallel-session-lock.test.mjs
// Stream H Task 7 — pure parallel-session-lock module tests.
import { describe, it, expect, vi } from 'vitest';
import {
acquire,
release,
refresh,
computeWorkspaceHash,
LOCK_DEFAULT_TTL_MS,
} from './parallel-session-lock.mjs';
function mkMockStore(initial = null) {
let current = initial;
return {
readLock: () => current,
writeLock: (v) => { current = v; },
deleteLock: () => { current = null; },
peek: () => current,
};
}
describe('parallel-session-lock pure module (Stream H Task 7)', () => {
it('acquire on empty store succeeds and writes holder record', () => {
const store = mkMockStore(null);
const r = acquire({
sessionId: 's1', pid: 100, workspaceHash: 'abc', now: 1000,
readLock: store.readLock, writeLock: store.writeLock,
});
expect(r.acquired).toBe(true);
expect(r.holder).toMatchObject({ session_id: 's1', pid: 100, acquired_at: 1000 });
expect(store.peek()).toMatchObject({ schema_version: 1, session_id: 's1', pid: 100, acquired_at: 1000, ttl_ms: LOCK_DEFAULT_TTL_MS });
});
it('acquire blocked when a fresh lock from another session exists', () => {
const existing = { schema_version: 1, session_id: 'other', pid: 999, acquired_at: 500, ttl_ms: LOCK_DEFAULT_TTL_MS };
const store = mkMockStore(existing);
const r = acquire({
sessionId: 's1', pid: 100, workspaceHash: 'abc', now: 1000,
readLock: store.readLock, writeLock: store.writeLock,
});
expect(r.acquired).toBe(false);
expect(r.holder).toMatchObject({ session_id: 'other', pid: 999 });
expect(store.peek()).toBe(existing); // unchanged
});
it('acquire takes over a stale lock from another session (past TTL)', () => {
const existing = { schema_version: 1, session_id: 'old', pid: 7, acquired_at: 0, ttl_ms: 100 };
const store = mkMockStore(existing);
const r = acquire({
sessionId: 's1', pid: 100, workspaceHash: 'abc', now: 1000,
readLock: store.readLock, writeLock: store.writeLock,
});
expect(r.acquired).toBe(true);
expect(r.holder).toMatchObject({ session_id: 's1', pid: 100, acquired_at: 1000 });
});
it('refresh same-session updates acquired_at without losing ownership', () => {
const existing = { schema_version: 1, session_id: 's1', pid: 100, acquired_at: 500, ttl_ms: LOCK_DEFAULT_TTL_MS };
const store = mkMockStore(existing);
const r = refresh({
sessionId: 's1', workspaceHash: 'abc', now: 1000,
readLock: store.readLock, writeLock: store.writeLock,
});
expect(r.refreshed).toBe(true);
expect(store.peek().acquired_at).toBe(1000);
});
it('refresh other-session is a no-op (does not steal lock)', () => {
const existing = { schema_version: 1, session_id: 'other', pid: 999, acquired_at: 500, ttl_ms: LOCK_DEFAULT_TTL_MS };
const store = mkMockStore(existing);
const r = refresh({
sessionId: 's1', workspaceHash: 'abc', now: 1000,
readLock: store.readLock, writeLock: store.writeLock,
});
expect(r.refreshed).toBe(false);
expect(store.peek()).toBe(existing);
});
it('release same-session deletes the lock', () => {
const existing = { schema_version: 1, session_id: 's1', pid: 100, acquired_at: 500, ttl_ms: LOCK_DEFAULT_TTL_MS };
const store = mkMockStore(existing);
release({ sessionId: 's1', workspaceHash: 'abc', readLock: store.readLock, deleteLock: store.deleteLock });
expect(store.peek()).toBe(null);
});
it('release other-session does NOT delete the lock', () => {
const existing = { schema_version: 1, session_id: 'other', pid: 999, acquired_at: 500, ttl_ms: LOCK_DEFAULT_TTL_MS };
const store = mkMockStore(existing);
release({ sessionId: 's1', workspaceHash: 'abc', readLock: store.readLock, deleteLock: store.deleteLock });
expect(store.peek()).toBe(existing);
});
});
describe('computeWorkspaceHash (Stream H Task 7)', () => {
it('returns 12 hex chars', () => {
const h = computeWorkspaceHash('/some/path');
expect(h).toMatch(/^[0-9a-f]{12}$/);
});
it('is deterministic per path', () => {
expect(computeWorkspaceHash('/some/path')).toBe(computeWorkspaceHash('/some/path'));
});
it('differs across paths', () => {
expect(computeWorkspaceHash('/a')).not.toBe(computeWorkspaceHash('/b'));
});
});