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
9 changes: 6 additions & 3 deletions bin/multiagent-safety.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down Expand Up @@ -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/<base>'],
['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)'],
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -3979,6 +3980,7 @@ function agents(rawArgs) {
String(options.cleanupIntervalSeconds),
'--idle-minutes',
String(options.idleMinutes),
'--include-pr-merged',
],
cwd: repoRoot,
logPath: cleanupLogPath,
Expand All @@ -3998,6 +4000,7 @@ function agents(rawArgs) {
pid: cleanupPid,
intervalSeconds: options.cleanupIntervalSeconds,
idleMinutes: options.idleMinutes,
includePrMerged: true,
script: path.resolve(__filename),
logPath: cleanupLogPath,
},
Expand Down
46 changes: 44 additions & 2 deletions test/install.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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 });
Expand All @@ -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/);
});

Expand Down