From b21cb4841e1f4030f9d70af8a2357af3bf6b2d5d Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 15 Apr 2026 14:22:56 +0200 Subject: [PATCH] Default shadow cleanup bots to one-hour idle pruning This updates the cleanup daemon defaults so background branch cleanup only prunes idle agent branches after 60 minutes, and cleanup watch mode follows the same default. The agents cleanup process now also requests merged-PR detection so stale squash-merged branches can be cleaned from local and remote refs. Constraint: Preserve existing safety guardrails that avoid deleting active or dirty agent worktrees Rejected: Keep 10-minute idle default | too aggressive for active multi-agent sessions Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep idle-threshold defaults aligned between 'agents start' and 'cleanup --watch' paths Tested: npm test -- test/install.test.js Not-tested: end-to-end long-running daemon behavior over real multi-hour cycles --- bin/multiagent-safety.js | 9 +++++--- test/install.test.js | 46 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 50 insertions(+), 5 deletions(-) diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index fe23c28..ce99aa3 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -35,6 +35,7 @@ const GIT_SYNC_STRATEGY_KEY = 'multiagent.sync.strategy'; const DEFAULT_PROTECTED_BRANCHES = ['dev', 'main', 'master']; const DEFAULT_BASE_BRANCH = 'dev'; const DEFAULT_SYNC_STRATEGY = 'rebase'; +const DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES = 60; const TEMPLATE_ROOT = path.resolve(__dirname, '..', 'templates'); @@ -185,7 +186,7 @@ const CLI_COMMAND_DESCRIPTIONS = [ ['copy-commands', 'Print setup checklist as executable commands only'], ['protect', 'Manage protected branches (list/add/remove/set/reset)'], ['sync', 'Check or sync agent branches with origin/'], - ['cleanup', 'Cleanup agent branches/worktrees (supports idle watch mode)'], + ['cleanup', 'Cleanup agent branches/worktrees (watch mode defaults to 60-minute idle threshold)'], ['agents', 'Start/stop repo-scoped review + cleanup bots'], ['install', 'Install templates/locks/hooks without running full setup (supports --no-gitignore)'], ['fix', 'Repair broken or missing guardrail files/config (supports --no-gitignore)'], @@ -1645,7 +1646,7 @@ function parseAgentsArgs(rawArgs) { subcommand, reviewIntervalSeconds: 30, cleanupIntervalSeconds: 60, - idleMinutes: 10, + idleMinutes: DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES, }; for (let index = 0; index < rest.length; index += 1) { @@ -2498,7 +2499,7 @@ function parseCleanupArgs(rawArgs) { } if (options.watch && options.idleMinutes === 0) { - options.idleMinutes = 10; + options.idleMinutes = DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES; } return options; @@ -3979,6 +3980,7 @@ function agents(rawArgs) { String(options.cleanupIntervalSeconds), '--idle-minutes', String(options.idleMinutes), + '--include-pr-merged', ], cwd: repoRoot, logPath: cleanupLogPath, @@ -3998,6 +4000,7 @@ function agents(rawArgs) { pid: cleanupPid, intervalSeconds: options.cleanupIntervalSeconds, idleMinutes: options.idleMinutes, + includePrMerged: true, script: path.resolve(__filename), logPath: cleanupLogPath, }, diff --git a/test/install.test.js b/test/install.test.js index 806e99e..4ccd63f 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -1386,6 +1386,48 @@ test('agents command starts review+cleanup bots for the target repo and stops th assert.equal(fs.existsSync(statePath), false, 'agents stop should remove state file'); }); +test('agents cleanup bot defaults to a 60-minute idle threshold', () => { + const repoDir = initRepo(); + seedCommit(repoDir); + const scriptsDir = path.join(repoDir, 'scripts'); + fs.mkdirSync(scriptsDir, { recursive: true }); + + const reviewScriptPath = path.join(scriptsDir, 'review-bot-watch.sh'); + fs.writeFileSync( + reviewScriptPath, + '#!/usr/bin/env bash\n' + + 'set -euo pipefail\n' + + 'while true; do sleep 60; done\n', + 'utf8', + ); + fs.chmodSync(reviewScriptPath, 0o755); + + const pruneScriptPath = path.join(scriptsDir, 'agent-worktree-prune.sh'); + fs.writeFileSync( + pruneScriptPath, + '#!/usr/bin/env bash\n' + + 'set -euo pipefail\n' + + 'exit 0\n', + 'utf8', + ); + fs.chmodSync(pruneScriptPath, 0o755); + + let result = runNode(['agents', 'start', '--target', repoDir], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + const statePath = path.join(repoDir, '.omx', 'state', 'agents-bots.json'); + const state = JSON.parse(fs.readFileSync(statePath, 'utf8')); + assert.equal(state.cleanup.idleMinutes, 60); + assert.equal(state.cleanup.includePrMerged, true); + assert.equal(isPidAlive(state.review.pid), true, 'review bot pid should be alive after start'); + assert.equal(isPidAlive(state.cleanup.pid), true, 'cleanup bot pid should be alive after start'); + + result = runNode(['agents', 'stop', '--target', repoDir], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(waitForPidExit(state.review.pid), true, 'review bot pid should exit after stop'); + assert.equal(waitForPidExit(state.cleanup.pid), true, 'cleanup bot pid should exit after stop'); +}); + test('finish command auto-commits dirty agent worktree and runs PR finish flow for the branch', () => { const repoDir = initRepoOnBranch('main'); seedCommit(repoDir); @@ -3380,7 +3422,7 @@ test('cleanup command can remove squash-merged agent branches via merged PR dete assert.equal(fs.existsSync(worktreePath), false, 'cleanup should remove merged PR worktree'); }); -test('cleanup command watch mode defaults to 10-minute idle threshold and supports one-cycle execution', () => { +test('cleanup command watch mode defaults to 60-minute idle threshold and supports one-cycle execution', () => { const repoDir = initRepo(); const scriptsDir = path.join(repoDir, 'scripts'); fs.mkdirSync(scriptsDir, { recursive: true }); @@ -3399,7 +3441,7 @@ test('cleanup command watch mode defaults to 10-minute idle threshold and suppor const result = runNode(['cleanup', '--target', repoDir, '--watch', '--once', '--interval', '15'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); const passedArgs = fs.readFileSync(markerArgs, 'utf8').trim(); - assert.match(passedArgs, /--idle-minutes 10/); + assert.match(passedArgs, /--idle-minutes 60/); assert.match(passedArgs, /--only-dirty-worktrees/); });