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
15 changes: 9 additions & 6 deletions docs/repo-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Preamble passed to [Open Code Review](https://alibaba.github.io/open-code-review

## `.localagent-box/setup.sh` — Setup Script

A committed shell script the host runs **instead of** any `environment.json` source before the agent starts. The host runs it from the workspace root as `bash .localagent-box/setup.sh`; anything it exits non-zero fails the agent start the same way `setup.failOnError: true` does (a timeout also fails the agent). The script must be committed in the repo — like `.localagent-box/environment.json`, it is read straight from the fresh clone before agent commits are ignored.
A committed shell script the host runs **instead of** any `environment.json` source before the agent starts. The host runs it from the workspace root as `bash .localagent-box/setup.sh`; a non-zero exit is recorded as a failed bootstrap and the error is fed to the agent's first prompt so it can attempt a fix — unless `setup.failOnError: true` is set in `environment.json`, in which case the agent start fails (a timeout behaves the same way). The script must be committed in the repo — like `.localagent-box/environment.json`, it is read straight from the fresh clone before agent commits are ignored.

A committed `setup.sh` **always wins** over `setup.command`, `profiles`, and lockfile auto-detect — commit an `environment.json` without touching the script and the script still runs.

Expand Down Expand Up @@ -103,31 +103,34 @@ When present, declares the host-run setup step.

#### `setup.command` *(string, required)*

The shell command run in the workspace root before the agent starts. Workspaces are fresh-cloned before every agent run; when the [dependency cache](#dependency-cache-`cacheKey`) is enabled the host restores the cached dependencies before running this command. A non-zero exit fails the agent start by default (see `setup.failOnError`).
The shell command run in the workspace root before the agent starts. Workspaces are fresh-cloned before every agent run; when the [dependency cache](#dependency-cache-`cacheKey`) is enabled the host restores the cached dependencies before running this command. A non-zero exit is recorded as a failed bootstrap and fed to the agent (see `setup.failOnError`).

#### `setup.timeoutMs` *(number, optional)*

Timeout in milliseconds before the shell is killed. Must be a positive integer no greater than `1800000` (30 min). Defaults to `600000` (10 min).

#### `setup.failOnError` *(boolean, optional, default true)*
#### `setup.failOnError` *(boolean, optional, default false)*

When `true` (default), a non-zero exit code (or timeout) from `setup.command` **fails the whole agent** — OpenCode never starts. Set to `false` to log the failure and continue anyway.
Controls what happens when the setup step (or `verifyCommand`) fails:

- `false` (default) — **non-blocking**: the failure is recorded on the agent's `bootstrap` record (`status: 'failed'` with the exit code and output tail) and the run continues; the host prepends a failure block to the agent's first prompt carrying the command, exit code, and output tail so the agent can diagnose and attempt to fix the workspace environment itself.
- `true` — **fail-hard**: a non-zero exit code (or timeout) fails the whole agent — OpenCode never starts.

#### `setup.runOnModes` *(array, optional)*

Agent modes for which the setup runs: any of `batch`, `interactive`, `loop`, `review`. When set and the agent's mode is not listed, the bootstrap is **skipped for that run** (the skip reason is logged). Omit to run the setup on every mode.

### `verifyCommand` *(string, optional)*

Post-setup smoke test run **after** a successful setup command (and before the agent starts). A non-zero exit **always fails the bootstrap** — there is no `failOnError` opt-out for verify, so a broken environment never reaches the agent even when the agent's own checks would be more forgiving. A success records `verifyCommand` and its exit code on the agent's `bootstrap` record.
Post-setup smoke test run **after** a successful setup command (and before the agent starts). A non-zero exit is handled exactly like a failed setup command under `setup.failOnError`: by default it is recorded (`verifyCommand` and its exit code land on the agent's `bootstrap` record), the run continues, and the error is fed to the agent's prompt for a fix attempt; with `setup.failOnError: true` the agent start fails.

### `verifyTimeoutMs` *(number, optional)*

Timeout in milliseconds for `verifyCommand`. Same constraints as `setup.timeoutMs` (positive integer, max `1800000`). Defaults to the setup timeout — i.e. `setup.timeoutMs` when set, otherwise `600000` (10 min).

### Post-setup summary

After the setup command (and, if set, the `verifyCommand`) finishes successfully, the host prepends a short workspace-ready block to the agent's first prompt so the model doesn't waste turns rediscovering how to build or test the repo. The block is only shown when setup **completed** — a skipped or failed bootstrap leaves the prompt untouched — and reports the resolved command, the runtime profiles, the setup duration, and any dependency-cache hit.
After the setup command (and, if set, the `verifyCommand`) finishes, the host prepends a short bootstrap block to the agent's first prompt so the model doesn't waste turns rediscovering how to build or test the repo. A **successful** bootstrap reports the resolved command, the runtime profiles, the setup duration, and any dependency-cache hit. A **failed** bootstrap (default `setup.failOnError: false`) reports the failing command, the exit code, the error, and the output tail, instructing the agent to diagnose and fix the environment as part of the task. Only a skipped bootstrap (nothing to run) leaves the prompt untouched.

### `profiles` *(array, optional)*

Expand Down
13 changes: 7 additions & 6 deletions src/domains/agents/worker/environment-detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,13 @@ export const setupScriptCommand = `bash ${setupScriptRelative}`;
* Resolution order (P4-T1): the script wins over `environment.json`
* `setup.command`, `profiles`, and lockfile auto-detect.
*
* For a bare script (no `environment.json` in the repo), the caller runs it
* with fail-hard semantics: a non-zero exit always fails the agent start
* (the `setup.failOnError: false` opt-out and `setup.verifyCommand` are
* `environment.json` settings that do not exist here), and `timeoutMs`,
* `runOnModes`, and `cacheKey` have no effect. The global
* `globalSetupTimeoutMs` and the lockfile-derived dep cache still apply.
* For a bare script (no `environment.json` in the repo), failures follow the
* default non-blocking path: they are recorded on the agent record and fed to
* the agent's prompt for a fix attempt (`setup.failOnError: true` /
* `setup.verifyCommand` are `environment.json` settings that do not exist
* here), and `timeoutMs`, `runOnModes`, and `cacheKey` have no effect. The
* global `globalSetupTimeoutMs` and the lockfile-derived dep cache still
* apply.
*/
export function detectSetupScript(workspaceDir: string): string | null {
return fs.existsSync(path.join(workspaceDir, setupScriptRelative))
Expand Down
8 changes: 5 additions & 3 deletions src/domains/agents/worker/loop-run-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,9 @@ interface RunLoopStepParams {
loopState: AgentLoopState;
/**
* P4-T5: host-run bootstrap state from the agent record. Injected into the
* first INITIAL_PLAN kickoff prompt when the bootstrap completed.
* first INITIAL_PLAN kickoff prompt when the bootstrap completed — and, on
* a failed bootstrap, feeds the failure block so the agent can attempt a
* fix.
*/
bootstrap?: AgentBootstrapState | null;
/** Agent data directory for loop handoff state (plan + loop-state.json). */
Expand Down Expand Up @@ -181,8 +183,8 @@ async function runLoopStep(params: RunLoopStepParams): Promise<{

const conversationParts: (string | null)[] = [interpolated];
// P4-T5: the INITIAL_PLAN kickoff (fresh session, step 0) carries the
// host-run bootstrap summary (completed only) so the model doesn't spend a
// turn rediscovering the environment.
// host-run bootstrap summary — completed, or failed with the error tail so
// the agent can attempt a fix.
if (stepIndex === 0) {
conversationParts.push(formatBootstrapSummaryBlock(params.bootstrap));
}
Expand Down
158 changes: 116 additions & 42 deletions src/domains/agents/worker/workspace-bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -333,10 +333,33 @@ describe('runWorkspaceBootstrap', () => {
assert.match(log, /Running workspace bootstrap/);
});

it('treats a failing setup.sh like any other failed setup and throws', async () => {
it('treats a failing setup.sh like any other failed setup — recorded, non-blocking by default', async () => {
const h = makeHarness();
writeSetupScript(h.workspaceDir, '#!/usr/bin/env bash\nexit 1\n');

const state = await runBootstrap(h, {
runCommand: fakeRunCommand(h, {
command: 'bash .localagent-box/setup.sh',
exitCode: 1,
outputTail: 'failure in setup.sh',
timedOut: false,
success: false,
}),
});

assert.equal(state.status, 'failed');
assert.equal(state.command, 'bash .localagent-box/setup.sh');
assert.equal(state.exitCode, 1);
assert.match(state.error ?? '', /^Bootstrap failed: `bash \.localagent-box\/setup\.sh` exited 1/);
assert.equal(h.agent.bootstrap?.status, 'failed');
assert.equal(h.agent.bootstrap?.source, 'script');
});

it('throws on a failing setup.sh when environment.json sets failOnError=true', async () => {
const h = makeHarness();
writeSetupScript(h.workspaceDir, '#!/usr/bin/env bash\nexit 1\n');
writeConfig(h.workspaceDir, JSON.stringify({ version: 1, setup: { command: 'npm ci', failOnError: true } }));

let caught: unknown;
try {
await runBootstrap(h, {
Expand Down Expand Up @@ -458,7 +481,7 @@ describe('runWorkspaceBootstrap', () => {
assert.deepEqual(before?.profiles, []);
});

it('throws when the setup command fails with the default failOnError', async () => {
it('does not throw on failure by default and records the failure state', async () => {
const h = makeHarness();
writeConfig(h.workspaceDir, JSON.stringify({ version: 1, setup: { command: 'npm ci' } }));
const opts = {
Expand All @@ -470,6 +493,34 @@ describe('runWorkspaceBootstrap', () => {
success: false,
}),
};

const state = await runBootstrap(h, opts);

assert.equal(state.status, 'failed');
assert.equal(state.exitCode, 1);
assert.equal(state.error, 'Bootstrap failed: `npm ci` exited 1');
assert.equal(state.outputTail, 'npm ERR! Missing script: "prepare"');
assert.equal(h.agent.bootstrap?.status, 'failed');
const log = fs.readFileSync(h.logPath, 'utf8');
assert.match(log, /Workspace bootstrap failed with exit code 1/);
assert.match(log, /continuing \(the agent will attempt to fix it\)/);
});

it('throws when the setup command fails with explicit failOnError=true', async () => {
const h = makeHarness();
writeConfig(
h.workspaceDir,
JSON.stringify({ version: 1, setup: { command: 'npm ci', failOnError: true } }),
);
const opts = {
runCommand: fakeRunCommand(h, {
command: 'npm ci',
exitCode: 1,
outputTail: 'npm ERR! Missing script: "prepare"',
timedOut: false,
success: false,
}),
};
let caught: unknown;
try {
await runBootstrap(h, opts);
Expand All @@ -483,40 +534,46 @@ describe('runWorkspaceBootstrap', () => {
assert.equal(h.agent.bootstrap?.status, 'failed');
const log = fs.readFileSync(h.logPath, 'utf8');
assert.match(log, /Workspace bootstrap failed with exit code 1/);
assert.match(log, /failOnError=true/);
});

it('does not throw when the setup command fails with failOnError=false', async () => {
it('treats a timed-out setup as a failure, records it, and does not throw by default', async () => {
const h = makeHarness();
writeConfig(
h.workspaceDir,
JSON.stringify({ version: 1, setup: { command: 'npm ci', failOnError: false } }),
JSON.stringify({
version: 1,
setup: { command: 'npm ci', timeoutMs: 60_000 },
}),
);

const state = await runBootstrap(h, {
const opts = {
runCommand: fakeRunCommand(h, {
command: 'npm ci',
exitCode: 1,
outputTail: 'npm ERR! missing',
timedOut: false,
exitCode: 124,
outputTail: '',
timedOut: true,
success: false,
}),
});
};
const state = await runBootstrap(h, opts);

assert.equal(state.status, 'failed');
assert.equal(state.exitCode, 1);
assert.equal(state.error, 'Bootstrap failed: `npm ci` exited 1');
assert.equal(state.exitCode, 124);
assert.match(state.error ?? '', /Bootstrap timed out/);
assert.equal(h.agent.bootstrap?.status, 'failed');
assert.equal(h.agent.bootstrap?.exitCode, 124);
const log = fs.readFileSync(h.logPath, 'utf8');
assert.match(log, /failOnError=false/);
assert.match(log, /Workspace bootstrap timed out/);
});

it('treats a timed-out setup as a failure and throws', async () => {
it('treats a timed-out setup as a failure and throws with explicit failOnError=true', async () => {
const h = makeHarness();
writeConfig(
h.workspaceDir,
JSON.stringify({
version: 1,
setup: { command: 'npm ci', timeoutMs: 60_000 },
setup: { command: 'npm ci', timeoutMs: 60_000, failOnError: true },
}),
);

Expand Down Expand Up @@ -958,36 +1015,27 @@ describe('runWorkspaceBootstrap', () => {
assert.equal(h.runCalls[1].timeoutMs, 45_000);
});

it('fails the bootstrap (throw) when verify fails, regardless of failOnError=false', async () => {
it('feeds a verify failure to the agent instead of throwing by default', async () => {
const h = makeHarness();
writeConfig(
h.workspaceDir,
JSON.stringify({
version: 1,
setup: { command: 'npm ci', failOnError: false },
setup: { command: 'npm ci' },
verifyCommand: 'npm test',
}),
);

let caught: unknown;
try {
await runBootstrap(h, {
runCommand: setupAndVerifyCommand(
h,
success('npm ci', 'ok'),
{ command: 'npm test', exitCode: 1, outputTail: 'npm ERR! test failed', timedOut: false, success: false },
),
});
} catch (err) {
caught = err;
}
const state = await runBootstrap(h, {
runCommand: setupAndVerifyCommand(
h,
success('npm ci', 'ok'),
{ command: 'npm test', exitCode: 1, outputTail: 'npm ERR! test failed', timedOut: false, success: false },
),
});

assert.ok(caught instanceof Error);
assert.match(
(caught as { message: string }).message,
/^Bootstrap verify failed: `npm test` exited 1/,
);
assert.match((caught as { message: string }).message, /npm ERR! test failed/);
assert.equal(state.status, 'failed');
assert.equal(state.error, 'Bootstrap verify failed: `npm test` exited 1');
assert.equal(h.agent.bootstrap?.status, 'failed');
assert.equal(h.agent.bootstrap?.command, 'npm ci');
assert.equal(h.agent.bootstrap?.verifyCommand, 'npm test');
Expand All @@ -997,19 +1045,16 @@ describe('runWorkspaceBootstrap', () => {

const log = fs.readFileSync(h.logPath, 'utf8');
assert.match(log, /Workspace bootstrap verify failed/);
assert.ok(
!log.includes('failOnError=false'),
'verify failure must not honor failOnError=false',
);
assert.match(log, /verify failed — continuing/);
});

it('treats a verify timeout as a failure and throws', async () => {
it('throws on a verify failure when setup.failOnError=true', async () => {
const h = makeHarness();
writeConfig(
h.workspaceDir,
JSON.stringify({
version: 1,
setup: { command: 'npm ci' },
setup: { command: 'npm ci', failOnError: true },
verifyCommand: 'npm test',
}),
);
Expand All @@ -1020,15 +1065,44 @@ describe('runWorkspaceBootstrap', () => {
runCommand: setupAndVerifyCommand(
h,
success('npm ci', 'ok'),
{ command: 'npm test', exitCode: 124, outputTail: '', timedOut: true, success: false },
{ command: 'npm test', exitCode: 1, outputTail: 'npm ERR! test failed', timedOut: false, success: false },
),
});
} catch (err) {
caught = err;
}

assert.ok(caught instanceof Error);
assert.match((caught as { message: string }).message, /Bootstrap verify timed out/);
assert.match(
(caught as { message: string }).message,
/^Bootstrap verify failed: `npm test` exited 1/,
);
assert.match((caught as { message: string }).message, /npm ERR! test failed/);
assert.equal(h.agent.bootstrap?.status, 'failed');
assert.equal(h.agent.bootstrap?.verifyExitCode, 1);
});

it('treats a verify timeout as a failure and does not throw by default', async () => {
const h = makeHarness();
writeConfig(
h.workspaceDir,
JSON.stringify({
version: 1,
setup: { command: 'npm ci' },
verifyCommand: 'npm test',
}),
);

const state = await runBootstrap(h, {
runCommand: setupAndVerifyCommand(
h,
success('npm ci', 'ok'),
{ command: 'npm test', exitCode: 124, outputTail: '', timedOut: true, success: false },
),
});

assert.equal(state.status, 'failed');
assert.match(state.error ?? '', /Bootstrap verify timed out/);
assert.equal(h.agent.bootstrap?.verifyExitCode, 124);
const log = fs.readFileSync(h.logPath, 'utf8');
assert.match(log, /Workspace bootstrap verify failed/);
Expand Down
Loading
Loading