fix(router-gate): key session-lock by session work-tree root, not hook cwd
enforce-parallel-session-lock keyed the lock on the hook's process.cwd(), which collapses to the main repo dir after a session resume — so sessions in DIFFERENT git worktrees shared one lock and false-blocked each other (observed: a brainrepo-worktree session blocked launching agents by a discipline-guard session). New resolveWorkspacePath() keys on the session's stable cwd (event.cwd) resolved to the git work-tree root (git -C <cwd> rev-parse --show-toplevel), with fallback to process.cwd() so behaviour never regresses when event.cwd is absent. Same-worktree concurrency stays serialized (unchanged) — discipline not weakened; only cross-worktree false-blocks fixed. TDD: RED (5 resolveWorkspacePath cases) -> GREEN -> tools-vitest 2003 passed / 2 skipped. Backlog item F; plan docs/superpowers/plans/2026-05-31-discipline-guard-backlog.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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-<hash>.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-<id>.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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user