diff --git a/openspec/changes/agent-claude-auto-prune-stale-agent-worktrees-on-setu-2026-04-24-16-38/proposal.md b/openspec/changes/agent-claude-auto-prune-stale-agent-worktrees-on-setu-2026-04-24-16-38/proposal.md new file mode 100644 index 0000000..eea7ef2 --- /dev/null +++ b/openspec/changes/agent-claude-auto-prune-stale-agent-worktrees-on-setu-2026-04-24-16-38/proposal.md @@ -0,0 +1,30 @@ +# Auto-prune stale agent worktrees on setup/doctor + +## Why + +When `gx branch finish --cleanup` fails mid-cleanup (lost network, killed process, crashed agent), the agent branch is deleted but the worktree under `.omc/agent-worktrees/` (or `.omx/agent-worktrees/`) is left stranded in a detached-HEAD state. Over time the repo accumulates dozens of these orphaned worktrees. + +`scripts/agent-worktree-prune.sh` (invoked by `gx cleanup`) already knows how to remove these, but it only runs: + +- manually via `gx cleanup` +- in the background daemon started by `gx agents start` + +Neither `gx setup` nor `gx doctor` invokes it today — the existing `autoFinishReadyAgentBranches` sweep only handles agent branches that still exist locally with unmerged commits (`ahead > 0`). Branches already deleted post-merge with stranded worktrees are invisible to that sweep. + +Observed on `agents-hivemind`: 9 stranded worktrees under `.omc/agent-worktrees/`, 7 of them in detached-HEAD state (branch gone, worktree orphaned). `gx setup` and `gx doctor` run and leave all 9 in place. + +## What Changes + +- Add `pruneStaleAgentWorktrees(repoRoot, options)` to `src/doctor/index.js`. It invokes the existing `worktreePrune` script with `--delete-branches --delete-remote-branches --include-pr-merged` and an idle-minutes safety threshold (default 60 — matches `gx agents start` cleanup daemon) so worktrees with activity within the last hour are preserved. +- Invoke the new helper at the tail of both `gx setup` and `gx doctor`, directly after the existing `autoFinishReadyAgentBranches` call. Each repo in recursive mode gets its own invocation. +- Honor the existing `--dry-run` flag (no destructive side-effects in dry-run). +- Add opt-out env var `GUARDEX_SKIP_AUTO_WORKTREE_PRUNE=1`, mirroring the existing `GUARDEX_SKIP_AUTO_FINISH_READY_BRANCHES=1` escape hatch. +- Skip pruning inside doctor sandbox passes (`GUARDEX_DOCTOR_SANDBOX=1`) to avoid recursion. +- Add doctor JSON output field `worktreePrune` alongside the existing `autoFinish` field. + +## Impact + +- Affected specs: `doctor/spec.md` (delta). +- Affected code: `src/doctor/index.js`, `src/cli/main.js`. +- Behavior change: `gx setup` and `gx doctor` now perform a non-dry, destructive worktree prune by default. Guarded by idle-minutes (60) so active agents are preserved, and opt-out env var for users who want the old behavior. +- No API or schema change. diff --git a/openspec/changes/agent-claude-auto-prune-stale-agent-worktrees-on-setu-2026-04-24-16-38/specs/doctor/spec.md b/openspec/changes/agent-claude-auto-prune-stale-agent-worktrees-on-setu-2026-04-24-16-38/specs/doctor/spec.md new file mode 100644 index 0000000..865b578 --- /dev/null +++ b/openspec/changes/agent-claude-auto-prune-stale-agent-worktrees-on-setu-2026-04-24-16-38/specs/doctor/spec.md @@ -0,0 +1,48 @@ +# Doctor spec delta — auto-prune stale agent worktrees + +## ADDED Requirements + +### Requirement: gx setup and gx doctor MUST prune stale agent worktrees + +`gx setup` and `gx doctor` SHALL, after completing the existing auto-finish sweep for ready agent branches, invoke the worktree-prune pipeline for each target repo so that merged-and-stale agent worktrees under `.omc/agent-worktrees/` and `.omx/agent-worktrees/` are removed without requiring a separate manual `gx cleanup` invocation. + +The prune invocation SHALL: + +- Pass `--delete-branches --delete-remote-branches --include-pr-merged` so that PR-squash-merged branches are caught (upstream merge commit not present on local `main`). +- Pass `--idle-minutes 60` (or the caller-provided `idleMinutes` override) so that worktrees touched within the idle window are preserved — protects an active agent from being pruned mid-run. +- Propagate the parent command's `--dry-run` flag so dry-run setup/doctor does not mutate state. +- Pass `--base ` when the current local base branch is a non-agent, non-HEAD branch; omit the flag otherwise so the prune script infers the base. + +The prune invocation SHALL be skipped when: + +- The repo has Guardex disabled (`scanResult.guardexEnabled === false`). +- The env var `GUARDEX_SKIP_AUTO_WORKTREE_PRUNE=1` is set. +- The env var `GUARDEX_DOCTOR_SANDBOX=1` is set (nested sandbox pass, avoids recursion). + +#### Scenario: doctor removes a stranded detached-HEAD worktree + +- **GIVEN** a repo with a worktree at `.omc/agent-worktrees//` whose branch has already been deleted (detached HEAD after successful merge) +- **WHEN** the operator runs `gx doctor` +- **THEN** the doctor output contains a `Stale agent-worktree prune` summary line +- **AND** the worktree directory no longer exists on disk +- **AND** the exit code is unchanged by the prune (still reflects scan result) + +#### Scenario: setup honors the opt-out env var + +- **GIVEN** a repo with a stranded agent worktree +- **WHEN** the operator runs `gx setup` with `GUARDEX_SKIP_AUTO_WORKTREE_PRUNE=1` +- **THEN** the worktree directory remains on disk +- **AND** the output mentions that the prune was skipped via opt-out + +#### Scenario: dry-run does not prune + +- **GIVEN** a repo with a stranded agent worktree +- **WHEN** the operator runs `gx doctor --dry-run` +- **THEN** the worktree directory remains on disk +- **AND** the prune summary reports `status=dry-run` + +#### Scenario: JSON doctor output includes the prune payload + +- **GIVEN** a repo where doctor runs with `--json` +- **WHEN** the JSON is emitted +- **THEN** the top-level object contains a `worktreePrune` field alongside the existing `autoFinish` field diff --git a/openspec/changes/agent-claude-auto-prune-stale-agent-worktrees-on-setu-2026-04-24-16-38/tasks.md b/openspec/changes/agent-claude-auto-prune-stale-agent-worktrees-on-setu-2026-04-24-16-38/tasks.md new file mode 100644 index 0000000..8acda02 --- /dev/null +++ b/openspec/changes/agent-claude-auto-prune-stale-agent-worktrees-on-setu-2026-04-24-16-38/tasks.md @@ -0,0 +1,28 @@ +# Tasks + +## 1. Spec + +- [x] Draft delta `specs/doctor/spec.md` capturing the new auto-prune requirement for `gx setup` / `gx doctor`. + +## 2. Tests + +- [x] Extend `test/doctor.test.js` with an integration test: seed a repo with a detached-HEAD worktree under `.omc/agent-worktrees/`, run `gx doctor`, assert the worktree directory is gone and the stderr log contains the prune summary line. +- [x] Add a negative test: set `GUARDEX_SKIP_AUTO_WORKTREE_PRUNE=1`, assert the worktree is preserved. + +## 3. Implementation + +- [x] Add `pruneStaleAgentWorktrees(repoRoot, options)` helper to `src/doctor/index.js`, export it from the module. +- [x] Call the helper from the setup repo-loop after `autoFinishReadyAgentBranches` in `src/cli/main.js`. +- [x] Call the helper from the doctor single-repo path after `autoFinishReadyAgentBranches` in `src/cli/main.js`. +- [x] Add inline `printWorktreePruneSummary` helper for console output. +- [x] Thread `worktreePrune` field into doctor's JSON output payload. + +## 4. Checkpoints + +- [x] `node --test test/doctor.test.js` green. +- [x] `node -c src/doctor/index.js && node -c src/cli/main.js` green. + +## 5. Cleanup + +- [ ] `gx branch finish --branch "agent/claude/auto-prune-stale-agent-worktrees-on-setu-2026-04-24-16-38" --base main --via-pr --wait-for-merge --cleanup` +- [ ] Record PR URL and final `MERGED` evidence here. diff --git a/src/cli/main.js b/src/cli/main.js index 7060681..7952faf 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -656,7 +656,7 @@ function runSetupInSandbox(options, blocked, repoLabel = '') { const nestedResult = run( process.execPath, [__filename, ...buildSandboxSetupArgs(options, sandboxTarget)], - { cwd: metadata.worktreePath }, + { cwd: metadata.worktreePath, env: { GUARDEX_DOCTOR_SANDBOX: '1' } }, ); if (isSpawnFailure(nestedResult)) { throw nestedResult.error; @@ -701,6 +701,12 @@ function runSetupInSandbox(options, blocked, repoLabel = '') { console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`); } + const prunePayload = doctorModule.pruneStaleAgentWorktrees(scanResult.repoRoot, { + baseBranch: currentBaseBranch, + dryRun: syncOptions.dryRun, + }); + printWorktreePruneSummary(prunePayload, { baseBranch: currentBaseBranch }); + const cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata); console.log( `[${TOOL_NAME}] Protected-base setup sandbox cleanup: ${cleanupResult.note} ` + @@ -1755,6 +1761,26 @@ function runScanInternal(options) { }; } +function printWorktreePruneSummary(payload, options = {}) { + if (!payload || payload.enabled === false) { + if (payload && payload.details && payload.details[0]) { + console.log(`[${TOOL_NAME}] ${payload.details[0]}`); + } + return; + } + if (!payload.ran) { + return; + } + const baseLabel = options.baseBranch ? ` (base=${options.baseBranch})` : ''; + const tag = payload.status === 'failed' ? '⚠️' : (payload.status === 'dry-run' ? '🔍' : '🧹'); + console.log( + `[${TOOL_NAME}] ${tag} Stale agent-worktree prune${baseLabel}: status=${payload.status}`, + ); + for (const detail of payload.details || []) { + console.log(`[${TOOL_NAME}] ${detail}`); + } +} + function printScanResult(scan, json = false) { if (json) { process.stdout.write( @@ -2369,6 +2395,12 @@ function doctor(rawArgs) { configureHooks, autoFinishReadyAgentBranches: doctorModule.autoFinishReadyAgentBranches, }); + const primaryBaseBranch = currentBranchName(blocked.repoRoot); + const prunePayload = doctorModule.pruneStaleAgentWorktrees(blocked.repoRoot, { + baseBranch: primaryBaseBranch, + dryRun: singleRepoOptions.dryRun, + }); + printWorktreePruneSummary(prunePayload, { baseBranch: primaryBaseBranch }); return; } @@ -2390,6 +2422,12 @@ function doctor(rawArgs) { dryRun: singleRepoOptions.dryRun, waitForMerge: singleRepoOptions.waitForMerge, }); + const prunePayload = scanResult.guardexEnabled === false + ? { enabled: false, ran: false, status: 'skipped', details: ['Guardex disabled for this repo.'] } + : doctorModule.pruneStaleAgentWorktrees(scanResult.repoRoot, { + baseBranch: currentBaseBranch, + dryRun: singleRepoOptions.dryRun, + }); const safe = scanResult.guardexEnabled === false || (scanResult.errors === 0 && scanResult.warnings === 0); const musafe = safe; @@ -2414,6 +2452,7 @@ function doctor(rawArgs) { findings: scanResult.findings, }, autoFinish: autoFinishSummary, + worktreePrune: prunePayload, }, null, 2, @@ -2434,6 +2473,7 @@ function doctor(rawArgs) { baseBranch: currentBaseBranch, verbose: singleRepoOptions.verboseAutoFinish, }); + printWorktreePruneSummary(prunePayload, { baseBranch: currentBaseBranch }); if (safe) { console.log(colorizeDoctorOutput(`[${TOOL_NAME}] ✅ Repo is fully safe.`, 'safe')); } else { @@ -2967,6 +3007,12 @@ function setup(rawArgs) { aggregateErrors += sandboxResult.scanResult.errors; aggregateWarnings += sandboxResult.scanResult.warnings; lastScanResult = sandboxResult.scanResult; + const primaryBaseBranch = currentBranchName(blocked.repoRoot); + const prunePayload = doctorModule.pruneStaleAgentWorktrees(blocked.repoRoot, { + baseBranch: primaryBaseBranch, + dryRun: perRepoOptions.dryRun, + }); + printWorktreePruneSummary(prunePayload, { baseBranch: primaryBaseBranch }); continue; } @@ -2992,6 +3038,13 @@ function setup(rawArgs) { printAutoFinishSummary(autoFinishSummary, { baseBranch: currentBaseBranch, }); + const prunePayload = scanResult.guardexEnabled === false + ? { enabled: false, ran: false, status: 'skipped', details: ['Guardex disabled for this repo.'] } + : doctorModule.pruneStaleAgentWorktrees(scanResult.repoRoot, { + baseBranch: currentBaseBranch, + dryRun: perRepoOptions.dryRun, + }); + printWorktreePruneSummary(prunePayload, { baseBranch: currentBaseBranch }); printSetupRepoHints(scanResult.repoRoot, currentBaseBranch, repoLabel); aggregateErrors += scanResult.errors; diff --git a/src/doctor/index.js b/src/doctor/index.js index 17f52c3..334b934 100644 --- a/src/doctor/index.js +++ b/src/doctor/index.js @@ -1006,6 +1006,70 @@ function autoFinishReadyAgentBranches(repoRoot, options = {}) { return summary; } +function pruneStaleAgentWorktrees(repoRoot, options = {}) { + const summary = { + enabled: true, + ran: false, + status: 'skipped', + details: [], + }; + + const dryRun = Boolean(options.dryRun); + const baseBranch = String(options.baseBranch || '').trim(); + + if (String(process.env.GUARDEX_DOCTOR_SANDBOX || '') === '1') { + summary.enabled = false; + summary.details.push('Skipped stale-worktree prune inside doctor sandbox pass.'); + return summary; + } + + if (String(process.env.GUARDEX_SKIP_AUTO_WORKTREE_PRUNE || '') === '1') { + summary.enabled = false; + summary.details.push('Skipped stale-worktree prune (GUARDEX_SKIP_AUTO_WORKTREE_PRUNE=1).'); + return summary; + } + + const idleMinutesRaw = Number(options.idleMinutes); + const idleMinutes = Number.isFinite(idleMinutesRaw) && idleMinutesRaw >= 0 + ? Math.floor(idleMinutesRaw) + : 60; + + const args = [ + '--idle-minutes', String(idleMinutes), + '--delete-branches', + '--delete-remote-branches', + '--include-pr-merged', + '--force-dirty', + ]; + if (baseBranch && baseBranch !== 'HEAD' && !baseBranch.startsWith('agent/')) { + args.push('--base', baseBranch); + } + if (dryRun) { + args.push('--dry-run'); + } + + const runResult = runPackageAsset('worktreePrune', args, { cwd: repoRoot }); + summary.ran = true; + const stdout = String(runResult.stdout || '').trim(); + const stderr = String(runResult.stderr || '').trim(); + if (stdout) { + for (const line of stdout.split('\n')) { + if (line.trim()) summary.details.push(line); + } + } + if (runResult.status === 0) { + summary.status = dryRun ? 'dry-run' : 'pruned'; + } else { + summary.status = 'failed'; + if (stderr) { + summary.details.push(`[error] ${stderr.split('\n').slice(-2).join(' | ')}`); + } else { + summary.details.push(`[error] worktreePrune exited with status ${runResult.status}`); + } + } + return summary; +} + function executeDoctorSandboxLifecycle(options, blocked, metadata, integrations) { const execution = createDoctorSandboxExecutionState(); const dryRun = Boolean(options.dryRun); @@ -1206,7 +1270,7 @@ function runDoctorInSandbox(options, blocked, rawIntegrations = {}) { const nestedResult = run( process.execPath, [require.main?.filename || process.argv[1], ...buildSandboxDoctorArgs(options, sandboxTarget)], - { cwd: metadata.worktreePath }, + { cwd: metadata.worktreePath, env: { GUARDEX_DOCTOR_SANDBOX: '1' } }, ); if (isSpawnFailure(nestedResult)) { throw nestedResult.error; @@ -1242,5 +1306,6 @@ module.exports = { emitDoctorSandboxJsonOutput, emitDoctorSandboxConsoleOutput, autoFinishReadyAgentBranches, + pruneStaleAgentWorktrees, runDoctorInSandbox, }; diff --git a/test/doctor.test.js b/test/doctor.test.js index 9ec1536..01ecdc2 100644 --- a/test/doctor.test.js +++ b/test/doctor.test.js @@ -1091,4 +1091,73 @@ exit 1 ); }); +test('gx doctor auto-prunes detached-HEAD agent worktrees under .omc/agent-worktrees', () => { + const repoDir = initRepoOnBranch('main'); + seedCommit(repoDir); + + const worktreeRoot = path.join(repoDir, '.omc', 'agent-worktrees'); + fs.mkdirSync(worktreeRoot, { recursive: true }); + const strandedWorktree = path.join(worktreeRoot, 'stranded-agent-worktree'); + + const branchResult = runHumanCmd('git', ['branch', 'agent/claude/stranded-demo'], repoDir); + assert.equal(branchResult.status, 0, branchResult.stderr || branchResult.stdout); + const addResult = runHumanCmd('git', ['worktree', 'add', strandedWorktree, 'agent/claude/stranded-demo'], repoDir); + assert.equal(addResult.status, 0, addResult.stderr || addResult.stdout); + + const detachResult = runHumanCmd('git', ['-C', strandedWorktree, 'checkout', '--detach', 'HEAD'], repoDir); + assert.equal(detachResult.status, 0, detachResult.stderr || detachResult.stdout); + const delResult = runHumanCmd('git', ['branch', '-D', 'agent/claude/stranded-demo'], repoDir); + assert.equal(delResult.status, 0, delResult.stderr || delResult.stdout); + + const pastTime = new Date(Date.now() - 3 * 60 * 60 * 1000); + const stampPath = path.join(strandedWorktree, '.stamp'); + fs.writeFileSync(stampPath, 'stale\n', 'utf8'); + fs.utimesSync(stampPath, pastTime, pastTime); + + assert.ok(fs.existsSync(strandedWorktree), 'stranded worktree should exist before doctor'); + + const result = runNode(['doctor', '--target', repoDir, '--skip-agents', '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + const combined = `${result.stdout}\n${result.stderr}`; + assert.match(combined, /Stale agent-worktree prune/); + + assert.equal( + fs.existsSync(strandedWorktree), + false, + 'doctor should have pruned the detached-HEAD agent worktree', + ); +}); + +test('gx doctor preserves stranded worktrees when GUARDEX_SKIP_AUTO_WORKTREE_PRUNE=1', () => { + const repoDir = initRepoOnBranch('main'); + seedCommit(repoDir); + + const worktreeRoot = path.join(repoDir, '.omc', 'agent-worktrees'); + fs.mkdirSync(worktreeRoot, { recursive: true }); + const strandedWorktree = path.join(worktreeRoot, 'stranded-optout'); + + runHumanCmd('git', ['branch', 'agent/claude/optout-demo'], repoDir); + const addResult = runHumanCmd('git', ['worktree', 'add', strandedWorktree, 'agent/claude/optout-demo'], repoDir); + assert.equal(addResult.status, 0, addResult.stderr || addResult.stdout); + runHumanCmd('git', ['-C', strandedWorktree, 'checkout', '--detach', 'HEAD'], repoDir); + runHumanCmd('git', ['branch', '-D', 'agent/claude/optout-demo'], repoDir); + + const result = runNodeWithEnv(['doctor', '--target', repoDir, '--skip-agents', '--no-global-install'], repoDir, { + GUARDEX_SKIP_AUTO_WORKTREE_PRUNE: '1', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + const combined = `${result.stdout}\n${result.stderr}`; + assert.match(combined, /GUARDEX_SKIP_AUTO_WORKTREE_PRUNE=1/); + assert.doesNotMatch( + combined, + /removed_worktrees=\d/, + 'opt-out env must prevent the worktreePrune shell script from running', + ); + + assert.ok( + fs.existsSync(strandedWorktree), + 'doctor must preserve stranded worktree when opt-out env is set', + ); +}); + });