Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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 <currentBaseBranch>` 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/<slug>/` 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
Original file line number Diff line number Diff line change
@@ -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.
55 changes: 54 additions & 1 deletion src/cli/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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} ` +
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;

Expand All @@ -2414,6 +2452,7 @@ function doctor(rawArgs) {
findings: scanResult.findings,
},
autoFinish: autoFinishSummary,
worktreePrune: prunePayload,
},
null,
2,
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
}

Expand All @@ -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;
Expand Down
67 changes: 66 additions & 1 deletion src/doctor/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -1242,5 +1306,6 @@ module.exports = {
emitDoctorSandboxJsonOutput,
emitDoctorSandboxConsoleOutput,
autoFinishReadyAgentBranches,
pruneStaleAgentWorktrees,
runDoctorInSandbox,
};
69 changes: 69 additions & 0 deletions test/doctor.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
);
});

});
Loading