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