Skip to content

Commit 94d646e

Browse files
authored
fix: exit nonzero on wait timeout (#165)
1 parent 42c390b commit 94d646e

7 files changed

Lines changed: 127 additions & 21 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ A colleague then used `agent-tty` to build an experimental TUI for Coder agents
113113

114114
## Command surface
115115

116-
Every user-facing command takes `--json` and returns a stable, machine-readable envelope, and exits with a stable code (`0` success, `2` usage error, `3` session not found, …) so scripts can branch without parsing output.
116+
Every user-facing command takes `--json` and returns a stable, machine-readable envelope, and exits with a stable code (`0` success, `2` usage error, `3` session not found, `11` wait timeout, …) so scripts can branch without parsing output.
117117

118118
| Group | Commands |
119119
| ----------------------- | ------------------------------------------------------------------------ |

docs/USAGE.md

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ Useful flags:
104104
- `--exit`: wait for the process to exit.
105105
- `--timeout <ms>`: maximum wait time in milliseconds, with `0` meaning infinite.
106106

107-
On timeout, a standalone `wait` still exits `0` and reports `matched: false` / `timedOut: true` in the JSON result — check the envelope, not the exit code. Inside `batch`, a timed-out `wait` step is a step failure (`WAIT_TIMEOUT`, exit code `11` under fail-fast).
107+
On timeout, a standalone `wait` exits `11` (`WAIT_TIMEOUT`) while preserving a success JSON envelope with `timedOut: true` in the result (`matched: false` for render waits). Inside `batch`, a timed-out `wait` step is a step failure with the same `WAIT_TIMEOUT` exit code under fail-fast.
108108

109109
### Screen Hash
110110

@@ -235,22 +235,22 @@ A lone `'%'` does **not** restore the marker (zsh treats it as a prompt escape t
235235

236236
## Exit Codes
237237

238-
Every command exits with a stable code, so scripts can branch without parsing output. The `--json` error envelope carries the precise `error.code` (for example `WAIT_TIMEOUT`); the exit code is a coarser, stable summary of it.
239-
240-
| Exit code | Meaning |
241-
| --------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
242-
| `0` | Success. |
243-
| `1` | Internal or unclassified error. |
244-
| `2` | Usage error: unknown command or flag, or an invalid argument (session ID, dimensions, keys, duration, signal, input). |
245-
| `3` | Session not found. |
246-
| `4` | Session is not running or already destroyed. |
247-
| `5` | Session host timed out. |
248-
| `6` | Session host unreachable. |
249-
| `7` | Export failed. |
250-
| `8` | Storage read/write or manifest validation error. |
251-
| `9` | Protocol or RPC error. |
252-
| `10` | Replay failed. |
253-
| `11` | A `wait` step inside a fail-fast `batch` timed out (standalone `wait` exits `0` with `timedOut: true` in the result — see [`wait`](#wait)). |
238+
Every command exits with a stable code, so scripts can branch without parsing output. The `--json` error envelope carries the precise `error.code` when a command fails before producing a result. Commands that preserve an observable result for a failed predicate, such as timed-out `wait` and fail-fast `batch`, still emit a success envelope while exiting non-zero.
239+
240+
| Exit code | Meaning |
241+
| --------- | --------------------------------------------------------------------------------------------------------------------------- |
242+
| `0` | Success. |
243+
| `1` | Internal or unclassified error. |
244+
| `2` | Usage error: unknown command or flag, or an invalid argument (session ID, dimensions, keys, duration, signal, input). |
245+
| `3` | Session not found. |
246+
| `4` | Session is not running or already destroyed. |
247+
| `5` | Session host timed out. |
248+
| `6` | Session host unreachable. |
249+
| `7` | Export failed. |
250+
| `8` | Storage read/write or manifest validation error. |
251+
| `9` | Protocol or RPC error. |
252+
| `10` | Replay failed. |
253+
| `11` | A standalone `wait` timed out, or a `wait` step inside a fail-fast `batch` timed out (`WAIT_TIMEOUT`; see [`wait`](#wait)). |
254254

255255
A fail-fast `batch` exits with the failed step's code (for example `11` for a wait timeout); `--keep-going` exits `1` if any step failed.
256256

src/cli/commands/wait.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@ import type {
55
import type { PreparedRenderWaitCondition } from '../../renderWait/matcher.js';
66
import type { SemanticSnapshot } from '../../renderer/types.js';
77

8+
import process from 'node:process';
9+
810
import { CliError } from '../errors.js';
911
import type { CommandContext } from '../context.js';
1012

13+
import { exitCodeForError } from '../exitCodes.js';
1114
import { emitSuccess } from '../output.js';
1215
import { sendRpc } from '../../host/rpcClient.js';
1316
import { ERROR_CODES, makeCliError } from '../../protocol/errors.js';
@@ -102,6 +105,12 @@ function renderWaitLines(result: WaitForRenderResult): string[] {
102105
return lines;
103106
}
104107

108+
function setWaitTimeoutExitCode(result: { timedOut: boolean }): void {
109+
if (result.timedOut) {
110+
process.exitCode = exitCodeForError(ERROR_CODES.WAIT_TIMEOUT);
111+
}
112+
}
113+
105114
function buildOfflineRenderWaitResult(
106115
preparedCondition: PreparedRenderWaitCondition,
107116
snapshot: SemanticSnapshot,
@@ -298,6 +307,7 @@ export async function runWaitCommand(options: CommandOptions): Promise<void> {
298307
result,
299308
lines: renderWaitLines(result),
300309
});
310+
setWaitTimeoutExitCode(result);
301311
return;
302312
}
303313

@@ -379,4 +389,5 @@ export async function runWaitCommand(options: CommandOptions): Promise<void> {
379389
result,
380390
lines: waitLines(result),
381391
});
392+
setWaitTimeoutExitCode(result);
382393
}

test/integration/io-loop.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ describe('io-loop integration', { timeout: 30000 }, () => {
137137
{ AGENT_TTY_HOME: testHome },
138138
30000,
139139
);
140-
expect(waitResult.status).toBe(0);
140+
expect(waitResult.status).toBe(11);
141141
expect(waitResult.stderr).toBe('');
142142

143143
const envelope = JSON.parse(

test/integration/screen-hash.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ describe('screen hash integration', { timeout: 120_000 }, () => {
213213
15_000,
214214
);
215215

216-
expect(result.status).toBe(0);
216+
expect(result.status).toBe(11);
217217
expect(result.stderr).toBe('');
218218
const envelope = JSON.parse(
219219
result.stdout,

test/integration/wait-render.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,60 @@ describe('wait render integration', { timeout: 120_000 }, () => {
375375
expect(envelope.result.cursorCol).toBe(snapshot.cursorCol);
376376
});
377377

378+
it('exits 11 with a success envelope when CLI --text times out', () => {
379+
const result = runCli(
380+
[
381+
'wait',
382+
sessionId,
383+
'--text',
384+
'MISSING_TEXT',
385+
'--timeout',
386+
'1000',
387+
'--json',
388+
],
389+
{ AGENT_TTY_HOME: testHome },
390+
10_000,
391+
);
392+
393+
expect(result.exitCode).toBe(11);
394+
expect(result.stderr).toBe('');
395+
const envelope = JSON.parse(
396+
result.stdout,
397+
) as SuccessEnvelope<WaitForRenderResult>;
398+
expect(envelope.ok).toBe(true);
399+
expect(envelope.result.matched).toBe(false);
400+
expect(envelope.result.timedOut).toBe(true);
401+
expect(envelope.result.capturedAtSeq).toBeGreaterThanOrEqual(0);
402+
});
403+
404+
it('exits 11 with a success envelope when legacy CLI --idle-ms times out', () => {
405+
const result = runCli(
406+
['wait', sessionId, '--idle-ms', '10000', '--timeout', '1000', '--json'],
407+
{ AGENT_TTY_HOME: testHome },
408+
10_000,
409+
);
410+
411+
expect(result.exitCode).toBe(11);
412+
expect(result.stderr).toBe('');
413+
const envelope = JSON.parse(result.stdout) as SuccessEnvelope<WaitResult>;
414+
expect(envelope.ok).toBe(true);
415+
expect(envelope.result.timedOut).toBe(true);
416+
});
417+
418+
it('exits 11 with a success envelope when legacy CLI --exit times out', () => {
419+
const result = runCli(
420+
['wait', sessionId, '--exit', '--timeout', '1000', '--json'],
421+
{ AGENT_TTY_HOME: testHome },
422+
10_000,
423+
);
424+
425+
expect(result.exitCode).toBe(11);
426+
expect(result.stderr).toBe('');
427+
const envelope = JSON.parse(result.stdout) as SuccessEnvelope<WaitResult>;
428+
expect(envelope.ok).toBe(true);
429+
expect(envelope.result.timedOut).toBe(true);
430+
});
431+
378432
it('rejects non-integer CLI --cursor-row values', () => {
379433
const result = runCli(
380434
['wait', sessionId, '--cursor-row', '1.5', '--json'],

test/unit/commands/wait.test.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
import { beforeEach, describe, expect, it, vi } from 'vitest';
1+
import process from 'node:process';
22

3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
5+
import { exitCodeForError } from '../../../src/cli/exitCodes.js';
36
import { ERROR_CODES, makeCliError } from '../../../src/protocol/errors.js';
47

58
const mocks = vi.hoisted(() => ({
@@ -144,6 +147,10 @@ describe('wait command', () => {
144147
mocks.readManifestIfExists.mockResolvedValue(createSessionRecord());
145148
});
146149

150+
afterEach(() => {
151+
process.exitCode = undefined;
152+
});
153+
147154
it('rejects --text and --regex together', async () => {
148155
await expect(
149156
runWaitCommand(createOptions({ text: 'hello', regex: 'world' })),
@@ -381,6 +388,40 @@ describe('wait command', () => {
381388
);
382389
});
383390

391+
it('sets WAIT_TIMEOUT exit code for legacy wait timeouts while preserving success output', async () => {
392+
const result = { timedOut: true };
393+
mocks.sendRpc.mockResolvedValue(result);
394+
395+
await runWaitCommand(createOptions({ waitForExit: true, timeout: 1000 }));
396+
397+
expect(process.exitCode).toBe(exitCodeForError(ERROR_CODES.WAIT_TIMEOUT));
398+
expect(mocks.emitSuccess).toHaveBeenCalledWith({
399+
command: 'wait',
400+
json: false,
401+
result,
402+
lines: ['Wait timed out.'],
403+
});
404+
});
405+
406+
it('sets WAIT_TIMEOUT exit code for render wait timeouts while preserving success output', async () => {
407+
const result = {
408+
matched: false,
409+
timedOut: true,
410+
capturedAtSeq: 7,
411+
};
412+
mocks.sendRpc.mockResolvedValue(result);
413+
414+
await runWaitCommand(createOptions({ text: 'missing', timeout: 1000 }));
415+
416+
expect(process.exitCode).toBe(exitCodeForError(ERROR_CODES.WAIT_TIMEOUT));
417+
expect(mocks.emitSuccess).toHaveBeenCalledWith({
418+
command: 'wait',
419+
json: false,
420+
result,
421+
lines: ['Wait timed out. (capturedAtSeq: 7)'],
422+
});
423+
});
424+
384425
it('routes --text waits to the render wait RPC', async () => {
385426
const result = {
386427
matched: true,

0 commit comments

Comments
 (0)