The runner's exit codes are part of processkit-cli's public compatibility
surface, alongside the CLI flags and the JSONL schema_version (see
AGENTS.md). Consumers and adapters such as processkit-py depend
on these codes, so changing them incompatibly is a major version bump.
The in-code source of truth for these values is src/exit.rs; this document is
the normative description that external consumers pin against. It also defines the
machine-error envelope the global --error-format json prints (its
error_version, its kind taxonomy, and the scope boundary around clap's
parse-time errors) — see "Machine-readable failures: --error-format json".
The runner's exit code is the child's exit code.
On a completed run, processkit-cli exits with the exact code the child process
returned — unchanged, unclamped, un-aliased. Nothing in the runner rewrites a
child's 0, its 1, or its 137. This is what makes the CLI a faithful,
transparent wrapper: a caller can branch on the child's status exactly as if it
had launched the child directly.
The one invocation this does not describe is run --detach, which by definition
stops being the child's parent: it reports whether the run started and leaves the
child's own code to the run's runner_exit event. See "Detached runs" below.
When the runner itself fails — before, around, or instead of running the child — it exits with a code from a distinct, reserved band so that a runner failure is not mistaken for a child result.
Reserved band: 100–119 inclusive.
| Code | Name | Meaning |
|---|---|---|
| 100 | USAGE |
Invalid command line: unknown flag, missing required option, malformed value (including a bad --timeout/--grace duration), a contradictory pair of flags (declared conflicts, and the value-level ones checked right after parsing — see run --run-id-env), or bad subcommand form. |
| 101 | SPAWN |
The target program could not be started (not found, not executable, bad --cwd, permission denied). |
| 102 | BACKEND |
ProcessKit backend/containment failure: kernel container, job object, IPC endpoint, or run registry could not be established — including a requested resource limit (--max-memory / --max-processes / --cpu-quota) the active mechanism could not apply (the machine-readable limit_hit event names which one; see "Resource limits" below). |
| 103 | CONTROL |
A by-run-id command could not be resolved to the single live run it names. For inspect / cancel / kill / attest that covers every way the target cannot be reached: no such run id, a stale/dead registry entry, an entry whose liveness could not be probed at all (reported as unprobed — a refusal, not a claim that the runner died), an ambiguous run id (more than one live run registered under it), or an IPC failure. The read-only verbs add two reasons that are not an unreachable runner, and the first of the two belongs to both of them: the target answered, and its answer was rejected — an inspect reply declaring a snapshot_version outside the range this client reads (newer than it implements, or older than it still decodes), or an attest reply declaring an attestation_version other than the single one this client reads, is refused rather than acted on under semantics its sender never promised (either way kind: incompatible_contract; see docs/control-plane.md, "Snapshot version: a newer runner's reply is refused, an older one is read", and "Attestation version"). The second reason is attest's alone: the target answered that it could not obtain a kernel-authenticated identity for the caller (kind: peer_identity_unsupported), so it declined to decide membership either way — again reached and healthy, again no answer to act on. Its decided negative is deliberately not this code but NOT_A_MEMBER (115). Retrying does not clear it, but that is no way to tell it apart from the others: a confirmed-stale entry and an ambiguous run id are equally unaffected by a retry. The registry-only wait shares exactly one of those reasons — an ambiguous run id — and reports it with this same code even though it contacts no runner: there is no single run to wait for. cancel --all / kill --all reuse this same code for a different fact — one or more record-addressed targets in the confirmed-live snapshot remained potentially live but could not be reached safely or did not acknowledge the command. A target confirmed gone before dispatch is instead the successful already_gone report status. The per-target reason is in the JSON report on stdout, not just the stderr tally. See docs/control-plane.md, "cancel --all / kill --all". |
| 104 | INTERNAL |
Unexpected runner fault: the runner reached a state its own logic rules out, or lost a trustworthy view of the run (a wait on the child failed and its fate is unknown; the backend returned an outcome this build cannot render). Reported with this code instead of panicking. A genuine runner bug — an ordinary setup failure is SETUP (111), not this. |
| 105 | NOT_IMPLEMENTED |
Retired. Formerly minted for a defined-but-not-yet-built code path; every subcommand is now implemented, so no active path mints it. The number stays permanently reserved (see "Stability" below) — it is never reused for a different meaning. |
| 106 | TIMEOUT |
The run exceeded a runner deadline — the whole-run --timeout, or the --idle-timeout (the child went silent past the idle window) — and the runner tore the process tree down. A runner-imposed outcome, not a child exit. The two are told apart by the timeout event's reason (overall / idle), not by the code; both reuse 106 (see "Timeout, cancel, and kill" below). |
| 107 | CANCELLED |
The run was cancelled by a local stop signal — an interactive Ctrl-C, on Unix a SIGTERM / SIGHUP (a kill, a systemctl stop, a cancelled CI job, a hung-up terminal), or on Windows a Ctrl-Break / console close / logoff / system shutdown — and the runner tore the process tree down. The signals share this one code (the same class of ending); which one arrived is named by the cancelled event's source (ctrl_c / sigterm / sighup / ctrl_break / ctrl_close / ctrl_logoff / ctrl_shutdown). Distinct from TIMEOUT and from any child result. |
| 108 | CONTROL_CANCELLED |
The run was cancelled by a control-plane cancel command (over the local control channel): the runner ran the same soft-stop → grace → hard-kill teardown as a Ctrl-C. Distinct from CANCELLED so "a control client cancelled it" is told from "a local signal stopped it". |
| 109 | CONTROL_KILLED |
The run was killed by a control-plane kill command: the runner hard-killed the whole tree immediately (no soft stop, no grace). Distinct from every other runner-imposed ending. |
| 110 | PROBE_INCOMPATIBLE |
The preflight probe (processkit-cli probe) found this binary's compatibility surface does not satisfy a --require-* expectation. A pre-launch verdict, not a run outcome — no child is ever spawned by a probe. See "Preflight probe" below. |
| 111 | SETUP |
A fail-closed setup / support failure: a prerequisite the runner needs to run — or to report a result — could not be established or produced for an ordinary reason (its async runtime would not build, a required --jsonl/--capture-dir output or --stdin-file input could not be opened, or a probe/inspect/control reply would not serialize). An environment/resource condition the caller can usually act on (a bad path, missing permissions, exhausted resources), not a runner bug — that stays INTERNAL (104). See "Setup failures vs internal faults" below. |
| 112 | WAIT_TIMEOUT |
The wait subcommand's own deadline (wait --run-id <id> --timeout <duration>) elapsed while the run it was waiting for was still live. The waiter gave up; the run was never touched — wait is read-only and reaches no runner — and is still going. Deliberately not TIMEOUT (106), which means the opposite (the runner enforced a deadline and tore the child's tree down), and not CONTROL (103), since the run was resolved unambiguously and found perfectly healthy. See "A waiter's deadline is not a run's deadline" below. |
| 113 | OUTPUT_OVERFLOW |
A capture stream exceeded --capture-max-bytes while --capture-overflow cancel was active. The runner ended the tree through its graceful-stop and escalation path. Distinct from a time deadline; output_overflow identifies the stream and limit. |
| 114 | EVENTS_INVALID |
events --validate found at least one line of the JSONL stream it checked that does not conform to the event schema this binary embeds (the document probe --print-schema prints). A verdict about a document, not about any run — events spawns no child, contacts no runner, and mutates nothing. Deliberately not PROBE_INCOMPATIBLE (110), whose subject is the opposite direction (this binary failing a consumer's --require-* expectations), and not SETUP (111): the stream was found, opened, and read perfectly well, and "it does not conform" is the answer, not a failure to produce one. A stream that could not be read at all is still SETUP (111) and a --run-id naming no single readable stream is still CONTROL (103), so a CI job can tell "invalid" from "could not be checked". See "Checking a stream: events --validate" below. |
| 115 | NOT_A_MEMBER |
attest --run-id <id> established that the calling process is not inside that run's container: the runner was reached, it named the caller from the control transport itself (unix peer credentials / GetNamedPipeClientProcessId), and that identity is not one of its container's members. A decided verdict, not a failure to obtain one — which is exactly why it is not CONTROL (103), whose every meaning is "no answer you can act on". attest's other failing outcome, a runner that could not obtain a kernel-authenticated identity for the caller at all, is a 103 (kind: peer_identity_unsupported), because nothing was established either way. See "A membership verdict is not an unreachable target" below and docs/control-plane.md, "attest". |
| 116 | HOST_UNQUALIFIED |
doctor finished its runtime qualification of this host and the verdict is no: a phase of the qualification failed (the registry could not be created or is not owner-only, the scratch run never became reachable, the control-plane round-trip did not complete, teardown left the container unconfirmed, an artifact was not cleaned up) or a --require-* expectation about the host was not met. The host-side twin of PROBE_INCOMPATIBLE (110): that one says this binary lacks a surface, this one says this machine did not do the job. The report is printed on stdout either way and names which phase and which requirement; a failed phase also leaves a named diagnostics directory (see "Qualifying a host: doctor" below). |
Codes 117–119 are reserved for future runner-own conditions. --help
and --version are not failures: they print to stdout and exit 0.
A code is deliberately coarse — CONTROL (103) alone covers eight different
situations. A consumer that needs the finer verdict without parsing the stderr
prose asks for it with the global --error-format json, which prints one bounded
JSON object naming this same code plus a more specific kind; see
"Machine-readable failures" below.
TIMEOUT (106), CANCELLED (107), CONTROL_CANCELLED (108), CONTROL_KILLED
(109), and OUTPUT_OVERFLOW (113) are not failures of the runner and not the
child's own exit — they are
outcomes the runner imposes when it ends a run that did not stop on its own. The
child did not choose to exit, so forwarding "its" code would be a lie; instead each
takes a distinct reserved-band code so a caller can tell them apart:
- the child exited by itself (its exact code, forwarded — possibly
0), - the runner ended it because a deadline elapsed — the whole-run
--timeout, or the--idle-timeout(no child output for the idle window) — both106, told apart by thetimeoutevent'sreason(overall/idle) rather than by a distinct code, - the runner ended it because a local stop signal reached it — the operator pressed
Ctrl-C, on Unix aSIGTERM/SIGHUParrived, or on Windows aCtrl-Break/ console close / logoff / system shutdown arrived (107for all of them, told apart by thecancelledevent'ssourcerather than by a distinct code), - a control-plane
cancelcommand ended it — the same graceful teardown as a Ctrl-C, but triggered over the network (108), and - a control-plane
killcommand force-killed it immediately, no grace (109), and - the opt-in capture-volume guard ended it through graceful teardown (
113).
The two control-plane codes are what make a remote end-of-run distinguishable from a
local one, and a graceful cancel from an immediate kill — by code alone, before
even reading the event stream.
Alongside the code, the runner writes an explanatory line to stderr (never the
child's stdout) that also states, truthfully, how the tree was torn down — including
that on Windows a soft stop can only reach a windowed member, so for the ordinary
console child no soft stop is delivered at all, the grace window elapses, and the Job
Object is then killed atomically (see README.md, "Timeouts, cancel, and
grace"). As with every runner-own code, the numeric value is a best-effort signal;
the authoritative, machine-readable form of these outcomes is the timeout /
output_overflow / cancelled / killed event (and the terminal runner_exit) in the versioned JSONL
stream — see docs/schema.md.
WAIT_TIMEOUT (112) is the one code in this table that describes the client, not
the run. It is minted only by wait --timeout <duration> — targeting one run
(--run-id <id>) or, in aggregate, every run confirmed live in a snapshot taken at the
start (--all) — (see docs/registry.md, "Waiting — wait"), and only
for one situation: the wait deadline elapsed while its target(s) were still live, so
the command stopped waiting.
The distinction from TIMEOUT (106) is the whole reason it exists, and the two must
never be conflated:
TIMEOUT(106) is reported by the run's own process: the runner enforced--timeout/--idle-timeoutand tore the child's process tree down. The run is over, and it ended because of the deadline.WAIT_TIMEOUT(112) is reported by a separate, read-onlywaitprocess: it gave up watching. Nothing was sent to the runner —waitnever connects to the control transport — so the run is unaffected, still running, and will end (and report its own outcome, with its own exit code andrunner_exitevent) whenever it does.
Nor is it a CONTROL (103) failure: nothing was unreachable or ambiguous — the run was
resolved to exactly one live entry and found perfectly healthy — so reporting "could not
reach the run" would be false. A caller that hits 112 has learned one fact and only
one: the run had not finished yet. Retrying the same wait is a perfectly reasonable
response to it, unlike a 103, which will keep failing until the registry state changes.
PROBE_INCOMPATIBLE (110) is different in kind from every code above. It is not the
ending of a run — the probe subcommand never spawns a child, opens the registry, or
creates a container — but the verdict of a preflight a consumer runs on a candidate
binary before launching anything through it. It is
minted only when the probe was asked to verify an expectation
(--require-schema-version, --require-exit-code-band, or --require-surface) that
this binary's surface does not satisfy. A satisfied (or unrequested) surface exits
0. The launcher contract is fail-closed: an incompatible binary must be reported
with this distinct, reserved code rather than silently used, so a consumer never
degrades into an uncontained launch. As with the run codes, the number is a
best-effort signal; the authoritative detail is the probe's JSON report (compatible
mismatches).
A malformed probe argument (for example a bad --require-exit-code-band value) is a
USAGE (100) error like any other bad flag — distinct from PROBE_INCOMPATIBLE, which
means "the arguments were well-formed, but this binary cannot meet them".
EVENTS_INVALID (114) is the second code that is a verdict rather than an ending, and
it points the other way from PROBE_INCOMPATIBLE (110). 110 says this binary does
not meet what a consumer requires of it; 114 says a document — a JSONL stream,
this binary's own or an adapter's fixture — does not conform to the event schema this
binary embeds. Neither is a run outcome: events spawns no child, contacts no runner,
and mutates nothing (it is read-only in the same sense list and wait are).
The three ways events --validate can end are deliberately distinguishable by code
alone, because a CI job gating a fixture needs "your file is wrong" told apart from "I
could not check it":
| Exit | Meaning |
|---|---|
0 |
Every checked line conforms. The summary line on stdout says how many were checked. |
114 (EVENTS_INVALID) |
The check ran and at least one line does not conform. Each violating line is reported on stdout by line number and by what it violated; the count is in the summary. A line that is not JSON at all counts as a violation, not as something to skip. |
111 (SETUP) |
The stream could not be read at all (no such file, unreadable). Nothing was checked, so nothing is claimed about it. |
103 (CONTROL) |
--run-id named no single readable stream: no registry record names that id, several records name different streams, or the run published none (it ran without --jsonl). The same "there is no single target" verdict every other by-run-id command gives. |
--validate never reports 0 for a stream it could not check, and never reports 114
for one it merely could not read: that separation is the whole point of the code.
NOT_A_MEMBER (115) is the third code that reports a verdict rather than an ending,
and the distinction it draws is the reason it exists at all. It is minted only by
attest --run-id <id> (see docs/control-plane.md, "attest"),
and only for one situation: the runner was reached, it named the calling process from
the control transport itself, and that process is not in the run's container.
The pressure to fold this into CONTROL (103) is obvious — both are non-zero results
from a control-plane client — and it is exactly what must not happen:
CONTROL(103) means there is no answer to act on. Every one of its situations is a way the target could not be resolved, reached, or understood.NOT_A_MEMBER(115) means the answer arrived and it is "no". The target was resolved to one live run, the connection succeeded, the runner checked its own container membership, and it says the caller is not in it.
An adapter gating work on membership must respond to those differently — deny, versus
investigate or retry — and it cannot, if a single code carries both. Nor is there an
earlier, more specific signal to lean on the way BACKEND (102) leans on the
limit_hit event or TIMEOUT (106) on the timeout event's reason: attest never
touches a run's JSONL stream, so the code (and, under --error-format json, the
kind) is all a caller has.
attest's three outcomes therefore land on three distinguishable results:
| Exit | Meaning |
|---|---|
0 |
The caller is inside the run's container (verdict: "member" on stdout). |
115 (NOT_A_MEMBER) |
The runner named the caller and it is not a member. A decided verdict; the attestation is still printed on stdout. |
103 (CONTROL) |
No verdict: the run was unreachable/stale/unprobed/ambiguous as for any control client — or it was reached and could not obtain a kernel-authenticated identity for the caller (kind: peer_identity_unsupported), which is a refusal to answer, never a negative answer. |
Like WAIT_TIMEOUT (112), this code says nothing about the run itself: attest is
read-only, and the run it asked about is untouched and still going either way.
HOST_UNQUALIFIED (116) is the verdict of the one subcommand whose subject is neither a
run nor this binary, but the machine. doctor performs a bounded scratch run of this
binary's own harmless child (see docs/troubleshooting.md,
"Qualifying a host: doctor") and reports what it observed; 116 is what it exits when
the answer is no.
It is deliberately the twin of PROBE_INCOMPATIBLE (110), not a variant of it:
110is about the file.probereads compile-time constants and the in-memory clap tree; a110means the binary you found does not expose the surface you need. Install or point at a different build.116is about the environment.doctorcreates a registry, contains a process, round-trips the control plane, and cleans up; a116means this machine did not complete that, or completed it and is not the machine you required. Fix or avoid the host — a different build of this binary will not help.
Two different situations share the code, and the report distinguishes them without a consumer having to parse prose:
| Report | Meaning |
|---|---|
failures non-empty |
A phase failed: the registry could not be created or is not owner-only, the scratch run never became reachable, the control-plane round-trip did not complete, teardown was not confirmed, or an artifact was not cleaned up. The scratch directory is kept, and diagnostics_dir names it. |
mismatches non-empty |
Every phase succeeded, but a --require-* expectation about the host was not met (--require-mechanism, --require-abrupt-cleanup, --require-resource-controller). Nothing failed, so nothing is kept. |
The codes it does not use are as deliberate as the one it does:
BACKEND(102) is what a run exits with when its own container or registry could not be established. Adoctorreturning it would be indistinguishable from the scratch run it drove having failed on its own account — and would say nothing at all for the second row above, where the host works perfectly and simply is not the one that was asked for.SETUP(111) is already this command's code for the opposite case:doctor's own machinery failing (no scratch directory, no path to this executable, a report that will not serialize). Keeping that on111is what lets116mean "the check ran, and the answer is no" without ambiguity.- No run-ending code applies.
doctorends no run of the caller's: the only run it ever touches is the scratch one it minted for itself, which it also ends.
The report is printed on stdout in both cases, exactly as probe prints its report
whether or not it is compatible — so a caller always has a parseable result, and the
number is the best-effort signal for a shell that cannot read it.
SETUP (111) and INTERNAL (104) are deliberately kept apart so the code alone tells a
caller which one happened:
SETUP(111) is a fail-closed setup / support failure: the runner could not establish a prerequisite it needs, or produce a result it must emit, for an ordinary reason the caller can usually act on. It covers arunwhose async runtime will not build; a required--jsonlevents file,--capture-dir, or--stdin-filethe operator asked for but that cannot be opened or created (an unwritable path, a missing parent, denied permissions); and aprobe/inspect/attest/doctor/ control (cancel/kill) report or reply that cannot be serialized. Fordoctorit additionally covers that command's own machinery failing before it can qualify anything — no scratch directory, no path to this executable — which is deliberately notHOST_UNQUALIFIED(116): the check never ran, so there is no verdict about the host to report (see "Qualifying a host:doctor" above). It also covers the two failures that belong to--detach's wrapper rather than to the run — the detached runner could not be spawned, or it never reported a started run before the startup budget elapsed (see "Detached runs" below). In every case the runner's own run-tracking logic is intact — a peripheral support step just failed — so reporting it as anINTERNAL"runner bug" would mislead the consumer. ASETUPfailure before the child is spawned takes theSETUPcode and (where a--jsonlstream is already open) a terminalrunner_exitwithsource: "setup"and a nullchild_code; no child code is ever lost, because no child ran.INTERNAL(104) stays strictly for a genuine invariant violation: the runner reached a state its own logic rules out (the backend reported aTimedOutoutcome when no deadline was armed on the child, or an outcome variant this build does not recognize), or lost a trustworthy view of the run it cannot recover from (awaiton the child failed and its fate is now unknown). These are runner bugs, and a consumer reading104can treat them as such.
The distinction is which side failed: an environment/resource condition the caller can fix
(SETUP) versus the runner's own logic being wrong (INTERNAL).
When run is given a whole-tree resource cap (--max-memory, --max-processes,
or --cpu-quota) that the active containment mechanism cannot apply, the run ends
with BACKEND (102) and a runner_exit source of container_error — the
same code as any other container-creation failure — not a new code from the
reserved band's free slots. This is a deliberate choice: the failure is genuinely
that a whole-tree container capable of the requested cap could not be established
here (FreeBSD's process reaper provides whole-tree normal containment, membership,
and kill/teardown semantics but no memory, CPU, or process-count cap primitive;
macOS/non-FreeBSD BSD process groups and the Linux process-group fallback have no
whole-tree cap container at all;
a Linux cgroup v2 whose controllers can't be enabled — under systemd, an ordinary
container, or typical CI — can't carry it either), which is the same class as the
existing container-creation error. The failure is always pre-spawn, so no child
code is ever at stake.
What makes a limit ending distinguishable is not the exit code but the dedicated
limit_hit JSONL event that precedes the container_failed/runner_exit tail
and names which limit (memory / processes / cpu) — the authoritative,
machine-readable channel, exactly as this document's core principle holds the exit
code to be only a best-effort hint (see "Why a band is not enough on its own"
below). A nonsensical value (--max-memory 0, a non-positive/non-finite
--cpu-quota) is instead a USAGE (100) argument error, rejected at parse time
before any container is touched. No reserved-band slot was spent on it.
run --detach is the one invocation where "the runner's exit code is the child's exit
code" does not apply, and it is not an exception carved out of the rule so much as a
consequence of the mode: the run is handed to a detached copy of the binary, so the
process the caller is waiting on has no child of its own to forward a code from. It
reports the only thing it can honestly report — whether the run started:
| Exit | Meaning |
|---|---|
0 |
The run started: the detached runner registered it and wrote run_started to --jsonl. It is now discoverable (list), reachable (inspect/cancel/kill), and waitable (wait). Says nothing about how the child will finish. |
| a reserved-band code | The run did not start, and the code is the same one the failure would have produced in the foreground. |
No new code was minted for this. A start can fail in exactly the ways a foreground
run can fail before it begins — a program that is not there (SPAWN 101), a container
that cannot be created or a limit that cannot be applied (BACKEND 102), an unwritable
--jsonl/--capture-dir (SETUP 111) — and the detached copy exits with precisely
that code, which the caller then relays unchanged. A caller therefore reads run --detach's failures with the same table as run's, and the reserved range 117–119
stays free. Two failures belong to the detach wrapper itself rather than to the run, and
both take SETUP (111): the detached copy could not be spawned at all (a support step
failed; blaming SPAWN would point at the caller's program, which was never reached),
and the detached copy was alive but had not reported a started run before the startup
budget elapsed (it is killed rather than left running unreported). An exit status that
is not a reserved-band code — including a 0 — is likewise reported as SETUP and
never relayed, because no run path can exit successfully without having written
run_started first.
The code is relayed; the machine-readable kind is not borrowed. Under
--error-format json (below), a relayed code that run itself mints reports the kind
the foreground failure would have reported — spawn_error, container_error, setup,
and so on. A reserved-band code run cannot mint reports kind: "unknown" instead:
that is PROBE_INCOMPATIBLE (110), WAIT_TIMEOUT (112), EVENTS_INVALID (114),
NOT_A_MEMBER (115), and HOST_UNQUALIFIED (116),
which only probe, wait, events --validate, attest, and doctor produce, plus
any number no build assigns yet. The respawned copy can be a different build — the binary on disk may have
been replaced between the spawn and the exec — so reading its number through this
build's table would invent a verdict: a relayed 112 would say wait_timeout, the one
kind that reports retryable: true and means "the run is still going, wait again",
about a run that never started. The number itself still reaches the caller unchanged;
only the claim about its meaning is withheld.
What the events file holds after a failed start. Whatever the detached copy managed
to write, and nothing invented on its behalf. A copy that started and then failed
records the failure itself — a spawn_failed/container_failed and a terminal
runner_exit with the matching source, exactly as a foreground run would — so the
stream explains the code the caller saw. The two wrapper failures above never reach that
point: the caller's own stderr and exit code are the entire account, and the events file
is left empty (or, if the copy was killed mid-startup, without a terminal runner_exit).
A stream with no runner_exit therefore means "no run started here", never "a run whose
ending was lost".
Where the child's code went. Nowhere: it is in the terminal runner_exit event of
the run's own --jsonl stream, with code, source, and child_code exactly as for
any other run. That is the whole trade of detaching — the caller gave up being the
runner's parent, so the process-exit channel for the child's result went with it, and
the event stream (which the 0 above guarantees exists and has begun) is the channel
that remains. wait --run-id <id> blocks until such a run is over, but it too reports
only that it ended, never with what code; the runner_exit event is the single source
for that.
Exit codes are a single small integer, and a child can, in principle, exit with
a number that happens to fall inside 100–119 too. The reserved band is
therefore a best-effort signal for shells and scripts, not the authoritative
channel. The authority is the JSONL event stream: every run ends with a
runner_exit event (defined by the JSONL schema — see docs/schema.md) that
carries the returned code, names why the runner exited, and preserves the child's
own code in a separate child_code field, so a consumer reading --jsonl can
always tell a runner failure apart from a child that merely exited with the same
number. A child's own code is never lost or aliased, because the runner's failures
are additionally recorded out of band.
There is a second way the band is not enough, and it applies to the commands that
never start a run at all: a code is coarse. CONTROL (103) alone covers eight
genuinely different situations — no such run id, a confirmed-stale entry, an
unprobeable one, an ambiguous id, a runner that could not be reached, one that was
reached but let a bounded window elapse, a reply whose version this build
refuses, and a runner that could not name the caller an attest asks about — and
inspect/cancel/kill/attest/wait/events
have no event stream of their own to disambiguate them in. (Those eight are the ones
that exist to split 103, and the eight the kind table below lists against it.
An unreadable registry can arrive under the same code as well, reported as
registry — the one kind published under two codes, since it is why a by-run-id
client could not resolve its target.) Historically the only
finer signal was the English sentence on stderr. The next section is the machine-readable
answer to that.
--error-format json is a global, opt-in flag: accepted before or after the
subcommand, honored by every one of them, and off by default. Under it, a failure
that would have printed
processkit-cli: cannot inspect run `build-42`: its registry entry is stale — the runner is gone (it exited without cleaning up)
prints exactly one bounded JSON object on stderr instead:
{"error_version":1,"code":103,"kind":"stale","operation":"inspect","run_id":"build-42","retryable":false,"message":"cannot inspect run `build-42`: its registry entry is stale — the runner is gone (it exited without cleaning up)"}The shape is published as a schema plus a golden fixture, exactly like this
project's other machine-readable outputs:
fixtures/schema/cli/error.schema.json
and error.jsonl, validated against the real binary by tests/machine_output.rs
and tests/error_envelope.rs. The in-code source of truth is
src/error_envelope.rs.
| Field | Stable? | Meaning |
|---|---|---|
error_version |
yes | The envelope's own format version, currently 1. Pin it. A breaking change to the shape bumps it; a new field or a new kind value does not (both are additive). |
code |
yes | The reserved-band code this invocation exits with — the same number $? reports, so the two can never disagree. |
kind |
yes | What actually failed, finer than code. The vocabulary is below. |
operation |
yes | The subcommand that failed: run, inspect, cancel, kill, attest, wait, events, list, prune, probe, doctor. |
run_id |
yes | The run id the invocation named, or null when it named none (an --all fan-out, list/prune/probe, a doctor, whose only run is the scratch one it mints for itself, or a run that let the runner generate one). Present-and-null, never omitted. |
retryable |
yes | Whether repeating this exact invocation may succeed later. A pure function of kind — see below. |
message |
no | The same free-text explanation the default prose prints. Never branch on it: it may be reworded in any release, and the golden fixture deliberately does not pin its text. |
kind is a finer axis over the codes above, not a competing set of them — no
new exit code was minted for this feature. It is never coarser than the code
either: every assigned code has at least one kind of its own, so branching on
kind alone loses nothing.
kind |
Code | What it says |
|---|---|---|
not_found |
103 | Nothing in the registry names that run — or, for events, the record names no stream to read (the run ran without --jsonl). |
stale |
103 | The entry is confirmed stale: the probe ran and the runner is gone. |
unprobed |
103 | The entry could not be probed at all, so nothing is established either way. Not the same claim as stale. |
ambiguous_run_id |
103 | More than one live run (or, for events, more than one stream) is registered under that id, so the command refuses to guess. |
control_unreachable |
103 | A single target was resolved but could not be reached or did not answer — no endpoint, a failed connect, a runner that died mid-conversation. Also the verdict of an --all fan-out where some targets could not be acted on. |
ipc_deadline |
103 | A bounded control-plane window (connect, or request/response) elapsed against a runner that was there. |
incompatible_contract |
103 | The other side declared a contract this build does not implement and the answer was refused rather than misread — an inspect reply whose snapshot_version is outside the range this client reads, or an attest reply whose attestation_version is not the one this client reads. Says nothing about the run's liveness. |
peer_identity_unsupported |
103 | attest reached the runner, and the runner could not obtain a kernel-authenticated identity for the calling process from the control transport, so it declined to decide membership either way. A refusal, never a negative answer — see "A membership verdict is not an unreachable target". Establish the capability at preflight with probe --json --require-surface attest:peer-identity. |
probe_incompatible |
110 | The preflight found this binary does not satisfy a --require-* expectation. The concrete reasons are in probe --json's own mismatches array on stdout. |
registry |
111, or 103 | The per-user run registry itself could not be opened or scanned. The one kind reachable under two codes: SETUP (111) for a whole-registry command, CONTROL (103) when it is why a by-run-id client could not resolve its target. |
setup |
111 | Any other prerequisite: an unwritable output, an unreadable stream, a runtime that would not build, a reply that would not serialize. |
wait_timeout |
112 | wait's own deadline elapsed; the run was never touched and is still going. |
events_invalid |
114 | events --validate checked a document and it does not conform. |
not_a_member |
115 | attest established that the calling process is not in the container of the run it asked about. One of the two kinds here that report a decided verdict rather than something that went wrong. |
host_unqualified |
116 | doctor finished qualifying this host and the verdict is no — a phase failed, or a --require-* expectation about the host was not met. The other decided verdict, and the one about the machine rather than a run; the per-phase detail is in doctor's own report on stdout. |
usage |
100 | An invalid command line detected after parsing — in practice only a detached start relaying the code its respawned copy reported. clap's own parse-time errors are outside this envelope (below). |
spawn_error |
101 | The child could not be started. |
container_error |
102 | The container / job object / IPC endpoint / registry could not be established, including an unappliable resource limit. |
internal |
104 | A genuine runner bug. |
timeout |
106 | The run exceeded --timeout or --idle-timeout and the runner tore the tree down. |
cancelled |
107 | A local stop signal (Ctrl-C, SIGTERM/SIGHUP, a Windows console event) ended the run. |
control_cancel |
108 | A control-plane cancel ended the run. |
control_kill |
109 | A control-plane kill ended the run. |
output_overflow |
113 | A capture stream exceeded --capture-max-bytes under --capture-overflow cancel. |
unknown |
any | A reserved-band code this build will not name here. Read code. Reachable only when a run --detach relays the code of a respawned copy that turned out to be a different build, and covering both shapes of that: a code no build assigns yet (the retired 105, the reserved 117–119), and a code this build assigns to a different subcommand (110, 112, 114, 115, 116 — minted only by probe, wait, events --validate, attest, and doctor, never by run), which the relay refuses to read as a verdict about a run. |
The nine run-family values in that table (usage is not one of them: a
run --detach can relay it, but it names no run ending) are not a second
vocabulary: spawn_error, container_error, timeout, cancelled,
control_cancel, control_kill, output_overflow, setup, and internal are
spelled exactly as the terminal runner_exit event's source spells the same
endings, and fixtures/schema/v1/schema.json's runnerExit.source remains their
single source of truth. A failing run gets an envelope because the flag is
global and because a run started without --jsonl (or a --detach that never got
far enough to write one) has no stream to read — not because the envelope wants to
restate a stream that exists.
New kind values may be added in a minor release. A consumer that meets one it
does not recognize should fall back to code, which is always present and always
inside the reserved band.
retryable is derived from kind alone, so the two can never disagree. Exactly
three kinds are true:
unprobed— nothing at all was established, so a second probe may establish something. If it persists, investigate the registry directory rather than the retry count.ipc_deadline— a live runner was merely slower than a bounded window.wait_timeout— the run is still live and untouched, so waiting again is the intended response.
false is conservative: it means this build does not promise a retry helps, not
that the condition is provably permanent. Every run-family kind is false on
purpose — re-running a command is a new run with new side effects, not a retry of
a read-only query, and whether that is safe is the caller's judgement.
Two things, both deliberate and both stated here rather than left as silent gaps:
- clap's parse-time usage errors. An unknown flag, a malformed
--timeout, a missing subcommand — everything that exitsUSAGE(100) before the binary has decided what it was asked to do — keeps clap's own human-readable usage/suggestion text even under--error-format json. The cross-argument refusals checked immediately after parsing (today:run --run-id-env <KEY>against an explicitrun --env <KEY>=…) are part of this group and behave identically: same reserved100, same clap rendering, no side effects. There is nooperationto name and no run to point at, and clap's text is a rendering for a human, not a verdict about a run; forcing it into an envelope would distort both. A machine still gets the reserved100, and the supported way to establish that a flag exists before using it is theprobepreflight (--require-surface inspect:--error-format, seedocs/integration.md). Note too that an invocation whose own--error-formatvalue failed to parse has no format to honor. Should a future version cover these as well, that is an additive change and will be announced inCHANGELOG.md. processkit-cli: warning: …lines. These are not failures; the envelope is printed once, on the way out, for the failure that ends the process. They keep their prose in both modes.
- stdout is never touched. The envelope is always on stderr, so a command that
prints a machine-readable report and then fails —
probe --jsonwith an unmet expectation (110),inspect --all --jsonwith an unreachable target (103) — still prints exactly the stdout it always did. A caller may leave the flag on permanently for every invocation. - The default is unchanged, byte for byte. Without the flag (or with
--error-format human) stderr is exactly what every earlier release printed. - The exit code is unchanged. The envelope reports the code; it never changes which one is chosen.
- Exactly one envelope per failed invocation, on one line. For every command
except
runit is the only thing on stderr; forrunthe child's echoed stderr shares the stream, so it is the runner's own final line.--no-echois the flag that keeps the child's bytes off that stream — it skips only the runner-side echo write, so a--capture-dirtranscript still receives them in full — while--capture-diron its own suppresses nothing and leaves the echo interleaved. On either channel, a reader identifies the envelope by its shape, not by being alone: theprocesskit-cli: warning: …lines named above are prose on the same stream.
- The band (
100–119) and the assigned codes above are stable; moving or repurposing an assigned code is a breaking change. NOT_IMPLEMENTED(105) was the one intentionally temporary member: it has now retired, since every subcommand it once stood in for is implemented. Its retirement was not a breaking change — it only ever meant "this build cannot do that yet" — but the number is not reassigned to a new meaning; it stays reserved and unused going forward.- New runner-own conditions take the next free code in the reserved range
rather than overloading an existing one, and only when no existing code (or an
earlier, more specific event or field) already tells the new situation apart.
HOST_UNQUALIFIED(116) is the most recent, taking the next free slot afterNOT_A_MEMBER(115) rather than overloadingBACKEND(102) — which would make a qualification's verdict about a host indistinguishable from the scratch run it drove having failed on its own account, and would say nothing at all for this code's other half, an unmet--require-*against a host that works perfectly (see "Qualifying a host:doctor" below);NOT_A_MEMBER(115) did the same before it, taking the slot afterEVENTS_INVALID(114) rather than overloadingCONTROL(103), whose every meaning is the absence of an answer rather than a decided one (see "A membership verdict is not an unreachable target" above);EVENTS_INVALID(114) did the same before it, taking the slot afterOUTPUT_OVERFLOW(113) rather than overloadingPROBE_INCOMPATIBLE(110), whose subject is this binary's own compatibility rather than a document's (see "Checking a stream:events --validate" above); andWAIT_TIMEOUT(112) before that, taking the slot afterSETUP(111) rather than overloadingTIMEOUT(106), whose meaning is the opposite one (see "A waiter's deadline is not a run's deadline" above). Codes117–119remain reserved. - The
--error-format jsonenvelope versions on its own axis,error_version(currently1), independent of every code above: removing or re-typing a stable field, or changing what an existingkindmeans, is a breaking change and bumps it; adding a field or a newkindvalue is additive and does not. Akindis never repurposed for a different meaning, for the same reason a retired code is not. The taxonomy adds no exit code and never will on its own — it is a finer axis over the codes above, so a new code still follows the next-free-slot rule in the bullet above and gains a matching kind in the same change.