* fix(worktree): unlock-retry on locked cleanup + startup orphan sweep (#3707) Two root causes fixed: 1. **In-session cleanup blocked**: `executeWorktreeWaveCleanupPlan` now attempts `git worktree unlock <path>` then retries `git worktree remove --force` when the initial single-force remove fails on a locked worktree. Previously every cleanup after a successful merge was silently blocked. 2. **Cross-session orphan accumulation**: new `reapOrphanWorktrees` helper sweeps `.git/worktrees/*/locked` at startup. It reaps entries where the pid is dead, the branch tip is an ancestor of the default branch (ancestry guard prevents data loss on squash-merge repos), and the lock mtime is older than 5 minutes (race guard). Wired into `quick.md` and `execute-phase.md` startup blocks guarded by `USE_WORKTREES != false`. SDK: adds `worktree.reap-orphans` query command (routes through gsd-tools.cjs). Tests: 11 real-fs tests covering unlock-retry, dead-pid reap, live-pid skip, unmerged skip, fresh-mtime skip, idempotent double-call, and structural wiring. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(changeset): add Fixed fragment for PR #3707 (worktree orphan cleanup) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(worktree): fix test portability on Windows + macOS for bug-3707 reap tests - worktreeMeta helper: replace /\/\.git$/ with /[/\\]\.git$/ so the gitdir path suffix is stripped on both Windows (backslash) and Unix. - worktreeMeta helper: normalize CRLF→LF before splitting porcelain blocks, fixing block parsing when git emits CRLF on Windows. - reapOrphanWorktrees: replace single 'main' rev-parse with a [defaultBranch, 'main', 'master'] candidate loop so test fixtures without a remote origin (where branch may be 'master') don't bail early. Intentionally excludes 'HEAD' to prevent false reaping when HEAD is detached or on a feature branch (Codex adversarial finding). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(worktree): CI green — macOS symlink path, Windows test helper, pid portability, EPERM liveness Four fixes to get macOS + Windows CI from red to green: 1. **macOS symlink mismatch** (worktree-safety.cjs): `reapOrphanWorktrees` now builds a canonical→listed path map from `git worktree list --porcelain` using `fs.realpathSync.native`. Uses the listed path (as git knows it) for `git worktree unlock/remove`, not the gitdir-derived path. Fixes the `/var/folders` vs `/private/var/folders` discrepancy on GitHub macOS runners where `git worktree unlock <realpath>` was silently failing because git's list stored the unresolved symlink path. 2. **Windows path separator in test helper** (test file): `worktreeMeta` `.replace(/\/\.git$/, '')` → `.replace(/[/\\]\.git$/, '')`. On Windows, git writes backslash separators in the gitdir file; the Unix-only regex was causing `Cannot find .git/worktrees/<name>` for all Suite 2 tests. 3. **Non-portable PID in tests** (test file): All `'999999'` dead-PID literals replaced with `deadPid()` helper that spawns a real short-lived child, captures its PID, and returns it after exit. Eliminates flakiness on Linux systems where `pid_max` can reach 4194304, making 999999 a live PID. 4. **EPERM fail-closed in isPidAlive** (worktree-safety.cjs): `catch { return false }` → checks `err.code === 'EPERM'` and returns `true` (alive). On Windows and cross-user scenarios, `process.kill(pid, 0)` throws EPERM for live but inaccessible processes; treating that as dead would reap a live worktree. Adversarial review via codex confirmed: - Squash-merge repos: fail-closed (CONCERN, not BUG — by design, not data-loss) - canonicalToListed map: SAFE (fail-closed on realpathSync error) - Concurrent reapers: SAFE (both prune; second gets skipped: remove_failed) - Startup blocking: CONCERN (no global cap, 10s/call × N worktrees) — tracked, not fixed here (requires separate perf work) - gsd-sdk missing: SAFE (quick.md checks and fails fast with guidance) All 27 local tests + Docker (holodeck) green. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(worktree): address codex adversarial findings — fail-closed default branch + CRLF map Two fixes from codex adversarial review of PR 3718: 1. **Default branch resolution (data-loss risk)**: `reapOrphanWorktrees` now uses `refs/remotes/origin/<branch>` exclusively when a remote is configured. If `origin/HEAD` is absent but a remote exists, we bail out (fail-closed) rather than falling back to a local `main`/`master` that may not be the real integration branch. The `main`/`master` fallback is only used when there is provably no remote (local-only test fixtures). 2. **CRLF normalization in canonical-path mapper**: The `worktree list --porcelain` output was split on '\n\n' without normalizing CRLF first. On Windows, git emits CRLF, which caused block-splitting to fail and left the canonicalToListed map only partially populated, weakening the symlink/path-mismatch fix introduced earlier. 3. **Windows 8.3 short-path fix (test helper)**: Both `beforeEach` blocks now call `resolvedTmpDir()` which pre-resolves `os.tmpdir()` via `fs.realpathSync.native` so temp paths avoid RUNNER~1-style short names that git stores in long form, causing worktreeMeta path comparisons to fail on Windows CI. All 11 real-fs + 16 unit tests green locally. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(worktree): adversarial findings + macOS CI path-mismatch fix ## Root cause (macOS CI fail) `reapOrphanWorktrees` stored `worktreePath` (gitdir-derived, real path via git's symlink resolution, e.g. `/private/var/folders/…`) in results, while the test's `wtDir` used the unresolved symlink form (`/var/folders/…`). After reaping, `canonicalPath(wtDir)` can no longer call `realpathSync.native` (directory gone), so it falls back to `path.resolve` — which returns the symlink form — causing the `result.find()` comparison to miss. ## Fixes applied ### Source — worktree-safety.cjs 1. **Finding 1 (fail-closed PID check)**: Non-parseable lock content (e.g. `"Locked by claude-code agent-xxx"`) is now treated as ALIVE with reason `lock_owner_unknown`, not as dead. Previously it fell through as dead. 2. **Finding 1b (EPERM safe)**: `isPidAlive` call wrapped in try/catch; any thrown error (EPERM = process exists but cross-user on Windows) → ALIVE. 3. **Finding 2 (startup warning)**: `cmdWorktreeReapOrphans` now writes a one-line stderr warning when ≥1 entry is skipped or when reaper throws, while keeping exit-zero so workflows don't break. 4. **Finding 3 (default-branch discovery)**: Local-only fallback now tries `init.defaultBranch` config and HEAD symref before `main`/`master`, so repos configured with `trunk`, `dev`, etc. get correct orphan detection. 5. **macOS path fix**: Result entry for reaped worktrees now uses `gitKnownPath` (from `git worktree list`) instead of `worktreePath` (from gitdir file), ensuring the caller always sees the path git uses for the worktree. ### Test — bug-3707-locked-worktree-cleanup.test.cjs 6. **macOS CI fix**: Pre-compute `wtDirCanonical = canonicalPath(wtDir)` before calling `reapOrphanWorktrees` so the comparison works after removal. 7. **Gap 1**: New test — Claude Code lock format (`"Locked by claude-code …"`) must not be reaped; asserts `status=skipped, reason=lock_owner_unknown`. 8. **Gap 2**: New test — `isPidAlive` throwing EPERM → must not reap. 9. **Gap 3**: New test — repo with `init.defaultBranch=trunk`; merged worktree must be reaped (verifies trunk is discovered as the integration branch). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(test): raise waitForStoppedAt timeout 2 s → 5 s for Windows/Node22 CI load Subprocess write latency exceeds 2 s on loaded windows-latest/Node22 runners (test duration was 6181 ms); 5 s gives sufficient headroom without changing any production behaviour. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
192 lines
6.7 KiB
JavaScript
192 lines
6.7 KiB
JavaScript
/**
|
|
* Integration tests for gsd-context-monitor.js auto-record on CRITICAL (#1974).
|
|
*
|
|
* Verifies:
|
|
* 1. On CRITICAL + active GSD project, subprocess is spawned and STATE.md
|
|
* receives the "Stopped At" field.
|
|
* 2. Subsequent CRITICAL firings within the same session do NOT re-fire
|
|
* the subprocess (sentinel guard prevents repeated overwrites).
|
|
* 3. When no .planning/STATE.md exists, the subprocess is not spawned.
|
|
* 4. Path resolution uses __dirname, not hardcoded ~/.claude/.
|
|
*/
|
|
|
|
'use strict';
|
|
|
|
const { test, describe, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const os = require('node:os');
|
|
const { spawnSync } = require('node:child_process');
|
|
const { cleanup } = require('./helpers.cjs');
|
|
|
|
const HOOK_PATH = path.resolve(__dirname, '..', 'hooks', 'gsd-context-monitor.js');
|
|
|
|
/**
|
|
* Run the hook with a given session id and context percentage.
|
|
* Writes a bridge metrics file first, then pipes the hook input via stdin.
|
|
* Returns after the hook exits.
|
|
*/
|
|
function runHook(sessionId, remainingPct, cwd) {
|
|
// Write the bridge metrics file the hook reads
|
|
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
|
|
fs.writeFileSync(bridgePath, JSON.stringify({
|
|
session_id: sessionId,
|
|
remaining_percentage: remainingPct,
|
|
used_pct: 100 - remainingPct,
|
|
timestamp: Math.floor(Date.now() / 1000),
|
|
}));
|
|
|
|
const input = JSON.stringify({
|
|
session_id: sessionId,
|
|
cwd,
|
|
});
|
|
|
|
const result = spawnSync(process.execPath, [HOOK_PATH], {
|
|
input,
|
|
encoding: 'utf-8',
|
|
timeout: 10000,
|
|
env: { ...process.env, HOME: process.env.HOME },
|
|
});
|
|
|
|
return { exitCode: result.status, stdout: result.stdout, stderr: result.stderr };
|
|
}
|
|
|
|
/**
|
|
* Wait up to `ms` for a file to exist (the subprocess is fire-and-forget).
|
|
*/
|
|
function waitForStoppedAt(statePath, ms = 5000) {
|
|
const deadline = Date.now() + ms;
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const content = fs.readFileSync(statePath, 'utf-8');
|
|
if (/Stopped [Aa]t.*context exhaustion/.test(content)) return content;
|
|
} catch { /* file may briefly not exist during atomic write */ }
|
|
// Tight poll loop — subprocess should complete in <100ms
|
|
const start = Date.now();
|
|
while (Date.now() - start < 50) { /* spin */ }
|
|
}
|
|
return null;
|
|
}
|
|
|
|
describe('#1974 context exhaustion auto-record', () => {
|
|
let tmpDir;
|
|
let statePath;
|
|
let sessionId;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1974-'));
|
|
const planningDir = path.join(tmpDir, '.planning');
|
|
fs.mkdirSync(planningDir, { recursive: true });
|
|
|
|
// Minimal STATE.md with Stopped At field
|
|
statePath = path.join(planningDir, 'STATE.md');
|
|
fs.writeFileSync(statePath, [
|
|
'# Session State',
|
|
'',
|
|
'**Current Phase:** 1',
|
|
'**Status:** executing',
|
|
'**Last session:** unset',
|
|
'**Last Date:** unset',
|
|
'**Stopped At:** None',
|
|
'**Resume File:** None',
|
|
'',
|
|
].join('\n'));
|
|
|
|
// Minimal config.json required by gsd-tools
|
|
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify({ project_code: 'TEST' }));
|
|
|
|
sessionId = `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
});
|
|
|
|
afterEach(() => {
|
|
const sleep = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
try {
|
|
cleanup(tmpDir);
|
|
break;
|
|
} catch (err) {
|
|
const code = err && err.code;
|
|
const transient = code === 'EPERM' || code === 'EBUSY' || code === 'ENOTEMPTY';
|
|
if (!transient || attempt === 4) throw err;
|
|
sleep(250 * (attempt + 1));
|
|
}
|
|
}
|
|
// Clean up bridge files
|
|
try {
|
|
const warnPath = path.join(os.tmpdir(), `claude-ctx-${sessionId}-warned.json`);
|
|
if (fs.existsSync(warnPath)) fs.unlinkSync(warnPath);
|
|
const bridgePath = path.join(os.tmpdir(), `claude-ctx-${sessionId}.json`);
|
|
if (fs.existsSync(bridgePath)) fs.unlinkSync(bridgePath);
|
|
} catch { /* noop */ }
|
|
});
|
|
|
|
test('spawns subprocess and writes Stopped At field on CRITICAL with active GSD', () => {
|
|
// Trigger CRITICAL — remaining <= 25
|
|
const result = runHook(sessionId, 20, tmpDir);
|
|
assert.strictEqual(result.exitCode, 0, `hook should exit 0: ${result.stderr}`);
|
|
|
|
// Wait for fire-and-forget subprocess to write STATE.md
|
|
const content = waitForStoppedAt(statePath);
|
|
assert.ok(content, `STATE.md should contain "context exhaustion" after CRITICAL fire`);
|
|
assert.match(content, /context exhaustion at \d+%/);
|
|
});
|
|
|
|
test('does NOT spawn subprocess when .planning/STATE.md is absent', () => {
|
|
// Delete STATE.md to simulate non-GSD project
|
|
fs.unlinkSync(statePath);
|
|
const originalMtime = Date.now();
|
|
|
|
const result = runHook(sessionId, 20, tmpDir);
|
|
assert.strictEqual(result.exitCode, 0);
|
|
|
|
// Wait a bit then verify STATE.md was NOT recreated
|
|
const start = Date.now();
|
|
while (Date.now() - start < 500) { /* spin */ }
|
|
assert.ok(!fs.existsSync(statePath), 'STATE.md should not be recreated when absent');
|
|
});
|
|
|
|
test('sentinel prevents repeated firing within same session', () => {
|
|
// First CRITICAL fire — should record
|
|
runHook(sessionId, 20, tmpDir);
|
|
const content1 = waitForStoppedAt(statePath);
|
|
assert.ok(content1, 'first fire should record Stopped At');
|
|
|
|
// Extract the timestamp from first fire
|
|
const firstMatch = content1.match(/context exhaustion at (\d+)%/);
|
|
assert.ok(firstMatch, 'first fire should have numeric usedPct');
|
|
|
|
// Manually set Stopped At to a sentinel value to detect second fire
|
|
const modified = content1.replace(/(\*\*Stopped At:\*\*) .+/, '$1 SENTINEL_SHOULD_NOT_CHANGE');
|
|
fs.writeFileSync(statePath, modified);
|
|
|
|
// Second CRITICAL fire — should NOT re-fire the subprocess
|
|
runHook(sessionId, 18, tmpDir);
|
|
|
|
// Wait and verify the sentinel is preserved
|
|
const start = Date.now();
|
|
while (Date.now() - start < 500) { /* spin */ }
|
|
const content2 = fs.readFileSync(statePath, 'utf-8');
|
|
assert.match(
|
|
content2,
|
|
/SENTINEL_SHOULD_NOT_CHANGE/,
|
|
'second CRITICAL fire should not re-record (sentinel guard)'
|
|
);
|
|
});
|
|
|
|
test('hook uses __dirname-based path (runtime-agnostic)', () => {
|
|
// Verify the hook source references __dirname, not ~/.claude/
|
|
const hookSource = fs.readFileSync(HOOK_PATH, 'utf-8');
|
|
assert.match(
|
|
hookSource,
|
|
/path\.join\(__dirname,\s*'\.\.',\s*'get-shit-done'/,
|
|
'hook must use __dirname-based path resolution for gsd-tools.cjs'
|
|
);
|
|
assert.doesNotMatch(
|
|
hookSource,
|
|
/process\.env\.HOME.*\.claude.*get-shit-done.*gsd-tools\.cjs/,
|
|
'hook must not hardcode ~/.claude/ path'
|
|
);
|
|
});
|
|
});
|