diff --git a/src/agents/finish.js b/src/agents/finish.js new file mode 100644 index 00000000..2ae12360 --- /dev/null +++ b/src/agents/finish.js @@ -0,0 +1,85 @@ +const { TOOL_NAME } = require('../context'); +const finishCommands = require('../finish'); +const { + readAgentSession, + updateAgentSession, + listAgentSessions, +} = require('./sessions'); + +function resolveSessionByBranch(repoRoot, branch) { + const matches = listAgentSessions(repoRoot).filter((session) => session.branch === branch); + if (matches.length === 0) { + return null; + } + if (matches.length > 1) { + throw new Error(`Multiple agent sessions found for branch: ${branch}`); + } + return matches[0]; +} + +function resolveAgentSessionForFinish(repoRoot, options) { + if (options.sessionId) { + const session = readAgentSession(repoRoot, options.sessionId); + if (!session) { + throw new Error(`Agent session not found: ${options.sessionId}`); + } + return session; + } + + if (options.branch) { + const session = resolveSessionByBranch(repoRoot, options.branch); + if (!session) { + throw new Error(`Agent session not found for branch: ${options.branch}`); + } + return session; + } + + throw new Error('agents finish requires --session or --branch '); +} + +function sessionStatusAfterFinish(finishArgs) { + const modeIndex = finishArgs.indexOf('--mode'); + const directMode = finishArgs.includes('--direct-only') || finishArgs[modeIndex + 1] === 'direct'; + return finishArgs.includes('--no-wait-for-merge') && !directMode ? 'pr-opened' : 'finished'; +} + +function finishAgentSession(repoRoot, options, deps = {}) { + const finishRunner = deps.finishRunner || finishCommands.finish; + const output = deps.output || process.stdout; + const session = resolveAgentSessionForFinish(repoRoot, options); + + if (!session.branch) { + throw new Error(`Agent session '${session.id}' has no branch metadata.`); + } + + updateAgentSession(repoRoot, session.id, { status: 'finishing' }); + + const finishArgs = [ + '--target', + repoRoot, + '--branch', + session.branch, + ...options.finishArgs, + ]; + + output.write(`[${TOOL_NAME}] Agent session: ${session.id}\n`); + output.write(`[${TOOL_NAME}] Branch: ${session.branch}\n`); + output.write(`[${TOOL_NAME}] Worktree: ${session.worktreePath || '(unknown)'}\n`); + + try { + const result = finishRunner(finishArgs); + const status = sessionStatusAfterFinish(finishArgs); + updateAgentSession(repoRoot, session.id, { status }); + output.write(`[${TOOL_NAME}] Finish result: ${status}\n`); + return { session, status, result, finishArgs }; + } catch (error) { + updateAgentSession(repoRoot, session.id, { status: 'failed' }); + output.write(`[${TOOL_NAME}] Finish result: failed\n`); + throw error; + } +} + +module.exports = { + finishAgentSession, + resolveAgentSessionForFinish, +}; diff --git a/src/agents/inspect.js b/src/agents/inspect.js new file mode 100644 index 00000000..0fa8c990 --- /dev/null +++ b/src/agents/inspect.js @@ -0,0 +1,189 @@ +const { fs, path, DEFAULT_BASE_BRANCH, LOCK_FILE_RELATIVE } = require('../context'); +const { run } = require('../core/runtime'); +const { resolveRepoRoot } = require('../git'); + +const INSPECT_EXCLUDE_PATHS = new Set([LOCK_FILE_RELATIVE]); + +function git(repoRoot, args, options = {}) { + const result = run('git', ['-C', repoRoot, ...args], options); + if (result.status !== 0 && !options.allowFailure) { + throw new Error(`git ${args.join(' ')} failed: ${(result.stderr || '').trim()}`); + } + return result; +} + +function readGitConfig(repoRoot, key) { + const result = git(repoRoot, ['config', '--get', key], { allowFailure: true }); + return result.status === 0 ? String(result.stdout || '').trim() : ''; +} + +function refExists(repoRoot, ref) { + return git(repoRoot, ['show-ref', '--verify', '--quiet', ref], { allowFailure: true }).status === 0; +} + +function parseWorktreeList(outputText) { + const worktrees = []; + let current = null; + + for (const line of String(outputText || '').split(/\r?\n/)) { + if (!line.trim()) { + if (current) worktrees.push(current); + current = null; + continue; + } + if (line.startsWith('worktree ')) { + if (current) worktrees.push(current); + current = { path: line.slice('worktree '.length), branch: '' }; + continue; + } + if (current && line.startsWith('branch ')) { + current.branch = line.slice('branch '.length).replace(/^refs\/heads\//, ''); + } + } + if (current) worktrees.push(current); + + return worktrees; +} + +function worktreePathForBranch(repoRoot, branch) { + const result = git(repoRoot, ['worktree', 'list', '--porcelain'], { allowFailure: true }); + if (result.status !== 0) return { worktreePath: repoRoot, worktreeFound: false }; + const match = parseWorktreeList(result.stdout).find((entry) => entry.branch === branch); + return { + worktreePath: match?.path || repoRoot, + worktreeFound: Boolean(match?.path), + }; +} + +function resolveBaseBranch(repoRoot, branch) { + return ( + readGitConfig(repoRoot, `branch.${branch}.guardexBase`) || + readGitConfig(repoRoot, 'multiagent.baseBranch') || + DEFAULT_BASE_BRANCH + ); +} + +function compareRefForBase(repoRoot, baseBranch) { + if (refExists(repoRoot, `refs/remotes/origin/${baseBranch}`)) { + return `origin/${baseBranch}`; + } + return baseBranch; +} + +function readLockRegistry(repoRoot, branch) { + 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 + : {}; + + return Object.entries(locks) + .map(([filePath, entry]) => { + if (!entry || typeof entry !== 'object' || entry.branch !== branch) return null; + return { + file: filePath, + branch: entry.branch, + claimedAt: entry.claimed_at || '', + allowDelete: Boolean(entry.allow_delete), + }; + }) + .filter(Boolean) + .sort((left, right) => left.file.localeCompare(right.file)); +} + +function splitGitLines(outputText) { + return String(outputText || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +function readUntrackedFiles(worktreePath) { + const result = git(worktreePath, ['ls-files', '--others', '--exclude-standard'], { allowFailure: true }); + return result.status === 0 + ? splitGitLines(result.stdout).filter((filePath) => !INSPECT_EXCLUDE_PATHS.has(filePath)) + : []; +} + +function inspectAgentBranch(options) { + const repoRoot = resolveRepoRoot(options.target || process.cwd()); + const branch = String(options.branch || '').trim(); + if (!branch) { + throw new Error('--branch requires an agent branch name'); + } + + const baseBranch = resolveBaseBranch(repoRoot, branch); + const compareRef = compareRefForBase(repoRoot, baseBranch); + const { worktreePath, worktreeFound } = worktreePathForBranch(repoRoot, branch); + return { repoRoot, branch, baseBranch, compareRef, worktreePath, worktreeFound }; +} + +function changedFiles(options) { + const context = inspectAgentBranch(options); + const diffTarget = context.worktreeFound ? context.compareRef : `${context.compareRef}...${context.branch}`; + const result = git(context.worktreePath, ['diff', '--name-only', diffTarget, '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`]); + const files = [...splitGitLines(result.stdout), ...(context.worktreeFound ? readUntrackedFiles(context.worktreePath) : [])]; + const uniqueFiles = Array.from(new Set(files)).sort((left, right) => left.localeCompare(right)); + return { ...context, files: uniqueFiles }; +} + +function branchDiff(options) { + const context = inspectAgentBranch(options); + const diffTarget = context.worktreeFound ? context.compareRef : `${context.compareRef}...${context.branch}`; + const result = git(context.worktreePath, ['diff', diffTarget, '--', '.', `:(exclude)${LOCK_FILE_RELATIVE}`]); + const untrackedDiff = context.worktreeFound + ? readUntrackedFiles(context.worktreePath) + .map((filePath) => git(context.worktreePath, ['diff', '--no-index', '--', '/dev/null', filePath], { allowFailure: true }).stdout || '') + .join('') + : ''; + return { ...context, diff: `${result.stdout || ''}${untrackedDiff}` }; +} + +function branchLocks(options) { + const context = inspectAgentBranch(options); + return { ...context, locks: readLockRegistry(context.repoRoot, context.branch) }; +} + +function renderFiles(payload, { json = false } = {}) { + if (json) return `${JSON.stringify(payload, null, 2)}\n`; + return payload.files.length > 0 ? `${payload.files.join('\n')}\n` : ''; +} + +function renderDiff(payload, { json = false } = {}) { + if (json) return `${JSON.stringify(payload, null, 2)}\n`; + return payload.diff; +} + +function renderLocks(payload, { json = false } = {}) { + if (json) return `${JSON.stringify(payload, null, 2)}\n`; + if (payload.locks.length === 0) return ''; + return `${payload.locks + .map((lock) => `${lock.file}\t${lock.branch}\t${lock.claimedAt}\tallow_delete=${lock.allowDelete ? 'true' : 'false'}`) + .join('\n')}\n`; +} + +function runInspectCommand(options) { + if (options.subcommand === 'files') return renderFiles(changedFiles(options), options); + if (options.subcommand === 'diff') return renderDiff(branchDiff(options), options); + if (options.subcommand === 'locks') return renderLocks(branchLocks(options), options); + throw new Error(`Unknown agents subcommand: ${options.subcommand}`); +} + +module.exports = { + branchDiff, + branchLocks, + changedFiles, + inspectAgentBranch, + parseWorktreeList, + readLockRegistry, + renderDiff, + renderFiles, + renderLocks, + resolveBaseBranch, + runInspectCommand, +}; diff --git a/src/cli/args.js b/src/cli/args.js index efa536de..a8ba9db4 100644 --- a/src/cli/args.js +++ b/src/cli/args.js @@ -277,10 +277,57 @@ function parseAgentsArgs(rawArgs) { cleanupIntervalSeconds: 60, idleMinutes: DEFAULT_SHADOW_CLEANUP_IDLE_MINUTES, pid: null, + branch: '', + json: false, + sessionId: '', + finishArgs: [], }; for (let index = 0; index < rest.length; index += 1) { const arg = rest[index]; + if (subcommand === 'finish') { + if (arg === '--session') { + const next = rest[index + 1]; + if (!next) { + throw new Error('--session requires an agent session id'); + } + options.sessionId = next; + index += 1; + continue; + } + if (arg === '--branch') { + const next = rest[index + 1]; + if (!next) { + throw new Error('--branch requires an agent branch name'); + } + options.branch = next; + index += 1; + continue; + } + options.finishArgs.push(arg); + if ( + ['--base', '--commit-message', '--mode'].includes(arg) && + rest[index + 1] && + !rest[index + 1].startsWith('-') + ) { + options.finishArgs.push(rest[index + 1]); + index += 1; + } + continue; + } + if (arg === '--branch') { + const next = rest[index + 1]; + if (!next) { + throw new Error('--branch requires an agent branch name'); + } + options.branch = next; + index += 1; + continue; + } + if (arg === '--json') { + options.json = true; + continue; + } if (arg === '--review-interval') { const next = rest[index + 1]; if (!next) { @@ -371,7 +418,7 @@ function parseAgentsArgs(rawArgs) { throw new Error(`Unknown option: ${arg}`); } - if (!['start', 'stop', 'status'].includes(options.subcommand)) { + if (!['start', 'stop', 'status', 'files', 'diff', 'locks', 'finish'].includes(options.subcommand)) { throw new Error(`Unknown agents subcommand: ${options.subcommand}`); } if (options.pid !== null && options.subcommand !== 'stop') { @@ -386,6 +433,23 @@ function parseAgentsArgs(rawArgs) { if (options.claims.length > 0 && !options.task) { throw new Error('gx agents start --claim requires a task'); } + if (['files', 'diff', 'locks'].includes(options.subcommand) && !options.branch) { + throw new Error('--branch requires an agent branch name'); + } + if (options.subcommand === 'finish') { + if (!options.sessionId && !options.branch) { + throw new Error('agents finish requires --session or --branch '); + } + if (options.sessionId && options.branch) { + throw new Error('agents finish accepts only one of --session or --branch'); + } + } + 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`'); + } return options; } diff --git a/src/cli/main.js b/src/cli/main.js index f49dca66..be647b15 100755 --- a/src/cli/main.js +++ b/src/cli/main.js @@ -5,6 +5,8 @@ const sandboxModule = require('../sandbox'); const toolchainModule = require('../toolchain'); const finishCommands = require('../finish'); const doctorModule = require('../doctor'); +const agentInspect = require('../agents/inspect'); +const { finishAgentSession } = require('../agents/finish'); const sessionSeverityReport = require('../report/session-severity'); const cockpitModule = require('../cockpit'); const agentsStart = require('../agents/start'); @@ -2644,9 +2646,21 @@ function spawnDetachedAgentProcess({ command, args, cwd, logPath }) { function agents(rawArgs) { const options = parseAgentsArgs(rawArgs); + if (['files', 'diff', 'locks'].includes(options.subcommand)) { + process.stdout.write(agentInspect.runInspectCommand(options)); + process.exitCode = 0; + return; + } + const repoRoot = resolveRepoRoot(options.target); const statePath = agentsStatePathForRepo(repoRoot); + if (options.subcommand === 'finish') { + finishAgentSession(repoRoot, options); + process.exitCode = 0; + return; + } + if (options.subcommand === 'start') { if (options.dryRun) { console.log(agentsStart.dryRunStart(options, repoRoot)); diff --git a/test/agents-finish.test.js b/test/agents-finish.test.js new file mode 100644 index 00000000..bd021fb8 --- /dev/null +++ b/test/agents-finish.test.js @@ -0,0 +1,79 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const { parseAgentsArgs } = require('../src/cli/args'); +const { finishAgentSession } = require('../src/agents/finish'); +const { createAgentSession, readAgentSession } = require('../src/agents/sessions'); + +function makeRepoRoot() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'guardex-agent-finish-')); +} + +function makeOutput() { + let text = ''; + return { stream: { write: (chunk) => { text += chunk; } }, read: () => text }; +} + +test('parseAgentsArgs accepts finish by session or branch', () => { + const bySession = parseAgentsArgs(['finish', '--session', 'session-1', '--no-wait-for-merge']); + assert.equal(bySession.subcommand, 'finish'); + assert.equal(bySession.sessionId, 'session-1'); + assert.equal(bySession.branch, ''); + assert.deepEqual(bySession.finishArgs, ['--no-wait-for-merge']); + + const byBranch = parseAgentsArgs(['finish', '--branch', 'agent/codex/demo', '--base', 'main']); + assert.equal(byBranch.subcommand, 'finish'); + assert.equal(byBranch.sessionId, ''); + assert.equal(byBranch.branch, 'agent/codex/demo'); + assert.deepEqual(byBranch.finishArgs, ['--base', 'main']); +}); + +test('agents finish resolves a session id and calls existing finish logic for its branch', () => { + const repoRoot = makeRepoRoot(); + const session = createAgentSession(repoRoot, { + id: 'session-finish-1', task: 'Finish known session', agent: 'codex', branch: 'agent/codex/session-finish', + worktreePath: path.join(repoRoot, '.omx', 'agent-worktrees', 'session-finish'), base: 'main', status: 'working', + }); + const calls = []; + const output = makeOutput(); + const result = finishAgentSession(repoRoot, { sessionId: session.id, branch: '', finishArgs: [] }, { + output: output.stream, + finishRunner(args) { calls.push(args); assert.equal(readAgentSession(repoRoot, session.id).status, 'finishing'); return { ok: true }; }, + }); + assert.deepEqual(calls, [['--target', repoRoot, '--branch', 'agent/codex/session-finish']]); + assert.equal(result.status, 'finished'); + assert.equal(readAgentSession(repoRoot, session.id).status, 'finished'); + assert.match(output.read(), /Branch: agent\/codex\/session-finish/); + assert.match(output.read(), /Worktree: .*session-finish/); + assert.match(output.read(), /Finish result: finished/); +}); + +test('agents finish resolves a branch and marks pr-opened when merge wait is disabled', () => { + const repoRoot = makeRepoRoot(); + createAgentSession(repoRoot, { + id: 'session-finish-2', task: 'Finish branch session', agent: 'codex', branch: 'agent/codex/no-wait-finish', + worktreePath: path.join(repoRoot, 'worktree'), base: 'main', status: 'working', + }); + const calls = []; + const result = finishAgentSession(repoRoot, { + sessionId: '', branch: 'agent/codex/no-wait-finish', finishArgs: ['--no-wait-for-merge', '--no-cleanup'], + }, { output: makeOutput().stream, finishRunner(args) { calls.push(args); } }); + assert.deepEqual(calls[0], ['--target', repoRoot, '--branch', 'agent/codex/no-wait-finish', '--no-wait-for-merge', '--no-cleanup']); + assert.equal(result.status, 'pr-opened'); + assert.equal(readAgentSession(repoRoot, 'session-finish-2').status, 'pr-opened'); +}); + +test('agents finish marks the session failed when existing finish logic fails', () => { + const repoRoot = makeRepoRoot(); + createAgentSession(repoRoot, { + id: 'session-finish-3', task: 'Failing finish', agent: 'codex', branch: 'agent/codex/failing-finish', + worktreePath: path.join(repoRoot, 'worktree'), base: 'main', status: 'working', + }); + assert.throws(() => finishAgentSession(repoRoot, { sessionId: 'session-finish-3', branch: '', finishArgs: [] }, { + output: makeOutput().stream, finishRunner() { throw new Error('mock finish failure'); }, + }), /mock finish failure/); + assert.equal(readAgentSession(repoRoot, 'session-finish-3').status, 'failed'); +}); diff --git a/test/agents-inspect.test.js b/test/agents-inspect.test.js new file mode 100644 index 00000000..244c20ad --- /dev/null +++ b/test/agents-inspect.test.js @@ -0,0 +1,97 @@ +const { + test, + assert, + fs, + path, + runNode, + runCmd, + initRepoOnBranch, + seedCommit, + commitFile, +} = require('./helpers/install-test-helpers'); + +function prepareAgentBranch() { + const repoDir = initRepoOnBranch('main'); + seedCommit(repoDir); + + const branch = 'agent/codex/foo'; + let result = runCmd('git', ['checkout', '-b', branch], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + result = runCmd('git', ['config', `branch.${branch}.guardexBase`, 'main'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + commitFile(repoDir, 'src/feature.js', 'export const value = 1;\n', 'add feature'); + return { repoDir, branch }; +} + +test('agents files lists changed files against branch base metadata', () => { + const { repoDir, branch } = prepareAgentBranch(); + fs.writeFileSync(path.join(repoDir, 'src', 'dirty.js'), 'export const dirty = true;\n', 'utf8'); + + const result = runNode(['agents', 'files', '--target', repoDir, '--branch', branch], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.deepEqual(result.stdout.trim().split(/\r?\n/).sort(), ['src/dirty.js', 'src/feature.js']); +}); + +test('agents files --json includes base and changed file payload', () => { + const { repoDir, branch } = prepareAgentBranch(); + + const result = runNode(['agents', 'files', '--target', repoDir, '--branch', branch, '--json'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + const payload = JSON.parse(result.stdout); + assert.equal(payload.branch, branch); + assert.equal(payload.baseBranch, 'main'); + assert.equal(payload.compareRef, 'main'); + assert.deepEqual(payload.files, ['src/feature.js']); +}); + +test('agents diff prints git diff against branch base metadata', () => { + const { repoDir, branch } = prepareAgentBranch(); + + const result = runNode(['agents', 'diff', '--target', repoDir, '--branch', branch], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /diff --git a\/src\/feature\.js b\/src\/feature\.js/); + assert.match(result.stdout, /\+export const value = 1;/); +}); + +test('agents locks lists locks owned by the selected branch', () => { + const { repoDir, branch } = prepareAgentBranch(); + 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/feature.js': { + branch, + claimed_at: '2026-04-29T19:00:00.000Z', + allow_delete: false, + }, + 'src/delete-me.js': { + branch, + claimed_at: '2026-04-29T19:01:00.000Z', + allow_delete: true, + }, + 'src/other.js': { + branch: 'agent/codex/other', + claimed_at: '2026-04-29T19:02:00.000Z', + allow_delete: false, + }, + }, + }, null, 2)}\n`, 'utf8'); + + const textResult = runNode(['agents', 'locks', '--target', repoDir, '--branch', branch], repoDir); + assert.equal(textResult.status, 0, textResult.stderr || textResult.stdout); + assert.match(textResult.stdout, /src\/delete-me\.js\tagent\/codex\/foo\t2026-04-29T19:01:00.000Z\tallow_delete=true/); + assert.match(textResult.stdout, /src\/feature\.js\tagent\/codex\/foo\t2026-04-29T19:00:00.000Z\tallow_delete=false/); + assert.doesNotMatch(textResult.stdout, /src\/other\.js/); + + const jsonResult = runNode(['agents', 'locks', '--target', repoDir, '--branch', branch, '--json'], repoDir); + assert.equal(jsonResult.status, 0, jsonResult.stderr || jsonResult.stdout); + const payload = JSON.parse(jsonResult.stdout); + assert.deepEqual( + payload.locks.map((entry) => [entry.file, entry.allowDelete]), + [ + ['src/delete-me.js', true], + ['src/feature.js', false], + ], + ); +});