From 82452bf7f255441afbe8c5bf98e8532e0f18b562 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 29 Apr 2026 21:43:05 +0200 Subject: [PATCH 1/2] Expose read-only agent lane inspection Add gx agents files, diff, and locks commands so a selected agent branch can be inspected without mutating the lane. The CLI resolves branch base metadata, uses the selected worktree when available, includes dirty worktree paths, and filters lock registry noise from file/diff output. Constraint: Commands must be read-only and cockpit-compatible. Rejected: Shelling out to the lock script for locks | JSON output and cockpit reuse need structured data from the registry. Confidence: high Scope-risk: narrow Directive: Keep agent inspection read-only; do not route these commands through finish or cleanup flows. Tested: node --test test/agents-inspect.test.js test/agents.test.js Tested: node --check src/agents/inspect.js && node --check src/cli/args.js && node --check src/cli/main.js --- src/agents/inspect.js | 189 ++++++++++++++++++++++++++++++++++++ src/cli/args.js | 66 ++++++++++++- src/cli/main.js | 14 +++ test/agents-inspect.test.js | 97 ++++++++++++++++++ 4 files changed, 365 insertions(+), 1 deletion(-) create mode 100644 src/agents/inspect.js create mode 100644 test/agents-inspect.test.js 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-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], + ], + ); +}); From e9ed6c127db6bbf134b1c70d1ff4e929985e59f6 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Wed, 29 Apr 2026 21:46:51 +0200 Subject: [PATCH 2/2] Allow finishing one known agent session The agents CLI already has session metadata and branch finish plumbing, so this adds a small resolver that updates session status around the existing finish command instead of duplicating merge behavior. Constraint: Must reuse existing gx finish / branch finish logic Rejected: Reimplement branch finish in agents module | would fork merge semantics Confidence: medium Scope-risk: narrow Tested: node --test test/agents-finish.test.js Not-tested: live GitHub PR merge flow for gx agents finish --- src/agents/finish.js | 85 ++++++++++++++++++++++++++++++++++++++ test/agents-finish.test.js | 79 +++++++++++++++++++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 src/agents/finish.js create mode 100644 test/agents-finish.test.js 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/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'); +});