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
126 changes: 126 additions & 0 deletions src/agents/cleanup-sessions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
const fs = require('node:fs');

const {
listAgentSessions,
removeAgentSession,
} = require('./sessions');
const { branchExists: defaultBranchExists } = require('../git');
const { TOOL_NAME } = require('../context');

const DEFAULT_STALE_AGE_MINUTES = 24 * 60;
const TERMINAL_STATUSES = new Set(['finished', 'pr-opened', 'failed']);

function parseTimestamp(value) {
if (!value) return null;
const timestamp = Date.parse(value);
return Number.isFinite(timestamp) ? timestamp : null;
}

function sessionAgeMinutes(session, nowMs) {
const timestamp = parseTimestamp(session.updatedAt) ?? parseTimestamp(session.createdAt);
if (timestamp === null) return null;
return Math.max(0, Math.floor((nowMs - timestamp) / 60000));
}

function evaluateSession(session, repoRoot, options) {
const reasons = [];
const worktreePath = session.worktreePath || '';
const branch = session.branch || '';

if (worktreePath && !options.existsSync(worktreePath)) {
reasons.push('missing-worktree');
}

if (branch && !options.branchExists(repoRoot, branch)) {
reasons.push('missing-branch');
}

const status = session.status || '';
const ageMinutes = sessionAgeMinutes(session, options.nowMs);
if (
TERMINAL_STATUSES.has(status)
&& ageMinutes !== null
&& ageMinutes >= options.staleAgeMinutes
) {
reasons.push('terminal-status-old');
}

return {
...session,
ageMinutes,
reasons,
stale: reasons.length > 0,
};
}

function cleanupAgentSessions(repoRoot, rawOptions = {}) {
const options = {
dryRun: Boolean(rawOptions.dryRun),
staleAgeMinutes: rawOptions.staleAgeMinutes ?? DEFAULT_STALE_AGE_MINUTES,
nowMs: rawOptions.nowMs ?? Date.now(),
existsSync: rawOptions.existsSync || fs.existsSync,
branchExists: rawOptions.branchExists || defaultBranchExists,
};

const sessions = listAgentSessions(repoRoot);
const candidates = sessions
.map((session) => evaluateSession(session, repoRoot, options))
.filter((session) => session.stale);

const removed = [];
if (!options.dryRun) {
for (const session of candidates) {
if (removeAgentSession(repoRoot, session.id)) {
removed.push(session.id);
}
}
}

return {
schemaVersion: 1,
repoRoot,
dryRun: options.dryRun,
staleAgeMinutes: options.staleAgeMinutes,
inspected: sessions.length,
candidates,
removed,
};
}

function formatSessionLine(session, verb) {
const reasonText = session.reasons.join(',');
return `- ${verb} ${session.id} status=${session.status || '-'} branch=${session.branch || '-'} ` +
`worktree=${session.worktreePath || '-'} reasons=${reasonText}`;
}

function renderCleanupSessionsResult(result, options = {}) {
if (options.json) return `${JSON.stringify(result, null, 2)}\n`;

const action = result.dryRun ? 'would remove' : 'removed';
const lines = [
`[${TOOL_NAME}] Agent session cleanup: ${action} ${result.dryRun ? result.candidates.length : result.removed.length} ` +
`of ${result.inspected} (${result.repoRoot})`,
];

if (result.candidates.length === 0) {
lines.push('- no stale session metadata found');
} else {
for (const session of result.candidates) {
lines.push(formatSessionLine(session, action));
}
}

return `${lines.join('\n')}\n`;
}

function runCleanupSessionsCommand(repoRoot, options = {}) {
return renderCleanupSessionsResult(cleanupAgentSessions(repoRoot, options), options);
}

module.exports = {
DEFAULT_STALE_AGE_MINUTES,
TERMINAL_STATUSES,
cleanupAgentSessions,
renderCleanupSessionsResult,
runCleanupSessionsCommand,
};
32 changes: 26 additions & 6 deletions src/cli/args.js
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ function parseAgentsArgs(rawArgs) {
reviewIntervalSeconds: 30,
cleanupIntervalSeconds: 60,
idleMinutes: DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES,
staleAgeMinutes: 24 * 60,
pid: null,
branch: '',
json: false,
Expand Down Expand Up @@ -367,6 +368,19 @@ function parseAgentsArgs(rawArgs) {
index += 1;
continue;
}
if (arg === '--older-than-minutes') {
const next = rest[index + 1];
if (!next) {
throw new Error('--older-than-minutes requires an integer minutes value');
}
const parsedValue = Number.parseInt(next, 10);
if (!Number.isInteger(parsedValue) || parsedValue < 1) {
throw new Error('--older-than-minutes must be an integer >= 1');
}
options.staleAgeMinutes = parsedValue;
index += 1;
continue;
}
if (arg === '--pid') {
const next = rest[index + 1];
if (!next) {
Expand Down Expand Up @@ -418,16 +432,19 @@ function parseAgentsArgs(rawArgs) {
throw new Error(`Unknown option: ${arg}`);
}

if (!['start', 'stop', 'status', 'files', 'diff', 'locks', 'finish'].includes(options.subcommand)) {
if (!['start', 'stop', 'status', 'files', 'diff', 'locks', 'finish', 'cleanup-sessions'].includes(options.subcommand)) {
throw new Error(`Unknown agents subcommand: ${options.subcommand}`);
}
if (options.pid !== null && options.subcommand !== 'stop') {
throw new Error('--pid is only supported with `gx agents stop`');
}
if ((options.task || options.agent || options.base || options.dryRun || options.claims.length > 0) && options.subcommand !== 'start') {
throw new Error('--task, --agent, --base, --dry-run, and --claim are only supported with `gx agents start`');
if ((options.task || options.agent || options.base || options.claims.length > 0) && options.subcommand !== 'start') {
throw new Error('--task, --agent, --base, and --claim are only supported with `gx agents start`');
}
if (options.dryRun && !options.task) {
if (options.dryRun && !['start', 'cleanup-sessions'].includes(options.subcommand)) {
throw new Error('--dry-run is only supported with `gx agents start|cleanup-sessions`');
}
if (options.subcommand === 'start' && options.dryRun && !options.task) {
throw new Error('gx agents start --dry-run requires a task');
}
if (options.claims.length > 0 && !options.task) {
Expand All @@ -447,8 +464,11 @@ function parseAgentsArgs(rawArgs) {
if (options.branch && !['files', 'diff', 'locks', 'finish'].includes(options.subcommand)) {
throw new Error('--branch is only supported with `gx agents files|diff|locks|finish`');
}
if (options.json && !['status', 'files', 'diff', 'locks'].includes(options.subcommand)) {
throw new Error('--json is only supported with `gx agents status|files|diff|locks`');
if (options.json && !['status', 'files', 'diff', 'locks', 'cleanup-sessions'].includes(options.subcommand)) {
throw new Error('--json is only supported with `gx agents status|files|diff|locks|cleanup-sessions`');
}
if (options.staleAgeMinutes !== 24 * 60 && options.subcommand !== 'cleanup-sessions') {
throw new Error('--older-than-minutes is only supported with `gx agents cleanup-sessions`');
}

return options;
Expand Down
7 changes: 7 additions & 0 deletions src/cli/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const finishCommands = require('../finish');
const doctorModule = require('../doctor');
const agentInspect = require('../agents/inspect');
const agentStatus = require('../agents/status');
const agentCleanupSessions = require('../agents/cleanup-sessions');
const { finishAgentSession } = require('../agents/finish');
const sessionSeverityReport = require('../report/session-severity');
const cockpitModule = require('../cockpit');
Expand Down Expand Up @@ -2662,6 +2663,12 @@ function agents(rawArgs) {
return;
}

if (options.subcommand === 'cleanup-sessions') {
process.stdout.write(agentCleanupSessions.runCleanupSessionsCommand(repoRoot, options));
process.exitCode = 0;
return;
}

if (options.subcommand === 'start') {
if (options.dryRun) {
console.log(agentsStart.dryRunStart(options, repoRoot));
Expand Down
143 changes: 143 additions & 0 deletions test/agents-cleanup-sessions.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
const {
test,
assert,
fs,
path,
runNode,
runCmd,
initRepo,
seedCommit,
} = require('./helpers/install-test-helpers');
const { createAgentSession, readAgentSession } = require('../src/agents/sessions');

function makeRepo() {
const repoDir = initRepo();
seedCommit(repoDir);
return repoDir;
}

function createBranch(repoDir, branch) {
const result = runCmd('git', ['branch', branch], repoDir);
assert.equal(result.status, 0, result.stderr || result.stdout);
}

function oldTimestamp(minutesAgo) {
return new Date(Date.now() - minutesAgo * 60000).toISOString();
}

function createSession(repoDir, overrides = {}) {
const worktreePath = overrides.worktreePath || path.join(repoDir, '.omx', 'agent-worktrees', overrides.id || 'session');
if (overrides.createWorktree !== false) {
fs.mkdirSync(worktreePath, { recursive: true });
}
const session = createAgentSession(repoDir, {
id: overrides.id || 'session',
agent: 'codex',
task: overrides.task || 'Cleanup session',
branch: overrides.branch || 'agent/codex/session',
base: 'main',
status: overrides.status || 'active',
worktreePath,
});
if (overrides.updatedAt) {
const filePath = path.join(repoDir, '.guardex', 'agents', 'sessions', `${session.id}.json`);
const stored = JSON.parse(fs.readFileSync(filePath, 'utf8'));
stored.updatedAt = overrides.updatedAt;
fs.writeFileSync(filePath, `${JSON.stringify(stored, null, 2)}\n`, 'utf8');
}
return session;
}

test('agents cleanup-sessions removes a session with a missing worktree', () => {
const repoDir = makeRepo();
createBranch(repoDir, 'agent/codex/missing-worktree');
createSession(repoDir, {
id: 'missing-worktree',
branch: 'agent/codex/missing-worktree',
createWorktree: false,
});

const result = runNode(['agents', 'cleanup-sessions', '--target', repoDir], repoDir);

assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /removed missing-worktree/);
assert.equal(readAgentSession(repoDir, 'missing-worktree'), null);
const branch = runCmd('git', ['show-ref', '--verify', '--quiet', 'refs/heads/agent/codex/missing-worktree'], repoDir);
assert.equal(branch.status, 0, 'cleanup-sessions must not remove branches');
});

test('agents cleanup-sessions removes a session with a missing branch', () => {
const repoDir = makeRepo();
createSession(repoDir, {
id: 'missing-branch',
branch: 'agent/codex/missing-branch',
});

const result = runNode(['agents', 'cleanup-sessions', '--target', repoDir, '--json'], repoDir);

assert.equal(result.status, 0, result.stderr || result.stdout);
const payload = JSON.parse(result.stdout);
assert.deepEqual(payload.removed, ['missing-branch']);
assert.deepEqual(payload.candidates[0].reasons, ['missing-branch']);
assert.equal(readAgentSession(repoDir, 'missing-branch'), null);
assert.equal(fs.existsSync(payload.candidates[0].worktreePath), true, 'cleanup-sessions must not remove worktrees');
});

test('agents cleanup-sessions preserves active sessions with existing branch and worktree', () => {
const repoDir = makeRepo();
createBranch(repoDir, 'agent/codex/active');
const session = createSession(repoDir, {
id: 'active-session',
branch: 'agent/codex/active',
status: 'active',
});

const result = runNode(['agents', 'cleanup-sessions', '--target', repoDir], repoDir);

assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /removed 0 of 1/);
assert.deepEqual(readAgentSession(repoDir, 'active-session'), {
...session,
status: 'active',
});
});

test('agents cleanup-sessions removes old terminal sessions by configurable age', () => {
const repoDir = makeRepo();
createBranch(repoDir, 'agent/codex/finished-old');
createSession(repoDir, {
id: 'finished-old',
branch: 'agent/codex/finished-old',
status: 'finished',
updatedAt: oldTimestamp(90),
});

const result = runNode([
'agents',
'cleanup-sessions',
'--target',
repoDir,
'--older-than-minutes',
'60',
], repoDir);

assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /reasons=terminal-status-old/);
assert.equal(readAgentSession(repoDir, 'finished-old'), null);
});

test('agents cleanup-sessions --dry-run does not delete stale sessions', () => {
const repoDir = makeRepo();
createBranch(repoDir, 'agent/codex/dry-run');
createSession(repoDir, {
id: 'dry-run-session',
branch: 'agent/codex/dry-run',
createWorktree: false,
});

const result = runNode(['agents', 'cleanup-sessions', '--target', repoDir, '--dry-run'], repoDir);

assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /would remove dry-run-session/);
assert.notEqual(readAgentSession(repoDir, 'dry-run-session'), null);
});
5 changes: 5 additions & 0 deletions test/cli-args-dispatch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,12 @@ test('parseAgentsArgs applies interval overrides and validates the subcommand',
reviewIntervalSeconds: 15,
cleanupIntervalSeconds: 45,
idleMinutes: 12,
staleAgeMinutes: 1440,
pid: null,
branch: '',
json: false,
sessionId: '',
finishArgs: [],
});

const dryRunOptions = parseAgentsArgs([
Expand Down
Loading