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
82 changes: 82 additions & 0 deletions src/agents/status.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
const { fs, path, LOCK_FILE_RELATIVE, TOOL_NAME } = require('../context');
const { listAgentSessions } = require('./sessions');

function readLockCounts(repoRoot) {
const lockPath = path.join(repoRoot, LOCK_FILE_RELATIVE);
let parsed = null;
try {
parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
} catch (_error) {
parsed = null;
}

const locks = parsed?.locks && typeof parsed.locks === 'object' && !Array.isArray(parsed.locks)
? parsed.locks
: {};
const counts = new Map();
for (const entry of Object.values(locks)) {
const branch = typeof entry?.branch === 'string' ? entry.branch : '';
if (!branch) continue;
counts.set(branch, (counts.get(branch) || 0) + 1);
}
return counts;
}

function normalizeSessionForStatus(session, lockCounts) {
const branch = session.branch || '';
const worktreePath = session.worktreePath || '';
return {
id: session.id || '',
agent: session.agent || '',
task: session.task || '',
branch,
base: session.base || '',
status: session.status || '',
worktreePath,
worktreeExists: worktreePath ? fs.existsSync(worktreePath) : false,
lockCount: lockCounts.get(branch) || 0,
};
}

function buildAgentsStatus(repoRoot) {
const lockCounts = readLockCounts(repoRoot);
return {
schemaVersion: 1,
repoRoot,
sessions: listAgentSessions(repoRoot).map((session) => normalizeSessionForStatus(session, lockCounts)),
};
}

function formatValue(value) {
const text = String(value || '');
return text || '-';
}

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

if (payload.sessions.length === 0) {
return `[${TOOL_NAME}] Agent sessions: none (${payload.repoRoot})\n`;
}

const lines = [`[${TOOL_NAME}] Agent sessions: ${payload.sessions.length} (${payload.repoRoot})`];
for (const session of payload.sessions) {
lines.push(
`- ${formatValue(session.id)} ${formatValue(session.agent)} ${formatValue(session.status)} ` +
`branch=${formatValue(session.branch)} base=${formatValue(session.base)} ` +
`worktreeExists=${session.worktreeExists ? 'yes' : 'no'} locks=${session.lockCount} ` +
`task=${formatValue(session.task)} worktree=${formatValue(session.worktreePath)}`,
);
}
return `${lines.join('\n')}\n`;
}

function runStatusCommand(repoRoot, options = {}) {
return renderAgentsStatus(buildAgentsStatus(repoRoot), options);
}

module.exports = {
buildAgentsStatus,
renderAgentsStatus,
runStatusCommand,
};
4 changes: 2 additions & 2 deletions src/cli/args.js
Original file line number Diff line number Diff line change
Expand Up @@ -447,8 +447,8 @@ 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 && !['files', 'diff', 'locks'].includes(options.subcommand)) {
throw new Error('--json is only supported with `gx agents files|diff|locks`');
if (options.json && !['status', 'files', 'diff', 'locks'].includes(options.subcommand)) {
throw new Error('--json is only supported with `gx agents status|files|diff|locks`');
}

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

const existingState = readAgentsState(repoRoot);
if (!existingState) {
console.log(`[${TOOL_NAME}] Repo agents status: inactive (${repoRoot})`);
process.exitCode = 0;
return;
}

const reviewPid = Number.parseInt(String(existingState?.review?.pid || ''), 10);
const cleanupPid = Number.parseInt(String(existingState?.cleanup?.pid || ''), 10);
console.log(
`[${TOOL_NAME}] Repo agents status: review=${processAlive(reviewPid) ? 'running' : 'stopped'}(pid=${reviewPid || 0}), cleanup=${processAlive(cleanupPid) ? 'running' : 'stopped'}(pid=${cleanupPid || 0})`,
);
process.stdout.write(agentStatus.runStatusCommand(repoRoot, options));
process.exitCode = 0;
}

Expand Down
122 changes: 122 additions & 0 deletions test/agents-status.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
const {
test,
assert,
fs,
path,
runNode,
initRepo,
seedCommit,
} = require('./helpers/install-test-helpers');
const { createAgentSession } = require('../src/agents/sessions');

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

test('agents status prints a compact empty state', () => {
const repoDir = makeRepo();

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

assert.equal(result.status, 0, result.stderr || result.stdout);
assert.equal(result.stdout.trim(), `[gitguardex] Agent sessions: none (${repoDir})`);
});

test('agents status prints one canonical session with worktree and lock count', () => {
const repoDir = makeRepo();
const worktreePath = path.join(repoDir, '.omx', 'agent-worktrees', 'demo');
fs.mkdirSync(worktreePath, { recursive: true });
createAgentSession(repoDir, {
id: 'session-1',
agent: 'codex',
task: 'Build status',
branch: 'agent/codex/status',
base: 'main',
status: 'working',
worktreePath,
});
const lockPath = path.join(repoDir, '.omx', 'state', 'agent-file-locks.json');
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
fs.writeFileSync(lockPath, `${JSON.stringify({
locks: {
'src/a.js': { branch: 'agent/codex/status' },
'src/b.js': { branch: 'agent/codex/status' },
'src/other.js': { branch: 'agent/codex/other' },
},
}, null, 2)}\n`, 'utf8');

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

assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, new RegExp(`Agent sessions: 1 \\(${repoDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\)`));
assert.match(result.stdout, /session-1 codex working branch=agent\/codex\/status base=main/);
assert.match(result.stdout, /worktreeExists=yes locks=2 task=Build status/);
assert.match(result.stdout, new RegExp(`worktree=${worktreePath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`));
});

test('agents status marks missing worktrees', () => {
const repoDir = makeRepo();
const missingWorktree = path.join(repoDir, '.omx', 'agent-worktrees', 'missing');
createAgentSession(repoDir, {
id: 'session-missing',
agent: 'claude',
task: 'Missing worktree',
branch: 'agent/claude/missing',
base: 'dev',
status: 'stale',
worktreePath: missingWorktree,
});

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

assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /session-missing claude stale branch=agent\/claude\/missing base=dev/);
assert.match(result.stdout, /worktreeExists=no locks=0 task=Missing worktree/);
});

test('agents status --json emits stable cockpit-ready payload', () => {
const repoDir = makeRepo();
const worktreePath = path.join(repoDir, '.omx', 'agent-worktrees', 'json');
fs.mkdirSync(worktreePath, { recursive: true });
createAgentSession(repoDir, {
id: 'session-json',
agent: 'codex',
task: 'JSON status',
branch: 'agent/codex/json',
base: 'main',
status: 'active',
worktreePath,
});

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

assert.equal(result.status, 0, result.stderr || result.stdout);
const payload = JSON.parse(result.stdout);
assert.deepEqual(Object.keys(payload), ['schemaVersion', 'repoRoot', 'sessions']);
assert.equal(payload.schemaVersion, 1);
assert.equal(payload.repoRoot, repoDir);
assert.deepEqual(Object.keys(payload.sessions[0]), [
'id',
'agent',
'task',
'branch',
'base',
'status',
'worktreePath',
'worktreeExists',
'lockCount',
]);
assert.deepEqual(payload.sessions[0], {
id: 'session-json',
agent: 'codex',
task: 'JSON status',
branch: 'agent/codex/json',
base: 'main',
status: 'active',
worktreePath,
worktreeExists: true,
lockCount: 0,
});
});
Loading