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>
127 lines
5.2 KiB
JavaScript
127 lines
5.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,
|
|
isStale,
|
|
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);
|
|
});
|
|
});
|
|
|
|
// isStale is exported (B, 2026-05-31) so the wrapper's prune step reuses the
|
|
// EXACT same staleness definition — single source of truth, no divergence that
|
|
// could ever prune a still-fresh (active) lock.
|
|
describe('isStale (exported for prune support)', () => {
|
|
it('true when now - acquired_at exceeds ttl_ms', () => {
|
|
expect(isStale({ acquired_at: 0, ttl_ms: 100 }, 1000)).toBe(true);
|
|
});
|
|
it('false when still within ttl (active lock — never pruned)', () => {
|
|
expect(isStale({ acquired_at: 900, ttl_ms: 1000 }, 1000)).toBe(false);
|
|
});
|
|
it('true for a malformed/missing record', () => {
|
|
expect(isStale(null, 1000)).toBe(true);
|
|
expect(isStale(undefined, 1000)).toBe(true);
|
|
});
|
|
it('uses the default TTL when ttl_ms is absent', () => {
|
|
expect(isStale({ acquired_at: 0 }, LOCK_DEFAULT_TTL_MS + 1)).toBe(true);
|
|
expect(isStale({ acquired_at: 0 }, LOCK_DEFAULT_TTL_MS - 1)).toBe(false);
|
|
});
|
|
});
|
|
|
|
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'));
|
|
});
|
|
});
|