#482 removed the afterEach outer retry guard, trusting rmSync maxRetries:20
(~5s). Under windows-2022 CI load the temp dir stays EBUSY longer, so the
bare cleanup() throws and the hook fails (next went red on full test
(windows-latest, 22)). Restore a bounded retry with async setTimeout backoff
(no Atomics.wait, per no-magic-sleep-in-tests).
Fixes#490
Co-authored-by: CI Rebase Check <ci@gsd-redux>
The 45s detached-subprocess poll caller was removed by #453; this deletes the
residual dead waitForStateMatch helper (silent-expiry anti-pattern) and the
redundant Atomics.wait-based afterEach retry loop (cleanup() already retries via
fs.rmSync maxRetries:20). Net deletion; deterministic tests untouched.
Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(#453): add deterministic clock seam to lock modules
Introduces get-shit-done/bin/lib/clock.cjs exporting realClock with
now() (Date.now) and sleep() (Atomics.wait). acquireStateLock,
writeStateMd, and readModifyWriteStateMd in state.cjs each accept an
optional trailing clock param (default: realClock). withPlanningLock in
planning-workspace.cjs gains the same seam. No production behavior
change — all callers that omit the param continue to use realClock.
Adds tests/helpers/clock.cjs (makeFakeClock) and tests/clock-seam.test.cjs
with 20 deterministic in-process tests covering: lock serialization,
timeout throw at maxWaitMs boundary, stale-lock takeover, lock released
on error path, withPlanningLock timeout recovery, exit-cleanup integration,
readModifyWriteStateMd call-site coverage (7 cmd*), and roadmap analyze
behavioral assertion (50 phases, no elapsed-time gate).
Deletes/converts per research verdicts: removes 11 source-grep/elapsed-time/
non-deterministic-concurrent tests across concurrency-safety.test.cjs,
locking-bugs-1909-1916-1925-1927.test.cjs, and bug-1974-context-exhaustion-
record.test.cjs. All deleted tests have deterministic replacements in
clock-seam.test.cjs or surviving barrier-based tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(#453): update module inventory for clock.cjs; make EEXIST-retry assertion behavioral
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(#453): satisfy lint-tests — allow-test-rule annotation on readFileSync/includes runtime output check
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: CI Rebase Check <ci@gsd-redux>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The test was restructured in #3726 to eliminate wall-clock polling.
This commit adds the #3775 cross-reference to the design note (Docker
intrinsic subprocess cost 900–1700ms vs deadline) and documents why
`callsSinceWarn: 10` uses 2× DEBOUNCE_CALLS for resilience.
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* test(3726): replace racy spawn-poll with deterministic sentinel assertions
The original bug-1974 test asserted STATE.md content against a
2000ms wall-clock deadline while the hook's fire-and-forget
spawn().unref() subprocess raced to write it — causing intermittent
CI failures under Docker contention (#3726).
Fix: assert the synchronously-written warnData.criticalRecorded
sentinel (readable the moment spawnSync runHook() returns), and
invoke `gsd-tools state record-session` synchronously via spawnSync
to verify the persistence seam. Adds a counter-test for WARNING-only
fires (5 tests total). No production code changed.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(3726): repair Windows state add-blocker concurrent-write race
acquireStateLock silently returned a false-success lockPath on any non-EEXIST
openSync error (the old guard was `if (err.code !== 'EEXIST') return lockPath`).
On Windows-24 CI, the releaseStateLock unlink briefly leaves the file in a
transitional state so the next O_CREAT|O_EXCL call from a racing process gets
EPERM or EBUSY instead of EEXIST. The old guard treated this as "lock not yet
created, you own it" and let the second process proceed with its own
read-modify-write while the first still held the actual lock — causing one
blocker to be silently overwritten.
Fix mirrors commits ab24d80b and 47983914 already on main: replace the
silent-bypass guard with an explicit ACQUIRE_LOCK_RETRY_ERRNOS allowlist
(EPERM, EBUSY, EAGAIN, EINTR, EINVAL, EIO, ENOENT, ESTALE) that retries on
transient filesystem conditions, and throw on all other non-EEXIST codes rather
than returning a false-success lockPath. The same retry set is applied to
withPlanningLock in planning-workspace.cjs (shared root cause, same fix).
Local test run: 10/10 pass (locking-bugs-1909-1916-1925-1927.test.cjs).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* 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>
Test spawns a fire-and-forget subprocess; on Windows the child may still
hold a handle on tmpDir when afterEach runs, producing EPERM from
fs.rmSync. Match helpers.cleanup() retry budget (20 × 250ms = 5s) to
absorb the deferred-handle window without pulling in the helper (this
suite predates it).
Closes the Windows test (windows-latest, 24/26) lane failure on #3649.
Review feedback from @trek-e — three blocking fixes:
1. **Sentinel prevents repeated firing**
Added warnData.criticalRecorded flag persisted to the warn state file.
Previously the subprocess fired on every DEBOUNCE_CALLS cycle (5 tool
uses) for the rest of the session, overwriting the "crash moment"
record with a new timestamp each time. Now fires exactly once per
CRITICAL session.
2. **Runtime-agnostic path via __dirname**
Replaced hardcoded `path.join(process.env.HOME, '.claude', ...)` with
`path.join(__dirname, '..', 'get-shit-done', 'bin', 'gsd-tools.cjs')`.
The hook lives at <runtime-config>/hooks/ and gsd-tools.cjs at
<runtime-config>/get-shit-done/bin/ — __dirname resolves correctly on
all runtimes (Claude Code, OpenCode, Gemini, Kilo) without assuming
~/.claude/.
3. **Correct subcommand: state record-session**
Switched from `state update "Stopped At" ...` to
`state record-session --stopped-at ...`. The dedicated command
updates Last session, Last Date, Stopped At, and Resume File
atomically under the state lock.
Also:
- Hoisted `const { spawn } = require('child_process')` to top of file
to match existing require() style.
- Coerced usedPct to Number(usedPct) || 0 to sanitize the bridge file
in case it's malformed or adversarially crafted.
Tests (tests/bug-1974-context-exhaustion-record.test.cjs, 4 cases):
- Subprocess spawns and writes "context exhaustion" on CRITICAL
- Subprocess does NOT spawn when .planning/STATE.md is absent
- Sentinel guard prevents second fire within same session
- Hook source uses __dirname-based path (not hardcoded ~/.claude/)