diff --git a/docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md b/docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md index aeb8427d..6ed0bd68 100644 --- a/docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md +++ b/docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md @@ -37,9 +37,34 @@ which stay hard-blacklisted because they can pull new/updated versions. `c:/` vs `/c/`, unexpanded `$env:` in gate messages. Polish only. -### C. TDD-gate cross-actor +### F. Parallel-session-lock false cross-worktree collision (2026-05-31, owner-raised) -`enforce-tdd-gate` does not see test edits made by a subagent. +Symptom: a session in worktree `discipline-guard` was blocked by +`enforce-parallel-session-lock` (held by another session `7f6efd48`, pid changed +12552→19044 across attempts → holder still active; pid is the transient hook-node pid, +session_id is the stable identity). + +**Investigation (read-only):** +- Lock keyed by `computeWorkspaceHash(process.cwd())` = md5(cwd).slice(0,12); file + `~/.claude/runtime/session-lock-.json`; release only on Stop; TTL 5 min. +- 9 lock files accumulated → stale files leak when a session closes without a clean Stop. +- `enforce-branch-switch` read branch "worktree-discipline-guard" via + `git branch --show-current` from `process.cwd()` → the hook's cwd IS the worktree → + **keying is already per-worktree** (NOT coarse main-dir). So the holder shared this + worktree's hash → genuine same-worktree concurrency, the lock working as designed — + NOT a false positive. Do NOT re-key (would weaken same-tree serialization). + +**Genuinely-fixable part (no weakening):** leaked lock on close-without-Stop blocks the next +same-worktree session for up to TTL. Fix: release on SessionEnd (not only Stop) + prune +stale lock files on acquire. Ground-truth the lock JSON before coding. + +### C. TDD-gate cross-actor — chosen: **Z** (full, 2026-05-31; on hold behind F) + +`enforce-tdd-gate` does not see test edits made by a subagent (scans only the controller's +own turn; subagent test edit + RED live in `agent-.jsonl`). **Z = Part 1 (close the +projects/ Write hole — verified prerequisite) then Part 2 (read subagent transcript bound to +a Task in this turn).** Condition 1 verified VIOLATED (no Write-tool gate covers +`~/.claude/projects/`), so Variant 1 alone would weaken — safe only bundled with Part 1. ### D. Smoke 8 — live Workflow-gate F2 test diff --git a/tools/enforce-parallel-session-lock.mjs b/tools/enforce-parallel-session-lock.mjs index ccac04ff..1b930061 100644 --- a/tools/enforce-parallel-session-lock.mjs +++ b/tools/enforce-parallel-session-lock.mjs @@ -13,6 +13,7 @@ */ import { acquire, release, computeWorkspaceHash } from './parallel-session-lock.mjs'; import { readFileSync, writeFileSync, unlinkSync, mkdirSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; import { join, dirname } from 'node:path'; import { readStdin, parseEventJson, exitDecision, runtimeDir } from './enforce-hook-helpers.mjs'; @@ -60,6 +61,38 @@ export function runReleaseAction({ event, cwd, readLock, deleteLock }) { return { released: true }; } +/** + * Resolve the stable work-tree root used as the lock key. Keys on the SESSION's + * cwd (`event.cwd`, stable across resume) resolved to the git work-tree root — + * NOT the hook's `process.cwd()`, which collapses to the main repo dir after a + * session resume and thereby false-blocks sessions in DIFFERENT worktrees. + * Pure (I/O injected): `runGitToplevel(dir)` returns the toplevel or '' on failure. + * + * @param {object} p + * @param {object} p.event + * @param {string} p.processCwd + * @param {(dir:string)=>string} p.runGitToplevel + * @returns {string} + */ +export function resolveWorkspacePath({ event, processCwd, runGitToplevel }) { + const dir = (event && typeof event.cwd === 'string' && event.cwd) ? event.cwd : processCwd; + try { + const top = runGitToplevel(dir); + if (top && typeof top === 'string') return top; + } catch { /* fall through to raw dir (fail-open) */ } + return dir; +} + +function realGitToplevel(dir) { + try { + return execFileSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { + encoding: 'utf-8', + timeout: 1000, + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { return ''; } +} + function lockPathFor(cwd) { return join(runtimeDir(), `session-lock-${computeWorkspaceHash(cwd)}.json`); } @@ -82,7 +115,10 @@ async function main() { // a lock bug can NEVER wedge the user out of their own session. try { const event = parseEventJson(await readStdin()); - const cwd = process.cwd(); + // Key by the session's stable work-tree root (event.cwd → git toplevel), + // not the volatile hook process.cwd() (collapses to main on resume → false + // cross-worktree blocks). Fallback to process.cwd() keeps prior behavior. + const cwd = resolveWorkspacePath({ event, processCwd: process.cwd(), runGitToplevel: realGitToplevel }); const p = lockPathFor(cwd); // Stop event carries no tool_name → release path. diff --git a/tools/enforce-parallel-session-lock.test.mjs b/tools/enforce-parallel-session-lock.test.mjs index e1d148c3..72fe7f49 100644 --- a/tools/enforce-parallel-session-lock.test.mjs +++ b/tools/enforce-parallel-session-lock.test.mjs @@ -131,3 +131,54 @@ describe('runReleaseAction — Stop release wiring', () => { expect(deleted).toBe(false); }); }); + +// Cross-worktree false-block fix (2026-05-31). The lock must key on the session's +// stable work-tree root (from event.cwd → git toplevel), NOT the hook process.cwd() +// — which collapses to the main repo dir after a session resume, making sessions in +// DIFFERENT worktrees share one lock and block each other. +import { resolveWorkspacePath } from './enforce-parallel-session-lock.mjs'; + +describe('resolveWorkspacePath — stable worktree key', () => { + it('keys on event.cwd (the session worktree), not the hook process.cwd()', () => { + const r = resolveWorkspacePath({ + event: { cwd: '/repo/.claude/worktrees/wt-A' }, + processCwd: '/repo', + runGitToplevel: (dir) => dir, + }); + expect(r).toBe('/repo/.claude/worktrees/wt-A'); + }); + + it('gives different keys for two different worktrees (no cross-block)', () => { + const opts = { processCwd: '/repo', runGitToplevel: (dir) => dir }; + const a = resolveWorkspacePath({ event: { cwd: '/repo/.claude/worktrees/wt-A' }, ...opts }); + const b = resolveWorkspacePath({ event: { cwd: '/repo/.claude/worktrees/wt-B' }, ...opts }); + expect(a).not.toBe(b); + }); + + it('resolves to the git work-tree root (collapses subdir variance)', () => { + const r = resolveWorkspacePath({ + event: { cwd: '/repo/.claude/worktrees/wt-A/tools' }, + processCwd: '/repo', + runGitToplevel: () => '/repo/.claude/worktrees/wt-A', + }); + expect(r).toBe('/repo/.claude/worktrees/wt-A'); + }); + + it('falls back to processCwd when event.cwd is absent', () => { + const r = resolveWorkspacePath({ + event: { tool_name: 'Edit' }, + processCwd: '/repo', + runGitToplevel: (dir) => dir, + }); + expect(r).toBe('/repo'); + }); + + it('falls back to the raw dir when git toplevel resolution fails (fail-open)', () => { + const r = resolveWorkspacePath({ + event: { cwd: '/some/dir' }, + processCwd: '/repo', + runGitToplevel: () => '', + }); + expect(r).toBe('/some/dir'); + }); +});