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
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Add startup file claims to `gx agents start`

## Problem

Agent lane startup can create the branch/worktree, but callers cannot pre-claim the files they already know the lane will edit. Agents must run a second command and can miss the ownership step.

## Scope

- Parse repeated `--claim <path>` flags on `gx agents start <task> --agent <name>`.
- After branch/worktree creation, claim the requested files for the created branch.
- Preserve existing repo-bot `gx agents start --target <repo>` behavior.
- Mark startup session state as `claim-failed` and print recovery commands when claims fail.

Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
## ADDED Requirements

### Requirement: `gx agents start` can claim files during lane startup

`gx agents start <task> --agent <agent> --claim <path>` SHALL create the agent branch/worktree and then claim each requested file for the created branch using the existing lock system.

#### Scenario: no startup claims

- **WHEN** a user runs `gx agents start "fix auth" --agent codex`
- **THEN** Guardex creates the agent branch/worktree
- **AND** Guardex does not create file lock entries.

#### Scenario: repeated startup claims

- **WHEN** a user runs `gx agents start "fix auth" --agent codex --claim src/auth.js --claim test/auth.test.js`
- **THEN** Guardex claims both files for the created branch after branch/worktree creation.

#### Scenario: claim failure

- **WHEN** branch/worktree creation succeeds but startup claim fails
- **THEN** Guardex SHALL exit nonzero
- **AND** Guardex SHALL mark the session state as `claim-failed`
- **AND** Guardex SHALL print recovery instructions with the worktree path and `gx locks claim --branch <branch> ...` command.
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Tasks

## 1. Spec

- [x] Record optional startup claim behavior for agent lane starts.

## 2. Tests

- [x] Add coverage for no claims, one claim, repeated claims, and claim failure recovery.
- [x] Keep legacy `gx agents start` repo-bot parser/behavior covered.

## 3. Implementation

- [x] Parse repeated `--claim` flags.
- [x] Run existing lock claiming after branch/worktree creation.
- [x] Write `claim-failed` session state and print recovery instructions on claim failure.

## 4. Cleanup

- [x] Focused verification: `node --test test/agents.test.js test/cli-args-dispatch.test.js test/agents-start-claims.test.js` -> 23/23 pass.
- [ ] Finish via PR merge and sandbox cleanup.

155 changes: 154 additions & 1 deletion src/agents/start.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
const { path } = require('../context');
const {
fs,
path,
TOOL_NAME,
SHORT_TOOL_NAME,
} = require('../context');
const { runPackageAsset } = require('../core/runtime');
const { currentBranchName } = require('../git');
const { buildAgentLaunchCommand } = require('./launch');
const { resolveAgent } = require('./registry');

const ACTIVE_SESSIONS_RELATIVE_DIR = path.join('.omx', 'state', 'active-sessions');

function sanitizeSlug(value, fallback = 'task') {
const slug = String(value || '')
.toLowerCase()
Expand Down Expand Up @@ -86,9 +94,154 @@ function dryRunStart(options, repoRoot) {
return renderDryRunPlan(buildStartPlan(options, repoRoot));
}

function isSpawnFailure(result) {
return Boolean(result?.error) && typeof result?.status !== 'number';
}

function extractAgentBranchStartMetadata(output) {
const outputText = String(output || '');
const branchMatch = outputText.match(/^\[agent-branch-start\] (?:Created branch|Reusing existing branch): (.+)$/m);
const worktreeMatch = outputText.match(/^\[agent-branch-start\] Worktree: (.+)$/m);
return {
branch: branchMatch ? branchMatch[1].trim() : '',
worktreePath: worktreeMatch ? worktreeMatch[1].trim() : '',
};
}

function sanitizeBranchForFile(branch) {
return String(branch || 'session')
.replace(/[^a-zA-Z0-9._-]+/g, '__')
.replace(/^_+|_+$/g, '') || 'session';
}

function sessionFilePathForBranch(repoRoot, branch) {
return path.join(
path.resolve(repoRoot),
ACTIVE_SESSIONS_RELATIVE_DIR,
`${sanitizeBranchForFile(branch)}.json`,
);
}

function writeClaimFailedSession(repoRoot, options, metadata, claimResult) {
if (!metadata.branch || !metadata.worktreePath) {
return '';
}
const targetPath = sessionFilePathForBranch(repoRoot, metadata.branch);
const now = new Date().toISOString();
const record = {
schemaVersion: 1,
repoRoot: path.resolve(repoRoot),
branch: metadata.branch,
taskName: options.task,
agentName: options.agent || 'codex',
worktreePath: path.resolve(metadata.worktreePath),
pid: process.pid,
cliName: SHORT_TOOL_NAME,
startedAt: now,
lastHeartbeatAt: now,
state: 'claim-failed',
claimFailure: {
claims: options.claims,
exitCode: typeof claimResult.status === 'number' ? claimResult.status : 1,
stderr: String(claimResult.stderr || '').trim(),
stdout: String(claimResult.stdout || '').trim(),
},
};
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
return targetPath;
}

function buildBranchStartArgs(options) {
const args = ['--task', options.task, '--agent', options.agent || 'codex'];
if (options.base) {
args.push('--base', options.base);
}
return args;
}

function buildRecoveryLines(metadata, claims, sessionPath) {
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 (metadata.worktreePath) {
lines.push(`[${TOOL_NAME}] Recovery: cd ${JSON.stringify(metadata.worktreePath)}`);
}
if (metadata.branch) {
lines.push(`[${TOOL_NAME}] Recovery: ${SHORT_TOOL_NAME} locks claim --branch ${JSON.stringify(metadata.branch)} ${quotedClaims}`);
}
lines.push(`[${TOOL_NAME}] Recovery: resolve the lock conflict or invalid path, then rerun the claim command above.`);
return `${lines.join('\n')}\n`;
}

function startAgentLane(repoRoot, options) {
const startResult = runPackageAsset('branchStart', buildBranchStartArgs(options), { cwd: repoRoot });
let stdout = String(startResult.stdout || '');
let stderr = String(startResult.stderr || '');
if (isSpawnFailure(startResult)) {
return {
status: 1,
stdout,
stderr: `${stderr}${startResult.error.message}\n`,
};
}
if (startResult.status !== 0) {
return {
status: typeof startResult.status === 'number' ? startResult.status : 1,
stdout,
stderr,
};
}

const metadata = extractAgentBranchStartMetadata(stdout);
if (options.claims.length === 0) {
return { status: 0, stdout, stderr };
}

if (!metadata.branch || !metadata.worktreePath) {
return {
status: 1,
stdout,
stderr: `${stderr}[${TOOL_NAME}] Unable to claim files: branch start output did not include branch/worktree metadata.\n`,
};
}

const claimResult = runPackageAsset(
'lockTool',
['claim', '--branch', metadata.branch, ...options.claims],
{ cwd: metadata.worktreePath },
);
stdout += String(claimResult.stdout || '');
stderr += String(claimResult.stderr || '');
if (!isSpawnFailure(claimResult) && claimResult.status === 0) {
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);
return {
status: typeof claimResult.status === 'number' ? claimResult.status : 1,
stdout,
stderr,
};
}

module.exports = {
buildBranchStartArgs,
buildStartPlan,
buildRecoveryLines,
dryRunStart,
extractAgentBranchStartMetadata,
renderDryRunPlan,
sanitizeSlug,
sessionFilePathForBranch,
startAgentLane,
};
20 changes: 15 additions & 5 deletions src/cli/args.js
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,7 @@ function parseAgentsArgs(rawArgs) {
task: '',
agent: '',
base: '',
claims: [],
dryRun: false,
reviewIntervalSeconds: 30,
cleanupIntervalSeconds: 60,
Expand Down Expand Up @@ -354,6 +355,15 @@ function parseAgentsArgs(rawArgs) {
options.dryRun = true;
continue;
}
if (arg === '--claim') {
const next = rest[index + 1];
if (!next || next.startsWith('-')) {
throw new Error('--claim requires a file path');
}
options.claims.push(next);
index += 1;
continue;
}
if (!arg.startsWith('-') && options.subcommand === 'start' && !options.task) {
options.task = arg;
continue;
Expand All @@ -367,15 +377,15 @@ function parseAgentsArgs(rawArgs) {
if (options.pid !== null && options.subcommand !== 'stop') {
throw new Error('--pid is only supported with `gx agents stop`');
}
if ((options.task || options.agent || options.base || options.dryRun) && options.subcommand !== 'start') {
throw new Error('--task, --agent, --base, and --dry-run are only supported with `gx agents start`');
}
if (options.task && !options.dryRun) {
throw new Error('gx agents start <task> is currently supported only with --dry-run');
if ((options.task || options.agent || options.base || options.dryRun || options.claims.length > 0) && options.subcommand !== 'start') {
throw new Error('--task, --agent, --base, --dry-run, and --claim are only supported with `gx agents start`');
}
if (options.dryRun && !options.task) {
throw new Error('gx agents start --dry-run requires a task');
}
if (options.claims.length > 0 && !options.task) {
throw new Error('gx agents start --claim requires a task');
}

return options;
}
Expand Down
7 changes: 7 additions & 0 deletions src/cli/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -2653,6 +2653,13 @@ function agents(rawArgs) {
process.exitCode = 0;
return;
}
if (options.task) {
const result = agentsStart.startAgentLane(repoRoot, options);
if (result.stdout) process.stdout.write(result.stdout);
if (result.stderr) process.stderr.write(result.stderr);
process.exitCode = result.status;
return;
}

const existingState = readAgentsState(repoRoot);
const existingReviewPid = Number.parseInt(String(existingState?.review?.pid || ''), 10);
Expand Down
Loading
Loading