Files
msd-core/tests/bug-1974-context-exhaustion-record.test.cjs
Tom Boucher ca2644a71a fix(worktree): unlock-retry on locked cleanup + startup orphan sweep (#3707) (#3719)
* 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>
2026-05-20 15:22:12 -04:00

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'
);
});
});