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
23 changes: 13 additions & 10 deletions src/agents/start.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ const { resolveAgent } = require('./registry');
const {
createAgentSession,
listAgentSessions,
sessionFilePath,
updateAgentSession,
} = require('./sessions');

Expand Down Expand Up @@ -157,15 +156,19 @@ function writeAgentSession(repoRoot, options, metadata, status, extra = {}) {
}

function writeClaimFailedSession(repoRoot, options, metadata, claimResult) {
const session = writeAgentSession(repoRoot, options, metadata, 'claim-failed', {
return writeAgentSession(repoRoot, options, metadata, 'claim-failed', {
claimFailure: {
claims: options.claims,
exitCode: typeof claimResult.status === 'number' ? claimResult.status : 1,
stderr: String(claimResult.stderr || '').trim(),
stdout: String(claimResult.stdout || '').trim(),
},
});
return session ? sessionFilePath(repoRoot, session.id) : '';
}

function appendSessionId(stdout, session) {
if (!session?.id) return stdout;
return `${stdout}[${TOOL_NAME}] Agent session id: ${session.id}\n`;
}

function buildBranchStartArgs(options) {
Expand All @@ -176,14 +179,14 @@ function buildBranchStartArgs(options) {
return args;
}

function buildRecoveryLines(metadata, claims, sessionPath) {
function buildRecoveryLines(metadata, claims, session) {
const quotedClaims = claims.map((claim) => JSON.stringify(claim)).join(' ');
const lines = [
`[${TOOL_NAME}] Claim failed after branch/worktree creation.`,
`[${TOOL_NAME}] Session status: claim-failed`,
];
if (sessionPath) {
lines.push(`[${TOOL_NAME}] Session record: ${sessionPath}`);
if (session?.id) {
lines.push(`[${TOOL_NAME}] Agent session id: ${session.id}`);
}
if (metadata.worktreePath) {
lines.push(`[${TOOL_NAME}] Recovery: cd ${JSON.stringify(metadata.worktreePath)}`);
Expand Down Expand Up @@ -215,8 +218,9 @@ function startAgentLane(repoRoot, options) {
}

const metadata = extractAgentBranchStartMetadata(stdout);
const session = writeAgentSession(repoRoot, options, metadata, 'active');
stdout = appendSessionId(stdout, session);
if (options.claims.length === 0) {
writeAgentSession(repoRoot, options, metadata, 'active');
return { status: 0, stdout, stderr };
}

Expand All @@ -236,15 +240,14 @@ function startAgentLane(repoRoot, options) {
stdout += String(claimResult.stdout || '');
stderr += String(claimResult.stderr || '');
if (!isSpawnFailure(claimResult) && claimResult.status === 0) {
writeAgentSession(repoRoot, options, metadata, 'active');
return { status: 0, stdout, stderr };
}

if (isSpawnFailure(claimResult)) {
stderr += `${claimResult.error.message}\n`;
}
const sessionPath = writeClaimFailedSession(repoRoot, options, metadata, claimResult);
stdout += buildRecoveryLines(metadata, options.claims, sessionPath);
const failedSession = writeClaimFailedSession(repoRoot, options, metadata, claimResult);
stdout += buildRecoveryLines(metadata, options.claims, failedSession);
return {
status: typeof claimResult.status === 'number' ? claimResult.status : 1,
stdout,
Expand Down
1 change: 1 addition & 0 deletions test/agents-start-claims.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ defineSpawnSuite('agents start claim suite', () => {
assert.notEqual(result.status, 0, 'claim failure should fail the command');
assert.match(result.stderr, /Path is outside repository/);
assert.match(result.stdout, /Session status: claim-failed/);
assert.match(result.stdout, /Agent session id: /);
assert.match(result.stdout, /Recovery: cd /);
assert.match(result.stdout, /Recovery: gx locks claim --branch /);

Expand Down
216 changes: 216 additions & 0 deletions test/agents-start.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const Module = require('node:module');

function loadStartWithMocks({
runPackageAsset,
createAgentSession,
updateAgentSession,
currentBranchName,
listAgentSessions = () => [],
}) {
const startPath = require.resolve('../src/agents/start');
const runtimePath = require.resolve('../src/core/runtime');
const sessionsPath = require.resolve('../src/agents/sessions');
const gitPath = require.resolve('../src/git');
const originalLoad = Module._load;

delete require.cache[startPath];
Module._load = function mockLoad(request, parent, isMain) {
const resolved = Module._resolveFilename(request, parent, isMain);
if (resolved === runtimePath) {
return { runPackageAsset };
}
if (resolved === sessionsPath) {
return { createAgentSession, updateAgentSession, listAgentSessions };
}
if (resolved === gitPath) {
return { currentBranchName };
}
return originalLoad.apply(this, arguments);
};

try {
return require(startPath);
} finally {
Module._load = originalLoad;
delete require.cache[startPath];
}
}

function branchStartOutput(branch = 'agent/codex/fix-auth', worktreePath = '/repo/.omx/agent-worktrees/repo__codex__fix-auth') {
return [
`[agent-branch-start] Created branch: ${branch}`,
`[agent-branch-start] Worktree: ${worktreePath}`,
'',
].join('\n');
}

test('agents start creates canonical session after successful branch start', () => {
const runCalls = [];
const created = [];
const start = loadStartWithMocks({
runPackageAsset(assetKey, args, options) {
runCalls.push({ assetKey, args, options });
return { status: 0, stdout: branchStartOutput(), stderr: '' };
},
createAgentSession(repoRoot, payload) {
created.push({ repoRoot, payload });
return {
id: 'session-1',
...payload,
createdAt: '2026-04-29T20:00:00.000Z',
updatedAt: '2026-04-29T20:00:00.000Z',
};
},
updateAgentSession() {
throw new Error('unexpected update');
},
currentBranchName: () => 'main',
});

const result = start.startAgentLane('/repo', {
task: 'fix auth',
agent: 'codex',
base: 'main',
claims: [],
});

assert.equal(result.status, 0);
assert.deepEqual(created, [
{
repoRoot: '/repo',
payload: {
task: 'fix auth',
agent: 'codex',
id: 'agent__codex__fix-auth',
branch: 'agent/codex/fix-auth',
worktreePath: '/repo/.omx/agent-worktrees/repo__codex__fix-auth',
base: 'main',
status: 'active',
},
},
]);
assert.equal(runCalls.length, 1);
});

test('agents start branch failure creates no session', () => {
let createCount = 0;
const start = loadStartWithMocks({
runPackageAsset() {
return { status: 2, stdout: '', stderr: 'branch failed\n' };
},
createAgentSession() {
createCount += 1;
},
updateAgentSession() {
throw new Error('unexpected update');
},
currentBranchName: () => 'main',
});

const result = start.startAgentLane('/repo', {
task: 'fix auth',
agent: 'codex',
base: 'main',
claims: [],
});

assert.equal(result.status, 2);
assert.equal(createCount, 0);
});

test('agents start claim failure updates canonical session to claim-failed', () => {
const runCalls = [];
const created = [];
const updates = [];
const start = loadStartWithMocks({
runPackageAsset(assetKey, args, options) {
runCalls.push({ assetKey, args, options });
if (assetKey === 'branchStart') {
return { status: 0, stdout: branchStartOutput(), stderr: '' };
}
return { status: 1, stdout: '', stderr: 'claim failed\n' };
},
createAgentSession(repoRoot, payload) {
created.push({ repoRoot, payload });
return {
...payload,
createdAt: '2026-04-29T20:00:00.000Z',
updatedAt: '2026-04-29T20:00:00.000Z',
};
},
updateAgentSession(repoRoot, sessionId, patch) {
updates.push({ repoRoot, sessionId, patch });
return {
id: sessionId,
status: patch.status,
};
},
currentBranchName: () => 'main',
listAgentSessions: () => created.map((entry) => entry.payload),
});

const result = start.startAgentLane('/repo', {
task: 'fix auth',
agent: 'codex',
base: 'main',
claims: ['src/auth.js'],
});

assert.equal(result.status, 1);
assert.equal(created.length, 1);
assert.deepEqual(updates, [
{
repoRoot: '/repo',
sessionId: 'agent__codex__fix-auth',
patch: {
id: 'agent__codex__fix-auth',
task: 'fix auth',
agent: 'codex',
branch: 'agent/codex/fix-auth',
worktreePath: '/repo/.omx/agent-worktrees/repo__codex__fix-auth',
base: 'main',
status: 'claim-failed',
claimFailure: {
claims: ['src/auth.js'],
exitCode: 1,
stderr: 'claim failed',
stdout: '',
},
},
},
]);
assert.deepEqual(runCalls[1], {
assetKey: 'lockTool',
args: ['claim', '--branch', 'agent/codex/fix-auth', 'src/auth.js'],
options: { cwd: '/repo/.omx/agent-worktrees/repo__codex__fix-auth' },
});
assert.match(result.stdout, /Session status: claim-failed/);
});

test('agents start output includes canonical session id', () => {
const start = loadStartWithMocks({
runPackageAsset() {
return { status: 0, stdout: branchStartOutput(), stderr: '' };
},
createAgentSession(repoRoot, payload) {
return {
...payload,
};
},
updateAgentSession() {
throw new Error('unexpected update');
},
currentBranchName: () => 'main',
});

const result = start.startAgentLane('/repo', {
task: 'fix auth',
agent: 'codex',
base: 'main',
claims: [],
});

assert.match(result.stdout, /\[gitguardex\] Agent session id: agent__codex__fix-auth/);
});
Loading