diff --git a/README.md b/README.md index f7291a4..91c81e4 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ A colleague then used `agent-tty` to build an experimental TUI for Coder agents ## Command surface -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. +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. | Group | Commands | | ----------------------- | ------------------------------------------------------------------------ | diff --git a/docs/USAGE.md b/docs/USAGE.md index 56a76a5..429fb14 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -104,7 +104,7 @@ Useful flags: - `--exit`: wait for the process to exit. - `--timeout `: maximum wait time in milliseconds, with `0` meaning infinite. -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). +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. ### Screen Hash @@ -235,22 +235,22 @@ A lone `'%'` does **not** restore the marker (zsh treats it as a prompt escape t ## Exit Codes -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. - -| Exit code | Meaning | -| --------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `0` | Success. | -| `1` | Internal or unclassified error. | -| `2` | Usage error: unknown command or flag, or an invalid argument (session ID, dimensions, keys, duration, signal, input). | -| `3` | Session not found. | -| `4` | Session is not running or already destroyed. | -| `5` | Session host timed out. | -| `6` | Session host unreachable. | -| `7` | Export failed. | -| `8` | Storage read/write or manifest validation error. | -| `9` | Protocol or RPC error. | -| `10` | Replay failed. | -| `11` | A `wait` step inside a fail-fast `batch` timed out (standalone `wait` exits `0` with `timedOut: true` in the result — see [`wait`](#wait)). | +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. + +| Exit code | Meaning | +| --------- | --------------------------------------------------------------------------------------------------------------------------- | +| `0` | Success. | +| `1` | Internal or unclassified error. | +| `2` | Usage error: unknown command or flag, or an invalid argument (session ID, dimensions, keys, duration, signal, input). | +| `3` | Session not found. | +| `4` | Session is not running or already destroyed. | +| `5` | Session host timed out. | +| `6` | Session host unreachable. | +| `7` | Export failed. | +| `8` | Storage read/write or manifest validation error. | +| `9` | Protocol or RPC error. | +| `10` | Replay failed. | +| `11` | A standalone `wait` timed out, or a `wait` step inside a fail-fast `batch` timed out (`WAIT_TIMEOUT`; see [`wait`](#wait)). | 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. diff --git a/src/cli/commands/wait.ts b/src/cli/commands/wait.ts index 2729f55..d14a270 100644 --- a/src/cli/commands/wait.ts +++ b/src/cli/commands/wait.ts @@ -5,9 +5,12 @@ import type { import type { PreparedRenderWaitCondition } from '../../renderWait/matcher.js'; import type { SemanticSnapshot } from '../../renderer/types.js'; +import process from 'node:process'; + import { CliError } from '../errors.js'; import type { CommandContext } from '../context.js'; +import { exitCodeForError } from '../exitCodes.js'; import { emitSuccess } from '../output.js'; import { sendRpc } from '../../host/rpcClient.js'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; @@ -102,6 +105,12 @@ function renderWaitLines(result: WaitForRenderResult): string[] { return lines; } +function setWaitTimeoutExitCode(result: { timedOut: boolean }): void { + if (result.timedOut) { + process.exitCode = exitCodeForError(ERROR_CODES.WAIT_TIMEOUT); + } +} + function buildOfflineRenderWaitResult( preparedCondition: PreparedRenderWaitCondition, snapshot: SemanticSnapshot, @@ -298,6 +307,7 @@ export async function runWaitCommand(options: CommandOptions): Promise { result, lines: renderWaitLines(result), }); + setWaitTimeoutExitCode(result); return; } @@ -379,4 +389,5 @@ export async function runWaitCommand(options: CommandOptions): Promise { result, lines: waitLines(result), }); + setWaitTimeoutExitCode(result); } diff --git a/test/integration/io-loop.test.ts b/test/integration/io-loop.test.ts index 6a3c7c7..d42a69d 100644 --- a/test/integration/io-loop.test.ts +++ b/test/integration/io-loop.test.ts @@ -137,7 +137,7 @@ describe('io-loop integration', { timeout: 30000 }, () => { { AGENT_TTY_HOME: testHome }, 30000, ); - expect(waitResult.status).toBe(0); + expect(waitResult.status).toBe(11); expect(waitResult.stderr).toBe(''); const envelope = JSON.parse( diff --git a/test/integration/screen-hash.test.ts b/test/integration/screen-hash.test.ts index fbc3399..b861cfa 100644 --- a/test/integration/screen-hash.test.ts +++ b/test/integration/screen-hash.test.ts @@ -213,7 +213,7 @@ describe('screen hash integration', { timeout: 120_000 }, () => { 15_000, ); - expect(result.status).toBe(0); + expect(result.status).toBe(11); expect(result.stderr).toBe(''); const envelope = JSON.parse( result.stdout, diff --git a/test/integration/wait-render.test.ts b/test/integration/wait-render.test.ts index ffad3de..02e1904 100644 --- a/test/integration/wait-render.test.ts +++ b/test/integration/wait-render.test.ts @@ -375,6 +375,60 @@ describe('wait render integration', { timeout: 120_000 }, () => { expect(envelope.result.cursorCol).toBe(snapshot.cursorCol); }); + it('exits 11 with a success envelope when CLI --text times out', () => { + const result = runCli( + [ + 'wait', + sessionId, + '--text', + 'MISSING_TEXT', + '--timeout', + '1000', + '--json', + ], + { AGENT_TTY_HOME: testHome }, + 10_000, + ); + + expect(result.exitCode).toBe(11); + expect(result.stderr).toBe(''); + const envelope = JSON.parse( + result.stdout, + ) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.matched).toBe(false); + expect(envelope.result.timedOut).toBe(true); + expect(envelope.result.capturedAtSeq).toBeGreaterThanOrEqual(0); + }); + + it('exits 11 with a success envelope when legacy CLI --idle-ms times out', () => { + const result = runCli( + ['wait', sessionId, '--idle-ms', '10000', '--timeout', '1000', '--json'], + { AGENT_TTY_HOME: testHome }, + 10_000, + ); + + expect(result.exitCode).toBe(11); + expect(result.stderr).toBe(''); + const envelope = JSON.parse(result.stdout) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.timedOut).toBe(true); + }); + + it('exits 11 with a success envelope when legacy CLI --exit times out', () => { + const result = runCli( + ['wait', sessionId, '--exit', '--timeout', '1000', '--json'], + { AGENT_TTY_HOME: testHome }, + 10_000, + ); + + expect(result.exitCode).toBe(11); + expect(result.stderr).toBe(''); + const envelope = JSON.parse(result.stdout) as SuccessEnvelope; + expect(envelope.ok).toBe(true); + expect(envelope.result.timedOut).toBe(true); + }); + it('rejects non-integer CLI --cursor-row values', () => { const result = runCli( ['wait', sessionId, '--cursor-row', '1.5', '--json'], diff --git a/test/unit/commands/wait.test.ts b/test/unit/commands/wait.test.ts index fb63e0b..7eddd2c 100644 --- a/test/unit/commands/wait.test.ts +++ b/test/unit/commands/wait.test.ts @@ -1,5 +1,8 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import process from 'node:process'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { exitCodeForError } from '../../../src/cli/exitCodes.js'; import { ERROR_CODES, makeCliError } from '../../../src/protocol/errors.js'; const mocks = vi.hoisted(() => ({ @@ -144,6 +147,10 @@ describe('wait command', () => { mocks.readManifestIfExists.mockResolvedValue(createSessionRecord()); }); + afterEach(() => { + process.exitCode = undefined; + }); + it('rejects --text and --regex together', async () => { await expect( runWaitCommand(createOptions({ text: 'hello', regex: 'world' })), @@ -381,6 +388,40 @@ describe('wait command', () => { ); }); + it('sets WAIT_TIMEOUT exit code for legacy wait timeouts while preserving success output', async () => { + const result = { timedOut: true }; + mocks.sendRpc.mockResolvedValue(result); + + await runWaitCommand(createOptions({ waitForExit: true, timeout: 1000 })); + + expect(process.exitCode).toBe(exitCodeForError(ERROR_CODES.WAIT_TIMEOUT)); + expect(mocks.emitSuccess).toHaveBeenCalledWith({ + command: 'wait', + json: false, + result, + lines: ['Wait timed out.'], + }); + }); + + it('sets WAIT_TIMEOUT exit code for render wait timeouts while preserving success output', async () => { + const result = { + matched: false, + timedOut: true, + capturedAtSeq: 7, + }; + mocks.sendRpc.mockResolvedValue(result); + + await runWaitCommand(createOptions({ text: 'missing', timeout: 1000 })); + + expect(process.exitCode).toBe(exitCodeForError(ERROR_CODES.WAIT_TIMEOUT)); + expect(mocks.emitSuccess).toHaveBeenCalledWith({ + command: 'wait', + json: false, + result, + lines: ['Wait timed out. (capturedAtSeq: 7)'], + }); + }); + it('routes --text waits to the render wait RPC', async () => { const result = { matched: true,