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
85 changes: 85 additions & 0 deletions src/agents/finish.js
Original file line number Diff line number Diff line change
@@ -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 <id> or --branch <agent/...>');
}

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,
};
189 changes: 189 additions & 0 deletions src/agents/inspect.js
Original file line number Diff line number Diff line change
@@ -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,
};
66 changes: 65 additions & 1 deletion src/cli/args.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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') {
Expand All @@ -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 <id> or --branch <agent/...>');
}
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;
}
Expand Down
14 changes: 14 additions & 0 deletions src/cli/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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));
Expand Down
Loading
Loading