From 941a63e8714daf3fc601052881743f1280f3df86 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 29 Apr 2026 22:09:25 +0200 Subject: [PATCH] Make agent status read canonical sessions gx agents status should be the stable readout for started agent lanes, so it now renders the canonical .guardex session records instead of the old review/cleanup bot process state. The JSON payload is shaped for cockpit and includes worktree existence plus branch-owned lock counts. Constraint: User requested edits only in agents status, CLI wiring, args parsing, and focused status tests. Rejected: Include legacy .omx active sessions | canonical sessions.js has no compatibility reader in this branch, so mixing stores would make status less reliable. Confidence: high Scope-risk: narrow Directive: Keep gx agents status backed by src/agents/sessions.js; add legacy compatibility only in the session store first. Tested: node --test test/agents-status.test.js test/agents-sessions.test.js test/agents-finish.test.js Tested: openspec validate --specs Not-tested: test/cli-args-dispatch.test.js has a pre-existing stale deep-equal expectation for parseAgentsArgs fields on this branch. --- src/agents/status.js | 82 +++++++++++++++++++++++++ src/cli/args.js | 4 +- src/cli/main.js | 14 +---- test/agents-status.test.js | 122 +++++++++++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 14 deletions(-) create mode 100644 src/agents/status.js create mode 100644 test/agents-status.test.js diff --git a/src/agents/status.js b/src/agents/status.js new file mode 100644 index 00000000..3db9ec15 --- /dev/null +++ b/src/agents/status.js @@ -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, +}; diff --git a/src/cli/args.js b/src/cli/args.js index a8ba9db4..aea52225 100644 --- a/src/cli/args.js +++ b/src/cli/args.js @@ -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; diff --git a/src/cli/main.js b/src/cli/main.js index be647b15..6b842c38 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -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'); @@ -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; } diff --git a/test/agents-status.test.js b/test/agents-status.test.js new file mode 100644 index 00000000..77dfa512 --- /dev/null +++ b/test/agents-status.test.js @@ -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, + }); +});