
processkit-cli
processkit-cli is the standalone command runner for ProcessKit. It executes
one shell-free command inside the public processkit
crate's containment boundary, preserves the child's exit code, and records a
versioned JSONL lifecycle stream without requiring a Rust or Python runtime at
the call site.
The runner owns the command-line and event contracts. ProcessKit-rs remains the single source of truth for containment, teardown, PID-reuse discipline, and platform lifecycle behavior. The cover above represents the wider ProcessKit family; this CLI deliberately focuses on one contained run and its control plane rather than exposing the core crate's pipelines, retries, or scheduling APIs.
For an adoption-oriented comparison with timeout, process groups, systemd,
containers, Tini, and PowerShell, see Why ProcessKit CLI?.
The guide explicitly identifies the cases where a smaller or wider supervisor
is the better fit.
Install
Download a platform archive from the latest GitHub release, or build and install from crates.io:
cargo install processkit-cli
Release archives contain the binary, shell completions, man pages, the JSON Schema, a SHA-256 checksum, and a signed build-provenance attestation. See Installation and distribution for target selection, checksum/attestation verification, completions, man pages, and post-install preflight.
Quick start
Run a command directly, with no shell between the runner and the program:
processkit-cli run --jsonl events.jsonl -- cargo --version
Child stdout and stderr pass through unchanged. Lifecycle events go only to
events.jsonl, so an adapter can consume them without parsing or contaminating
the child's output.
Use a stable run id when another process needs to inspect or stop the live run:
processkit-cli run --run-id build-42 --jsonl events.jsonl -- cargo test
processkit-cli inspect --run-id build-42 --json
processkit-cli cancel --run-id build-42
The control commands address a per-user registry entry and a live local IPC endpoint, never an operating-system PID. A reused PID therefore cannot retarget an old command at an unrelated process.
Choose a run shape
| You need | Use |
|---|---|
| A normal CI command with live output | Default run: closed stdin, pipe + echo stdout/stderr. |
| A real existing terminal | --inherit-stdio (no capture or idle timeout). |
| Finite input | --stdin-file FILE. |
| Durable bounded transcripts | --capture-dir DIR, optionally --no-echo. |
| A stuck-worker detector | --idle-timeout DURATION. |
| A recorded history of how the process tree evolved | --snapshot-interval DURATION (composes with every I/O mode and with --detach). |
| External tools launched by an automation agent | A foreground run with a unique id, finite deadlines, JSONL, and bounded capture. |
| Launch now, supervise from another process | --detach plus a durable JSONL path and run id. |
| Whole-tree resource caps | --max-memory, --max-processes, --cpu-quota where supported. |
The Cookbook gives copyable complete invocations. The narrative guides explain why combinations are accepted or rejected.
Use from automation agents
An automation or coding agent can use this binary without a dedicated SDK. A
project instruction can simply require external tools to be launched through
processkit-cli run with a unique run id, finite deadlines, lifecycle JSONL,
and bounded capture. The agent then has explicit inspect / cancel / wait /
kill recovery operations instead of tracking a fragile PID or cleaning up by
process name.
This makes agent-driven builds, tests, compilers, and long-lived workers more robust: descendant cleanup is scoped to the run, silent hangs can be bounded, diagnostics survive an interrupted agent turn, and different workloads can use different timeout, output, environment, and resource strategies. It does not pretend that disappearance of an arbitrary agent process is itself a portable cleanup signal; prefer foreground runs, terminate the runner during agent teardown, and use finite deadlines. Detached work needs a separate supervisor.
See Agent and automation workflows for a ready-to-paste agent policy and complete foreground, recovery, and escalation examples.
What the runner guarantees
- One owned process tree. Normal completion, timeout, cancellation, and runner errors tear down the current run's ProcessKit container. Cleanup never searches by executable name.
- Exit-code fidelity. A normal child exit is returned unchanged. Runner
failures occupy the documented
100-119band and also emitrunner_exit, so a child code is never silently aliased. - Separated streams. Child output stays on stdout/stderr; JSONL events stay
in
--jsonl; runner diagnostics never enter child stdout. - Redacted diagnostics. Events contain a SHA-256 argv fingerprint and a
classified worker hint by default. Raw arguments require
--argv-raw. - Bounded capture.
--capture-dirtees stdout and stderr into separate, size-capped transcripts with byte counts, hashes, and truncation metadata. - Honest platform reporting.
run_startedrecords the active containment mechanism and the real abrupt-runner-death cleanup guarantee rather than presenting every operating system as equivalent.
Command surface
| Command | Purpose |
|---|---|
run | Start one contained, shell-free command and write lifecycle JSONL. |
inspect | Snapshot a live run and its current members. |
cancel | Request soft stop, wait through the grace window, then hard-kill survivors. |
kill | Hard-kill the run's whole container immediately. |
attest | Ask a live run whether the calling process is inside its container — a kernel-checked containment fact. |
wait | Wait for one run, or a snapshot of all live runs, to finish. |
events | Read a run's JSONL lifecycle stream back: render, follow, pass through, or validate it. |
list | Discover live, stale, and unprobed registry entries. |
prune | Remove only entries confirmed stale. |
probe | Verify the binary's versioned compatibility surface before launch. |
Run processkit-cli <command> --help for the complete flag set. The
integration guide shows a fail-closed adapter workflow from
preflight through cleanup.
Platform behavior
| Platform | Preferred mechanism | Abrupt runner death |
|---|---|---|
| Windows | Job Object | Whole tree is reaped by kernel kill-on-close. |
| Linux | cgroup v2, with process-group fallback | Direct child only when parent-death signaling is available. |
| FreeBSD | ProcessKit process reaper | No automatic whole-tree guarantee after an uncatchable runner death; normal teardown covers the reaper tree. |
| macOS / non-FreeBSD Unix | POSIX process group | No automatic whole-tree guarantee after an uncatchable runner death. |
Every ordinary teardown still uses the active container on every supported
platform. The last column is intentionally narrower: it describes only a crash,
SIGKILL, or comparable event that prevents the runner from executing its own
cleanup path. See the architecture and
troubleshooting guide for the exact caveats.
Guides
| Guide | Covers |
|---|---|
| Installation and distribution | Archives, package-manager manifests, target selection, checksums, attestations, Cargo, completions, man pages. |
| Cookbook | Task → command recipes for common foreground, detached, capture, control, and container workflows. |
| Agent and automation workflows | A drop-in agent instruction, bounded execution strategies, recovery, and honest agent-stop guarantees. |
| Running commands | Shell-free argv, cwd, environment, run ids, foreground lifecycle, and flag interactions. |
| Standard I/O and capture | Default pipes, inherited handles, stdin files, no-echo, bounded transcripts, TTY caveats. |
| Detached runs | Startup proof, changed launcher exit semantics, recovery, and out-of-band supervision. |
| Timeouts and cancellation | Overall/idle clocks, grace, signals, cancel vs kill, and platform soft-stop behavior. |
| Resource limits | Whole-tree memory/process/CPU caps and fail-closed enforcement. |
| Platform support | Release targets, mechanisms, abrupt cleanup, capability and CI matrices. |
| Running in containers | musl/glibc images, PID 1, signals, writable paths, cgroup delegation, outer limits. |
| Integration guide | Probe, launch, event consumption, supervision, and housekeeping for adapters. |
| Compatibility and upgrades | Surface tokens, schema/exit-band pinning, rolling upgrades, and acceptance policy. |
| Live-run control plane | IPC transport, inspect/cancel/kill/attest semantics, and safe targeting. |
| Run registry | Per-user records, liveness probing, ambiguity, waiting, and pruning. |
| JSONL event schema | The normative schema_version = 1 contract and golden fixtures. |
| Exit-code contract | Child-code fidelity, the reserved runner failure band, and the --error-format json machine-error envelope built over it. |
| Troubleshooting | Symptom-to-cause diagnosis for operators and CI. |
| Threat model | Trusted boundaries, hostile inputs, local IPC, and supply chain. |
| Architecture | Module map and the data flow of one run. |
The 60-second tour
# 1. Prove the installed runner supports the contract your caller needs.
processkit-cli probe --json \
--require-schema-version 1 \
--require-exit-code-band 100-119 \
--require-surface run:--capture-dir
# 1b. Once per host: prove this machine can actually contain and control a process.
processkit-cli doctor --json
# 2. Start one shell-free command with a stable id and bounded transcripts.
processkit-cli run --run-id demo --capture-dir ./demo-output \
--jsonl demo.jsonl -- cargo test
# 3. While it is live, inspect or cancel it from another process.
processkit-cli inspect --run-id demo --json
processkit-cli cancel --run-id demo
# 4. Read its lifecycle story back — live, or long after it finished.
processkit-cli events --run-id demo --follow
processkit-cli events --file demo.jsonl
# 5. Discover or clean up registry state after an orchestrator restart.
processkit-cli list --json
processkit-cli prune --dry-run --json
The JSONL file is the durable lifecycle record. The registry and control endpoint exist only while a run is live (or as detectable stale leftovers after an abrupt runner death).
Source, release history, and contribution guidance live in the GitHub repository.
Why ProcessKit CLI?
ProcessKit CLI is for one shell-free command whose process tree must be cleaned up on ordinary completion, timeout, or cancellation, while an adapter receives a stable lifecycle record. It is not a universal service manager, container runtime, or security sandbox. Choose the tool that owns the widest requirement; the choices below can also be layered.
Quick choice
| Primary requirement | Start with |
|---|---|
| One portable command surface, tree-scoped teardown, exact ordinary child exit status, and versioned lifecycle JSONL | ProcessKit CLI |
| The shortest available Unix command deadline with no new installation | GNU timeout |
| Terminal/session detachment or a new POSIX process group, with lifecycle code supplied by the caller | setsid / start_new_session |
| A Linux service or scope owned by the host manager, with native cgroup policy, journaling, and service integration | systemd-run / a systemd unit |
| Filesystem, network, user, and image isolation; deployment scheduling or restart policy | A container runtime / orchestrator |
| Correct PID 1 signal forwarding and zombie reaping inside a container | Tini or the runtime's --init mode |
| PowerShell-native asynchronous objects, streams, or remoting | A PowerShell job |
| A custom Windows-only host that already owns Win32 lifecycle code | A raw Win32 Job Object |
Comparison at a glance
| Option | Process boundary | If its immediate launcher dies abruptly | Result and observability | Where that option wins |
|---|---|---|---|---|
| ProcessKit CLI | The obtained ProcessKit mechanism: Windows Job Object, Linux cgroup v2, FreeBSD process reaper, or a reported process-group fallback | Explicit abrupt_cleanup: whole_tree on Windows, direct_child_only on Linux, none on FreeBSD/macOS/other Unix | Ordinary child status is preserved; runner-imposed endings and runner failures are distinct. Versioned JSONL, bounded diagnostics, local inspect/cancel/kill/wait. | One binary and one adapter contract across Windows, Linux, macOS, and source-built FreeBSD. |
GNU timeout | A time limit and signal policy around one command; default and foreground modes have different process-group behavior | No separate kill-on-owner-death containment contract | Familiar shell status conventions and stderr diagnostics, not a versioned lifecycle stream | Near-ubiquitous, tiny, and ideal when a deadline is the whole requirement. |
setsid / start_new_session | A POSIX session and process group, not a resource container | No owner-death reap; a descendant can create another session | Whatever wait, exit, logging, and cleanup logic the caller writes | Minimal mechanism for terminal detachment and shell/job-control composition. |
| systemd service or scope | A cgroup owned by the system or user service manager | The unit remains manager-owned rather than depending on the short-lived CLI client; stop and restart behavior follows unit policy | Native unit state, cgroup accounting, journal integration, and systemd resource controls | Durable Linux host supervision, delegated cgroups, boot integration, restart policy, and administrator tooling. |
| Container runtime / orchestrator | A container boundary, normally including namespaces and cgroups | The runtime owns container lifetime; daemon/orchestrator and restart settings decide recovery | Runtime-specific status, logs, events, health, and scheduling | Actual workload isolation, image distribution, network/filesystem policy, and fleet orchestration. |
| Tini / subreaper | One child, zombie adoption, and signal forwarding; optional process-group signaling | Tini alone does not add an independent kernel tree container | Reuses the child's exit status and solves PID 1 hygiene; no lifecycle JSONL or live run registry | Very small, transparent container init when reaping and signal forwarding are the missing pieces. |
| PowerShell job | A PowerShell job repository plus a child process, remote command, or thread depending on job type | Session-owned child jobs end with the parent session; this is not the Win32 Job Object kill-on-close contract | Rich PowerShell job state and serialized output/error streams | Interactive PowerShell concurrency, remoting, and object-oriented result handling. |
| Raw Win32 Job Object | A Windows kernel job; descendants normally join unless breakaway policy permits otherwise | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE can provide whole-job termination when the last handle closes | Whatever schema, exit mapping, IPC, ACLs, and graceful-stop code the host implements | Maximum control inside an existing Windows-native host without adopting another CLI contract. |
The ProcessKit row is deliberately conditional. Read the actual mechanism and
abrupt_cleanup values from run_started; do not infer Windows' whole_tree
owner-death guarantee on Linux, FreeBSD, or macOS. The normative matrix is in
Platform support.
Compared with GNU timeout
GNU timeout is the right answer for
many shell scripts: it is already installed, concise, supports a configurable
signal and kill-after interval, and normally preserves a command's status when
the command finishes before the deadline.
Choose ProcessKit CLI when the deadline is only one part of the contract:
cleanup must be tied to the obtained container, stdout and stderr must remain
separate from machine events, another process must inspect or stop a live run,
or an adapter must distinguish child exit, timeout, cancellation, and runner
failure without reverse-engineering shell status conventions. ProcessKit CLI is
larger and requires a JSONL destination; timeout is simpler when no consumer
needs those guarantees.
Compared with setsid or start_new_session
POSIX setsid creates a
session and a new process group. Python's start_new_session=True requests the
same primitive. That is useful for terminal detachment and for giving the
caller a group to signal, but it is not a persistent list of every descendant:
a cooperating or hostile descendant may start another session, and the session
does not gain a kill-on-owner-death policy.
ProcessKit itself may honestly fall back to a process group, especially on
macOS/non-FreeBSD BSDs or when Linux cgroup delegation is unavailable. In that
case the CLI reports mechanism as process_group; resource-limit requests fail
rather than becoming no-ops, and the documented escape and abrupt-death
limitations apply. FreeBSD instead reports its distinct process_reaper
mechanism, whose normal teardown and membership scope cover the whole reaper
tree but whose resource limits and statistics remain unsupported. Use plain
setsid when that primitive plus your own wait/signal code is sufficient.
Compared with systemd-run --scope
On a systemd Linux host, systemd is the natural owner of long-lived service lifecycle and the cgroup tree. A transient service or scope gives administrators native unit state, resource policy, accounting, and journal integration. Systemd's documented delegation model is also the correct way to grant a nested manager its own cgroup subtree. Prefer it for host services, boot integration, restart policy, and durable supervision.
ProcessKit CLI instead standardizes one child run across operating systems and
emits its own portable event schema. It does not replace systemd. They can be
layered: let systemd own the service and its outer limits, and use ProcessKit CLI
inside it when an adapter still needs per-invocation JSONL and the same command
contract on Windows and macOS. In a normal non-delegated systemd unit, ProcessKit
may report process_group; do not mount or rewrite cgroup state merely to force a
different mechanism.
Compared with a container runtime
A container runtime solves a wider isolation and deployment problem. Docker, for example, provides cgroup-backed resource constraints and configurable restart policies, while an orchestrator adds scheduling, health, networking, secrets, and rollout policy. ProcessKit CLI provides none of that isolation and intentionally trusts other processes running as the same OS user.
Use the outer runtime for container lifetime and limits. ProcessKit CLI can be the container entrypoint when one payload still needs its portable JSONL, timeout/cancel outcome, and local control plane. Cgroup v2 is not automatically delegated inside ordinary containers, so this layering can legitimately report the process-group fallback; see Running in containers.
Compared with Tini or another subreaper
Tini is intentionally small: as PID 1
or a Linux subreaper it adopts and reaps zombies, forwards signals to its child,
can signal the child's process group, and exits with the child's status. Docker's
--init
mode addresses the same PID 1 hygiene problem.
That is a feature, not an omission: Tini is an excellent choice when zombie reaping and signal forwarding are the whole problem. ProcessKit CLI adds a command deadline, reported containment, capture, resource-limit negotiation, versioned events, and live-run IPC. It is correspondingly more opinionated and requires persistent paths for JSONL and registry state.
Compared with PowerShell jobs and raw Windows Job Objects
A PowerShell background job is a shell-level concurrency and result abstraction.
It exposes job state and PowerShell's output, error, warning, and other streams;
remote and thread jobs cover still different execution shapes. The official
about_Jobs
documentation describes that session-owned repository. Prefer it when a
PowerShell operator wants asynchronous objects or remoting rather than a
language-neutral binary contract.
A PowerShell job is not the same thing as the Win32 kernel Job Object used by ProcessKit on Windows. A raw Win32 Job Object can group processes, enforce limits, and terminate the job on last-handle close. It is the right primitive for a Windows-native host willing to implement its own spawn race handling, nested-job policy, exit mapping, diagnostics, IPC security, and wire schema. ProcessKit CLI packages that work behind a tested command surface, but a custom host can expose deeper application-specific integration.
Exit status, telemetry, and secrets
ProcessKit CLI preserves the child's exact status only when the child chose the
ordinary outcome. A timeout, cancellation, output-overflow stop, or runner failure
uses a documented reserved code and a terminal runner_exit event, so those
cases cannot masquerade as the child's decision. See the
exit-code contract and JSONL schema.
The JSONL stream is separate from child stdout/stderr and versioned for adapters.
Argv is hashed by default, with only a classified worker hint; raw argv is an
explicit --argv-raw opt-in. Other supervisors may provide richer native logs
or object streams, but their command metadata has its own disclosure rules. Do
not assume that switching wrappers preserves ProcessKit CLI's redaction contract.
A practical decision rule
- Need isolation, deployment, restart, or a durable host service? Choose the container runtime/orchestrator or systemd first.
- Need only a Unix deadline, a new session, PowerShell concurrency, or PID 1 reaping? Use the smaller native tool.
- Need one cross-platform invocation contract plus tree teardown and lifecycle
JSONL? Use ProcessKit CLI, then validate
mechanismandabrupt_cleanupfor the actual host. - Need both layers? Let the outer supervisor own machine/container policy and ProcessKit CLI own one inner command. Treat the outer supervisor's OOM, eviction, and restart events as authoritative for that outer boundary.
Continue with Installation and distribution, or start from the copyable Cookbook.
Installation and distribution
processkit-cli is distributed as one self-contained executable. A machine
that runs the binary does not need Python, a virtual environment, or a Rust
toolchain. Choose a prebuilt archive directly or through cargo-binstall for
production and CI; use cargo install when building from source is already part
of your environment.
Prebuilt archives
One-command verified install
The repository installers select the host archive, download its published
.sha256 sidecar, fail closed on any mismatch, and install only after successful
verification. They refuse to replace an existing destination unless running that
file with --version identifies it as processkit-cli.
Linux or macOS:
curl -fsSL https://raw.githubusercontent.com/ZelAnton/ProcessKit-CLI/main/install.sh | sh
Windows PowerShell:
irm https://raw.githubusercontent.com/ZelAnton/ProcessKit-CLI/main/install.ps1 | iex
The defaults install the latest release under ~/.local/bin. For a pinned
version, custom destination, or explicit target, download the script and pass its
options directly:
sh install.sh --version X.Y.Z --install-dir /opt/processkit/bin
.\install.ps1 -Version X.Y.Z -InstallDir C:\Tools\ProcessKit
The scripts print the installed binary's --version result and tell you when the
destination is not yet on PATH. The POSIX installer supports the published Linux
x86_64/Arm64 and macOS Arm64 builds; use --target x86_64-unknown-linux-musl or --target aarch64-unknown-linux-musl when the
static musl archive is required for the matching architecture. The PowerShell
installer selects x86_64 or Arm64 from PROCESSOR_ARCHITECTURE.
Every GitHub Release contains one archive per supported target:
| Platform | Target | Archive format |
|---|---|---|
| Windows x86_64 | x86_64-pc-windows-msvc | .zip |
| Windows Arm64 | aarch64-pc-windows-msvc | .zip |
| Linux x86_64, glibc | x86_64-unknown-linux-gnu | .tar.gz |
| Linux Arm64, glibc | aarch64-unknown-linux-gnu | .tar.gz |
| Linux x86_64, static musl | x86_64-unknown-linux-musl | .tar.gz |
| Linux Arm64, static musl | aarch64-unknown-linux-musl | .tar.gz |
| macOS Apple Silicon | aarch64-apple-darwin | .tar.gz |
The naming convention is
processkit-cli-v<version>-<target>.<format>. Each archive has a neighboring
.sha256 file and a signed GitHub build-provenance attestation.
Linux and macOS
version=X.Y.Z
target=x86_64-unknown-linux-gnu
archive="processkit-cli-v${version}-${target}.tar.gz"
base="https://github.com/ZelAnton/ProcessKit-CLI/releases/download/v${version}"
curl -sSLO "$base/$archive"
curl -sSLO "$base/$archive.sha256"
sha256sum -c "$archive.sha256" # macOS: shasum -a 256 -c
tar -xzf "$archive"
install -m 0755 processkit-cli "$HOME/.local/bin/processkit-cli"
Windows PowerShell
$version = 'X.Y.Z'
$target = 'x86_64-pc-windows-msvc'
$archive = "processkit-cli-v$version-$target.zip"
$base = "https://github.com/ZelAnton/ProcessKit-CLI/releases/download/v$version"
Invoke-WebRequest "$base/$archive" -OutFile $archive
Invoke-WebRequest "$base/$archive.sha256" -OutFile "$archive.sha256"
Get-FileHash -Algorithm SHA256 $archive
# Compare the printed hash with the value in $archive.sha256 before extracting.
Expand-Archive $archive -DestinationPath .\processkit-cli
Put processkit-cli.exe in a directory already present in PATH, or add its
installation directory to PATH for the account that launches the runner.
Verify provenance
The checksum detects damaged or substituted bytes. The attestation additionally proves that GitHub Actions built the archive from this repository:
gh attestation verify "$archive" --repo ZelAnton/ProcessKit-CLI
Both checks are useful in automation: the checksum is portable and offline once downloaded, while attestation verification ties the bytes to the release workflow and repository identity.
Package-manager manifests
After the release-archive matrix settles, the release workflow reads the
published .sha256 sidecars for each archive the channels use and attaches
distributor-ready manifests for
winget,
Scoop, and a
Homebrew tap. The same
files are collected in
processkit-cli-v<version>-package-manifests.tar.gz, with a neighboring bundle
checksum. The generator rejects a sidecar unless it names the exact archive the
manifest will download, so URLs and digests cannot be paired accidentally.
Package managers need a repository of their own; a manifest attached to this project's GitHub Release is distributor input, not a public package source. The release workflow can publish the Homebrew and Scoop files into repositories this project owns, but that publication stays off until an operator provisions those repositories (see "Publishing to a tap or bucket" below), and winget's is external by nature. The current publication boundary is explicit:
| Channel | Generated release asset | Availability |
|---|---|---|
| winget | Three-file ZelAnton.ProcessKitCLI manifest for x86_64 and Arm64 | Submit all three files to microsoft/winget-pkgs; installation is available only after Microsoft's external review accepts the version. Deliberately not automated — see "Why winget is submitted by hand" below. |
| Scoop | processkit-cli.json for x86_64 and Arm64 | Ready for bucket/processkit-cli.json in an account-owned bucket, and published there by every release once the operator sets SCOOP_BUCKET_TOKEN; no canonical public bucket is advertised yet. |
| Homebrew | processkit-cli.rb for macOS Arm64 and Linux x86_64/Arm64 | Ready for Formula/processkit-cli.rb in an account-owned tap, and published there by every release once the operator sets HOMEBREW_TAP_TOKEN; both Linux architectures use their static musl archive so the formula does not inherit the release runner's glibc floor. No canonical public tap is advertised yet. |
Once a source is actually published, that package manager provides its normal
install and upgrade lifecycle. Until its availability row changes, use the
verified installer, cargo-binstall, or cargo install; do not paste a future
bucket/tap name into automation and assume it exists.
Publishing to a tap or bucket
The last job of .github/workflows/release.yml, publish-package-repos, takes
the formula and Scoop manifest the job before it attached to the Release and
pushes each into the package repository it belongs in. It is inert until an
operator sets that up, and it cannot fail a release:
| Channel | Target repository (default) | Enabling secret | Published path |
|---|---|---|---|
| Homebrew | <owner>/homebrew-tap | HOMEBREW_TAP_TOKEN | Formula/processkit-cli.rb |
| Scoop | <owner>/scoop-bucket | SCOOP_BUCKET_TOKEN | bucket/processkit-cli.json |
To turn a channel on:
- Create the target repository under the same owner. For Homebrew the name is
not free-form:
brew tap <owner>/tapresolves to<owner>/homebrew-tap. - Add the repository secret (Settings → Secrets and variables → Actions) with a
token that can push to that repository and nothing else — a fine-grained
personal access token with
Contents: read and writeon the tap/bucket alone, or a GitHub App installation token. The workflow's built-inGITHUB_TOKENcannot be used: it is scoped to this repository. - Optionally set the repository variable
HOMEBREW_TAP_REPOSITORYorSCOOP_BUCKET_REPOSITORYwhen the target is named differently. The token secret alone decides whether a channel publishes; the variable only renames the target. - After a release has actually published, update the availability table above. That table, not this section, is what a reader is told to trust.
The two channels are independent — configure either, both, or neither. The job publishes exactly the bytes attached to the Release rather than regenerating them, pushes nothing when the target already holds identical bytes, and can be re-run on its own. Without the secret it skips and logs a notice naming the repository and secret to create. A configured channel that fails (repository missing, token expired, protected branch) is reported as an annotation but leaves the release green, because publication runs after crates.io, the tag, the GitHub Release, and every asset upload.
For this repository today, neither ZelAnton/homebrew-tap nor
ZelAnton/scoop-bucket exists, so no release has published to either and the
availability rows above still say so. As soon as the operator creates
ZelAnton/homebrew-tap and sets HOMEBREW_TAP_TOKEN, the next release publishes
the formula there automatically and brew install ZelAnton/tap/processkit-cli
becomes a working command — the point at which that row has to change.
Why winget is submitted by hand
winget has no repository under this project's control. A version becomes
installable only after a pull request to microsoft/winget-pkgs passes
Microsoft's automated validation and human review. Automating that submission
with wingetcreate submit — the standard tool — would require a maintained fork
of that repository plus a token scoped broadly enough to push to it, and a green
release step still would not mean the version is installable, because the
decision belongs to reviewers outside this project and a rejected or stalled
pull request is not something a release workflow can act on. The three-file
manifest is therefore attached to the Release and submitted deliberately, by
hand or with wingetcreate submit, when the maintainer chooses to.
Install with cargo-binstall
If cargo-binstall is already
available, install the matching prebuilt binary without a local source build:
cargo binstall processkit-cli
The crate metadata resolves to the same GitHub Release archive documented above:
.tar.gz with a root-level processkit-cli on Unix, and .zip with a root-level
processkit-cli.exe on Windows. The adjacent .sha256 and signed provenance
attestation belong to that same release, but cargo-binstall does not verify those
sidecars automatically. Use the manual archive path when policy requires those
checks or when you also need completions/, man/, or schema/.
Install from crates.io
cargo install processkit-cli
This builds for the current host and installs into Cargo's binary directory.
Use cargo install processkit-cli --locked when you want the dependency graph
from the published lockfile rather than a newly resolved compatible graph.
Building from source also generates shell completions and man pages under
target/assets/ in the build tree. Release archives already contain those
assets.
Shell completions
The completions/ directory contains bash, zsh, fish, PowerShell, and Elvish
scripts generated from the same clap definition as the binary.
# bash
install -Dm644 completions/processkit-cli.bash \
"$HOME/.local/share/bash-completion/completions/processkit-cli"
# fish
install -Dm644 completions/processkit-cli.fish \
"$HOME/.config/fish/completions/processkit-cli.fish"
$destination = Join-Path (Split-Path $PROFILE) '_processkit-cli.ps1'
Copy-Item completions\_processkit-cli.ps1 $destination
Add-Content $PROFILE ". '$destination'"
The exact filenames are visible in the extracted completions/ directory;
shell conventions differ slightly between package managers.
Man pages
Each subcommand has a page under man/man1/, plus the top-level
processkit-cli.1 page:
install -Dm644 man/man1/*.1 "$HOME/.local/share/man/man1/"
man processkit-cli-run
Schema files in an archive
Release archives carry schema/schema.json and schema/events.jsonl. They are
the machine-readable JSON Schema and golden event stream for the binary's
current schema_version. An installed binary can print the same schema without
an archive:
processkit-cli probe --json --print-schema > processkit-cli.schema.json
See Compatibility and upgrades before replacing a runner that is consumed by an adapter.
Post-install verification
processkit-cli --version
processkit-cli probe --json \
--require-schema-version 1 \
--require-exit-code-band 100-119 \
--require-surface run \
--require-surface run:--jsonl
The first command confirms which executable PATH resolves. The second is a
side-effect-free compatibility check: it launches no child, creates no registry
entry, and exits with 110 when a requirement is not met.
Next steps
- Cookbook — copyable task-oriented examples.
- Running commands — argv, working directory, environment, run identity, and foreground behavior.
- Platform support — containment strength and the
meaning of
mechanism/abrupt_cleanup.
Cookbook
Task-oriented command shapes for ProcessKit CLI. Each recipe keeps the JSONL
destination explicit and places the child after --, so the boundary between
runner options and child argv remains visible.
For copyable scripts exercised on Linux and Windows in CI, see the runnable examples.
Run a command and preserve its exit code
processkit-cli run --jsonl run.jsonl -- cargo test
For a foreground run, the CLI exits with the child's exact code after tearing down the contained tree. Runner-owned failures and cancellations use the reserved band; read Exit-code contract when the caller must distinguish them from a child that returned the same number.
Run in another directory
processkit-cli run \
--cwd ./services/catalog \
--jsonl catalog-test.jsonl \
-- cargo test --locked
run_started.cwd records the resolved absolute directory.
Start from a controlled environment
processkit-cli run \
--env-clear \
--env PATH=/usr/bin:/bin \
--env HOME=/tmp/worker-home \
--env CI=true \
--jsonl hermetic.jsonl \
-- worker
Use absolute program paths when clearing PATH entirely. Applied order is
clear → remove → env-file → set → --run-id-env.
Remove one inherited secret
processkit-cli run \
--env-remove GITHUB_TOKEN \
--env-remove AWS_SECRET_ACCESS_KEY \
--jsonl sanitized.jsonl \
-- third-party-tool
Environment values are not placed in JSONL, but the child can still echo them.
Let the child know which run it is in
processkit-cli run \
--run-id-env PROCESSKIT_RUN_ID \
--jsonl correlated.jsonl \
-- ./build.sh
The child reads the run's final id — the explicit --run-id when given, the
generated one otherwise — from PROCESSKIT_RUN_ID, matching run_started.run_id
and the registry record. Correlation only: the value authorizes nothing and
anything that can set an environment variable can forge it.
Capture output without echoing it live
processkit-cli run \
--no-echo \
--capture-dir ./capture \
--capture-max-bytes 16m \
--jsonl captured.jsonl \
-- noisy-build
Read output_captured.truncated before treating either file as complete.
Feed a finite input file
processkit-cli run \
--stdin-file request.json \
--capture-dir ./response \
--jsonl request-run.jsonl \
-- json-transform
The file closes the child's stdin at EOF and its bytes never enter argv.
Run an interactive terminal program
processkit-cli run --inherit-stdio --jsonl interactive.jsonl -- repl-tool
The child sees the caller's existing terminal. Capture, no-echo, idle timeout,
detach, and --create-no-window are unavailable in this mode. This preserves a
terminal; it does not create a PTY.
Bound total runtime
processkit-cli run \
--timeout 15m \
--grace 10s \
--jsonl timed.jsonl \
-- integration-tests
Expiry emits timeout with reason: "overall", then the cleanup sequence and
terminal runner_exit.
Kill a worker that stops producing output
processkit-cli run \
--idle-timeout 2m \
--grace 5s \
--jsonl worker.jsonl \
-- build-worker
Every observed stdout/stderr chunk re-arms the idle clock. Use only for tools whose silence is a meaningful health signal.
Give no soft-stop grace
processkit-cli run --timeout 30s --grace 0 --jsonl fast.jsonl -- disposable-task
0 is legal for grace and means immediate progression to the hard tier. It is
rejected for overall, idle, and wait deadlines.
Launch out of band and supervise later
processkit-cli run \
--detach \
--run-id nightly-build \
--capture-dir ./nightly-output \
--jsonl nightly.jsonl \
-- cargo build --release
processkit-cli inspect --run-id nightly-build
processkit-cli wait --run-id nightly-build --timeout 30m
The detached launcher's 0 means “started.” Read terminal JSONL for the child's
eventual result.
Inspect a live tree as JSON
processkit-cli inspect --run-id nightly-build --json
The snapshot includes the mechanism, root pid, start time, and current members with nullable enriched fields. It is an observation at request time, not a durable history.
Ask one run to stop cooperatively
processkit-cli cancel --run-id nightly-build
processkit-cli wait --run-id nightly-build --timeout 30s
cancel acknowledges the request; wait is the completion barrier.
Hard-kill one run now
processkit-cli kill --run-id wedged-worker
processkit-cli wait --run-id wedged-worker --timeout 10s
This skips soft stop and grace and produces a distinct killed outcome.
Shut down every currently live run
processkit-cli cancel --all
processkit-cli wait --all --timeout 30s
processkit-cli prune --dry-run
processkit-cli prune
Both --all operations use their own snapshots. Prevent new launches during a
global shutdown or repeat the sequence to catch later registrations.
Discover runs without knowing their ids
processkit-cli list
processkit-cli list --json
processkit-cli list --label pipeline=ci --health live
The human table abbreviates argv_sha256; JSON Lines carry the full digest.
live, stale, and unprobed are intentionally distinct health states.
Label filters are exact and conjunctive, matching the aggregate control commands.
New records also expose absolute jsonl and optional capture_dir locators, so a
supervisor discovering a detached run can open its artifacts without launch-time
state.
Preview stale-record cleanup
processkit-cli prune --dry-run --json
Only entries whose liveness probe succeeded and reported stale appear as
candidates. unprobed entries are preserved.
Verify a runner before using it
processkit-cli probe --json \
--require-schema-version 1 \
--require-exit-code-band 100-119 \
--require-surface run:--capture-dir \
--require-surface cancel:--all \
--require-surface wait:--all
Exit 110 means the binary is incompatible with at least one requirement. No
child or registry entry is created.
Qualify the host, not just the binary
processkit-cli doctor --json
processkit-cli doctor --require-abrupt-cleanup whole_tree
processkit-cli doctor --json --check-resource-controller --require-resource-controller
probe proves the binary; doctor proves the machine, by running a bounded scratch
containment of this binary's own harmless child and reporting the registry,
containment, control-transport, and cleanup facts it observed. Exit 116 means a
phase failed or a --require-* expectation about the host was not met — and a failed
phase keeps a diagnostics directory the report names. Unlike probe it has real
(self-cleaning) side effects, so run it once per host at setup time rather than before
every launch.
Export the exact event schema
processkit-cli probe --json --print-schema > processkit-cli.schema.json
This prints the schema embedded in that exact binary, which is useful when the consumer has an installed executable but no matching git checkout.
Require whole-tree resource caps
processkit-cli run \
--max-memory 2g \
--max-processes 64 \
--cpu-quota 2 \
--jsonl limited.jsonl \
-- untrusted-compiler
Unsupported enforcement fails before spawn with limit_hit; it never runs the
child without the requested policy. See Resource limits
before using this in Linux containers or systemd.
Hide a detached Windows console
processkit-cli run --detach --create-no-window `
--run-id headless-worker `
--jsonl headless.jsonl `
-- worker.exe
Use this only for a child that does not require a console. The runner never
forces CREATE_NO_WINDOW by default.
Invoke a shell explicitly
processkit-cli run --jsonl shell.jsonl -- sh -c 'make all && make test'
processkit-cli run --jsonl shell.jsonl -- `
pwsh -NoProfile -Command 'Get-ChildItem Env: | Sort-Object Name'
The shell is now an explicit child program. Its quoting, expansion, and pipeline semantics are outside ProcessKit CLI.
Keep child stdout machine-clean
processkit-cli run --jsonl events.jsonl -- report-generator > report.bin
JSONL never goes to stdout. Runner diagnostics use stderr, and child stderr is
also forwarded there. Use --no-echo --capture-dir when stdout must not be
forwarded at all.
Read a run's lifecycle events
processkit-cli events --run-id build-42 # what happened, rendered
processkit-cli events --run-id build-42 --follow # ... as it happens
processkit-cli events --file build-42.jsonl # once the record is gone
processkit-cli events --file build-42.jsonl --json # raw lines, for a parser
events is read-only: it resolves the stream through the registry (--run-id)
or reads a path directly (--file), never contacts the run, and mutates nothing.
It hands out only complete lines, and --follow stops at the terminal
runner_exit — or, for a runner killed before it could write one, once the run
is gone and the stream has stopped growing.
Check a stream against the event schema
processkit-cli events --file fixture.jsonl --validate
Checks every line against the schema embedded in that exact binary and reports
each violation by line number; exit 0 when all lines conform, 114
(EVENTS_INVALID) when any does not. Useful in CI for an adapter's own recorded
fixtures — no separate validator, and no second copy of the schema to keep in
sync.
Tail lifecycle events safely in your own reader
When something other than events reads the stream, treat JSONL as an
append-only sequence of complete lines. A reader should:
- buffer until newline;
- parse one object;
- verify
schema_version; - dispatch on the
eventdiscriminator, tolerating unknown event types, unknown fields, new always-present fields on an event it already parses, and repeats of an event type that previously occurred only once (see Compatibility and upgrades); - stop only after terminal
runner_exitor an explicit external recovery decision.
The file may end with a partial line if the runner is killed during a write. Do not parse that suffix as a complete event.
Recover after the supervising application restarts
processkit-cli list --json
processkit-cli inspect --run-id recovered-run --json
processkit-cli wait --run-id recovered-run --timeout 30s
Use the registry for current liveness and the JSONL file for durable history. Never reconnect by recorded PID.
Give an automation agent a bounded execution policy
Instruct the agent to launch external tools through a foreground runner with a unique run id, finite deadlines, lifecycle JSONL, and bounded capture:
mkdir -p .agent-runs/agent-task-42
processkit-cli run --run-id agent-task-42 \
--timeout 20m --idle-timeout 3m \
--capture-dir .agent-runs/agent-task-42/capture \
--jsonl .agent-runs/agent-task-42/events.jsonl \
-- <program> <args...>
The agent should cancel and wait by run id, never clean up by PID or process
name, and reserve --detach for work with a separate supervisor. See
Agent and automation workflows for a ready-to-paste
instruction, recovery strategy, and the precise limits of cleanup when the
agent itself stops.
Use as a container entrypoint
ENTRYPOINT ["/usr/local/bin/processkit-cli", "run", "--jsonl", "/run/events.jsonl", "--"]
CMD ["/app/worker"]
Exec form preserves signal delivery and avoids a shell wrapper. Ensure /run
is writable and the orchestrator's termination grace exceeds the CLI's grace.
Diagnose a failed start
- Read stderr for the operator message.
- Read JSONL for
spawn_failed,limit_hit, orcontainer_failed. - Read terminal
runner_exitfor runner code and nullable child code. - If a registry entry remains after abrupt runner death, use
listandprune --dry-run; do not kill the recorded pid.
Guide map
| Need | Read |
|---|---|
| argv, cwd, environment | Running commands |
| terminal, stdin, capture | Standard I/O and capture |
| out-of-band lifecycle | Detached runs |
| deadlines and stop behavior | Timeouts and cancellation |
| memory/process/CPU caps | Resource limits |
| OS differences | Platform support |
| agent tool execution | Agent and automation workflows |
| copyable end-to-end scripts | Runnable examples |
| adapter design | Integration guide |
| event fields | JSONL event schema |
Runnable examples
The repository ships four paired POSIX and PowerShell examples. They are executed against the built binary on Linux and Windows in CI, so their public command shapes cannot drift unnoticed.
| Scenario | Use it when | Scripts |
|---|---|---|
| Compatibility preflight | An adapter must fail closed when the installed schema, exit band, or required flags drift. | POSIX · PowerShell |
| Foreground JSONL | A supervisor needs a finite deadline and must parse the terminal lifecycle event. | POSIX · PowerShell |
| Detached supervision | A launcher hands work to the runner, then uses separate inspect and wait clients. | POSIX · PowerShell |
| Label-scoped fleet stop | An operator discovers and cancels only a uniquely labeled fleet snapshot. | POSIX · PowerShell |
See the repository's
examples README
for prerequisites, PROCESSKIT_CLI_BIN selection, and cleanup guarantees.
Running external tools from automation agents
An automation or coding agent can use processkit-cli as the execution boundary
for builds, tests, compilers, package managers, development servers, and other
external tools. The agent does not need a special SDK: it only needs to be
instructed to invoke the tool through the runner instead of starting it directly.
This is especially useful for commands that can spawn workers, become silent, hold output handles open, or outlive the agent action that launched them.
Why use a runner
A direct process launch gives an agent one process handle, but the real workload may be a tree: a compiler can start helpers, a test host can start workers, and a build tool can keep reusable nodes alive. Treating only the root PID as the unit of cleanup creates several failure modes:
- stopping the agent or cancelling its tool call may leave descendants running;
- a descendant that retains stdout or stderr can make the caller wait forever;
- a lost process handle leaves the next agent turn with little reliable state;
- cleanup by process name can terminate unrelated work;
- a reused PID can make delayed cleanup target the wrong process;
- unbounded output and silent hangs consume time, disk, and compute without a useful diagnostic record.
ProcessKit CLI gives the agent a higher-level unit: one run with a contained process tree, explicit deadlines, bounded transcripts, versioned lifecycle events, and a run-id-based control plane.
| Agent concern | Runner capability |
|---|---|
| A tool spawns descendants | The ProcessKit container is the cleanup scope. |
| A command never finishes | --timeout bounds total runtime. |
| A worker becomes silent | --idle-timeout detects missing output activity. |
| The tool's process tree grew or lingered, and nobody was watching | --snapshot-interval records periodic members_snapshot events into the JSONL for post-hoc reading. |
| How much did that step actually cost | The terminal resource_summary event carries the tree's peak memory, total CPU, IO bytes, and peak process count — on every run, with no flag — but which of them carry a number is the mechanism's answer, not the event's promise: each is null where the mechanism does not account for it or kept no record surviving to the read point, never 0. On Linux cgroup v2, memory and CPU are null after a natural exit and populated on a --timeout/cancel/kill ending. Read the per-mechanism matrix before relying on an axis (resource limits). |
| Live output is too noisy | --no-echo suppresses relay while capture continues. |
| Output is needed after failure | --capture-dir keeps bounded stdout/stderr files with hashes and truncation metadata. |
| The agent loses its local process handle | list, inspect, and wait operate through the per-user run registry. |
| Cooperative stop fails | cancel escalates after grace; kill hard-kills the owned container immediately. |
| A caller needs machine-readable evidence | --jsonl records the versioned lifecycle and terminal outcome. |
| Several execution policies are needed | The agent can choose deadlines, capture, environment, resource limits, and foreground/detached supervision per task. |
A drop-in instruction for an agent
The following policy can be placed in an AGENTS.md, system prompt, tool
description, or project automation guide. Adjust paths and default durations to
the repository:
When launching an external command that may run for more than a few seconds, spawn descendants, or require reliable cleanup, execute it through
processkit-cli runrather than starting it directly. Create a private, per-invocation run directory and a unique, non-secret run id. Pass the program and its arguments after--without a shell unless shell syntax is explicitly required. Always write JSONL events, set a total timeout, add an idle timeout when silence indicates a stuck tool, and use bounded capture when output may be needed for diagnosis. Prefer a foreground run. On cancellation or uncertainty, address the run by its run id withinspect,cancel,wait, and finallykillif graceful cancellation does not finish. Never clean up by process name or by a PID copied from earlier output. Use--detachonly when a separate supervisor deliberately owns the continuing run.
The executable can be preflighted once per agent session:
processkit-cli probe --json \
--require-schema-version 1 \
--require-exit-code-band 100-119 \
--require-surface run:--timeout \
--require-surface run:--capture-dir \
--require-surface cancel:--run-id
That preflight is about the executable. If the agent runs on a machine it has not used before — a fresh CI image, a new container, an unfamiliar developer box — the host itself is worth qualifying once as well, since a compatible binary can still meet an unwritable registry directory or a containment mechanism the kernel will not hand out:
processkit-cli doctor --json
It performs one bounded scratch containment of this binary's own harmless child and
reports what the host did, exiting 116 if a phase failed. Unlike probe it has real
(self-cleaning) side effects, so it belongs at session setup rather than before every
tool invocation.
An agent can then adapt this invocation template to each tool:
# Create RUN_DIR first and choose a unique RUN_ID for this invocation.
processkit-cli run \
--run-id "$RUN_ID" \
--timeout 20m \
--idle-timeout 3m \
--grace 5s \
--capture-dir "$RUN_DIR/capture" \
--capture-max-bytes 16m \
--jsonl "$RUN_DIR/events.jsonl" \
-- <program> <args...>
The runner's exit code is the program's exit code on normal completion. The
documented 100-119 band is a useful first signal for runner outcomes, but a
child can coincidentally return a number in the same range. The terminal
runner_exit event is authoritative: its source and separate child_code let
the agent distinguish a failing test from a timeout, cancellation, spawn
failure, or backend failure without guessing from the number alone.
A robust foreground strategy
Foreground execution is the safest default for an agent action:
- Generate a unique run id and create its diagnostic directory.
- Start
processkit-cli runwith an overall timeout and durable JSONL path. - Stream ordinary child output to the agent, or use
--no-echowith bounded capture when live output would consume too much context. - Interpret the returned code together with terminal
runner_exit. - If the tool call is cancelled, send a supported stop signal to the foreground runner and wait for its normal container teardown.
- If completion becomes uncertain, use
list --jsonandinspect --jsonrather than guessing from a PID. - Request
cancel, wait for a bounded interval, and usekillonly as the escalation step. - Retain JSONL and capture files for failed runs; discard them according to the project's artifact policy after success.
This makes retries safer because each attempt has a distinct identity and diagnostic record. A later agent turn can tell whether an earlier attempt is live, finished, stale, or unprobeable before deciding whether to retry.
Strategy examples
Build or test with a hard ceiling
mkdir -p .agent-runs/agent-test-42
processkit-cli run --run-id agent-test-42 \
--timeout 15m --idle-timeout 2m \
--capture-dir .agent-runs/agent-test-42/capture \
--jsonl .agent-runs/agent-test-42/events.jsonl \
-- cargo test --all-features
This protects the agent from both a long-running test suite and a build worker that stops producing output.
Keep verbose output out of the agent context
mkdir -p .agent-runs/agent-build-42
processkit-cli run --run-id agent-build-42 \
--timeout 20m --no-echo \
--capture-dir .agent-runs/agent-build-42/capture \
--jsonl .agent-runs/agent-build-42/events.jsonl \
-- cargo build --release
The agent can read the bounded transcript only when it needs to diagnose a failure. JSONL still provides the lifecycle and terminal result.
Recover after an interrupted agent turn
processkit-cli list --json
processkit-cli inspect --run-id agent-build-42 --json
processkit-cli cancel --run-id agent-build-42
processkit-cli wait --run-id agent-build-42 --timeout 30s --report-outcome
If the bounded wait expires and the run must stop immediately:
processkit-cli kill --run-id agent-build-42
Control commands resolve the registry identity and live IPC endpoint. They do
not target the root PID printed by an earlier observation. The reporting wait
keeps its own exit-code contract and emits the remembered run's terminal
runner_exit fields as JSON; status:"unknown" means the waiter could not
recover that event and must not guess the outcome.
Apply a policy to expensive tools
Where the platform can enforce whole-tree limits, an agent can combine time and resource budgets:
mkdir -p .agent-runs/agent-compiler-42
processkit-cli run --run-id agent-compiler-42 \
--timeout 20m --max-memory 4g --max-processes 64 --cpu-quota 4 \
--jsonl .agent-runs/agent-compiler-42/events.jsonl \
-- compiler <args...>
Limit requests fail before spawn when the active backend cannot enforce them. The agent should treat that as a policy failure, not silently rerun the command without limits.
What happens when the agent stops
The runner substantially improves cancellation and cleanup, but it is important to state the boundary precisely:
- If the agent host cancels the foreground tool call by delivering a supported
stop signal to
processkit-cli, the runner performs its normal whole-container teardown. - Overall and idle timeouts remain active inside a surviving runner, so work is bounded even if the agent no longer observes the call.
- The runner does not monitor an agent identity and cannot infer that an unrelated parent application has disappeared. If the host abandons the runner without terminating it, the run may continue until its own deadline or child completion.
- If the runner itself is killed before normal teardown, the
abrupt_cleanupfield reports the real platform guarantee:whole_treefor the Windows Job Object;direct_child_onlyon Linux, whether the mechanism iscgroup_v2or theprocess_groupfallback;nonefor FreeBSD'sprocess_reaper; andnonefor the macOS/other-Unixprocess_groupfallback. FreeBSD's reaper still covers the whole tree during normal teardown;nonedescribes only cleanup after an abrupt runner death. - A detached run deliberately outlives the launching call. It requires a durable JSONL path, a stable run id, deadlines, and a separate supervisor or recovery policy.
For a host that must guarantee cleanup when an agent session ends, combine the
runner with an explicit host policy: keep run in the foreground, terminate the
runner during agent teardown, require finite deadlines, and reconcile remaining
registry entries before the next session. Do not describe --detach as a leak
prevention mechanism.
Diagnostic workflow for agents
When a command fails or appears stuck, an agent can collect evidence in this order:
- Read the
processkit-cliexit code. - Read the final complete JSONL records, especially
runner_exitand any preceding timeout, cancellation, spawn, container, or limit event —processkit-cli events --run-id <id>(or--file <events.jsonl>once the registry record is gone) renders them without an external tailer, andevents --jsonhands the raw lines to a parser unchanged. - Read bounded stdout/stderr capture only when the program's output is needed.
- If the run may still be live, call
inspect --jsonfor the current member snapshot and containment mechanism. - Use
cancelfollowed by boundedwait; escalate tokillonly when needed. - Use
prune --dry-run --jsonbefore removing confirmed-stale registry state; add the same--label KEY=VALUEfilters to preview and reap when the registry is shared with other projects or orchestrators.
This separates program failure, runner failure, policy failure, and ambiguous external interruption instead of collapsing them into “the command hung.”
See also
- Cookbook — shorter copyable command recipes.
- Running commands — argv, environment, and flag interactions.
- Timeouts and cancellation — deadline and escalation semantics.
- Detached runs — the intentionally out-of-band mode.
- Integration guide — a complete adapter lifecycle.
- Platform support — normal and abrupt cleanup strength.
Running commands
run executes exactly one program inside a ProcessKit container and keeps the
runner alive until the child and its descendants have been reaped. The program
is always passed after --:
processkit-cli run [RUNNER OPTIONS] --jsonl <events.jsonl> -- <program> [args...]
The separator makes the ownership boundary explicit. Everything before --
belongs to processkit-cli; everything after it is the child argv.
Shell-free argv
There is no shell mode. The runner does not expand wildcards, interpolate variables, interpret pipes, or process redirection characters:
processkit-cli run --jsonl run.jsonl -- git status --short
This starts git directly with two arguments. A string such as *.rs, $HOME,
%TEMP%, |, or > remains a literal argument unless the child itself gives
it special meaning.
If a shell is genuinely required, name it as the program so that the security and quoting boundary is visible at the call site:
processkit-cli run --jsonl shell.jsonl -- sh -c 'printf "%s\n" "$HOME"'
processkit-cli run --jsonl shell.jsonl -- pwsh -NoProfile -Command 'Get-Date'
Prefer direct execution whenever possible. It preserves argv boundaries and avoids a second language's quoting and injection rules.
Working directory
By default the child inherits the runner's current directory. Set it explicitly when the caller and payload use different roots:
processkit-cli run --cwd ./service --jsonl service.jsonl -- cargo test
The runner resolves the effective directory before launch and records its
absolute form in run_started.cwd. An invalid or inaccessible directory fails
before the child starts.
Environment
Without environment flags, the child inherits the runner's environment. Five options make the result explicit:
| Flag | Effect |
|---|---|
--env-clear | Start from an empty environment. |
--env-remove KEY | Remove one inherited key. |
--env-file FILE | Read UTF-8 KEY=VALUE lines without placing their values in argv; blank lines and # comments are ignored. |
--env KEY=VALUE | Set or replace one key. The value may contain =. |
--run-id-env KEY | Set one key to this run's final id (see Publishing the run id to the child). |
Application order is fixed, regardless of flag order: clear, remove, env-file,
explicit set, run-id injection. Repeated files are applied in argument order, an
explicit --env therefore wins over every file or removal for the same key, and
--run-id-env — applied last — wins over all of them. A file read,
UTF-8, or syntax failure is SETUP (111) before the child starts.
Every flag that names a key holds it to one rule: non-empty, and no whitespace or
control characters. Diagnostics for malformed entries do not
repeat their values.
processkit-cli run \
--env-clear \
--env PATH=/usr/bin:/bin \
--env CI=true \
--jsonl env.jsonl \
-- /usr/bin/env
Environment values are not copied into lifecycle events or registry records. They can still reach child output, so capture files and echoed output require the same secret-handling discipline as any other process log.
Publishing the run id to the child
--run-id-env KEY sets KEY in the child's environment to this run's final
id — the explicit --run-id when one was given, otherwise the id the runner
generated:
processkit-cli run \
--run-id-env PROCESSKIT_RUN_ID \
--jsonl build.jsonl \
-- ./build.sh
The injected value is the same one run_started.run_id, the registry record,
and every control-plane reply carry, so the child (and anything it spawns) can
correlate its own work with the run without the caller minting an id itself and
passing it twice as --run-id <id> --env KEY=<id> — which duplicates an identity
that can then drift, and rules out a generated id, since a generated id is not
knowable outside the run until the run has started.
The flag is strictly opt-in: omit it and nothing is injected. No key is set by default.
Collisions are settled without reference to flag order:
| Also given | Result |
|---|---|
--env-clear | The injection is applied after the clear, so the key survives. |
--env-remove KEY | The injection is applied after removals, so the key is set. |
--env-file entry for KEY | The injection is applied last, so the run id wins. |
--env KEY=VALUE | Refused at parse time as USAGE (100), before anything runs. |
The last row is the deliberate choice: an explicit --env for the same key asks
for a different value of one variable, and silently discarding what the caller
typed would be worse than refusing the pair. The refusal names the key and never
repeats the value beside it.
"The same key" is decided the way the platform decides it. Windows environment
names are case-insensitive, so --env KEY=value --run-id-env key names one child
variable there and is refused just like the identical spelling; on other
platforms KEY and key are two variables, so that pair is accepted and each
keeps its own value.
The value is correlation data, not a credential or a security proof. It says which run some work belongs to; it does not establish who started that run, carries no authority, and can be set by anything able to write an environment variable. Do not use it to authorize anything — see Threat model.
Run identity
--run-id gives the run a stable application-level name:
processkit-cli run \
--run-id build-2026-07-27 \
--jsonl build.jsonl \
-- cargo build
When omitted, the runner generates an id and writes it in run_started. A
caller that needs to address the live run immediately should provide an id or
read the first event before calling inspect, cancel, kill, attest, or
wait. Whichever way the id was decided, --run-id-env KEY publishes that
final value into the child's environment (see Publishing the run id to the
child).
Explicit ids must contain 1-256 Unicode characters and cannot contain terminal control or invisible formatting characters. The same validation applies to every by-id command, so an unsafe id is rejected before registry access.
Run ids are not operating-system PIDs and are not required to be globally
unique. Two live runs with the same explicit id make by-id control ambiguous;
the client fails rather than choosing one. list shows every record, and the
aggregate --all commands address records rather than resolving a shared id.
Repeat --label KEY=VALUE on run to attach non-secret operator metadata. The
resulting map appears in run_started.labels and list; later values replace
earlier values for a duplicate key. Keys are 1-64 ASCII bytes, begin with a letter
or _, and otherwise contain letters, digits, ., -, or _. Values are at most
256 characters and, like an explicit run id above, cannot contain terminal control
or invisible formatting characters; the same check runs on read, so a label an
older record already carries is dropped from that record's map when it fails, while
the record itself is kept. Repeated label filters on cancel --all, kill --all,
and wait --all combine with logical AND.
Foreground lifecycle
A foreground invocation has four high-level phases:
- Validate arguments and open the JSONL destination.
- Create a ProcessKit container, spawn the child, and publish the registry record/control endpoint.
- Wait for child exit, timeout, signal, or control-plane action while pumping output when the selected I/O mode requires it.
- Tear down the owned container, write terminal events, remove the registry record, and exit.
On normal completion the runner exits with the child's exact code. A runner failure or runner-imposed ending uses the reserved band documented in Exit-code contract.
Child stdout and stderr
The default mode pipes both child streams through ProcessKit and echoes them to
the runner's matching streams. JSONL never goes to stdout. This allows a parent
to treat processkit-cli like the command it wraps while independently tailing
the lifecycle file.
For terminal inheritance, stdin sources, echo suppression, and bounded transcripts, see Standard I/O and capture.
Time bounds and cancellation
processkit-cli run \
--timeout 20m \
--idle-timeout 2m \
--grace 5s \
--jsonl bounded.jsonl \
-- cargo build
The overall and idle clocks are independent. Either expiry enters the shared
soft-stop → grace → hard-kill path. Ctrl-C and cancel use the same teardown
shape but remain distinguishable outcomes. See
Timeouts and cancellation.
Recorded tree snapshots
processkit-cli run \
--snapshot-interval 30s \
--jsonl long-run.jsonl \
-- ./build-fleet
By default the tree's shape is recorded once, right after spawn.
--snapshot-interval re-emits that same members_snapshot event on the given
cadence while the child runs, so a long, quiet, or detached run leaves a recorded
history of how the tree evolved rather than one that is only observable live
via inspect. The periodic events are told apart from the post-spawn one by the
event's reason field (interval vs spawn), and stop as soon as the run's
ending is decided. A sample whose member read fails is still recorded, carrying
read_error: true and an empty member list rather than being dropped. See
JSONL event schema.
It composes with every I/O mode, including --inherit-stdio, and it is forwarded
by --detach — see Detached runs, the scenario it exists for.
Sizing the stream: there is intentionally no ceiling
Before this flag, a run's JSONL file held a fixed handful of events regardless of
how long the run took. A cadence changes that: the file now grows with the run's
duration, and the runner imposes no ceiling, rotation, or sampling on it.
That is a deliberate choice, not an oversight, and it is the opposite of the choice
made for captured child output (--capture-max-bytes with an explicit
--capture-overflow truncate|cancel policy). Three reasons:
- The operator already holds the dial. Output volume is dictated by the child and is unknowable in advance, which is why capture needs a runtime ceiling. Here the volume is a direct, computable consequence of a number the operator typed — a ceiling would only second-guess an explicit instruction.
- Neither truncation policy fits. Silently dropping later snapshots would destroy exactly the end-of-run history the feature exists to record, and ending the run over a diagnostics budget would let an observability option kill the payload it was only meant to watch.
- The bound belongs where the file does. The stream's destination is the
operator's
--jsonlpath; disk budgets, retention, and rotation are properties of that location, not of the runner.
The arithmetic. One snapshot line costs roughly 130 bytes of envelope plus
about 80 bytes per member of the tree, and the run writes
duration / interval of them:
| Run | Cadence | Tree | Lines | Added to --jsonl |
|---|---|---|---|---|
| 1 hour | 30s | 10 | 120 | ≈ 110 KB |
| 8 hours | 1m | 100 | 480 | ≈ 4 MB |
| 24 hours | 1m | 100 | 1 440 | ≈ 12 MB |
| 24 hours | 1s | 100 | 86 400 | ≈ 700 MB |
Choosing an interval. Pick the coarsest cadence that still answers the question you expect to ask; the resolution that matters is usually "when did this change", not "what was it at every instant". Seconds-scale intervals are for short investigations, minutes-scale for day-long or detached runs. As a rule of thumb keep the projected line count in the thousands rather than the millions — with a 100-process tree that is a few tens of megabytes, which any run that was worth supervising can afford.
If the file cannot be written, the runner reports the failure once on stderr and
then stops emitting events for the rest of the run; the run itself continues. A full
disk therefore shows up as a stream that simply ends mid-run, and in a detached run
(whose stderr is null) with no warning anywhere. This is the pre-existing behavior
of the JSONL emitter, not something the cadence introduces, but a long cadence is the
most likely way to meet it — one more reason to size the interval rather than rely on
a ceiling that does not exist.
Resource limits
processkit-cli run \
--max-memory 2g \
--max-processes 64 \
--cpu-quota 2 \
--jsonl limited.jsonl \
-- build-worker
Limits apply to the whole contained tree where the active mechanism can enforce them. Unsupported limits fail before launch instead of silently running unbounded. Platform constraints are collected in Resource limits.
Windows console policy
--create-no-window maps to Windows CREATE_NO_WINDOW and is a no-op on other
platforms. It is intentionally opt-in: a normal run should not suppress a
console the child legitimately expects.
It conflicts with --inherit-stdio, because hiding the child's console and
promising to preserve the caller's terminal are contradictory. It is especially
useful with detached runs, whose detached runner owns no
console for a console child to inherit.
--windows-graceful-ctrl-break opts a Windows console child into ProcessKit's
cooperative CTRL_BREAK tier before Job Object escalation. It is a no-op off
Windows and conflicts with both consoleless modes (--create-no-window, --detach).
Flag interaction summary
| Combination | Result |
|---|---|
--capture-dir + --no-echo | Valid: capture continues; only live echo is suppressed. |
--idle-timeout + --capture-dir | Valid: the shared pump drives both. |
--inherit-stdio + --capture-dir | Rejected: inherited output bypasses the pump. |
--inherit-stdio + --idle-timeout | Rejected: the runner cannot observe activity. |
--inherit-stdio + --snapshot-interval | Valid: snapshots read the container, not the pump. |
--detach + --inherit-stdio | Rejected: the caller is no longer present. |
--detach + --capture-dir | Valid: the detached runner still captures. |
--create-no-window + --inherit-stdio | Rejected on every platform at parse time. |
--windows-graceful-ctrl-break + --create-no-window/--detach | Rejected: CTRL_BREAK needs a shared console. |
Parse-time conflicts are usage failures (100); no child is spawned.
Next steps
- Cookbook for complete command shapes.
- Detached runs for out-of-band supervision.
- JSONL event schema for the machine-readable lifecycle.
- Live-run control plane for
inspect,cancel,kill, andattest.
Standard I/O and capture
I/O mode determines whether ProcessKit can observe the child's streams, whether the child sees a terminal, and whether the runner can capture or suppress output. Choose the mode from the child's actual contract rather than treating terminal inheritance as a cosmetic switch.
Mode matrix
| Mode | Child stdin | Child stdout/stderr | TTY preserved | Pump available |
|---|---|---|---|---|
| Default | Closed / null | Pipe → echo | No | Yes |
--inherit-stdin | Runner stdin | Pipe → echo | Output: no | Yes |
--stdin-file FILE | File until EOF | Pipe → echo | No | Yes |
--inherit-stdio | Runner stdin | Direct inherited handles | If caller has one | No |
| Detached | Null | Pump with echo discarded | No | Yes |
The JSONL file is independent of every mode. Events always go to --jsonl,
never to stdout.
Default: closed stdin, pipe and echo
processkit-cli run --jsonl run.jsonl -- cargo test
The child cannot wait forever for input the runner does not own. Its stdout and stderr are drained through separate pipes and retransmitted to the runner's stdout and stderr.
Because the child sees pipes rather than a terminal, it may disable color, progress bars, cursor movement, and interactive prompts. This is intentional and makes the default suitable for CI and automation.
Inherit all three handles
processkit-cli run --inherit-stdio --jsonl interactive.jsonl -- cargo watch
--inherit-stdio passes the caller's stdin, stdout, and stderr handles directly
to the child. If the caller is attached to a terminal, the child sees that same
terminal. The runner does not create a PTY.
Direct inheritance means there is no output pump. The following features are therefore unavailable and rejected at parse time:
--capture-dirand--capture-max-bytes;--no-echo;--idle-timeout;--create-no-window;--inherit-stdinand--stdin-file;--detach.
That list is bounded by the pump, not by observability in general.
--snapshot-interval composes with every I/O mode, --inherit-stdio
included: a snapshot queries the container's own member list, so it needs no pipes
to observe and conflicts with nothing here. --idle-timeout is the contrast that
makes the boundary concrete — it is rejected above precisely because it can only
re-arm on bytes the pump observes, while --snapshot-interval never looks at the
child's output at all. --jsonl likewise keeps recording the full lifecycle under
inheritance; only the child's bytes are outside the runner's view in this mode.
Terminal signal behavior is platform-dependent. On Unix a child in a separate
foreground process group may receive Ctrl-C directly and report a signal exit;
on Windows both child and runner can observe the console event. Use the local
control-plane cancel command when an orchestrator needs one deterministic
runner-owned cancellation outcome.
Inherit stdin only
processkit-cli run --inherit-stdin --jsonl input.jsonl -- sort
The child reads from the runner's input handle, while stdout/stderr remain on the default pump. The runner neither records nor mediates stdin bytes. This mode does not create a terminal; redirected caller input remains redirected input.
Feed a file
processkit-cli run \
--stdin-file requests.ndjson \
--jsonl importer.jsonl \
-- importer --format jsonl
The checked file is streamed to the child and stdin closes at EOF. File bytes do
not enter argv or lifecycle JSONL. A missing or unreadable file is a pre-spawn
SETUP (111) failure.
--stdin-file and --inherit-stdin are mutually exclusive. For small literal
input, create a file or explicitly invoke a shell/pipeline outside the runner;
the CLI intentionally has no string-to-stdin flag that could encourage secrets
inside process listings or shell history.
Suppress live echo
processkit-cli run \
--no-echo \
--capture-dir ./capture \
--jsonl run.jsonl \
-- noisy-worker
--no-echo removes only the runner's retransmission. Pipes stay open and the
pump continues to:
- drain the child so it cannot block on a full pipe;
- write bounded capture files;
- re-arm
--idle-timeouton observed output; - preserve lifecycle events.
This is the normal embedding mode for an orchestrator that owns presentation and does not want the runner's live copy of child output interleaved with its own logs.
Bounded transcript capture
--capture-dir DIR creates two files:
DIR/
├── stdout.log
└── stderr.log
Streams remain separate and are still echoed unless --no-echo is present.
Each file is capped independently at 8 MiB by default:
processkit-cli run \
--capture-dir ./capture \
--capture-max-bytes 32m \
--jsonl run.jsonl \
-- compiler
The terminal output_captured event reports, per stream:
| Field | Meaning |
|---|---|
path | Capture file location. |
bytes_seen | Total bytes produced, including bytes beyond the file cap. |
bytes_written | Bytes retained in the file. |
sha256 | Digest of the retained bytes. |
truncated | Whether output exceeded the configured cap. |
write_error | Capture write failure, if one occurred. |
Do not infer completeness from file size. Use truncated; a file whose length
equals the cap may still be complete when the stream ended at exactly that
boundary.
Capture setup is all-or-nothing
Both transcripts are opened before either one is emptied, and a setup that fails
part-way — for example a stderr.log path that already names a directory, or a
transcript the runner may not write — rolls back the files and directories that
attempt created. Such a run exits SETUP (111) before the child spawns, and
without leaving an empty stdout.log behind to be mistaken for a real, silent
transcript.
Paths the runner found rather than created are not rollback candidates: the rollback neither removes nor empties them, so a setup that cannot open one of the two transcripts leaves an existing file at either path as it was, contents included, and leaves an existing capture directory in place. A successful setup still starts both transcripts empty — a run owns its capture files — so keep anything worth retaining out of the capture directory.
Two independent bounds
--capture-max-bytes limits retained disk bytes per stream. The ProcessKit
pump also has a fixed 64 MiB in-flight line-assembly ceiling for a single
unterminated line. They protect different resources and neither derives from
the other.
Binary output
The live echo and capture path operate on bytes. Capture files preserve the
bytes written up to the cap; they are not decoded or line-normalized. Runner
diagnostics remain on stderr and are not written into stdout.log or
stderr.log.
When stdout itself is a binary protocol, use --no-echo plus capture, or invoke
the child directly if no lifecycle control is needed. The JSONL stream never
shares stdout, so it cannot corrupt a binary child stream.
Secret hygiene
Argv is redacted in events by default, but child output is not. A command may echo credentials, environment values, file contents, or tokens into live output and capture files. Apply access controls and retention policies to:
- the JSONL file when
--argv-rawis used; stdout.logandstderr.log;- any parent process that records runner stdout/stderr.
Diagnosing a degraded terminal UI
If a tool loses color, switches to plain progress messages, or refuses an
interactive prompt under the default mode, confirm that it requires a TTY. Use
--inherit-stdio only when the caller already owns a real console/terminal and
does not need capture or idle detection.
ProcessKit CLI does not emulate a PTY. A program that specifically requires PTY semantics cannot obtain them from this runner today.
See also
- Running commands — argv, cwd, environment, and run ids.
- Timeouts and cancellation — how output drives the idle deadline.
- JSONL event schema — normative
output_capturedfields. - Troubleshooting — terminal and capture symptoms.
Detached runs
--detach transfers a run to a detached copy of processkit-cli and returns
once that copy has provably started the child. It is intended for an
orchestrator that wants to launch now and supervise later from a different
process.
processkit-cli run \
--detach \
--run-id build-42 \
--capture-dir ./build-42-output \
--jsonl build-42.jsonl \
-- cargo build
processkit-cli inspect --run-id build-42 --json
processkit-cli wait --run-id build-42
What successful return proves
The launching invocation waits until run_started is readable in the JSONL
file. That event is written after the detached runner has:
- created the ProcessKit container;
- published the registry record and control endpoint;
- spawned the child.
After a successful return, the run is discoverable by list, addressable by
inspect / cancel / kill, and waitable by wait. A timeout in the detached
startup handshake kills the detached copy instead of leaving an unreported run
behind.
Exit-code semantics change
Foreground run forwards the child's exact exit code. Detached launch cannot:
the launching process exits while the child is still running.
| Detached launch result | Launcher exit code |
|---|---|
Run reached run_started | 0 |
| Program could not spawn | SPAWN (101) |
| Container could not be created | BACKEND (102) |
| JSONL or setup failed | SETUP (111) or the applicable reserved code |
The child's eventual result is in the terminal runner_exit event. A detached
caller must treat 0 as “started,” never as “payload succeeded.” See
Exit-code contract.
Output behavior
The detached runner owns none of the launching caller's standard handles. It
uses the same pump as --no-echo: child output is drained, but not retransmitted
to a terminal that no longer exists.
These features remain active:
--jsonl(still required);--capture-dirand--capture-max-bytes;--idle-timeout;--snapshot-interval(see below);- overall timeout, grace, environment, cwd, and resource limits.
--inherit-stdio and --inherit-stdin are rejected because there is no caller
left to provide those handles. Use --stdin-file when a detached payload needs
finite input.
Recorded tree snapshots without a live watcher
--snapshot-interval <duration> works under --detach — the launcher forwards it
to the detached copy like any other run flag — and this is the mode it was designed
for. A detached run is precisely the one nobody is watching with inspect at the
interesting moment, so the periodic members_snapshot events are the only record
of how the tree evolved between spawn and teardown.
processkit-cli run --detach \
--run-id nightly-index \
--snapshot-interval 5m \
--jsonl /var/lib/my-orchestrator/runs/nightly-index.jsonl \
-- indexer
Two consequences are specific to detaching:
- The JSONL file is the only diagnostic channel. The detached runner's
stdin/stdout/stderr are
null, so every warning the foreground runner would print reaches nobody. This is why a failed member read is recorded in the stream as amembers_snapshotwithread_error: truerather than merely warned about (see JSONL event schema). It is also why a JSONL write failure — a full disk, most plausibly — is invisible: the runner disables event logging after one unseen warning and the stream simply stops mid-run. - Detached runs are the longest ones, so size the cadence first. The stream
grows as
duration / interval; a day-scale run wants minutes, not seconds. The arithmetic and the recorded decision not to impose a ceiling are in Running commands.
Windows
The detached runner is created with DETACHED_PROCESS and owns no console.
A console child may therefore receive a new visible console from Windows. Pass
--create-no-window for a headless payload:
processkit-cli run --detach --create-no-window `
--run-id worker `
--jsonl worker.jsonl `
-- worker.exe
A detached runner cannot escape a Job Object that already contains its caller. If the outer job is kill-on-close, its owner still controls the detached run's ultimate lifetime.
Unix
The detached copy starts a new session with setsid, so terminal hang-up and
Ctrl-C from the launching session no longer target it. After the launcher
exits, the system's init process adopts the detached runner.
This changes session ownership, not ProcessKit's containment mechanism. The detached run reports the mechanism it actually obtained:
| Platform / obtained mechanism | mechanism | abrupt_cleanup if the runner dies before teardown |
|---|---|---|
| Windows Job Object | job_object | whole_tree |
| Linux cgroup v2 | cgroup_v2 | direct_child_only |
| Linux process-group fallback | process_group | direct_child_only |
| FreeBSD process reaper | process_reaper | none |
| macOS / non-FreeBSD Unix process group | process_group | none |
FreeBSD is distinct from the POSIX process-group fallback: its
process_reaper covers the whole reaper tree during normal teardown, including
descendants that call setsid or double-fork. It has no owner-death cleanup
primitive, so abrupt_cleanup remains none if the detached runner is killed
before it can perform teardown. The table's Linux value is direct_child_only
for both the cgroup and process-group cases; the last column applies only when
an abrupt death prevents the runner from reaching its cleanup code.
Supervision pattern
Use one durable JSONL path and one stable run id per detached run:
run_id=nightly-index
events="/var/lib/my-orchestrator/runs/${run_id}.jsonl"
processkit-cli run --detach --run-id "$run_id" --jsonl "$events" -- indexer
processkit-cli wait --run-id "$run_id" --timeout 30m
The run id is an address while the runner is live. The JSONL file is the durable record after the registry entry has been removed on clean completion.
Reading the stream back — events
events closes the detach loop: it reads that JSONL stream back without an
external tailer or JSON filter, and without the caller having to look the locator
up first.
# Watch a detached run as it happens, until its terminal `runner_exit`.
processkit-cli events --run-id nightly-index --follow
# What happened, after the fact — the registry record is gone, the file is not.
processkit-cli events --file "$events"
# The runner's own bytes, for a machine (line-for-line, nothing re-serialized).
processkit-cli events --file "$events" --json
# Conformance-check a stream against this binary's embedded event schema.
processkit-cli events --file "$events" --validate
Naming the stream. --run-id resolves the locator through the registry — the
same jsonl field list --json publishes — which works while a record exists,
live or merely not yet reaped. --file reads a path directly and is the answer
once the record is gone (a clean exit deletes its own record, and prune reaps
what an abrupt death left), or for a stream this registry never knew about. The two
are mutually exclusive and exactly one is required; passing both is a USAGE (100)
error rather than a silent choice between them. When --run-id names no single
readable stream — no record, several records naming different streams, or a run
started without --jsonl — the refusal is the same CONTROL (103) verdict every
other by-run-id command gives.
Where a follow stops. --follow polls the file for growth (there is no
notification to subscribe to for either a file or a runner's death) and returns at
the first of: the terminal runner_exit event, or the registry reporting the run
over with the stream no longer growing. The second case is what an abruptly killed
runner leaves behind — it never got to write its terminal event — and it is
explained on stderr rather than passed off as a complete stream. --follow never
invents a deadline of its own: it is bounded by the run's lifetime, so a caller
that wants a wall-clock bound imposes one itself (wait --timeout alongside it, or
a bound on the whole invocation).
Read-only, like list/wait. events opens the registry read-only, never
connects to a run's control transport, and mutates nothing — so following a
production run cannot disturb, end, or even be noticed by it.
Shutdown sequence
For one detached run:
processkit-cli cancel --run-id nightly-index
processkit-cli wait --run-id nightly-index --timeout 30s
processkit-cli prune
For all runs confirmed live at one instant:
processkit-cli cancel --all
processkit-cli wait --all --timeout 30s
processkit-cli prune --dry-run
processkit-cli prune
The --all target set is a snapshot. A run registered afterward is out of
scope; repeat the sequence when the surrounding system allows concurrent new
launches.
Recovery after orchestrator restart
- Run
list --jsonto discover registry entries. - Read each durable JSONL file back with
events --file <path>(orevents --run-id <id> --followfor one still live), instead of hand-rolling a tailer: it hands out only complete lines, so a run being appended to while it is read never yields a half-written event. - Use
inspect --jsononly for entries confirmed live. - Treat stale records as evidence of abrupt runner loss, not as permission to address the recorded PID.
- Preview
prune --dry-run --json, then prune confirmed-stale entries.
See also
- Run registry — liveness and stale-entry semantics.
- Live-run control plane — control acknowledgements.
- Integration guide — adapter startup and recovery.
- Platform support — abrupt-runner-death guarantees.
Timeouts and cancellation
ProcessKit CLI has separate outcomes for a child exit, overall timeout, idle timeout, local cancellation, control-plane cancellation, and immediate kill. They share teardown machinery without collapsing their meaning.
Overall deadline
processkit-cli run --timeout 10m --jsonl run.jsonl -- cargo test
--timeout bounds wall-clock lifetime from launch. When it expires, the runner
enters soft-stop → grace → hard-kill teardown and exits TIMEOUT (106). The
child did not choose that code; the terminal runner_exit.child_code is
therefore separate.
Idle deadline
processkit-cli run \
--idle-timeout 90s \
--capture-dir ./capture \
--jsonl run.jsonl \
-- build-worker
--idle-timeout measures silence, not total duration. Every observed stdout or
stderr chunk re-arms the clock. A chatty ten-hour process can remain healthy,
while a stuck worker that produces no output for 90 seconds is torn down.
Idle detection requires the output pump. It conflicts with
--inherit-stdio, but composes with capture and --no-echo. Overall and idle
deadlines may be used together; the first to expire wins.
Both expiries use code 106. The JSONL timeout.reason field distinguishes
overall from idle.
Duration grammar
Durations are non-negative integers with an optional unit:
| Input | Meaning |
|---|---|
30 | 30 seconds |
500ms | 500 milliseconds |
5s | 5 seconds |
2m | 2 minutes |
1h | 1 hour |
0 is rejected for overall, idle, and wait deadlines because it expires before
the first meaningful check. --grace 0 is valid and requests no pause between
the soft and hard tiers.
Grace window
processkit-cli run \
--timeout 20m \
--grace 5s \
--jsonl run.jsonl \
-- service
The runner first asks the tree to stop, waits up to the grace window, and then
hard-kills survivors through the owning container. cleanup_finished reports
whether a soft request was delivered, was unsupported, or failed. Its shutdown
object also records the pre-attempt soft_stop_scope and ProcessKit's
ShutdownReport: observed member counts, early drain, escalation, and elapsed time.
Grace is a maximum opportunity for cooperative exit, not a promise that every platform has a signal capable of reaching the child.
Local stop signals
The foreground runner catches:
| Platform | Sources |
|---|---|
| All | Ctrl-C where delivered to the runner |
| Unix | SIGTERM, SIGHUP |
| Windows | Ctrl-Break, console close, logoff, shutdown |
These use the local CANCELLED code (107). The cancelled.source field names
the exact source (ctrl_c, sigterm, sighup, ctrl_break, ctrl_close,
ctrl_logoff, or ctrl_shutdown).
An inherited Unix terminal can deliver Ctrl-C directly to the foreground
child group. In that case the runner may report a child signal exit rather than
a runner-owned 107. Use the control plane when a supervisor needs one stable
outcome independent of terminal routing.
Control-plane cancel
processkit-cli cancel --run-id build-42
cancel reaches the live runner over local IPC and requests the same soft →
grace → hard teardown. The run exits CONTROL_CANCELLED (108) and writes a
cancelled event whose source identifies the control command.
The client returns only after the runner acknowledges the command. An
acknowledgement means the run accepted the request, not that teardown has
already completed; use wait as the barrier.
Immediate kill
processkit-cli kill --run-id build-42
kill skips soft stop and grace, hard-kills the container immediately, and
produces CONTROL_KILLED (109) plus a killed event. Use it when cooperative
shutdown is not wanted or after an external policy has already spent its grace
budget.
Aggregate cancellation
processkit-cli cancel --all
processkit-cli wait --all --timeout 30s
--all takes one snapshot of entries confirmed live. It addresses each by
record identity and remembered endpoint, so duplicate run ids do not make the
aggregate ambiguous. A newly registered or initially unprobed run is outside
that invocation's target set.
The command prints a JSON array with per-target accepted, already_gone, or
failed status. Any unresolved target makes the aggregate exit CONTROL
(103); an empty snapshot is successful.
Windows soft-stop behavior
A Job Object has no POSIX signal. ProcessKit first tries a best-effort soft
close of eligible windowed members. run --windows-graceful-ctrl-break also
launches the direct console child as an addressable console process-group leader,
so ProcessKit can send CTRL_BREAK before the grace window. The flag is a no-op
off Windows and conflicts with --create-no-window/--detach because those modes
have no shared console through which to deliver the event.
The runner probes soft_stop_scope before delivery and reports none when no
eligible target exists. It never labels the grace as served by the child when
nothing was delivered.
Windows gives a console-close handler only a short system deadline. For that source the runner caps an excessive grace request so it can finish teardown and write terminal events before the OS ends the process.
Unix soft-stop behavior
The soft tier sends SIGTERM to the contained process group or cgroup members,
then hard-kills survivors after grace. A descendant that deliberately escapes a
POSIX process group is outside that weaker mechanism; the runner reports the
active mechanism so an adapter can decide whether that limitation is acceptable.
Event ordering
A runner-imposed ending normally emits:
timeout | cancelled | killed
resource_summary
cleanup_started
cleanup_finished
runner_exit
resource_summary — what the tree consumed — is part of the tail on every ending,
not just a natural exit: the child ran, so it has consumption to report. It sits between
the reason event and cleanup_started because what it reads lives in the container that
cleanup_finished hard-kills. A run that also requested a resource cap emits
limit_evidence immediately before it, from the same read point.
On these endings the read happens before the soft stop, while the tree is still
running — which is why a forced ending is the better case for the numbers, not the
worse one. On Linux cgroup v2 it is in fact the only case that populates
peak_memory_bytes and total_cpu_ms at all: there they are summed over the members
live at the read point, so a child that exited on its own leaves both null
(resource limits, consequence 5).
members_snapshot is not part of that tail: one is emitted right after
run_started, and — with the opt-in --snapshot-interval <duration> cadence —
more of them while the child runs, but the cadence stops the instant the ending
is decided, so none can appear between the reason event and runner_exit.
Exact presence and ordering rules are normative in JSONL event schema.
See also
- Exit-code contract — numeric outcomes.
- Live-run control plane — request/ack protocol.
- Platform support — containment caveats.
- Troubleshooting — failures and stale records.
Resource limits
Three run flags request kernel-enforced limits over the whole contained tree:
| Flag | Scope | Grammar |
|---|---|---|
--max-memory SIZE | Total tree memory | Bytes, or k / m / g binary units |
--max-processes N | Live processes in the tree | Positive integer |
--cpu-quota CORES | CPU relative to one core | Finite number greater than zero |
processkit-cli run \
--max-memory 1g \
--max-processes 32 \
--cpu-quota 1.5 \
--jsonl limited.jsonl \
-- compiler-worker
Omitting a flag leaves that resource unbounded. The runner never invents a default cap.
Fail closed, before spawn
A requested limit is a requirement, not a hint. If the active platform and
containment mechanism cannot enforce it, run:
- does not spawn the child;
- emits
limit_hitnamingmemory,processes, orcpu; - emits
container_failedand terminalrunner_exit; - exits
BACKEND(102).
An adapter must inspect limit_hit; code 102 also covers unrelated backend
failures.
Platform matrix
| Mechanism | Memory | Process count | CPU | Notes |
|---|---|---|---|---|
| Windows Job Object | Yes | Yes | Yes | Whole-job enforcement. |
| Linux cgroup v2 with usable controllers | Yes | Yes | Yes | Requires controller delegation at the effective root. |
| Linux process-group fallback | No | No | No | Fails before spawn. |
| FreeBSD process reaper | No | No | No | ProcessKit 3.3 reports unsupported before spawn. |
| macOS / non-FreeBSD BSD process group | No | No | No | Fails before spawn. |
The run_started.mechanism field tells an observer what was actually obtained.
Linux controller requirements
The current ProcessKit implementation can apply limits when the runner is a direct member of the real cgroup-v2 root and can enable the required controllers. This is common in a minimal init environment, but not in:
- a normal systemd user session, scope, or service;
- ordinary Docker or Kubernetes containers;
- typical GitHub Actions jobs;
- any environment that delegates a nested cgroup without writable controllers.
In those environments a limit request fails rather than falling back to an unenforced process-group run.
Linux process-count caveat
The cgroup pids controller reliably bounds descendants forked inside the
cgroup. It does not reject additional top-level launches into the same group in
the same way Windows Job Object active-process limits do.
For ProcessKit CLI, which launches one root per run, interpret
--max-processes as a cap on that tree's own growth. It protects against a
contained fork explosion; it is not a general admission controller for unrelated
launchers.
Size parsing
Units are binary:
| Input | Bytes |
|---|---|
1048576 | 1,048,576 |
512k | 524,288 |
256m | 268,435,456 |
2g | 2,147,483,648 |
Zero, malformed values, and overflow are usage failures (100). CPU quota also
rejects negatives, NaN, and infinities.
Applied limit versus observed limit hit
limit_hit proves only that a requested limit could not be applied before
launch. It does not describe a successfully installed cap firing later, and
its payload and meaning remain unchanged for compatibility.
With processkit 3.2.0, a run that requested at least one cap emits a separate
limit_evidence event after the child ending is known and immediately before
the teardown pair. It carries one verdict for each axis (memory, processes,
and cpu):
This event exists only when ProcessGroup::with_options successfully creates a
container. On FreeBSD's process reaper, on macOS and other BSD process groups,
and on the Linux process-group fallback, ProcessKit returns ResourceLimit
during group creation because that mechanism has no
whole-tree limit primitive. The runner therefore emits the existing pre-spawn
limit_hit event and its backend-error tail, but there is no group from which to
read limit_evidence; the event is not emitted on that path. unknown is
reserved for a successfully created group whose active mechanism cannot provide
the post-run answer.
- Three-valued, never a boolean. The JSONL
limit_evidenceevent representsTripped/NotTripped/Unknownastripped/not_tripped/unknown.Unknownnever collapses into "did not fire": that would silently misreport a platform's inability to answer as a clean run on every axis where evidence is unavailable. - Authoritative on Linux cgroup v2 only. There,
Tripped/NotTrippedcome from real kernel counters (memory.events'oom,pids.events'max,cpu.stat'snr_throttled). On Windows Job Object and on a POSIX process group that was successfully created, a capped axis instead reportsUnknownas a measured result, not an omission — those mechanisms keep no post-mortem record that a cap fired. In practice the POSIX limit request fails during group creation as described above, so POSIX fallback runs have no post-run event at all. Windows is a first-class platform for this CLI, and runtime limit attribution remainsunknownthere; this closes the gap on Linux cgroup v2 only. - Readable only while the container still exists. The evidence lives in
the container itself, so the runner reads it before
ProcessGroupis dropped or consumed by shutdown.limit_evidencetherefore precedescleanup_started, preserving thecleanup_started→cleanup_finishedordering.
The event is absent when no cap was requested. On an event that is present,
uncapped axes are reported as not_tripped by ProcessKit because nothing was
in force that could fire; unknown is reserved for a missing authoritative
answer from the active mechanism.
None of this changes what limit_hit means today: it stays the pre-spawn
"the requested cap could not be applied" event, and a cap-dependent adapter
still treats it as a hard failure signal in that scope (see
docs/schema.md).
What the tree consumed
limit_hit and limit_evidence are both about a cap: one says it could not be
installed, the other whether it fired. Neither says how much the tree actually used.
That is the separate resource_summary event, which every run that spawned a child
emits exactly once — no flag, no cap required, every platform (normative field list:
docs/schema.md). With processkit 3.3.0 it takes one
ProcessGroup::stats() reading of whatever the active mechanism accounts for — a Job
Object's own accounting block on Windows; on Linux the cgroup's io.stat/pids.peak
counters plus a per-member /proc sum for memory and CPU, which is why the matrix below
ties those two axes to the read point — at the same place in the teardown tail as
limit_evidence and immediately after it.
What it does not prove
- It is not limit attribution. A
peak_memory_bytesat or near a requested--max-memoryis not evidence the cap engaged; onlylimit_evidence'strippedis, and only where that verdict is authoritative (Linux cgroup v2). Reading a high peak as "it was capped" would invent an attribution the kernel never made. - It is not a time series. One reading at the end of the run, not a sample. It
cannot say when the peak occurred, or what the tree looked like at any earlier
moment.
run --snapshot-intervalanswers the second of those — the tree's shape over time, and it is opt-in precisely because that has an ongoing cost — but it carries no resource numbers, so nothing in this stream is a consumption series. - It is not a per-process breakdown. Every number is whole-tree. A member's individual share is not recoverable from it.
- It does not measure disk. Bytes written to a capture file are
output_captured.bytes(seedocs/io-and-capture.md); the IO counters here are the tree's own read/write traffic and are a different quantity that happens to share a unit.
Platform matrix
Every measurement is independently nullable, and null means this mechanism, at this
read point, does not account for it — never zero, and never a number the runner
improved by taking a maximum over its own periodic reads (that would report when the
runner looked, not what the tree did). Two of the axes below depend on the read point and
not on the platform alone, so the "mechanism" column is a necessary condition for a
number, never a sufficient one. This matrix is normative; do not read completeness into
the event's field list.
| Mechanism | peak_memory_bytes | total_cpu_ms | io_read_bytes / io_write_bytes | peak_process_count |
|---|---|---|---|---|
| Windows Job Object | Yes — peak committed memory (PeakJobMemoryUsed) | Yes — every process ever in the job, terminated ones included | Yes — IO_COUNTERS, all read/write traffic (file, pipe, device) | Always null |
| Linux cgroup v2 | Only for members live at the read point — the sum of their VmHWM; null once the tree has exited, which is the natural-exit case (consequence 5) | Only for members live at the read point; null once the tree has exited (consequence 5) | Only with the io controller enabled — io.stat rbytes/wbytes, block layer only | Only with the pids controller enabled — pids.peak |
| Linux process-group fallback | null | null | null | null |
| FreeBSD process reaper | null | null | null | null |
| macOS / non-FreeBSD BSD process group | null | null | null | null |
Five consequences that are easy to misread as bugs:
-
peak_process_countis alwaysnullon Windows. A Job Object keepsActiveProcesses(how many are in it now) andTotalProcesses(how many were ever assigned to it). Neither is a high-water mark of concurrency, and this runner will not synthesize one from its ownstats()calls. -
IO bytes are always
nullon FreeBSD, macOS, non-FreeBSD BSDs, and the Linux process-group fallback. Those mechanisms contain a tree without accounting for it. On Linux cgroup v2 they additionally require theiocontroller to be enabled for the group's cgroup — which is what makesio.statexist at all. This CLI does not enable it:processkitenables exactly the controllers a requestedResourceLimitsneeds (memory,pids,cpu) and no others, soiois on only if the environment already delegated and enabled it. The same is true ofpids.peak, which needs thepidscontroller — in practice that means a run with--max-processes. -
The IO counters are not comparable across platforms. A Job Object counts bytes moved by every read/write the job's processes issued, whatever the target. A cgroup's
io.statcounts only what crossed the block layer, so a read served from the page cache, or any traffic over a pipe, socket, or tmpfs, is simply not in it. The same workload legitimately reports very different numbers on the two, and neither is wrong. Compare a series only against itself on one mechanism; readrun_started.mechanismto know which one produced it. -
On Linux,
io_write_bytescan undercount. A write reaches the block layer when the kernel writes the page back, which may be after the member that dirtied it exited — or never, if the page is still dirty when the group is torn down. A short write-and-exit run can report fewer bytes than it handed towrite(2). -
On Linux cgroup v2,
peak_memory_bytesandtotal_cpu_msarenullafter a natural exit — the most common ending, so this is the ordinary reading there and not a corner case. Unlike the two axes beside them, these are not counters the cgroup keeps: ProcessKit sums them out of/proc/<pid>over the members listed incgroup.procsat the moment of the read, and it does so whether or not a cap was requested (memory.peakandcpu.statare never consulted, so--max-memorydoes not change it). A process leavescgroup.procsas soon as it exits — a zombie never appears there — and this read happens after the ending is decided, so on the natural-exit path the child has already exited and been reaped and there is normally nothing left to sum. Both axes then come backnullwithread_error: false, which is a correct answer about that read point and not a failure. Two corollaries follow, and the second is the useful one:- A child that leaked a descendant which outlived it makes the two axes
non-
null— but they then cover only the survivor, arbitrarily far below what the tree as a whole used. A small number here is not a whole-tree total. - On a runner-imposed ending (
timeout,cancelled,killed,output_overflow) the read happens before the soft stop, while the tree is still running, so there both axes are populated. If a workload's memory or CPU is what you need on Linux, a run the runner itself ended is the only place this stream carries it: no other event does,members_snapshot(including--snapshot-interval's samples) carrying member identity only —pid,ppid,name,start_time.
Neither Windows nor Linux's other two axes are affected. A Job Object's accounting block outlives the processes charged to it, so its memory and CPU cover the whole job whatever the ending;
io_read_bytes/io_write_bytesandpeak_process_counton Linux are cgroup-kept counters and likewise survive their members — whether they are present is the controller question in consequence 2. - A child that leaked a descendant which outlived it makes the two axes
non-
peak_memory_bytes and total_cpu_ms are likewise platform-specific in meaning
(committed memory vs. resident high-water mark; the whole job's history vs. only the
members live at the read point), so the same caution applies to them: comparable
within a mechanism, not across.
A failed read is in the stream, not missing from it
If stats() fails, the event is still emitted with read_error: true and every
measurement null. Check that flag before drawing a conclusion from a null, because
an all-null summary is also a correct success — it is exactly what row 3 and row 4
of the matrix above report by design, and what row 2 reports as well for the
commonest case of all: a plain run on Linux cgroup v2 that ended by its child exiting
has no live member left to sum for memory and CPU (consequence 5) and — unless the
environment itself enabled the io controller, and --max-processes the pids one —
no container counter to answer for the other three (consequence 2), so all five
measurements are null with read_error: false. An all-null summary therefore
carries no information about whether the read worked, on any platform: read_error is
the only thing that separates "this mechanism, at this read point, accounts for
nothing" from "the read failed", and a foreground run's stderr warning does not help a
--detach run, whose stderr is null.
Preflighting it
resource_summary is present on every build that has it, so a consumer pins the
event, not a platform:
processkit-cli probe --json --require-surface run:resource-summary
That token's presence guarantees the event will be in the stream. It does not
promise any particular axis is populated — that is what the matrix above governs, and it
follows from run_started.mechanism, plus (on Linux cgroup v2, for memory and CPU) from
how the run ended. Never from a probe token.
Limits and outer containers
An outer Docker/Kubernetes/systemd limit and a ProcessKit CLI limit are separate
layers. The stricter layer wins, but only the outer runtime can explain its own
termination reason. If the outer runtime kills the runner itself, the
platform-specific abrupt_cleanup contract applies.
Use outer-runtime limits when they are the authoritative scheduler policy. Use CLI limits only where ProcessKit can install them and the adapter needs the limit request attached to this specific run.
Operational checklist
- Run
probe --jsonto verify the flags exist. - Launch a harmless limited command in the real deployment environment.
- Read
run_started.mechanismrather than assuming cgroup availability. - Treat pre-spawn
limit_hitas a hard configuration failure. - For a successfully created capped run, read
limit_evidencefor post-run attribution and preserveunknownas distinct fromnot_tripped. For a pre-spawnlimit_hit, do not expect post-run evidence: the container did not exist to be queried. - For actual consumption, read
resource_summary— present on every run that spawned a child, capped or not. Checkread_errorfirst, then treat eachnullas "this mechanism does not account for it at this read point" per the matrix in "What the tree consumed", never as zero, and never compare its IO counters across platforms. On Linux cgroup v2 in particular, do not expectpeak_memory_bytesortotal_cpu_msfrom a run whose child exited on its own — there they arenullby construction (consequence 5). - Keep a separate outer-runtime signal for limits imposed outside this run.
See also
- Platform support — mechanism selection.
- Running in containers — cgroup delegation in images and orchestrators.
- JSONL event schema — normative event fields; see also
resource_summaryfor what the tree consumed. - Exit-code contract.
Platform support
ProcessKit CLI exposes one command surface across Windows, Linux, macOS, and source-built BSD targets, but it does not pretend their kernel containment primitives are equivalent. Every run reports both the mechanism it obtained and what happens if the runner dies before it can execute teardown.
Release targets
| Operating system | Target triple | Distribution |
|---|---|---|
| Windows x86_64 | x86_64-pc-windows-msvc | Prebuilt release archive |
| Windows Arm64 | aarch64-pc-windows-msvc | Prebuilt release archive |
| Linux x86_64 glibc | x86_64-unknown-linux-gnu | Prebuilt release archive |
| Linux Arm64 glibc | aarch64-unknown-linux-gnu | Prebuilt release archive |
| Linux x86_64 musl | x86_64-unknown-linux-musl | Static prebuilt archive |
| Linux Arm64 musl | aarch64-unknown-linux-musl | Static prebuilt archive |
| macOS Apple Silicon | aarch64-apple-darwin | Prebuilt release archive |
Other Rust-supported targets may build from source, but are not part of the release-artifact or CI promise unless listed here.
Mechanism and abrupt cleanup
run_started contains two independent fields:
mechanism: how normal teardown addresses the tree;abrupt_cleanup: what the kernel guarantees when the runner never executes normal teardown.
| Platform / obtained mechanism | mechanism | abrupt_cleanup |
|---|---|---|
| Windows Job Object | job_object | whole_tree |
| Linux cgroup v2 | cgroup_v2 | direct_child_only |
| Linux process-group fallback | process_group | direct_child_only |
| FreeBSD process reaper | process_reaper | none |
| macOS / non-FreeBSD Unix process group | process_group | none |
Normal completion, timeout, caught cancellation signals, and control-plane
actions still run the full owned-container teardown on every platform. The
last column applies only to a crash, SIGKILL, outer Job termination, or an
equivalent event that prevents the runner from reaching its cleanup code.
Windows Job Objects
A run owns a Job Object configured for kill-on-close. Child processes and their descendants are assigned to the job, and closing the last owning handle reaps the whole job.
Properties:
- strongest abrupt-owner-death guarantee (
whole_tree); - whole-tree memory, CPU, and active-process limits;
- member snapshots through Job queries;
- atomic hard kill on timeout/cancel/kill teardown;
- best-effort graceful close before hard kill where a member exposes a window;
- opt-in
CTRL_BREAKfor console children via--windows-graceful-ctrl-break.
Nested jobs
Modern Windows versions allow nested Job Objects when the outer job's policy permits it. A CI runner, service host, or container runtime may already place the CLI in a job. The E2E suite covers nested-job launch behavior, but an outer job remains authoritative and can terminate the runner plus its child job.
Console behavior
The runner does not allocate a console. A normal child inherits what the
platform would ordinarily provide. --create-no-window is an explicit
Windows-only request and conflicts with --inherit-stdio.
--windows-graceful-ctrl-break keeps the shared console, creates a child console
process group, and lets ProcessKit address that group during graceful teardown. It
therefore conflicts with --create-no-window and detached execution.
Linux cgroup v2
When cgroup v2 is available and delegated, ProcessKit creates a run cgroup and moves the child tree into it. Normal hard teardown addresses all members in the cgroup.
If controller/delegation requirements are not met, an unrestricted run may
fall back to a POSIX process group and reports process_group. A run that
explicitly requests resource limits fails instead, because falling back would
discard a required policy.
Linux parent-death signaling kills the direct child if the runner dies
abruptly. The cgroup itself persists and does not automatically kill every
grandchild, hence direct_child_only rather than whole_tree.
FreeBSD process reaper
On FreeBSD, ProcessKit 3.3 uses the kernel's procctl(2) process reaper.
Normal teardown and member discovery cover the whole reaper tree, including
descendants that call setsid or double-fork. This is stronger than the POSIX
process-group fallback for membership and kill scope, so it reports
process_reaper rather than process_group.
FreeBSD does not provide the resource-limit or statistics primitives used by
ProcessKit's other backends. A run that requests a resource cap fails before
spawn with the normal limit_hit/backend-error sequence; an unrestricted run
reports resource_summary with all measurements null and
read_error: false. Parent-death cleanup remains none: procctl(2) gives
normal teardown a whole-tree scope, but ProcessKit 3.3 has no supported
owner-death primitive for this path.
macOS and non-FreeBSD Unix
The runner uses a POSIX process group. Normal teardown signals and kills the group, covering ordinary descendants that remain in it.
Limitations:
- a descendant can deliberately escape with
setsid/ double-fork; - the backend cannot provide whole-tree resource limits;
- a just-exited member may still appear briefly during diagnostics;
- no portable owner-death primitive reaps the group after an uncatchable runner
death, so
abrupt_cleanupisnone.
The mechanism field exists so an adapter can reject this weaker contract when its workload requires stronger containment.
Capability matrix
| Capability | Windows Job | Linux cgroup v2 | FreeBSD process reaper | POSIX process group |
|---|---|---|---|---|
| Normal whole-tree hard teardown | Yes | Yes | Yes | Group members only |
| Whole-tree abrupt runner-death reap | Yes | No | No | No |
| Enriched member snapshots | Yes | Yes | Backend-dependent | Backend-dependent |
| Memory limit | Yes | With controller access | No | No |
| Process-count limit | Yes | With controller access | No | No |
| CPU quota | Yes | With controller access | No | No |
| Soft-stop request | Window close plus opt-in console CTRL_BREAK | SIGTERM | Whole-tree hard-stop | SIGTERM |
| Direct inherited terminal | Yes | Yes | Yes | Yes |
| PTY emulation | No | No | No | No |
CI coverage
The repository's GitHub Actions matrix builds and tests Windows, Linux, and macOS, including Arm runners where available. FreeBSD is source-build-only and is not currently a CI or release-artifact target. The opt-in E2E tier drives the built binary through real containment scenarios: leaked descendants, nonzero roots, abrupt runner death, nested Windows jobs, PID reuse, real console/terminal I/O, and cancellation.
That matrix proves the repository's release contract; it does not prove that a particular Linux deployment grants cgroup controllers. Test mechanism selection and limit application in the actual service/container environment.
Confirming the table on the machine in front of you
Everything above is what a platform can give. Which of it this particular host
actually gives is a question about the host — cgroup delegation may be absent, a
registry directory may be unwritable, a local endpoint may not bind — and
doctor answers it by running a bounded scratch containment and reporting what it
observed:
processkit-cli doctor --json
Its containment.mechanism and containment.abrupt_cleanup are the same two fields
this page's first table lists, read off a real run on this host rather than inferred
from the platform; --check-resource-controller additionally reports whether the
limit rows of the capability matrix are available here. It is the setup-time
counterpart to the run-time acceptance policy below, and unlike probe it proves the
containment path end to end — see docs/troubleshooting.md,
"Qualifying a host: doctor".
Choosing an acceptance policy
An adapter can read the first run_started event and fail closed:
require mechanism == job_object or cgroup_v2
require abrupt_cleanup == whole_tree # Windows-only today
The same two policies can be applied one step earlier, to the host rather than to a run, with the same vocabulary and an exit code instead of a field to compare:
processkit-cli doctor --require-mechanism cgroup_v2
processkit-cli doctor --require-abrupt-cleanup whole_tree
Each is an exact match against what this host reports — deliberately not an "at least
this strong" comparison, because the three abrupt_cleanup levels are platform facts
and this project publishes no ordering between them. An unmet requirement exits
HOST_UNQUALIFIED (116) and still prints the full report.
These are separate policies. A Linux cgroup gives strong normal teardown but not Windows-equivalent abrupt cleanup.
See also
- Running in containers.
- Resource limits.
- Timeouts and cancellation.
- JSONL event schema.
- Troubleshooting — qualifying a
host with
doctor, and reading a negative verdict.
Running in containers
ProcessKit CLI can run inside Windows and Linux containers, but an outer container and an inner ProcessKit run solve different problems. The outer runtime controls the pod/container; ProcessKit owns one payload tree and its lifecycle contract inside that boundary.
Choosing the Linux archive
| Base image | Recommended artifact |
|---|---|
| Debian / Ubuntu / glibc distroless | x86_64-unknown-linux-gnu or Arm64 glibc |
| Alpine / musl / minimal static image | x86_64-unknown-linux-musl or aarch64-unknown-linux-musl |
The musl archives are statically linked against libc and are suitable for images without glibc, on both x86_64 and Arm64. They are a distribution option, not a different containment mechanism.
Minimal multi-stage image
FROM alpine:3.22 AS unpack
ARG PROCESSKIT_CLI_VERSION
ARG ARCHIVE=processkit-cli-v${PROCESSKIT_CLI_VERSION}-x86_64-unknown-linux-musl.tar.gz
ADD https://github.com/ZelAnton/ProcessKit-CLI/releases/download/v${PROCESSKIT_CLI_VERSION}/${ARCHIVE} /tmp/processkit-cli.tar.gz
RUN tar -xzf /tmp/processkit-cli.tar.gz -C /tmp
FROM scratch
COPY --from=unpack /tmp/processkit-cli /usr/local/bin/processkit-cli
ENTRYPOINT ["/usr/local/bin/processkit-cli"]
On an Arm64 host, override the build argument instead of the default:
--build-arg ARCHIVE=processkit-cli-v${PROCESSKIT_CLI_VERSION}-aarch64-unknown-linux-musl.tar.gz.
Verify the archive checksum and attestation in the build pipeline before this copy step; the abbreviated example focuses on final-image shape.
Shell-free entrypoints
The CLI itself does not require a shell. Use JSON/exec-form container commands:
ENTRYPOINT ["/usr/local/bin/processkit-cli", "run", "--jsonl", "/run/events.jsonl", "--"]
CMD ["/usr/local/bin/worker", "--serve"]
In Kubernetes, express the same boundary with command and args. Avoid
wrapping the runner in sh -c unless the payload truly needs shell semantics;
the wrapper changes signal routing and adds another process to diagnose.
Persist lifecycle data
--jsonl is required. Mount or create a writable location whose lifetime
matches the observer's needs:
volumeMounts:
- name: run-state
mountPath: /var/lib/processkit-cli
args:
- run
- --jsonl
- /var/lib/processkit-cli/events.jsonl
- --
- /app/worker
Use a second bounded directory for --capture-dir when transcripts must
survive the container process. Apply a storage quota outside the CLI in addition
to its per-stream byte cap.
PID 1 and signals
When processkit-cli is PID 1, it receives the container runtime's SIGTERM
directly. The runner catches it and performs ordinary cancel teardown, including
terminal JSONL events and registry cleanup, before exit.
Set the orchestrator's termination grace period longer than the runner's
--grace plus application shutdown overhead. If the orchestrator sends
SIGKILL first, normal teardown cannot run and Linux guarantees only the
abrupt_cleanup value reported in run_started.
cgroup v2 is not automatically delegated
Seeing /sys/fs/cgroup or a cgroup-v2 mount does not mean the runner may create
and configure child cgroups. Ordinary Docker/Kubernetes containers usually run
inside a delegated subtree without permission to enable controllers at the
effective root.
Consequences:
- an unrestricted run may report
process_groupfallback; --max-memory,--max-processes, or--cpu-quotamay fail before spawn withlimit_hit/BACKEND(102);- mounting cgroup files read-write or running privileged changes the threat boundary and should not be done solely to silence that failure.
Prefer outer container limits when the orchestrator is the resource-policy owner.
Outer limits and inner limits
| Layer | Typical responsibility |
|---|---|
| Kubernetes/Docker/systemd | Scheduling, pod/container memory and CPU, restart policy |
| ProcessKit CLI | One payload tree, timeout/cancel semantics, JSONL, local IPC |
If both layers install limits, the stricter limit wins. ProcessKit currently cannot attribute an outer OOM kill or every inner kernel limit event; use the orchestrator's status/events as the source of truth for outer-runtime termination.
Registry location and container users
The run registry is per-user and created with owner-only permissions. Keep the
same user identity for run and its inspect / cancel / kill / attest /
wait clients. A sidecar running under a different UID should not expect access.
For detached runs, ensure the registry and JSONL locations survive for as long as the detached runner. An ephemeral container that exits immediately after launch cannot host a meaningful detached child.
Read-only root filesystems
The binary itself works from a read-only image, but it still needs writable locations for:
- the required JSONL file;
- the per-user registry/control socket directory;
- optional capture files.
Mount explicit writable volumes or tmpfs paths and set the child working directory accordingly. A setup failure is reported before the payload runs.
Health and shutdown pattern
The CLI does not implement retries, pooling, or application health checks. Let the orchestrator own restart policy and use ProcessKit CLI for deterministic one-run control:
- start
runas the container's main process; - read
run_startedbefore declaring startup complete; - on shutdown, send
SIGTERMand allow the configured grace; - collect terminal JSONL and capture files;
- use the outer runtime's status for OOM/eviction attribution.
See also
Live-run control plane
The control plane lets a client query and (later) steer a running
processkit-cli run. It lives in the live runner process, not in named kernel
objects (AGENTS.md, "The control plane lives in the live runner process"): a runner
must stay alive to hold its kill-on-drop container, so the live process is exactly
where clients reach it. If the runner dies, the container tears the tree down and the
run becomes detectably gone — never a dangling handle a client could act on by
mistake.
This document is the normative description of the local transport, the wire
protocol, and the four clients — the read-only inspect and attest, and
the mutating cancel / kill — including their behavior when the runner
cannot be reached. Four clients, not four callers: doctor
(docs/troubleshooting.md, "Qualifying a host: doctor")
drives two of these very verbs — inspect, then cancel — through the same client
code, against the scratch run it started for itself and never against a caller's run.
It adds no verb, no reply shape, and no behavior of its own here; it is a consumer of
this contract, which is what makes a successful qualification evidence about this
contract working on this host.
Discovery — how a client finds a live runner — is the run registry, described in
docs/registry.md. The in-code source of truth is src/control/.
cancel and kill add verbs to the same transport and protocol as inspect
without reshaping either: one request verb line in, one JSON line out, connection
closed. They are mutating — they end the live run — and they reuse the run's own
teardown path (the same one a --timeout or a Ctrl-C drives, see
docs/schema.md), so a control command never invents a second way to
kill a tree.
Discovery: the registry, never a PID
A client never addresses a run by PID (AGENTS.md: "Nothing is addressed by PID").
It finds one through the per-user run registry: it scans records, matches the target
run_id, and acts only on a live entry (see docs/registry.md,
"Staleness"). A record's endpoint field carries the address of that run's local
transport — the channel this document describes.
The registry does not enforce run_id uniqueness, so more than one live entry can
match. That is an ambiguous run id — a hard CONTROL (103) failure for every
verb (inspect included), never a silent pick of whichever entry the scan returns
first. See docs/registry.md, "Run id resolution — ambiguity is a
hard failure".
Local transport
Each run stands up one local IPC endpoint, restricted to the current user, and publishes its address in the run's registry record:
- Unix: a unix domain socket. The socket file is created in a short per-run
owner-only (
0700) directory under/tmp(with the platform temp directory as a fallback), and its own mode is tightened to0600. The short path is independent of the registry location so deeply nested CI/project paths cannot exceed macOS'ssun_pathlimit. The endpoint address is the socket's absolute path. - Windows: a named pipe (
\\.\pipe\processkit-cli-<unique>), created with a protected DACL that grants full access to the current user alone (D:P(A;;FA;;;<current-user-SID>), built from the same SID the registry restricts to), created withFILE_FLAG_FIRST_PIPE_INSTANCE(so no other process can pre-own the name), and rejecting remote clients. The endpoint address is the pipe name.
Both are locked to the same single user as the registry, because an endpoint is a control channel — a world-reachable one would hand it to any local process.
Concurrency, and never blocking the run
The transport is served concurrently with the child's output pump, on the same runtime. It never blocks the happy path:
- A live run that no one inspects pays only an idle accept.
- The run's exit and teardown do not wait on any control client. When the child
exits (or a
--timeout/Ctrl-Cends the run), the run resolves and the control server is dropped along with it — tearing the transport down. The child's exit-code fidelity is never at the mercy of a slow or absent control client.
The transport is best-effort infrastructure: if it cannot be stood up, the runner
warns on stderr, records a null endpoint, and runs the child normally — the run is
simply not inspectable. Losing it never costs the child its faithfully forwarded exit
code (AGENTS.md, "Exit-code fidelity").
Cleanup and leaks
On a clean teardown (a normal child exit, a --timeout, or a Ctrl-C) the transport
is torn down with the run — on unix the socket file and its private directory are
removed. An abrupt runner death (crash, SIGKILL, a parent's Job Object
terminate) skips that removal, stranding the socket directory exactly as it strands
the registry record and lock. The leak is inert while it lasts: a client detects the
run as stale through the registry before it ever connects, so it never touches the
orphaned socket.
It does not last, either. prune reaps all three together: reaping a
confirmed-stale record now also removes the pkc-… directory and socket that
record published, so an abrupt death no longer accumulates dead socket directories in
/tmp — see docs/registry.md, "Reaping — prune", for the shape
check that endpoint has to pass first (it is untrusted data, like everything else in
a record) and for what the reaper deliberately refuses to touch. On Windows there is
nothing to reap: the pipe simply vanishes with the process.
Wire protocol
Line-oriented and deliberately tiny. Over an accepted connection:
- The client writes one request verb line, UTF-8, terminated by
\n. The verbs areinspect,cancel,kill, andattest. (An empty line is also treated asinspect, so a bare connect-and-read probe still works.) - The server writes back one JSON line — the response — and closes the connection.
The responses per verb:
| Verb | Response line | Effect on the run |
|---|---|---|
inspect | a snapshot | none (read-only). |
cancel | an ack {"accepted":true,"action":"cancel","run_id":"…"} | the run runs its shared soft-stop → grace → hard-kill teardown and exits with CONTROL_CANCELLED (108). |
kill | an ack {"accepted":true,"action":"kill","run_id":"…"} | the run hard-kills the whole tree immediately (no soft stop, no grace) and exits with CONTROL_KILLED (109). |
attest | an attestation | none (read-only). |
No verb carries an argument, and for attest that is load-bearing. The request
line is the verb and nothing else, so everything a response depends on is either the
run's own state or a property of the connection itself. attest answers about the
process on the other end of this connection, which the runner reads from the
transport rather than from the request — see below.
An unrecognized verb yields a JSON error object ({"error":"..."}) instead, and
changes nothing about the run — a foreign client cannot end a run by sending garbage.
Short error diagnostics retain their existing text; when JSON escaping would make an
error envelope reach the line ceiling, the diagnostic is shortened and marked
... (truncated) before it is written. The writer also applies the same bound as a
final guard to every response serializer, replacing an unexpectedly oversized
non-error payload with a fixed structured error.
Every request and response line, including its terminating newline, is bounded at
64 KiB. The runner checks a complete inspect snapshot before writing it. If the
member list or enriched member fields make that snapshot too large, it sends the
bounded structured error {"error":"control-plane inspect snapshot exceeds the 65536-byte response limit"} instead of truncating or fragmenting the snapshot.
The client reports that reply through the existing CONTROL (103) error path; it is
not a successful, partial snapshot and does not change attest, version, or other
error reply semantics.
For the mutating verbs the runner writes its ack first, then signals its own main loop to tear down. The client therefore always receives its confirmation even though the run ends the instant the signal lands; and if the ack cannot even be written (a broken client), no teardown is signaled — an unconfirmed cancel never silently ends a run.
inspect
processkit-cli inspect (--run-id <id> | --all [--label <KEY=VALUE>]...) [--json]
inspect finds the live runner for <id> through the registry, connects to its
endpoint, sends the inspect verb, and prints the snapshot to stdout — as a
single JSON line with --json, or, by default, as a human-readable rendering
(snapshot version, run id, mechanism, root pid, start time, artifact locators, and a member table),
mirroring list/prune's optional --json. --json is optional; inspect --json's
output is unchanged from before --json became optional.
The aggregate form takes one snapshot of all confirmed-live registry records,
optionally filters them by repeated exact --label KEY=VALUE matches (logical AND),
and addresses each target by the record path and endpoint captured in that snapshot.
Without --json, it prints a terminal-safe table with one row per target and the
three-way status inspected / already_gone / failed, followed by a detailed
snapshot block for each inspected target. Those blocks reuse the single-run renderer,
including its member table and bounded handling of every untrusted string. An empty
matching fleet prints no live runs to inspect.
With --json, the original output remains one byte-compatible JSON array. Each
element has run_id and either a snapshot with error: null, or snapshot: null
with a bounded error string. If any target fails, either output form returns CONTROL
(103) after printing the complete report. This preserves partial fleet visibility
without turning registry churn into silent omission.
The inspect snapshot
The snapshot is the machine-readable state of a live run. It is the control plane's
own client/runner contract, versioned on its own axis (snapshot_version), distinct
from the JSONL event schema_version and the registry_version.
| Field | Type | Notes |
|---|---|---|
snapshot_version | integer | Snapshot format version this build writes (2), and the version the runner declared when reading. The client acts on it: a version newer than it implements is refused rather than rendered, an older one down to 1 is read — see "Snapshot version: a newer runner's reply is refused, an older one is read" below. |
run_id | string | The run's identifier — the key matched in the registry. Not a PID. |
mechanism | string | Containment mechanism: job_object, cgroup_v2, process_group, process_reaper, or unknown (same vocabulary as the JSONL run_started). |
root_pid | integer, nullable | The root child's PID; null if the backend exposed none. |
started_at | string | Run start time, RFC 3339 UTC, millisecond precision. |
jsonl | string, nullable | Absolute path to the JSONL lifecycle stream; null only when reading a version-1 snapshot, which had no such field. A runner of snapshot_version 2 always publishes a path — the nullability is what makes the older contract readable (below), not a caveat about this one. |
capture_dir | string, nullable | Absolute capture directory, or null when capture is disabled. |
members | array of member | The container's members, enriched with ppid/executable name/start_time wherever ProcessKit's members_info() can report them — the same member shape as the JSONL members_snapshot's own members array (docs/schema.md, "Enriched member fields"), and read through the same call, so the two views never drift. Only the member entries are shared: the JSONL event's own envelope fields (its reason, for instance) belong to that event, not to this reply. Fields stay null on platforms/members that can't report them (e.g. the "bare" BSDs). Queried at request time, so it reflects the container's composition when inspected, not at start. |
Example:
{"snapshot_version":2,"run_id":"build-42","mechanism":"job_object","root_pid":4242,"started_at":"2026-07-20T21:00:00.000Z","jsonl":"C:\\runs\\build-42.jsonl","capture_dir":null,"members":[{"pid":4242,"ppid":4200,"name":"build.exe","start_time":"133456789000000000"}]}
Snapshot version: a newer runner's reply is refused, an older one is read
snapshot_version is not decoration — the client checks it and acts on it. This
is the normative statement of that policy; the two inspect forms share one
implementation of it (src/control/mod.rs, SnapshotReply::accept).
The rule. This build reads a snapshot declaring version 1 or 2 — the range
from MIN_READABLE_SNAPSHOT_VERSION to SNAPSHOT_VERSION in src/control/mod.rs —
and refuses anything outside it with the reserved CONTROL (103) code and a
message naming the version that arrived, the range this build reads, and which way
the runner is out of that range (newer than this client, or older than anything it
still decodes). Nothing about a refused reply is printed: it never reaches the human
rendering or --json, and under --all that target is reported failed (with the
message in its error field), never inspected and never the successful
already_gone — the runner did not end, it answered something this client cannot
read. The verdict is taken from the declared number before the payload's shape is
parsed, so it holds even for a newer reply this build could not deserialize at all
(which is exactly the shape a breaking change produces).
Why a newer version is refused. A number above the one this build implements is
the runner's statement that the shape moved on in some way this build predates, and
this build cannot know which way: it holds no decoder for a contract written after it.
Rendering it anyway would present a payload interpreted under semantics its sender
never promised — and quietly, because the client re-serializes what it parsed, so a
newer runner's added fields are dropped at deserialization and never appear in the
output. The operator would see a confident rendering with no marker of what was lost.
This is the mixed-binaries case a mid-upgrade user really has (an older inspect
against a newer run), and the one this check exists for.
Why an older version is not. The refusal is deliberately one-sided. A lower number
does not, by itself, mean "unreadable": the only bump this contract has had — 1 → 2 —
was purely additive (it introduced jsonl and capture_dir, both optional with a
default, and changed no existing field), so this build decodes a version-1 snapshot
correctly, reporting those two as null — "not reported", which is precisely what a
version-1 runner meant. That is not a tolerance policy about numbers in general; it is
a checkable fact about this repository, pinned by a regression test, and it matters in
practice: every binary released so far (v0.1.0 … v0.3.1) writes version 1, so refusing
it would make an upgraded client unable to inspect the runs its own predecessor
started. When a future bump does make the older shape undecodable or misleading — a
removed, renamed, or retyped field, or an existing field whose meaning changed — the
floor (MIN_READABLE_SNAPSHOT_VERSION) moves up in that same change, and that is
where the judgement is recorded, rather than being inferred from the number.
This is a narrower refusal than the registry read side's, which skips a record whose
registry_version is not exactly its own (docs/registry.md), and the
difference is earned: that check gates destructive action — probing a lock file and
reaping the record behind it — on liveness semantics an unknown version may have
redefined. A snapshot is read-only output whose only failure mode is being misread.
What is printed. The snapshot_version in a rendered snapshot is the value the
runner declared, unchanged — it reports which contract answered, so against an
older runner it is legitimately lower than the version this binary implements. The
rest of the object is this client's own re-serialization, so its field set is always
this build's. fixtures/schema/cli/inspect.schema.json therefore admits the readable
range on this field rather than pinning one value, and it moves when the range moves.
What to do about a refusal. Inspect that run with a processkit-cli build that
implements its snapshot version — for a newer runner, one at least as new as the
binary that started the run. Retrying the same command will not change the answer.
probe --json reports the version (and probe_version) of the binary you run,
which is how you tell two installed builds apart; it does not report a runner's
snapshot version, and no preflight can — that number arrives only in the runner's own
reply, which is what the refusal message quotes back to you.
Consequence for a bump. Bumping SNAPSHOT_VERSION is a real event for a mixed
deployment, not just a schema edit: every older client loses the ability to inspect
a runner that writes the new number — loudly, with 103, rather than by
misinterpreting it. Newer clients keep reading older runners as long as the floor
allows, so a bump is not automatically a fleet-wide outage; deciding whether the floor
moves with it is part of making the bump, and both are announced in CHANGELOG.md.
cancel/kill are unaffected (their ack carries no version and is verified by
accepted/action/run_id instead), as are list, wait, and prune, which never
read a snapshot, and attest, whose reply is versioned on its own separate axis
("Attestation version" below) and moves only when that number does.
attest
processkit-cli attest --run-id <id> [--json]
Asks the live run named <id> one question: is the process running this command
inside your container? The runner answers from the kernel's own record of who
opened the connection, so a positive answer is a containment fact rather than a claim
the caller repeated.
This is what an environment variable cannot be. An adapter that gates work on "the
caller belongs to run X" can otherwise only check the shape of a string the caller
carries — a convention enforced by instructions, not a checkable fact, since any
process can hold any string (and a string is inherited by processes that later leave
the container). attest turns that convention into an invariant the runner itself
verifies.
The caller cannot name a process, and that is the whole design. There is no
--pid and no equivalent. A caller-supplied pid would prove that some chosen
process is a member, which says nothing about the caller and would let any process
launder a membership claim about a pid it picked. The identity comes from the
transport:
- Unix — the socket's peer credentials. On Linux (and Android/OpenBSD) that is
getsockopt(SOL_SOCKET, SO_PEERCRED), whoseucredcarries the peer's pid. On macOSSO_PEERCREDdoes not exist and the portablegetpeereid(3)reports only the effective uid/gid — not an identity a membership check can use — so the pid comes from the Darwin-specificgetsockopt(SOL_LOCAL, LOCAL_PEEREPID)instead; NetBSD usesLOCAL_PEEREID, and Solaris/illumosgetpeerucred(3C). The mechanism differs per platform; the guarantee — a pid the kernel attributes to the peer, not one the peer asserted — does not. - Windows —
GetNamedPipeClientProcessIdon the connected pipe instance, which the object manager answers from its own record of the handle that opened it.
PID reuse cannot produce a false positive. The identity is read from the
connection while it is open, and the membership check runs on that same open
connection: a process holding an open socket or pipe handle has not exited, so its pid
cannot yet have been recycled onto a different process. An attestation is therefore a
statement about a peer that is demonstrably still there, which is also why it carries
checked_at — it is a point-in-time fact about a live connection, not a token to keep
and present later.
The pid is checked against the run's own container membership, through the very
same members_info() read that produces the inspect snapshot and the JSONL
members_snapshot (docs/schema.md, "Enriched member fields") — one notion of "a
container member" for the whole binary, never a second list assembled for this
command, and never a pid read back from the registry or any other file on disk.
The reply (--json; the default is the same fields rendered for a terminal):
{"attestation_version":1,"run_id":"build-42","verdict":"member","peer_pid":4242,
"mechanism":"job_object","checked_at":"2026-07-20T21:00:05.000Z"}
| Field | Meaning |
|---|---|
attestation_version | The attestation contract's own version, currently 1. Its own axis, independent of snapshot_version (see "Attestation version" below). |
run_id | The run that answered, echoed and checked by the client, so a reply describing another run is refused rather than printed. |
verdict | member | not_a_member | peer_identity_unsupported — see below. |
peer_pid | The pid the kernel reported for the caller, as the runner sees it; null only for peer_identity_unsupported. Output, never input. |
mechanism | The containment the verdict is about (job_object | cgroup_v2 | process_group | process_reaper | unknown), which fixes its scope — see "What member means, per mechanism". |
checked_at | When the runner decided, RFC 3339 UTC with millisecond precision. |
The three verdicts are three outcomes, deliberately not a boolean:
| Verdict | Exit code | --error-format json kind | Meaning |
|---|---|---|---|
member | 0 | — | The caller is inside this run's container. |
not_a_member | NOT_A_MEMBER (115) | not_a_member | The runner named the caller and it is not in the container. A decided answer. |
peer_identity_unsupported | CONTROL (103) | peer_identity_unsupported | The runner could not obtain a kernel-authenticated identity for the caller, so it declined to answer either way. |
There is a fourth outcome, and it is deliberately not a verdict: if the runner
cannot read its own container membership at that moment (the members_info() query
itself fails), it produces no attestation at all. The client gets the same structured
error an unrecognized verb does, surfaces the runner's own words, and reports
CONTROL (103) — "the runner could not read its own container membership, so it
refused to decide". Answering not_a_member there would state a decided verdict, and
deny access through exit 115, on the strength of a failed query; answering
peer_identity_unsupported would blame the wrong thing, since the caller was named.
This is the same honest-degradation discipline the JSONL members_snapshot event's
read_error flag follows — an inspect snapshot still degrades that failure to an
empty members array, because a diagnostic that reports nothing is still a
diagnostic, while a verdict that decides on nothing is not a verdict.
The attestation is printed on stdout for every verdict, including the two that
make the command exit non-zero — the same shape probe --json and inspect --all --json already use when they report and then fail. The verdict is the answer the
caller asked for; the exit code says what to do about it, and under
--error-format json the matching envelope goes to stderr while stdout stays exactly
as it is (fixtures/schema/cli/attest.schema.json).
Why a negative gets its own code. Every CONTROL (103) result means no answer
you can act on — the target is missing, stale, unprobeable, ambiguous, unreachable,
too slow, or speaking a contract this build refuses. A not_a_member is the opposite:
the target was resolved, reached, and answered. An adapter that gated a lease on
membership must be able to tell "the runner says no" from "no runner said anything",
because the correct response differs (deny versus investigate or retry), so the two
never share a code. See docs/exit-codes.md.
Fail-closed on a platform that cannot answer. A runner whose transport cannot name
its peer reports peer_identity_unsupported rather than degrading to an unproven
member. A consumer establishes that this cannot happen before it depends on
attestation, at preflight, with the capability token in probe --json's surface:
processkit-cli probe --json --require-surface attest --require-surface attest:peer-identity
That token carries no -- precisely because it is not a flag: it says this build can
obtain a kernel-authenticated peer identity on this platform, and it is absent
where the build cannot promise that. A missing capability is then the ordinary
fail-closed PROBE_INCOMPATIBLE (110) every other unmet --require-* produces, at
preflight, rather than a surprise in the middle of a job. The claim is deliberately
one-directional: its presence is a guarantee, its absence withholds one rather than
predicting failure (FreeBSD, for instance, supplies a peer pid on a new enough kernel
and not on an older one, which no compile-time claim can distinguish — so it is
excluded rather than over-claimed, and attest there still answers from whatever the
kernel really provides).
What member means, per mechanism
member means exactly this: the run's own container reports the caller as one of
its members. Since that is the same enumeration inspect and members_snapshot
publish, what a member answer covers follows the mechanism the run obtained
(run_started.mechanism, and the mechanism field of the attestation itself):
job_object(Windows),cgroup_v2(Linux), andprocess_reaper(FreeBSD) enumerate the whole contained tree, so any process in the tree — a grandchild, a great-grandchild — attests as a member. The FreeBSD reaper keeps descendants that callsetsidin its subtree, unlike the POSIX process-group fallback.process_group(the POSIX fallback: macOS and the non-FreeBSD BSDs, and Linux with no usable cgroup) contains a whole tree but enumerates only the tracked group leaders. Membership there is therefore decided against the caller's own process group — the predicate that mechanism actually enforces, and the one its teardown (killpg) reaches — so a contained grandchild attests as a member on this mechanism too, and a process that escaped the group withsetsid(and so escaped this mechanism's containment) correctly does not.
An unknown mechanism is the conservative forward-compatible fallback for a
ProcessKit mechanism this CLI does not recognize. It is not evidence that the
runner selected a fourth current backend, and consumers that require a specific
containment guarantee must fail closed on it.
Nested runs. A run started inside another run is an ordinary run: a client
inside it attests against it exactly as a client inside a top-level run does, on every
platform. Whether that client is also a member of the outer run is
mechanism-dependent, and the honest answer differs: Windows Job Objects nest, so the
inner run's processes are in the outer job as well and attest as members of it; a
Linux cgroup leaf is created inside the outer run's own cgroup, and a process moved
into that leaf is no longer listed in the outer cgroup's cgroup.procs, so it attests
as a non-member of the outer run even though the outer run's recursive teardown would
still reach it. Neither is a bug — each is a faithful report of what that mechanism
enumerates — so ask about the run you actually mean, and read mechanism if you
need to reason about the containment behind the answer.
Attestation version
attestation_version is the contract's own axis, exactly as snapshot_version is
inspect's, and the client acts on it: a reply declaring any other version is
refused with CONTROL (103) / incompatible_contract rather than rendered.
That is stricter than inspect's range, on purpose rather than by omission. A misread
snapshot is a diagnostic shown under the wrong semantics; a misread attestation is a
security verdict, and an adapter would grant or deny access on a sentence its sender
never said. And strictness costs nothing here: this contract has had exactly one
version, so there is no older shape being refused — unlike snapshot_version, whose
floor records a checked fact about a bump that really happened (see "Snapshot version:
a newer runner's reply is refused, an older one is read"). If an additive bump ever
makes an older attestation genuinely readable, the range widens in the same change
that makes the widening true.
The boundary: containment, not authentication
attest reports a containment fact inside the existing same-OS-user threat model
(docs/threat-model.md). It is not authentication between
mutually hostile peers, and must not be used as one:
- the control transport is owner-only, so every party to this exchange is already the same OS user, and that user's processes are inside the trust boundary this project draws — a same-user process that wanted to interfere with a run never needed to forge an attestation, since it can reach the control plane directly;
- what
attestcloses is the forgeable correlation: a process that is not contained claiming it is, by carrying a string. That is a real and common failure mode (a stale environment variable, a copied id, a process that outlived the run it was started for), and it is closed by asking the kernel instead of the caller; - what it does not close is a same-user process that is genuinely inside the container behaving badly, nor anything about a different user (that is the transport's owner-only permissions, not this verb), nor any claim that survives the connection — the fact is scoped to the moment it was checked.
A consumer that needs a security boundary between mutually distrusting parties needs
OS-level isolation (separate users, containers, sandboxes); attest is a containment
invariant within one such boundary, not a replacement for one.
cancel and kill
processkit-cli cancel (--run-id <id> | --all [--label <KEY=VALUE>]...)
processkit-cli kill (--run-id <id> | --all [--label <KEY=VALUE>]...)
--run-id and --all are mutually exclusive and exactly one is required, the same
clap convention docs/registry.md's wait --all
(T-216) established — a bare cancel/kill with neither is a USAGE (100) form
error at parse time.
--run-id <id> finds the live runner for <id> through the registry exactly as
inspect does — by matching run_id, never a PID — connects to its endpoint,
sends the verb, and ends the run. cancel and kill differ only in how the run is
ended:
cancelasks the runner to run its shared soft-stop → grace → hard-kill teardown — the same path a--timeoutor aCtrl-Cdrives. On Unix a realSIGTERMis delivered to the tree, the--gracewindow (if the run was started with one) elapses, and the container's kill-on-drop then hard-tears-down whatever remains. On Windows a Job Object has no POSIX signal, so the soft tier isWM_CLOSEto windowed members plusCTRL_BREAKfor a child launched with--windows-graceful-ctrl-break; a capability probe reports when neither target exists, and ProcessKit then escalates atomically. The run exits with the reservedCONTROL_CANCELLED(108).killhard-kills the whole tree immediately: no soft stop, no grace. The run exits with the reservedCONTROL_KILLED(109).
The scope of either is only the target run's container, discovered by run_id
through the registry. Nothing is ever killed by executable name, and no process
outside the run's own ProcessKit container is touched.
The ack
On success the runner replies with one JSON line — an ack — and the client prints
it to stdout before exiting 0:
| Field | Type | Notes |
|---|---|---|
accepted | boolean | true — the runner accepted the command and began tearing down. |
action | string | The action taken: cancel or kill (echoed so the client can confirm the runner answered the verb it sent). |
run_id | string | The run the command targeted. |
{"accepted":true,"action":"cancel","run_id":"build-42"}
The client parses the ack back and checks it names the action it asked for; a rejected or garbled reply is treated as an unreachable-runner failure (below), never a false success.
The outcome is visible to any observer, not just the client
The client's ack is not the only record of the command. The run also writes the
outcome to its JSONL stream (--jsonl), so an external observer reading the event
file — not the control client — still sees that the run ended by an outside command:
cancelwrites acancelledevent withsourcecontrol_cancel(told apart from the local stop signals, which arectrl_c/sigterm/sighup(Unix) /ctrl_break/ctrl_close/ctrl_logoff/ctrl_shutdown(Windows)), aresource_summary, thecleanup_started/cleanup_finishedteardown pair, and a terminalrunner_exitwithsourcecontrol_canceland code108.killwrites a dedicatedkilledevent withsourcecontrol_kill, aresource_summary, the cleanup pair (withsoft_terminatenull— no soft stop was attempted), and a terminalrunner_exitwithsourcecontrol_killand code109.
An outside command therefore does not cost the observer the run's consumption figures:
resource_summary is emitted on these endings exactly as on a natural exit, before the
teardown that destroys what it reads. The reading is if anything more complete here,
because it happens while the tree is still running — on Linux cgroup v2 that is what
makes peak_memory_bytes and total_cpu_ms present at all
(resource limits, consequence 5).
See docs/schema.md for these events.
cancel --all / kill --all
--all is the aggregate counterpart to --run-id: instead of one named run,
it acts on every registry record confirmed live in a snapshot taken the moment the invocation
starts. Repeated --label KEY=VALUE filters narrow that snapshot with logical AND;
only records carrying every exact pair remain, and labels are rejected with the
by-id form. This is the mutating counterpart to wait --all (T-216; see
docs/registry.md, "Waiting — wait", "The aggregate barrier —
wait --all"), reusing its exact snapshot discipline. The target set is fixed once, before
the first mutation is dispatched: a run that registers after the snapshot is out of
scope for this invocation, and a run that is only unprobed (not confirmed live) at
that instant is excluded from the snapshot outright, the same asymmetry wait --all
documents. Each target is keyed by its unique registry-record path and the endpoint
that exact record advertised at snapshot time, never by its potentially duplicated
run_id. --all can therefore reach two live records sharing an id independently,
while the by-run-id form remains the hard ambiguity described above. Immediately
before dispatch the client re-reads and probes that exact record path, without
scanning or probing unrelated entries, and requires it to remain live with the same
id and endpoint; only then does it use the ordinary wire exchange.
An empty snapshot (no confirmed-live entry at all — an empty or fully-stale
registry) is not an error, mirroring prune: it prints an empty report ([]) and
exits 0. Opening or scanning the registry itself failing (not "found nothing", but
"could not even look") is a exit::SETUP (111) failure, the same
support/prerequisite failure list/prune/wait report for the identical condition
— distinct from the single-run form's CONTROL (103), since there is no one target's
reachability in question yet at that point.
The report. Instead of the single-run form's one ack object,
--all prints one JSON array, one entry per snapshot target, to stdout:
| Field | Type | Notes |
|---|---|---|
run_id | string | The target record's descriptive run id; not its aggregate identity key. |
accepted | boolean | Whether the runner acknowledged this invocation's mutation. |
status | string | accepted, already_gone, or failed. |
error | string, omitted unless failed | Present only for failed; names why the still-potentially-live target could not be safely reached or did not acknowledge. |
[{"run_id":"build-42","accepted":true,"status":"accepted"},{"run_id":"build-43","accepted":false,"status":"already_gone"}]
A target that disappears or becomes confirmed stale between the snapshot and its
dispatch is reported as already_gone: no runner acknowledged the verb, so
accepted remains false, but the aggregate's terminal-state goal is already met and
the outcome is non-error. An entry that becomes unprobeable, changes identity, cannot
be reached while still confirmed live, or rejects/mismatches its ack is failed and
does not stop fan-out to the remaining targets. --all never skips a snapshot entry
silently: every one gets exactly one array entry.
The aggregate exit code. Full success — every snapshot target is either
accepted or already_gone — is 0. A partial or full failure is never a silent
0: it reuses the reserved CONTROL (103) code (the same one the single-run form
uses for "could not reach the target run" — there being one or more unreachable
targets is the same class of fact for the aggregate), with a summary message on
stderr naming how many of the snapshot targets failed; the full per-target detail is
only in the JSON report on stdout, printed before that failing exit. A caller
that needs --all to fail loudly on any partial failure (the typical teardown
sequence — cancel --all before wait --all/prune) gets that for free from the
non-zero exit; one that wants the detail parses the report.
Skipped entries — unchanged from the single-run form. A registry entry that is
stale or unprobed at snapshot time is never in the target set at all (--all
acts only on entries Health::Live confirms), exactly the same bar
the single-run form's own resolver applies — --all only distributes that existing
rule over a snapshot, it never widens or narrows it.
When the runner cannot be reached: a distinguishable result, never a hang
Every client — inspect, cancel, kill, and attest — can lose the runner the
same three ways (this applies per target under --all too, one snapshot entry at a
time). All
of them are reported as the reserved CONTROL exit code (103) — "could
not reach the target run" (see docs/exit-codes.md) — with an
explanatory message on stderr (naming the action and the run) and nothing on
stdout for the single-run form (under --all, the same message text lands in that
target's error field in the report instead). None is a generic error, and none
hangs:
- Stale registry entry. The runner died abruptly, leaving its record behind; the released liveness lock makes the entry stale. The client detects this before connecting and reports the run as gone (its registry entry is stale).
- Unprobeable registry entry. The liveness probe could not be performed at all —
the entry's lock file would not open (a directory in its place, a permission error,
a rejected symlink/reparse point), the same case
listprints asunprobedandprunerefuses to reap (seedocs/registry.md). The client refuses just as it does for a stale entry — it acts only on a confirmed-live match, and this is not one — but it says so differently: the message reports that liveness could not be probed and names the entryunprobed, never that the runner is gone, which is a confirmed death nothing established. So a refusal you cross-check againstlistwill always agree with whatlistshows for that record. - Died mid-conversation. The entry read live, but the runner exited between the liveness probe and the reply — so the connect fails, or the connection closes before a complete response arrives. The client reports that the runner could not be reached or closed the connection before answering.
Every wait — connecting, and the whole request/response exchange — is bounded by a
deadline, so a runner that accepts a connection but never answers cannot wedge the
client either; it, too, ends as a bounded CONTROL failure. A run id that is not
registered at all is likewise a CONTROL failure naming the missing run.
For the mutating verbs this matters twice over: a cancel/kill against a run that
is already gone is the same bounded CONTROL (103) result — it never blocks waiting
for a teardown that will not happen, and it does not mistake a dead run for a
successful cancel.
Two refusals share this code without being a lost runner, and both belong to the
read-only verbs. A runner
that answers with a snapshot declaring a snapshot_version outside the range this
client reads is reachable and perfectly healthy — the exchange completed and the reply
arrived — but that reply cannot be interpreted, so inspect refuses it with the same
103 instead of rendering it (see "Snapshot version: a newer runner's reply is
refused, an older one is read"). attest has the same refusal for its own
attestation_version, plus one more of its own: a runner that could not obtain a
kernel-authenticated identity for the caller answers peer_identity_unsupported,
which is again 103 — reached, healthy, and unable to give an answer this client may
act on. That is what sets these apart from the reasons above:
they are all ways the target could not be resolved or reached, while here the target
answered and its answer was rejected or withheld. (Determinism is not the
distinguishing property — a confirmed-stale entry and an ambiguous run_id are just
as unaffected by a retry; only "died mid-conversation" is genuinely transient.)
cancel/kill cannot hit any of them — their ack carries no version and no identity.
attest's not_a_member is emphatically not in this family, even though it is
also a non-zero exit from a control client: nothing was lost or unreachable, the
runner answered, and the answer is the point. It carries NOT_A_MEMBER (115) for
exactly that reason (see "attest").
This is the exit-code half of the contract: a caller distinguishes "here is the run's
state" / "the command was accepted" (exit 0, JSON on stdout) from "that run is not
reachable" (exit 103, message on stderr) without parsing free text.
Telling the reasons within that 103 apart is the one thing the code cannot do —
which is what the global --error-format json is for: under it the same failure
prints a bounded JSON object on stderr whose kind is exactly the distinction this
section draws (stale, unprobed, control_unreachable, ipc_deadline,
ambiguous_run_id, not_found, and — for the two refusals above —
incompatible_contract and peer_identity_unsupported). Still no free text parsed,
and stdout is untouched. See
docs/exit-codes.md.
Run registry
The run registry is the first brick of processkit-cli's control plane. The
control plane lives in the live run process, not in named kernel objects
(AGENTS.md, "The control plane lives in the live runner process"): a runner must
stay alive to hold its kill-on-drop container, so the live process is exactly where
inspect / cancel / kill / attest reach it. The registry is how those
clients find a live runner — a per-user directory holding one record per in-flight
run.
This document is the normative description of the registry's location, record
format, and staleness signal. The transport those clients speak over, and the
inspect client itself, are described in docs/control-plane.md;
here we define only the registry.
list (see "Discovery" below), prune (see "Reaping" below), and wait (see
"Waiting" below) are the clients that read the registry directly, without connecting
to any runner's control transport: list scans every entry and prints it, so an
operator that has lost (or never had) a run_id can find one before reaching for
inspect/cancel/kill; prune reaps the entries list would show as stale; and
wait blocks on one entry until it is no longer live, so a supervisor that is not the
runner's parent can still wait for a run to end.
Location
The registry is a per-user directory — not system-wide and not tied to any one run. It is resolved in this order:
PROCESSKIT_CLI_REGISTRY_DIR— if set (and non-empty), it is used verbatim as the registry directory. This lets an orchestrator pin the location and lets the tests isolate a scratch registry.- Platform default, otherwise:
- Unix:
$XDG_RUNTIME_DIR/processkit-cli/runswhenXDG_RUNTIME_DIRis set — a user-private, per-session runtime directory is the natural home for live-run state — else$HOME/.local/state/processkit-cli/runs. - Windows:
%LOCALAPPDATA%\processkit-cli\runs, falling back to the same path built from%USERPROFILE%.
- Unix:
Permissions
The registry directory is created restricted to its owner, and every mutating
open guarantees that restriction before a record is written into it —
including on a pre-existing directory whose permissions were widened out of band,
which is repaired rather than trusted. A record names a run's private control-channel
endpoint, so a world-readable registry would hand that channel to any local process.
Two commands take that open: run, which is about to write a record, and doctor,
whose whole job is to establish that this host reaches the state described here (it
re-reads the resulting permissions and reports them, rather than assuming the open
succeeded means it worked — see docs/troubleshooting.md,
"Qualifying a host: doctor"). The read-only open every other client takes —
list, prune, wait, events,
and the control clients — deliberately does neither: it does not create the
directory and does not touch its permissions, since a read-only scan must not
mutate registry state.
- Unix: mode
0700. Applied at creation and re-asserted withchmod(which, unlike the creatingmkdir, is not filtered by the umask) on every mutating open. - Windows: a protected DACL that grants full control only to the current
user — the equivalent of
0700. Concretely the directory's DACL isD:P(A;OICI;FA;;;<current-user-SID>): Protected (inherited ACEs from the parent are blocked), a single allow-Full-Access ACE for the current user, inherited by child objects and containers (OICI) so the records and lock files inside are covered too. The directory is created carrying that descriptor, so it never exists momentarily reachable through permissions inherited from its parent.
The two platforms differ in how a mutating open reaches that state, because the
cost of asserting it differs by three orders of magnitude. Unix simply re-applies
the mode: one chmod, constant cost. Windows first verifies — one read of the
directory's own security descriptor — and writes only when what it finds is not
already exactly the DACL above; the write it avoids is SetNamedSecurityInfoW,
which re-propagates the inheritable ACE across every record and lock file in the
directory and therefore costs more the more runs the registry remembers (measured
at roughly 0.15 ms per file — about 310 ms for a registry holding 1024 entries —
by benches/registry_open_bench.rs).
The guarantee is identical either way, and deliberately so: the write is skipped only when the directory's DACL is already the target, compared ACE for ACE (protected bit, allow type, inheritance flags, access mask, and binary SID). Any deviation, any unreadable descriptor, and any path that is not a directory all fall through to the unconditional write. Nothing weaker — the directory merely existing, a marker file, a timestamp, a cached "already done" flag — is ever accepted as evidence, precisely because a principal who cannot defeat the DACL could still forge those and suppress the repair.
Record format
Each run writes one record file (<opaque-stem>.json) plus a sibling lock
file (<opaque-stem>.lock). The record is a single JSON object:
{
"registry_version": 1,
"run_id": "run-1234-...",
"endpoint": "\\\\.\\pipe\\processkit-cli-1234-...",
"started_at": "2026-07-20T21:00:00.000Z",
"argv_sha256": "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
"hint": null,
"labels": { "batch": "42" },
"liveness": {
"kind": "advisory_lock",
"lock_file": "run-000...-0000.lock"
}
}
| Field | Meaning |
|---|---|
registry_version | Record format version (currently 1). Independent of the JSONL event schema_version — the registry is a private per-user contract, not the public event stream, so it versions on its own axis. |
run_id | The run's identifier (--run-id, or a generated one). This is the key clients match on. |
endpoint | The run's local control-transport connection address — a unix socket path, or a Windows named-pipe name (see docs/control-plane.md). A live runner publishes it here so a client can reach it; null only when the transport could not be stood up (best-effort degradation — the run still works, it is just not inspectable). |
started_at | Run start time, RFC 3339 UTC with millisecond precision. |
argv_sha256 | The run's one-way argv fingerprint — lowercase-hex SHA-256 of the canonical argv encoding, byte-identical to the run_started event's command.argv_sha256 for the same run (docs/schema.md, "Fingerprint"). null on a record written before this field existed, or whose value failed the read-side shape check below. Never argv itself (see "Which run is which" below). |
hint | The run's worker-shape category from the same classifier catalog the event stream uses (docs/schema.md, "Hint classifier") — e.g. msbuild_node_reuse — or null when the command matches no known shape (the common case) and on a record predating the field. A fixed category label, never argv content. |
labels | Operator metadata from repeated run --label KEY=VALUE; an empty object on an unlabeled or older record. Used for discovery and exact-match aggregate filtering, not as a secret store. |
jsonl | Absolute path to the run's JSONL lifecycle stream, or null on an older record. This is the locator events --run-id resolves — which is why it keeps working for a finished-but-not-yet-reaped record, and why events --file exists for after the record is gone. |
capture_dir | Absolute output-capture directory, or null when capture is disabled or the record predates this field. |
liveness | How to decide whether the record is live or stale (see below). |
Serialized size policy
The serialized record has an inclusive limit of 65,536 bytes (64 KiB). Readers
read one byte beyond that boundary so they can distinguish an exact-boundary record
from an oversized one. Writers serialize the complete JSON object first and apply the
same byte limit before creating the .json file; a record exactly at 64 KiB remains
discoverable by list, wait, and the control-plane readers.
An oversized record is rejected as InvalidData with its measured size and the
supported maximum in the diagnostic. The runner treats this as best-effort registry
degradation: it warns on stderr, continues the contained child, and preserves the
child's exit code. It does not truncate or silently drop individual labels or artifact
locators. The reservation guard removes the pre-publication .lock, so a rejected
record cannot leave a .json/.lock pair for prune to recover.
Registry identity, endpoint, and artifact-path strings remain byte-for-byte intact
for matching and JSON output. Every human-readable renderer of one sanitizes
terminal formatting and limits the field to 256 characters plus an explicit ...
marker, so a corrupt record cannot create an unbounded terminal row — list,
inspect, and prune for the fields each prints, and events for the single
field it echoes: the jsonl locator, named in the diagnostics for a stream it
could not open or read.
list --label KEY=VALUE applies the same exact-match, logical-AND label filters as
the aggregate control and wait commands. list --health live|stale|unprobed
restricts that result to one liveness verdict; the two filter kinds combine.
Which run is which — and what a record never carries
argv_sha256 and hint exist so an operator (or an orchestrator) staring at
several live entries can tell which run is which before picking one to
inspect/cancel/kill — see "Discovery" below, where both are printed. They are
the redaction-safe half of the run's command: the fingerprint says whether two
entries are running the same command, the hint names a recognized worker shape, and
neither can disclose a command line (AGENTS.md, "Argv is redacted by default").
Both are produced by exactly the implementation the JSONL stream uses, so the same
run never fingerprints differently in the two artifacts.
The artifact locators are published on every new run (capture_dir only when that
feature is enabled). This is deliberate: they are operator-selected observability
locations rather than argv, and the registry is already owner-only because it holds
the live control endpoint. Publishing them closes the detached-run discovery loop:
a supervisor with only a run_id can find the event stream and transcripts as well
as inspect, wait for, or stop the run. Paths can still contain a sensitive project or
user name, so consumers must treat registry JSON as private operational metadata and
must not copy it into public logs. Human list/inspect output terminal-sanitizes
and visibly bounds both paths; JSON preserves the exact strings.
The raw argv is never written to a record, under any flag. --argv-raw widens
the run_started event only; the registry's register is handed a
fingerprint-and-hint value, not the argv, so there is no code path — and no future
flag — through which a command line can reach a registry record.
Two further fields were considered for the same "tell runs apart" purpose and deliberately left out:
root_pidwould put a reused-at-any-moment number in front of an operator inside the one artifact whose entire design says a PID cannot identify a run (see "No PID addressing" below) — an invitation tokillthe process that inherited it. The fingerprint distinguishes runs without that hazard.cwdis raw, unredacted text with no redaction rule in this project's contract (which covers argv only), and a working directory routinely spells out a customer, ticket, branch, or user name. Persisting it verbatim into a long-lived per-user file that every local client of this binary reads is a disclosure decision distinct from therun_startedevent's — that stream is written only to a file the caller explicitly asked for with--jsonl. It would also add a second untrusted path to validate on read.
Reading a record: additive fields, and untrusted values
The command-identification and artifact fields are optional on read, and that is
what makes adding them a
non-breaking change to registry_version (still 1):
- A record written before they existed — or by any writer that publishes none —
reads back with them
null. They are deserialized as optional/defaulted, so their absence is never an error. - A record written by a newer writer, carrying fields this binary does not know,
is read as an ordinary record: unknown fields are ignored, not treated as
corruption. Both directions matter in the mixed registry a mid-upgrade user
actually has, where an older
list/prunebinary and a newerrunshare one directory.
A registry_version bump is reserved for the opposite kind of change: renaming,
removing, or retyping an existing field, changing what a value means, or adding a
field a reader must understand in order to behave correctly.
Like every other field, both are untrusted deserialized data on the read side
(the same stance liveness.lock_file and endpoint are held to) and are validated
by shape: argv_sha256 must be exactly 64 lowercase hex characters, and hint must
be a non-empty, at-most-64-character label of ASCII lowercase letters, digits, and
_ — the snake_case shape the classifier catalog requires. hint is checked by
shape rather than membership in this binary's own catalog on purpose, so a label
minted by a newer runner is not silently dropped.
A value failing its check is dropped to null, and the record is kept — unlike a
malformed started_at or lock_file, which skip the whole record. The difference is
what the value can do: those two steer an action (a path that gets opened, an
ordering every client reasons about), while these two are reported and nothing more.
Discarding a record over one of them would let a purely cosmetic field hide a live
run from list, wait, and every control client at once — one hand-edited byte in a
live entry's hint and cancel/kill could no longer find the run they are aimed
at. Losing the field is strictly the smaller loss.
No PID addressing
A record is never indexed or identified by a bare PID (AGENTS.md: "Nothing is
addressed by PID"). The file name (<opaque-stem>) is a PID-free time+counter token
whose only job is to be unique; the authoritative identity is the run_id field.
Clients find a run by scanning records and matching run_id, so PID reuse cannot
alias one run onto another. Uniqueness of the file name is guaranteed by the
filesystem (the lock file is created with O_EXCL / CREATE_NEW), so concurrent
runs always get independent entries that neither overwrite nor block each other.
Staleness — detectable, and not by file existence
If a runner dies abruptly (crash, SIGKILL, a parent's Job Object terminate), the
kernel container reaps the whole process tree, but the record file is left
behind. A client must be able to tell that leftover record from a live one — and
crucially, the file merely existing is not enough to conclude the run is alive.
The signal is an OS advisory lock:
- A live runner holds an exclusive advisory lock on the record's lock file for
the entire run (
flock(LOCK_EX)on unix;LockFileExwithLOCKFILE_EXCLUSIVE_LOCKon Windows). The lock is tied to the open file handle, and the operating system releases it automatically when the process dies — by any means, clean or abrupt. - A client checks liveness by trying to take that same lock, non-blocking:
- Denied (the lock is held) → a live runner owns it → the entry is live.
- Acquired (no one holds it) → the runner is gone → the entry is stale.
- Lock file missing → stale by definition.
Because the verdict comes from the lock — which the OS frees on death — and not from the file's presence, an orphaned record is reliably classified as stale. A client performing a pure liveness query releases the lock immediately after acquiring it; a client that intends to reclaim a stale entry would instead keep the lock held to claim it atomically.
Run id resolution — ambiguity is a hard failure
The registry does not enforce uniqueness of run_id at register time: two
concurrent runs started with the same explicit --run-id are both written as
independent entries (independent opaque file stems — see "No PID addressing" above)
and both read as live for as long as they run. Resolution is therefore the client's
job — in resolve_live_endpoint (src/control/mod.rs) for the control-plane verbs, and
in Registry::probe_run (src/registry/mod.rs) for the registry-only wait, which
reaches the same verdict from its own scan — and it is deliberately conservative:
- The client scans every entry and filters to those matching the requested
run_id, then counts how many of those are live (see "Staleness" above) — deliberately before ever looking at whether they publish anendpoint. A live entry that has not (yet, or ever) published an endpoint — a disconnected or failed transport — still counts as a live duplicate; if endpoint presence narrowed the count first, such an entry would be silently skipped and a duplicate could evade detection. - Zero live matches → a distinguishable
CONTROL(103) failure naming why (no such run registered at all, or the sole match is stale) — seedocs/control-plane.md. - More than one live match → also a
CONTROL(103) failure, "ambiguous run id", instead of silently acting on whichever entry the directory scan happens to return first. This applies to every by-run-idclient the same way — the destructivecancel/killverbs, the read-onlyinspectandattest, and the registry-onlywait— rather than a softer fallback for the read-only ones: guessing wrong on a mutating verb ends the other run instead of the intended one, a snapshot of the wrong run underinspectis exactly as misleading as acting on it, anattestverdict attributed to the wrong run would answer a containment question nobody asked, and awaitthat silently tracked one of two duplicates would report "your run finished" on the strength of the wrong run's ending. A caller that hits this is expected to pick a--run-idthat is unique among currently live runs. - Exactly one live match → only now does its endpoint matter: resolved
normally if it published one, or a distinguishable
CONTROL(103) failure ("the run is live but exposes no control endpoint") if it did not.
That single check happening once, at the start of the call, is a TOCTOU race for the
mutating verbs: register never enforces uniqueness, so a duplicate can register
under the same run_id in the window between the scan and the verb reaching the
runner over the transport (the connect round trip in between). cancel/kill
narrow that window as tightly as the registry's decentralized, no
locking-across-processes design allows: immediately before writing the verb, the
client re-runs the same scan+match and requires it to resolve back to the exact
endpoint it already connected to — any other outcome (a fresh ambiguity, the entry
having gone stale, or the resolution landing on a different entry) aborts the
command without ever writing to the wire. inspect does not repeat this check: being
read-only, a race that surfaces a snapshot from just before a duplicate registered is
merely stale information, not a wrong-target action.
That pre-dispatch re-check is a synchronous scan, while the verb write that follows
it is a separate, later .await; the two cannot be made atomic with each other, so a
duplicate can in principle still register in the sub-instruction gap between the
re-check returning and the write reaching the OS. Closing that residual gap
completely would need a run_id-keyed lock held across process boundaries through
the write — a registry redesign this resolver deliberately does not attempt (see "No
PID addressing" above). It is not needed for correctness, though: by the time the
re-check runs, the client has already connected to the target's specific,
uniquely-tokened transport endpoint (endpoint_tokens_are_unique in
src/control/tests.rs), and a later registry write cannot retarget bytes already destined
for an open connection. So the guarantee the re-check actually buys is narrower than
"no ambiguity can ever exist at write time" (impossible without that cross-process
lock) and is instead: the verb can never be misdirected to a different run than the
one already resolved and reconfirmed. A duplicate that registers in the residual
gap is simply invisible to that call — it becomes visible on the next one — never a
wrong-target action. See
racing_duplicate_after_reconfirm_does_not_misdirect_the_dispatched_verb in
src/control/tests.rs for a deterministic proof of this property.
Discovery — list
processkit-cli list [--json] opens the registry through
[Registry::open_read_only] (src/registry/mod.rs) — not the mutating
[Registry::open] run and doctor use, so listing never creates the registry
directory and
never touches its permissions — and scans it with [Registry::entries], the same
scan every other client shares, printing every entry it finds, whatever its health:
run_id, health (live/stale/unprobed), started_at, hint, argv_sha256,
labels, jsonl, capture_dir, and endpoint. It is
deliberately read-only and never connects to any runner's control transport, so
it carries none of the "could not reach the target run" failure modes
inspect/cancel/kill/attest do — it has no single target to fail to reach.
- No
--jsonprints a human-readable table (orno runs registeredfor an empty registry). Because record files are untrusted input, control characters in identity, endpoint, or artifact paths are collapsed to spaces and visibly bounded at this terminal boundary; they cannot forge another row or inject an ANSI sequence. --jsonprints one JSON object per entry, one per line, sorted byrun_id, thenstarted_at, then the entry's registry record path (a tertiary tie-break, never itself printed) for a fully deterministic order even when two entries share both arun_idand a millisecond-precisionstarted_at— the same "JSON Lines" shapeinspect --jsonuses for a single snapshot.- An empty registry is not an error:
listprints an empty result (or theno runs registerednotice) and exits0, exactly like scanning any other registry state. - A stale (or unprobed) entry is listed, not hidden — unlike
inspect/cancel/kill/attest, which treat anything other than a confirmed-live match as an unreachable-run failure,list's whole purpose is discovery, so a stale leftover (evidence of a runner that died abruptly without cleaning up) is exactly the kind of thing an operator wants to see, e.g. before reaping it. unprobedis a distinct value, never folded intostale(T-206). A record whose liveness lock could not even be opened (permission denied, a rejected symlink/reparse point, or an unexpected non-regular file in its place) is healthunprobed: the probe could not run, so nothing is confirmed — printing it asstalewould assert a confirmed death the probe never established, which could lead an operator to hand-delete a record that may still belong to a live run. This is the same three-way vocabularyprune --json'sunprobedtally andwait'sRunStatus::Unprobedalready use for the identical case (see "The reaping safety invariant" and "Liveness it cannot confirm" below) —list's health field is additive: existing--jsonconsumers that already treat any non-"live"value as "not live" need no change, only ones that matched exhaustively on exactly"live"/"stale".- Telling several live runs apart. Health,
run_id, andstarted_atcannot say what a run is running, so a registry with three live entries used to leave an operator guessing which one to act on. Each entry therefore also prints the two redaction-safe command fields the record carries (see "Which run is which" above):hint— the worker-shape label, or-/nullwhen the command matches no known shape — andargv_sha256, the one-way argv fingerprint, which is equal for two entries exactly when they are running the same command. Neither discloses a command line, which is why they can be printed at all.- In the table,
ARGV_SHA256is abbreviated to its first 12 hex characters followed by...(a full digest is six times the width of every other column put together, for a value read comparatively rather than character by character). An absent value renders as-, exactly like an absentENDPOINT. - In
--jsonboth are full-precision fields —argv_sha256carries the whole 64-character digest, so it can be compared byte-for-byte against the same run'srun_startedevent — and both are always present,nullwhen the record carries no value. Additive: a consumer reading the fields it knows is unaffected. labelsis the full key/value object in JSON and a comma-separatedLABELScell in the table. Human output passes both keys and values through the terminal sanitizer.
- In the table,
- A single corrupt or unreadable record is skipped by
Registry::entriesitself (see "Staleness" and the per-record degradation documented there) and never blindslistto the other, healthy entries — including a record whosestarted_atis not the well-formedYYYY-MM-DDTHH:MM:SS.sssZshape a runner actually writes. A malformedhint/argv_sha256is the one case that does not skip the record: the offending field alone is dropped tonull(see "Reading a record" above), so a cosmetic value can never hide an entry from this listing.
Reaping — prune
processkit-cli prune [--json] [--label KEY=VALUE]... is the cleanup counterpart to
list. Where list shows a stale leftover, prune deletes it: it opens the registry through
[Registry::open_read_only] (src/registry/mod.rs) — like list, so it never creates
the directory or touches its permissions; a missing or empty registry simply has
nothing to prune — scans it with the same shared scan list uses, and for each
scanned record deletes both its files (<stem>.json then <stem>.lock, the same
order [Registration::remove] uses) only when it can confirm the record is stale.
On unix it deletes a third leftover of the same death — the control socket that
record published, see "Reaping the control socket" below.
Repeated --label KEY=VALUE filters use the shared label parser and combine with
logical AND. When filters are present, only paired records carrying every requested
label enter the prune tally or liveness probe. Lone orphaned .lock files have no
record from which labels or ownership can be recovered, so an explicitly filtered
prune leaves them out of scope entirely. With no filter, the original registry-wide
paired-record and orphan-lock passes are unchanged.
It then makes a second pass over any orphaned lock files — a .lock with no
.json sibling at all. Such a .lock is invisible to the shared scan (which only
ever walks .json records), so however long it has sat there the paired-record pass
above can never find it. An orphan can still arise two ways: Registry::register
reserves and locks the .lock file before it writes the .json record, so a
fs::write failure in between would otherwise leave the fresh lock file behind
forever — this is now closed at the source by a Drop-backstop guard on the
reservation, armed until the record is published and disarmed right after, so a
failed register deletes its own lock file instead of leaking it; or
Registration::remove's best-effort .json delete can succeed while its .lock
delete does not, which is not similarly guarded. The second pass reuses the exact
same lock-probe safety as the first (a Live lock is never touched, a probe failure
leaves the file in place, only a confirmed-stale lock is deleted) and reports its
reaps under the separate orphaned_locks tally below — kept distinct from pruned
because it deletes one file, not a .json/.lock pair.
A record-less lock file needs one more guard the paired-record pass does not: a
freshly create_new-d .lock file that a just-starting Registry::register has
not yet locked is, for a moment, indistinguishable from a genuine, long-dead orphan —
both are simply an unlocked .lock with no .json next to them. The second pass
therefore only ever considers a candidate whose mtime is already at least a few
seconds old (ORPHAN_LOCK_MIN_AGE in src/registry/mod.rs); a genuine orphan never ages
out of that check, so the extra latency costs nothing, while the two-syscall
reservation window reliably falls inside it. Registry::reserve_entry closes the
same window from its own side: after taking its lock it re-checks that lock_path
still resolves to the identical file it is holding open (device/inode on Unix, file
index + volume serial number on Windows) before trusting it enough to publish a
record naming it, and retries with a fresh stem — never a hard error — both when the
lock is denied and when that identity check fails. Together these close the race a
lock-probe-only guarantee would otherwise leave open (see "The reaping safety
invariant" below).
Reaping the control socket
An abruptly-killed runner leaves more than a .json/.lock pair behind. On unix its
control transport is a socket file inside a per-run 0700 directory named
pkc-<token>, created under /tmp (or the platform temp directory) and removed only
by the clean-teardown Drop that an abrupt death never runs — see
docs/control-plane.md, "Cleanup and leaks". The dead record
publishes that socket's path in its endpoint field, and nothing else on the system
knows about it, so once the record went the directory was stranded forever: over time,
SIGKILLs and crashes accumulate dead pkc-* directories that no prune pass ever
looked at. Reaping a confirmed-stale entry therefore also reaps the socket that
entry published, closing the half of the "leftovers of runners that died abruptly"
contract that used to stay open.
It is reaped before the record naming it, still under the same held lock: the record is the only thing that points at the socket directory, so a pass interrupted between the two deletions must not be the one that leaves the socket unreferenced.
A record's endpoint is untrusted deserialized data, exactly like its
liveness.lock_file (whose own single-component name check the scan already applies
before ever joining it onto the registry directory) — and this step deletes what it
names, so it is never used as a path on trust. It is first validated by shape, and
a value failing any part of that check simply contributes no deletion at all (the
record and lock are still reaped as usual — the check gates only this extra step):
- absolute, and written as plain
/-separated names: a relative path, a./..segment, a doubled separator, or an embedded NUL/control character is refused — and refused as written, without normalizing anything away first; - the final component is exactly the socket file name the control server binds
(
c.sock), and its parent ispkc-plus a non-empty token of ASCII alphanumerics and-— the character set the transport's own token generator mints; - that parent sits directly inside one of the base directories the control server
binds in (
/tmp, or the platform temp directory). A perfectly-shaped path anywhere else —/etc/pkc-x/c.sock,$HOME/pkc-x/c.sock, one directory deeper — is not a candidate.
Shape alone cannot settle whether the path is a symlink, since that answer can change
between the check and the deletion. So it is settled where it cannot be raced, at open
time: the validated directory is opened with O_NOFOLLOW | O_DIRECTORY (the same
discipline the liveness probe applies to a lock file), and the socket is then removed
relative to that open handle. A pkc-… name that is really a symlink fails the
open outright, and a swap landing after it cannot redirect the deletion. Two further
refusals bound what can be deleted: only an entry that really is a socket is
unlinked (a regular file, a symlink, or a device node under that name is left alone),
and the directory itself is removed with rmdir, which never follows a final symlink
and only ever removes an empty directory — so anything unexpected still inside keeps
the directory too.
Every step is best-effort, exactly like the record/lock deletions: a socket that will
not go is a leftover to retry next pass, never a reason to abort the reaping of other
entries. A run whose socket was created under a different temp directory than the
pruning process sees (a changed TMPDIR between the run and the prune) keeps its
socket rather than having an unrecognized path deleted on its behalf. The tally is
unchanged: a reaped socket is counted by its own entry's pruned, not by a counter of
its own — the socket belongs to that entry and is never reaped without it.
Windows is unaffected. A Windows run publishes a named pipe, which lives in the
kernel object namespace and disappears with the process that created it. There is no
filesystem leftover to reap, so no endpoint is ever a candidate there and prune
behaves exactly as it did before.
The reaping safety invariant
Pruning deletes files, so it is deliberately conservative: an entry is reaped only when its own liveness probe succeeds and reports stale. The three probe outcomes are kept strictly apart — and this is the load-bearing distinction:
- Confirmed stale ⇒ reaped. The lock file is absent (stale by definition), or its exclusive lock was free and the probe took it (no live runner holds it). Only this case deletes anything.
- Live ⇒ never touched. A live runner holds the lock, so its entry is left exactly as it is. Prune never deletes a running run's record.
- Probe failed ⇒ left in place. The probe could not even be performed — the lock
file would not open (a directory in its place, a permission error, a rejected
symlink/reparse point) or the lock call itself errored. Liveness is unknown, not
confirmed stale, so the entry is kept, on every repeated prune. This is the
same case
Registry::entriesreports as [Health::Unprobed] (T-206) — never folded intoStale— so the read pathlist/inspectshare already keeps it apart from a confirmed-dead entry too, at theHealthlevel;inspect/cancel/kill/atteststill act on it exactly as they do onStale(they refuse, because they act only on [Health::Live], and a probe-failed record is not that whichever of the two non-live values it carries) — but they no longer word the refusal the same way: an unprobeable entry is reported asunprobed, liveness unknown, not as a runner confirmed gone (seedocs/control-plane.md, "When the runner cannot be reached"). Prune, though, cannot simply reuseEntry::healthhere even now that it distinguishes the case: reaping needs the probe's acquired lock held across the two deletions below, and a pure liveness query likeentries()already released it — so prune probes on its own path that keeps the failure distinct and keeps the lock, and errs toward keeping a record it is unsure about.
Two further guarantees hold, mirroring the rest of the registry:
- Never by PID. A reaped entry is addressed only through the record path the directory scan produced (the same PID-free tokened stem — see "No PID addressing" above), never by a process id, so PID reuse can never misdirect a deletion.
- Reclaim under the lock. A confirmed-stale entry is deleted while the probe still holds its exclusive lock — the "keep the lock to reclaim" behavior noted under "Staleness" above — so a second concurrent prune sees the entry as live and skips it instead of racing on the same files.
A .lock file with no .json sibling carries one further precondition before
any of the three probe outcomes above even apply: it must already be at least
ORPHAN_LOCK_MIN_AGE old (by mtime). A confirmed-stale-or-live-or-unprobed
verdict on a lock-probe alone is not enough to call a record-less lock file safe to
touch, because a freshly reserved-but-not-yet-locked file would otherwise read as
"probe succeeded, no live holder" — indistinguishable from a genuine orphan purely by
scheduling luck. A too-young candidate (or one whose age could not be confirmed at
all) is simply left alone this round, to be reconsidered once it has aged.
Corrupt records the scan already skips (unreadable, unparsable JSON, a malformed
started_at, or a lock_file that is not a simple in-directory name) are not
prune candidates: they are never probed and never deleted, exactly as list leaves
them alone — and a .lock file that does have a .json sibling, however corrupt,
is likewise left to that first pass (or to neither pass, for a corrupt record),
never treated as orphaned. Every deletion is best-effort and per-entry — an OS error
reaping one entry never aborts the reaping of the others (the leftover just reads as
stale again next time) — and pruning an already-clean, empty, or missing registry is
a no-op that exits 0.
- No
--jsonprints a one-line summary (no stale entries to prunewhen there was nothing to reap). --jsonprints a single JSON object with the tally:pruned(paired entries reaped — each including whatever control socket that entry published, see "Reaping the control socket" above),live(live entries left untouched, paired or orphaned lock alike),unprobed(entries whose probe failed and were left in place, paired or orphaned lock alike), andorphaned_locks(lone.lockfiles with no.jsonsibling that were reaped). These four fields are unchanged: reaping a socket adds no counter of its own.
Previewing a reap — prune --dry-run
processkit-cli prune --dry-run [--json] [--label KEY=VALUE]... answers "what would prune reap
right now?" without reaping anything. It is [Registry::preview_prune]
(src/registry/mod.rs), the non-destructive sibling of [Registry::prune]: the exact
same two-pass scan (paired records via Registry::scan, then orphaned locks via
Registry::orphaned_lock_paths) classified through the exact same
[probe_for_prune] three-way probe described in "The reaping safety invariant"
above, so a candidate here is confirmed stale by precisely the same rule a real
prune would use to reap it. The only thing that differs is the action taken on a
confirmed-stale (Reapable) verdict: instead of deleting the entry's files while
holding the probe-acquired lock, preview_prune releases that lock immediately —
there is nothing to reclaim it for, since nothing is being reaped — and records the
candidate instead. Live and probe-Err verdicts are handled identically to
prune. The result is that preview_prune's aggregate tally is exactly what a
following, untouched prune pass over the same on-disk registry state would
report, and the preview itself never calls fs::remove_file on anything, so the
registry is left byte-for-byte as it was.
Label filters are applied before the same liveness probes in both paths. A filtered preview therefore lists and counts exactly what a real prune with the identical filters would reap; it also excludes ownerless orphan locks by the same rule.
Because a paired record carries run_id/started_at but an orphaned .lock file
has no record to pull identifying fields from at all, each candidate is described
differently:
- a confirmed-stale paired entry is identified by its
run_idandstarted_at, the same fieldslistalready prints for it, plus the control-socket directory (socket_dir) a real reap would remove along with its two files; - a confirmed-stale orphaned lock is identified by its lock file name (there is
no
run_id/started_atto report).
socket_dir is classified by the very same shape check the real reap applies (see
"Reaping the control socket" above), so the preview can neither promise a deletion the
reap would refuse nor stay silent about one it would perform. It is null whenever
that reap would remove nothing — no endpoint was published, the endpoint is not the
shape a control server creates, or the record is a Windows one, whose named-pipe
endpoint has no filesystem leftover. Like every other part of a preview it is read
from the record alone: nothing is stat-ed, so a directory named here may already be
gone (reaping it is best-effort, exactly like the record/lock deletions).
- No
--jsonlists each confirmed-stale candidate on its own line — a paired entry asrun_id=<id> started_at=<ts>, followed bysocket_dir=<path>when there is a socket directory to reap with it (omitted entirely when there is not), an orphaned lock by its file name — then the same summary-line shapeprune's human-readable output uses, prefixedwouldthroughout since nothing is actually reaped (no stale entries to prune (dry run)when there is nothing to preview). Control characters in the record'srun_idor the directory entry's lock-file name are collapsed to spaces before interpolation, so neither can forge output lines. --jsonprints a single JSON object with the exact same aggregate fieldsprune --jsonreports (pruned/live/unprobed/orphaned_locks), plus an additionalcandidatesarray: one object per confirmed-stale candidate, internally tagged"kind":"entry"(withrun_id/started_at/socket_dir, the last always present andnullwhen there is no socket to reap) or"kind":"orphaned_lock"(withlock_file_name).prunewithout--dry-runis unchanged: its human-readable and--jsonoutput, and its exit codes, are identical to before this flag existed.- Like
prune,--dry-runopens the registry throughRegistry::open_read_only, so previewing never creates the registry directory or touches its permissions, and an empty or missing registry previews an all-zero tally with an emptycandidateslist rather than erroring.
Waiting — wait
processkit-cli wait (--run-id <id> [--report-outcome] | --all [--report-outcome]) [--timeout <duration>] is the lifetime
counterpart to list's discovery and prune's cleanup: it blocks while its target is
live and returns as soon as it is not. --run-id and --all are mutually exclusive
(clap rejects both together) and exactly one is required — see "The aggregate barrier
— wait --all" below for the second mode. It exists for the supervisor that did
not start the run — an adapter that restarted, a cleanup step, anything holding
only a run_id (or nothing but "I want every run gone") — and therefore has no child
process to wait on. Like list and prune it
opens the registry through Registry::open_read_only (src/registry/mod.rs), so waiting
never creates the registry directory or touches its permissions, and unlike
inspect/cancel/kill/attest it never connects to the run's control transport:
the run is not disturbed, not ended, and not even aware of the waiter. A run whose transport never
came up (a null endpoint, see "Record format" above) is still perfectly waitable —
wait needs no endpoint.
A run started with run --detach is the case this was written for: that call returns
once the run has started and is never the runner's parent, so wait is how its
caller learns the run is over. wait --report-outcome also consumes the run's
published --jsonl locator and terminal event for callers that want the outcome in
the same supervision step. A
detached run publishes an ordinary record here — nothing about the entry, its liveness
lock, or its removal on a clean exit differs — so list, prune, and wait treat it
exactly like any other.
How it waits. Liveness is the advisory lock described under "Staleness" above, and
that lock offers no event, notification, or wakeup a third process could subscribe to.
Waiting on it is therefore honest periodic probing — one scan plus one non-blocking
lock attempt per matching record, a few times a second (POLL_INTERVAL in
src/wait.rs) — not an event subscription dressed up as one. Blocking on the lock
itself would be worse than merely slow-to-notice: acquiring a stale entry's lock is how
a reclaimer claims it (see "Reaping" above), and it would still miss the ordinary
clean exit, which deletes both files rather than handing the lock over.
Three outcomes, by exit code:
0— the run is over: no record matches therun_idany more, or every record that does probed as stale.WAIT_TIMEOUT(112) — the--timeoutgiven towaitelapsed while the run was still live. This is the waiter's deadline, not the run's: the run was left running, untouched, and will report its own ending in its own time. It is deliberately not the run'sTIMEOUT(106), which means the opposite — seedocs/exit-codes.md, "A waiter's deadline is not a run's deadline". Without--timeout,waitblocks indefinitely.CONTROL(103) — therun_idis ambiguous: more than one live entry matches, so there is no single run whose end could be waited for (see "Run id resolution" above). Re-checked on every probe, not just the first, since a duplicate can register at any moment.
Nothing is printed on ordinary success. --report-outcome instead prints exactly
one JSON object for --run-id, or one JSON array for --all, while leaving all
exit-code semantics above untouched:
{"run_id":"build-42","status":"reported","code":7,"source":"child_exit","child_code":7}
The waiter remembers the sole confirmed-live record's absolute jsonl locator on
its polling passes. Once that record disappears, it reads the terminal
runner_exit; code, source, and child_code use that event's vocabulary. A
short bounded retry bridges the runner's normal teardown ordering, where record
removal can precede the final flushed event by a few instructions. If the waiter
never observed the run live, an older record published no locator, the runner died
without a terminal event, or the stream cannot be read, success remains honest data:
{"run_id":"build-42","status":"unknown","code":null,"source":null,"child_code":null}
The mode does not forward code as the wait process's exit status. A registry that cannot be
opened or read at all remains a SETUP (111) failure, exactly as for list/prune.
An unknown run_id reads as "finished"
A run that exits cleanly deletes its own registry entry (see "Lifecycle" below), and
the registry keeps no history of what used to be there. So "build-42 was never
registered" and "build-42 finished a moment before you asked" are the same
observation: no matching record. Nothing in the registry can separate them.
wait therefore answers both the same way — exit 0, "it is not running" — rather than
inventing a third outcome that could only ever be a guess. Failing on an unknown id
would be worse than unhelpful: it would make the result depend on when the caller
asked, turning the ordinary, expected race (the run finished while the adapter was
starting up) into a hard error — precisely the failure mode a wait command exists to
remove.
The consequence a caller must plan for is the mirror image: a mistyped run_id
returns 0 immediately, indistinguishable from a fast, successful run. wait's 0
means "not running", never "existed and completed". A caller that needs the stronger
fact must establish it separately — it launched the run itself, or it saw the id in
list — and must not read a 0 as proof the run ever existed.
Liveness it cannot confirm
Scoped to wait --run-id only — see "The aggregate barrier — wait --all" below for
how --all reuses this same conservative stance on every pass after its snapshot,
but not on the snapshot step itself, which excludes an unconfirmed entry rather than
waiting on it (a genuine, documented asymmetry with the single-run_id case below).
One case is neither live nor confirmed over: a matching record whose lock file cannot be
probed at all (a directory in its place, a permission error, a rejected reparse point —
the same "probe failed" case "The reaping safety invariant" above keeps apart from
"confirmed stale"). Registry::entries reports that case as its own
[Health::Unprobed] value (T-206), never folded into Stale — right for list,
whose whole purpose is showing the operator exactly what was and was not confirmed,
and behaviorally unchanged for the control clients inspect/cancel/kill/attest,
which act only on [Health::Live] and so refuse on Unprobed exactly as they refuse
on Stale (their message now tells the two apart, so no client asserts a
death this case never established). Minting a positive "finished" from that same
unconfirmed case would still be wrong for wait --run-id, whose 0 is a positive claim
about a run's lifetime — so on this path wait probes through [Registry::probe_run]
directly and does not read Entry::health at all, live or otherwise. (wait --all
does read it — see below.)
So wait --run-id probes on its own path and keeps waiting on an unconfirmable
entry rather than announcing a completion it never observed. A bounded caller still gets
a definite answer when --timeout elapses (WAIT_TIMEOUT, honestly meaning "could not
confirm completion in time"); an unbounded one keeps waiting for a real verdict. This is
the same "unknown is not confirmed" stance prune takes when it refuses to reap an
entry it could not probe.
The aggregate barrier — wait --all
wait --all [--label KEY=VALUE]... [--timeout <duration>] [--report-outcome] is the counterpart for a caller that does
not hold one run_id but wants a barrier on every run — the typical orchestrator
teardown sequence: cancel everything, wait for it all to be gone, then prune. It
reuses the exact same periodic-probing mechanism (src/wait.rs::run_all), differing
from --run-id only in what it tracks.
Repeated label filters combine with logical AND and are applied while forming the one initial snapshot: a record must carry every exact pair to enter the target set. Conflicting filters for one key therefore match no run. Labels are rejected with the by-id form.
Snapshot semantics, fixed rather than left ambiguous. At the moment --all starts,
wait takes a single [Registry::entries] scan and fixes its target set to exactly
the entries confirmed Health::Live at that instant, each identified by its record
file (not by run_id — two entries can share one, since the registry never enforces
uniqueness; see "Run id resolution" above). A run that registers after the snapshot
is out of scope for this invocation and is never waited for — this is a deliberate,
documented trade-off, the same "one clear rule beats a plausible-sounding but unbounded
alternative" stance the "An unknown run_id reads as finished" section above already
takes for the single-run case. The alternative — keep discovering new runs forever —
would leave a caller unable to say when --all could ever return at all. A caller that
wants to catch a run starting concurrently with the wait re-issues wait --all once
this one returns.
Unprobed entries stay "not confirmed done" — once they are in the target set. On
every later pass, a snapshot entry that re-probes Health::Live or
Health::Unprobed stays outstanding — the exact same conservative stance the
"Liveness it cannot confirm" section above documents for --run-id, applied per-entry
instead of to a single target. An entry that re-probes Health::Stale, or that has
vanished from the scan entirely (a clean exit deletes its own record), is dropped from
the target set — the same "confirmed over" observation that lets an unknown run_id
read as finished.
This conservative rule governs every pass after the snapshot, not the snapshot
itself: because the target set is fixed to exactly the entries confirmed Health::Live
at the snapshot instant (above), an entry that is only Health::Unprobed — not
confirmed live — at that instant is excluded from the target set from the start,
never entering it at all. This is a genuine, deliberate asymmetry with --run-id,
which never excludes its one target this way (it always has exactly the id it was
asked to wait for): a registry holding only unprobeable entries and no confirmed-live
ones makes wait --all return 0 immediately, the same as an empty registry, even
though none of those entries' liveness was actually established. A caller relying on
--all as a teardown barrier must not read that 0 as proof every run in the registry
was actually confirmed over — only that nothing was confirmed live at the moment the
snapshot was taken.
Outcomes. Success (0) means every snapshot entry probed stale or vanished; a
bounded --timeout that elapses with entries still outstanding is the same
WAIT_TIMEOUT (112) --run-id uses, reporting how many snapshot entries are still
outstanding and, when at least one of them was only Unprobed on the last pass, saying
so rather than confidently claiming they are all still live. There is no aggregate
CONTROL/ambiguity outcome: --all never resolves an id at all, so the duplicate-id
question --run-id answers with CONTROL does not arise for it. Nothing is printed on
success without --report-outcome, exactly like --run-id. With the flag, success
prints one JSON array after the barrier clears, with one entry per original snapshot
target in stable run_id then record-path order. Each entry has the same
run_id/status/code/source/child_code shape as the single-run report and
uses reported when its snapshotted JSONL locator yields a terminal runner_exit,
or unknown with null outcome fields when the locator is absent, unavailable, or
malformed. A timeout still prints no report and exits WAIT_TIMEOUT (112); outcome
data never changes the barrier's exit code.
Lifecycle
- Create.
runwrites the record and takes the liveness lock before the child is spawned, so the entry exists for the whole run. Creating the registry is best-effort: if it fails, the runner warns on stderr and proceeds — the registry is control-plane discovery infrastructure, and losing it must never cost the child its faithfully forwarded exit code (AGENTS.md, "Exit-code fidelity"). - Remove. On a clean exit the entry is removed from the same teardown site as
the container reap (
clear_registrationinsrc/run/teardown.rs), on every decided ending — a normal child exit, a--timeout, a local stop-signal cancel (Ctrl-C, on Unix aSIGTERM/SIGHUP, or on Windows aCtrl-Break/console close/logoff/system shutdown, all of which the runner catches), or a control-planecancel/kill— not just the happy path. - Leak → stale. An abrupt death skips that removal by definition, leaving the
record on disk — and, on unix, the control socket that record published (see
"Reaping the control socket" above). The released lock makes the record detectably
stale, per the section above. This is genuinely abrupt death only — a crash, a
SIGKILL, an outer Job Object terminate — not an ordinary UnixSIGTERM/SIGHUPor a caught Windows console-control event (Ctrl-Break/console close/logoff/system shutdown), all of which the runner catches and turns into the clean removal above. - Stale → reaped.
pruneis what finally clears such a leftover, deleting the record, its lock, and the socket directory together — only once it has confirmed the entry stale.
Troubleshooting
This is an operator's guide: symptom, what to look at, and where the
normative answer lives. It does not restate the normative documents —
docs/schema.md, docs/exit-codes.md,
docs/registry.md, and docs/control-plane.md
— it points at them. For the consumer/adapter walkthrough (preflight,
launching, reading the stream, supervision, housekeeping), see
docs/integration.md instead; this document is organized by
symptom rather than by call sequence, and each entry below is deliberately
short. On any disagreement between this document and one of the normative
ones, the normative document is the source of truth.
Qualifying a host: doctor
Symptom. probe says the binary is compatible, but the first real run on this
machine fails — or runs work on one host and not on another, and you want to know
which part of the environment differs before spending a production run finding out.
Diagnose. Run the host qualification:
processkit-cli doctor # human-readable
processkit-cli doctor --json # one JSON line, for an adapter
doctor is the side-effecting counterpart of probe, and the difference is the whole
point of having both. probe reads compile-time constants and the in-memory CLI tree:
it proves this binary exposes the surface you need, spawns nothing, and touches no
registry, container, or transport. doctor proves this host can actually run a
contained process, by doing it: it performs a bounded scratch run of this binary's own
harmless child (doctor --scratch-child, which sleeps briefly and does nothing else),
drives that run as an ordinary control-plane client, and reports what it observed. A
passing probe and a failing doctor is the normal shape of an environment problem.
The report is a list of facts, never one boolean:
| Fact | What it tells you |
|---|---|
registry.dir, registry.owner_only, registry.protection | Which per-user registry directory this host resolves to, and whether it really is protected to its owner alone — re-read from the filesystem, not assumed from the create having succeeded (docs/registry.md). |
containment.mechanism, containment.abrupt_cleanup | Which containment mechanism this host actually gave the run, and what still reaps the tree if the runner is killed outright. The same values the run_started event publishes (docs/schema.md) — so a cgroup_v2 → process_group fallback (above) is visible here at setup time instead of mid-incident. |
control.* | That the local transport bound, answered an inspect with real members, accepted a cancel, and that the run ended because of that cancel (terminal_exit_code: 108, terminal_source: "control_cancel"). |
cleanup.* | That teardown was confirmed empty rather than assumed — kill_error says whether the hard kill succeeded and read_error whether the member read succeeded — and that every artifact is gone: the registry record, the control endpoint, the scratch files. |
resource_controller | Only with --check-resource-controller: whether a whole-tree cap (run --max-processes) can be installed here at all. available: false is written only on the scratch run's own limit_hit evidence that the cap was refused (the entry below), so null always means nothing was established — the check was not asked for, or it ran and could not reach a verdict, in which case its phase fails and says why — and never not available. |
phases[].elapsed_ms | Where the time went. A host that is merely slow is diagnosable as such instead of arriving as a generic hang. |
Reading a negative verdict. doctor exits HOST_UNQUALIFIED (116) when a phase
failed or a --require-* expectation was not met, and prints the report either way
(see docs/exit-codes.md, "Qualifying a host: doctor"). The two
cases are distinguishable in the report itself: failures names phases that did not
work, mismatches names host properties that are not what you asked for. A failed
phase keeps its evidence: the scratch directory is not deleted, and
diagnostics_dir names it — it holds the scratch run's own JSONL stream, the runner's
stdout/stderr, and a copy of the report. A qualified host leaves nothing behind at all.
Pinning what you need. The requirement flags gate the exit code and nothing else — the observed facts are reported identically with or without them:
processkit-cli doctor --json --require-abrupt-cleanup whole_tree
processkit-cli doctor --json --require-mechanism cgroup_v2
processkit-cli doctor --json --check-resource-controller --require-resource-controller
Each compares for exact equality against the value this host reports, and names
both sides on a mismatch. --require-abrupt-cleanup in particular is not an "at least
this strong" comparison: the three levels are platform facts
(docs/platform-support.md), and this project publishes no
ordering between them to compare against.
What it costs, and what it touches. One (or, with
--check-resource-controller, two) short scratch runs of this binary against itself,
in the real per-user registry the host uses — which is the point: a qualification of an
isolated sandbox would qualify the sandbox. The whole check is bounded by --timeout
(default 30s), and the scratch run and its child carry that bound themselves, so a
doctor that is itself killed leaves nothing running behind it.
BACKEND (102) with a limit_hit event, often only on CI or under systemd
Symptom. run --max-memory <size> / --max-processes <n> / --cpu-quota <cores> exits BACKEND (102) immediately — no child output at all — even
though the same command works locally without the flag, or works locally
with it.
Diagnose. Read the --jsonl stream: a limit_hit event (naming which
limit — memory / processes / cpu — in its limit field) precedes the
container_failed (phase: "create") and terminal runner_exit
(source: "container_error", code: 102). The limit_hit event, not the
exit code, is what tells you this specific ending was a resource cap the
platform could not apply — see docs/schema.md.
Why it happens. A whole-tree cap needs a real container. On Linux that
means cgroup v2 at the real hierarchy root — a minimal, non-systemd init.
It does not work under a systemd session/scope/service, inside an
ordinary container (Docker/Kubernetes), or under typical hosted CI (including
GitHub Actions' ubuntu-latest), because the controllers cannot be enabled
there; the run fails fast rather than silently running unbounded. FreeBSD's
process reaper still provides whole-tree normal containment, membership, and
kill/teardown semantics, but it has no memory, CPU, or process-count cap support,
so any cap request fails there too. macOS and non-FreeBSD BSD process groups,
along with the Linux process-group fallback, have no whole-tree cap container at
all, so any cap request fails the same way there too. See the
resource-limit platform matrix for the
separate FreeBSD process-reaper row and the distinct macOS/non-FreeBSD BSD
process-group row, and
docs/exit-codes.md for
why this reuses BACKEND (102) instead of a dedicated code.
Fix. Either run somewhere the cap can actually be enforced (a Windows Job Object, or a real Linux cgroup v2 root), or drop the resource-limit flags — there is no partial/best-effort mode.
The honest fallback: cgroup_v2 → process_group
Symptom. On Linux you expected cgroup v2 containment (whole-tree teardown
and process accounting) but observe process-group-only behavior instead — for
example a descendant that left the process group via setsid/double-fork
surviving an ordinary teardown, or a just-exited child still listed briefly in
a post-kill member snapshot.
Diagnose. The run_started event's mechanism field (also echoed live by
inspect --json's snapshot) reports which containment mechanism this
specific run actually got — cgroup_v2 or process_group — never a promise
based on the platform alone; that field alone tells you whether the fallback
happened. Do not use abrupt_cleanup (also on run_started) to tell the two
apart: it is a separate, OS-derived contract — whole_tree on Windows,
direct_child_only on Linux, none on macOS/other Unix — sourced from the
platform's parent-death-signal capability, not from which mechanism this run
got. On Linux it reads direct_child_only whether the run got cgroup_v2 or
fell back to process_group, so comparing it against mechanism tells you
nothing about the fallback. See docs/schema.md and
docs/control-plane.md.
Why it happens. Where cgroup v2 delegation is unavailable to the runner,
it falls back to the POSIX process-group mechanism rather than claiming a
cgroup it did not get — the same unavailability this document's first entry
covers for resource limits, but here it is a silent, successful fallback
instead of a hard failure, because plain containment (unlike a requested
cap) has a working fallback. What the fallback actually costs is ordinary
teardown/accounting strength, not extra abrupt-death coverage: if the runner
itself dies abruptly, a cgroup does not automatically kill grandchildren
either — only the direct child is covered, by the parent-death signal, under
either mechanism. See README.md, "Platform matrix", for the per-mechanism
guarantees.
A console window pops up for a detached run
Symptom. run --detach launches a console-based child on Windows and a
new, unwanted console window appears (or flashes) even though nothing about
the invocation looks interactive.
Diagnose. No JSONL event is involved — this is purely an OS behavior:
Windows gives a console-allocating child a fresh console of its own whenever
its parent has none. The detached runner itself has no console (it was
launched with DETACHED_PROCESS), so any console-based child it starts gets
one unless told not to.
Fix. Pass --create-no-window alongside --detach — it maps directly
onto ProcessKit's Command::create_no_window() (the CREATE_NO_WINDOW
creation flag; a no-op on non-Windows platforms). It defaults to off for an
ordinary foreground run (so a bare run still behaves like a direct
launch), but a detached run is exactly the case where passing it matters
most. See README.md, "Windows console", and README.md, "Detached runs".
list shows an entry as unprobed
Symptom. list/list --json shows a registry entry's health as
unprobed rather than live or stale, and you are not sure whether it is
safe to delete by hand; or prune --json's tally keeps reporting a non-zero
unprobed count across repeated runs instead of reaping those entries.
Diagnose. list's health field has three values, matching the same
tri-state verdict prune/wait already use internally: "live", "stale"
(confirmed dead — the liveness lock probed as released), and "unprobed" (the
liveness lock genuinely could not be probed at all: the lock file would not
open — a directory in its place, a permission error, a rejected reparse
point — or the lock call itself errored). "unprobed" is a deliberately
distinct, conservative verdict — "could not confirm liveness" is not the same
claim as "confirmed dead" — and prune (and its non-destructive
prune --dry-run preview) never reap an entry in this state, on every
repeated run, until the probe itself can succeed. A control client
(inspect/cancel/kill/attest) aimed at such an entry refuses with CONTROL
(103), since it acts only on a confirmed-live entry — but its message,
too, reports that liveness could not be probed rather than that the runner is
gone (see the CONTROL (103) entry below).
A non-zero unprobed count in prune --json/prune --dry-run --json is not
always the same set of things list shows you as unprobed, though: the
tally is shared between this per-entry probe (one .json/.lock pair, the
same one list reports on) and a second, independent pass over orphaned
.lock files — a .lock with no .json sibling at all, invisible to
list, which only ever walks .json records. So the count can include lock
files list has no entry for at all, on top of any unprobed entries list
already showed you. See docs/registry.md ("Discovery" for
what list reports) and
docs/registry.md for exactly
which of the three probe outcomes prune reaps.
Fix. Run prune --dry-run --json first to see precisely what a real
prune would reap (and what it would leave as unprobed) before running the
destructive form. For an unprobed entry list already shows you, or for any
excess the dry-run's tally reports beyond that, investigate the registry
directory and its .lock files directly (the usual cause is a permissions
issue or a path collision) rather than deleting registry files by hand.
CONTROL (103): the runner could not be reached
Symptom. inspect / cancel / kill / attest exits 103 and prints an
explanatory line on stderr. For the by-run_id form this means the command did
nothing to any run; cancel --all / kill --all are the exception — see
"cancel --all / kill --all and a partial 103" below before assuming
nothing happened.
Diagnose. stderr names which reason applied. Most of them mean the runner
itself was never reached: a run_id the registry names nowhere (not_found) or
names more than once ("An ambiguous run_id" below), plus the three described
here, where an entry was found but no answer came back. The read-only verbs then
add two refusals of their own below, where the target was reached and did
answer. If you are diagnosing this from a script rather than by eye, re-run the
same command with --error-format json, which prints the reason as a
machine-readable kind (stale / unprobed / control_unreachable, plus
not_found, ambiguous_run_id, ipc_deadline, incompatible_contract, and
peer_identity_unsupported) instead of a sentence: a stale registry
entry (the runner died abruptly, so the entry's record is left behind but
its liveness lock has been released, detected before connecting), an
unprobeable registry entry (the liveness lock could not be probed at all,
so the runner is not confirmed gone — the message says liveness could not be
probed and calls the entry unprobed, never "the runner is gone"), or died
mid-conversation (the entry read live, but the runner exited between the
liveness probe and the reply, or the connection closed before a complete
response arrived). All three are bounded — no client hangs waiting for a
runner that is not going to answer. list is the fastest cross-check for the
first two without retrying the failing command, and it reports the same
verdict the refusal did: a stale entry shows as stale, an unprobeable one as
unprobed (see "list shows an entry as unprobed" above for what to do
with that one — in short, do not hand-delete it). See
docs/control-plane.md, "When the runner cannot be
reached: a distinguishable result, never a hang", and the CONTROL (103) row
of the reserved-band table in docs/exit-codes.md.
The read-only verbs add a refusal the three above cannot produce, and it is not
a lost runner. If the message says the runner answered with a control-plane
version this client does not read (kind: "incompatible_contract"), the runner
is reachable and healthy: the exchange completed, and it is the answer that was
rejected, because it declares a contract this binary does not implement — in
practice a runner newer than the client you are running. inspect and attest
each carry a version axis of their own, and either one can be refused this way —
the same kind, a different contract, a different fix:
inspectsays "the runner answered with control-plane snapshot version …", naming the number that arrived and the range this build reads. Retrying will not help: inspect that run with a build that implements its snapshot version — for a newer runner, one at least as new as the binary that started the run.attestsays "the runner answered with control-plane attestation version …", naming the number that arrived and the single version this build reads. There is no read-down range on that axis on purpose — a misread membership verdict is a security answer rather than a diagnostic — so retrying is equally useless: attest that run with a build that implements its attestation version.
cancel/kill cannot hit either refusal (their ack carries no version), and
neither can list/wait/prune, which ask no runner for one, so in both cases the
run itself is still fully controllable. processkit-cli probe --json reports the
version of whichever binary you run, which is how you tell two installed
builds apart (no preflight can report a runner's snapshot or attestation version —
those numbers only arrive in its reply). See
docs/control-plane.md, "Snapshot version: a newer runner's
reply is refused, an older one is read" and "Attestation version", and
docs/integration.md §6 ("An unreadable contract version").
Beyond that shared refusal, attest has one more of its own, and it is neither
a lost runner nor a negative verdict. If the message says the runner could not
obtain a kernel-authenticated identity for this client from the control transport
(kind: "peer_identity_unsupported"), the runner is reachable and healthy: the
exchange completed, and it declined to decide membership because a transport
that cannot name the caller leaves it nothing to decide on — reporting an
unproven member is the one answer it will not give. This is a refusal, never a
"no": a decided non-membership is not a 103 at all but NOT_A_MEMBER
(115), kind: "not_a_member" (the attestation is printed on stdout either way).
Retrying will not help, because the capability is a property of the runner's
platform and build rather than of the moment; rule it out at preflight instead,
with processkit-cli probe --json --require-surface attest:peer-identity run
against the runner's own binary — meeting it at runtime means that check was
skipped, or the runner is a different build. No other command can hit it:
inspect/cancel/kill/wait/list never ask the question, and the run
itself is untouched and still going, since attest is read-only. See
docs/control-plane.md, "attest", and
docs/integration.md §6 ("A caller the runner cannot name").
wait does not share this code. The registry-only wait --run-id <id>
never connects to a run's control transport, so "died mid-conversation" is
not something it can hit, and a stale registry entry does not give it 103
either — only the ambiguous-run_id reason below does. A stale or missing
entry makes wait exit 0, the same as a run that finished cleanly (the
registry keeps no history, so "build-42 was never registered" and
"build-42 finished a moment before you asked" read identically): do not
take a 0 from wait as proof a stale-looking run_id was ever live. See
docs/registry.md.
Not a run outcome. A 103 says nothing about how the target run itself
ended (or whether it is still running) — for the by-run_id form it is purely
"this client could not resolve or reach a single target run". Do not conflate
it with the run-outcome codes (106–109, or the child's own code), which
come only from the run's own process exit. (cancel --all / kill --all's
103 is different — see below.)
cancel --all / kill --all and a partial 103
Symptom. cancel --all or kill --all exits 103, but the JSON
report it printed to stdout shows at least one target with "accepted": true
— so, unlike the by-run_id form's 103 above, this run was acted on.
Diagnose. --all's 103 is a different fact from the by-run_id form's:
it means "one or more targets in the confirmed-live snapshot could not be
reached or did not acknowledge the command", not "nothing was found or
reached". A snapshot with several live runs commonly has a mix — most targets
ack cleanly while one becomes unprobeable, changes identity, or does not respond in
time — and the aggregate exit code reflects that any failure occurred so an
automated caller never mistakes a partial teardown for a complete one. Read
the JSON report on stdout (not just the stderr tally) to see exactly which
records failed and why; stderr on its own only gives the failure count. A duplicate
run_id is not itself an aggregate failure because --all addresses each record
path and endpoint independently. Likewise status: "already_gone" is non-error: the
target ended after the snapshot, so accepted is false but teardown is already
complete for it. See
docs/control-plane.md, "cancel --all / kill --all",
and the CONTROL (103) row of the reserved-band table in
docs/exit-codes.md.
Fix. Re-running cancel --all / kill --all is safe: a target already
ended is absent from the next snapshot (or already_gone if it ended during this
one), and a target that failed for a
transient reason (e.g. it was mid-exit) is retried. If a specific run_id
keeps failing, use list --json or inspect --run-id <id> to see why (a
stale entry, an unprobed one, or a genuine ambiguity — the same reasons the
by-run_id sections above and below cover).
An ambiguous run_id
Symptom. inspect / cancel / kill / attest / wait --run-id <id>
exits CONTROL (103) with an "ambiguous run id" message, even though you
believe exactly one run with that id is alive.
Diagnose. The registry does not enforce run_id uniqueness at
register time: two runs started concurrently with the same explicit
--run-id are both written as independent, live entries. Every by-run-id
client — including the read-only inspect and attest, and the registry-only
wait — fails closed with CONTROL (103) the moment more than one live entry
matches, rather than silently acting on whichever entry a directory scan
happens to return first; run list --json and filter by run_id to see the
duplicates directly. See docs/registry.md, "Run id
resolution — ambiguity is a hard failure".
Fix. Keep run_ids unique among your own concurrently-live runs (a
counter, a UUID, or any value your launcher does not reuse before the
matching run has ended); there is no way to disambiguate after the fact
other than avoiding the collision at launch time.
The child's terminal behavior degrades under the default pipe + echo
Symptom. Colors, progress bars, spinners, or other cursor-based rendering from the child look wrong, missing, or replaced with plain line-by-line output — even though the same command renders correctly when run directly in a terminal.
Diagnose. By default run gives the child pipe + echo, not a real
inherited terminal: ProcessKit reads the child's stdout/stderr through
pipes and this runner re-emits the bytes onto its own stdout/stderr. The
child therefore sees no TTY on either stream, so any code path in it that
checks isatty() (or equivalent) before drawing takes its non-interactive
branch — this is the child's own, otherwise-correct terminal detection
working as designed, not a bug in the runner's pump.
Fix. Pass --inherit-stdio for an interactive command: it hands the
child the runner's own stdin, stdout, and stderr handles directly — no pump,
no echo, no --capture-dir tee in this mode — so an existing terminal is
preserved unmediated instead of proxied. It is mutually exclusive with
--capture-dir, --create-no-window, --inherit-stdin, --stdin-file,
--no-echo, --idle-timeout, and --detach (a detached run has no terminal
to hand over in the first place); Ctrl-C behavior also becomes
platform-dependent under this flag rather than the runner's own uniform
cancelled/107 outcome. See README.md, "Standard I/O", for the full
contract, including exactly how Ctrl-C is delivered in this mode on each
platform.
See also
docs/schema.md— the normative JSONL event schema.docs/exit-codes.md— the normative reserved exit-code band.docs/registry.md— the normative registry location, staleness signal, and reaping rules.docs/control-plane.md— the normative local transport, wire protocol, andinspect/cancel/kill/attestbehavior.docs/integration.md— the consumer/adapter walkthrough, organized by call sequence rather than by symptom.docs/platform-support.md— what each platform guarantees, and which of those guaranteesdoctorconfirms on the machine in front of you.
Integration guide for adapters
This is the walkthrough for a consumer of processkit-cli — an orchestrator or
adapter (in particular the processkit-py CLI) that launches runs through this
binary and reads its results back — rather than for a contributor to this
repository (see docs/architecture.md for that audience). It
ties together, in the order an adapter actually exercises them, the five
normative documents that each cover one part of the compatibility surface on
their own: docs/schema.md, docs/exit-codes.md,
docs/control-plane.md, docs/registry.md,
and docs/compatibility.md. This document does not restate
their normative text — every concrete claim below is a pointer to, and a minimal
worked example of, the contract those documents define; on any disagreement,
the linked document is the source of truth.
1. Fail-closed preflight: probe (the binary), then doctor (the host)
Before launching anything through a candidate processkit-cli binary, verify
it is compatible. probe is side-effect-free — it spawns no child and touches
no registry or container — and prints one JSON report line to stdout:
processkit-cli probe --json \
--require-schema-version 1 \
--require-exit-code-band 100-119 \
--require-surface run:--jsonl \
--require-surface run:--capture-dir \
--require-surface inspect:--json \
--require-surface cancel:--run-id \
--require-surface kill:--run-id
The report (one line, shown reformatted here):
{
"probe_version": 1,
"binary": "processkit-cli",
"version": "0.2.2",
"schema_version": 1,
"exit_code_band": { "start": 100, "end": 119 },
"surface": ["cancel", "cancel:--run-id", "inspect", "..."],
"compatible": true,
"mismatches": []
}
- Pin
schema_version(--require-schema-version <N>) and the reserved exit-code band (--require-exit-code-band <start>-<end>) so a future breaking change is caught here, before a run, rather than by a JSONL parser or an exit-code table drifting silently out of sync. - Pin the exact CLI flags the adapter is about to use with one
--require-surface <token>per token (a bare subcommand name, or<subcommand>:--<long-flag>) — this is how an adapter confirms a flag it depends on (for examplerun:--capture-dir) actually exists on this build before passing it. - Pin
run:resource-summaryif the adapter will read what a run consumed — peak memory, CPU, IO bytes, peak process count. Like the token below it this is a capability, not a spelling (note the missing--), and it says this build'srunemits the terminalresource_summaryevent. Requiring it is the only way to learn that before a run: an older binary simply writes no such line, which an adapter would otherwise discover by finding it missing from a stream it has already finished collecting. The token guarantees the event, not any particular number in it — which measurements are populated follows the containment mechanism and, for memory and CPU on Linux cgroup v2, how the run ended, perdocs/resource-limits.md. Read that matrix before building an adapter around a specific axis; pinning the token is not the same as learning a number will be there. - Pin
attest:peer-identityif the adapter will gate anything on containment membership (§4). It is the other capability rather than a spelling — note the missing--— and it says this build can obtain a kernel-authenticated identity for a control-plane client on this platform, which is what makesattestable to answer at all. Requiring it turns "this platform cannot prove membership" into an ordinary fail-closedPROBE_INCOMPATIBLE(110) here, instead of apeer_identity_unsupportedrefusal in the middle of a job. Its presence is a guarantee; its absence withholds one rather than predicting failure, so an adapter that requires it is choosing not to depend on an unguaranteed capability. - An unmet expectation makes
probeexitPROBE_INCOMPATIBLE(110) withcompatible: falseand the concretemismatches; a malformed--require-*argument (not an incompatibility, a bad flag) is the ordinaryUSAGE(100). A satisfied — or unrequested — surface exits0.
This is a fail-closed contract: an adapter that skips the preflight (or
silently proceeds after a PROBE_INCOMPATIBLE) re-introduces exactly the
uncontained-launch hazard this project exists to prevent. See
src/probe.rs and the normative exit-code table in
docs/exit-codes.md.
The report's shape is published as a JSON Schema with a golden fixture —
fixtures/schema/cli/probe.schema.json and probe.jsonl — so an adapter can
validate what it parsed instead of re-deriving the shape by hand. Every
machine-readable output in this guide has such a pair; see
fixtures/schema/cli/README.md
for the full table, and docs/compatibility.md, "Machine-output schemas", for
why some of these outputs carry no version field of their own (the probe
report's probe_version, the inspect snapshot's snapshot_version, the
failure envelope's error_version — §7 — the attestation's
attestation_version — §4 — and the qualification report's doctor_version
— below — are the five that do).
probe --print-schema is a separate, simpler mode on the same subcommand: it
prints this binary's embedded JSONL event-schema document instead of the
report above and exits 0, so an adapter that only needs the schema for its
own version — no clone, no tag to match — can fetch it offline without a
compatibility check. It cannot be combined with any --require-* flag:
that combination is rejected as an ordinary USAGE (100) parse error, never a
silent skip of the requested checks, so it can never produce a false "ok" on
an invocation that also asked probe to verify expectations. See
docs/schema.md, "Getting the schema without a git checkout".
Qualifying the host: doctor
A passing probe says the binary you found is the one you need. It does not say this
machine can run a contained process — by construction, since proving that would mean
running one, and a preflight that spawned a child would not be a preflight. The two
claims come apart in practice: a registry directory that cannot be created or is not
owner-only, a containment mechanism the kernel will not hand out, a local IPC endpoint
that will not bind. Each of those passes every --require-* check above and fails the
first production run.
doctor closes that gap by doing the thing:
processkit-cli doctor --json --require-abrupt-cleanup whole_tree --check-resource-controller --require-resource-controller
It performs a bounded scratch run of this binary's own harmless child
(doctor --scratch-child), drives that run through the ordinary control plane
(inspect, cancel, terminal wait), confirms teardown left nothing, and reports the
facts it observed — the registry directory and its owner-only protection, the
containment mechanism and abrupt-cleanup level this host really gives a run, the
transport round-trip, a confirmed-empty cleanup, and per-phase timings. On success it
leaves nothing behind; on a failed phase it keeps a diagnostics directory and names it
in the report (diagnostics_dir).
Where it belongs in an adapter's flow: once per host, at setup or install time, not
before every run. It is the counterpart of the probe above, on the other axis —
probe | doctor | |
|---|---|---|
| Subject | This binary | This host |
| Side effects | None: no child, no registry, no container, no endpoint | A real scratch run: registry entry, container, control endpoint, all cleaned up |
| Cost | Milliseconds | Under a second, bounded by --timeout (default 30s) |
| Run it | Before every launch, or at least whenever the binary may have changed | Once per host, at setup time — or when a host starts behaving differently |
| Fail-closed code | PROBE_INCOMPATIBLE (110) | HOST_UNQUALIFIED (116) |
The requirement flags gate the exit code only; the report carries the observed
facts either way, so an adapter can act on the code and still log everything the
qualification saw. --require-resource-controller needs
--check-resource-controller alongside it — a requirement about a fact this
invocation never observed is refused as a USAGE (100) rather than guessed.
doctor --json's shape is published like every other machine-readable output here:
fixtures/schema/cli/doctor.schema.json and doctor.jsonl. See
docs/troubleshooting.md, "Qualifying a host: doctor", for
reading a negative verdict, and docs/exit-codes.md, "Qualifying a
host: doctor", for why 116 is its own code.
2. Launching a run
The recommended invocation for an adapter:
processkit-cli run \
--run-id build-42 \
--jsonl .processkit/build-42.jsonl \
--capture-dir .processkit/build-42/capture \
--env-clear \
--env PATH="$PATH" \
--env-remove CI_SECRET_TOKEN \
--timeout 10m \
--grace 5s \
-- dotnet build
--jsonl <file>is the only place lifecycle events are written — never stdout, so the child's own stdout/stderr stay pristine. Give every run a distinct path; the file is created or truncated at the start of the run.--run-id <id>is the identifierinspect/cancel/kill/attestlater match on — supply one you control (rather than the generated default) so the supervision step (§4) has a stable handle. Two live runs sharing one--run-idis legal but makes every supervision command against it fail closed as ambiguous (§4, §6) — keep run ids unique across an adapter's own concurrently-live runs.--capture-dir <dir>additionally tees stdout/stderr to<dir>/stdout.log/<dir>/stderr.logwith a byte count, a SHA-256, and explicit truncation/write-error flags per stream (theoutput_capturedevent, §3) — use this when the adapter needs the transcript as a file rather than (or in addition to) the live echo.--no-echosuppresses the runner's own live retransmission of the child's stdout/stderr — the exact "pure noise" an adapter reading results from--jsonl/--capture-diralone does not want interleaved with its own output. The pipe,--capture-dir, and the JSONL stream are all unaffected; it conflicts with--inherit-stdio, which runs no pump to suppress in the first place.--detachreturns as soon as the run has provably started instead of blocking for its whole duration — the "launch and let go" shape, for an adapter that supervises out of band (§4) rather than by staying the runner's parent. It re-spawns the CLI detached (a new session on Unix, aDETACHED_PROCESSon Windows) and waits only until that copy has registered the run and writtenrun_startedto--jsonl, so on return the run is already visible tolist/inspect/wait. An adapter that captures the launch command's output (subprocess.run(..., capture_output=True)) gets end-of-file when the call returns, not when the run ends: the detached runner keeps none of the caller's pipes open. The exit code changes meaning under this flag and only under it: it reports the start —0once the run started, or the same reserved code the failure would have produced in the foreground (a missing program is stillSPAWN101) — never the child's own code, which stays in the terminalrunner_exitevent (§3). Adapters that need the child's result must read it there, or viawaitplus the event stream. It conflicts with--inherit-stdio/--inherit-stdin(nothing interactive survives detaching) and implies--no-echo's discarding sinks, while--jsonl,--capture-dir,--idle-timeout, and--snapshot-intervalbehave exactly as they do in the foreground. The detached runner's own stderr isnull, so--jsonlis the only channel that reports anything — including a failed member read, which is why that failure is a flagged event rather than a warning (§3). On Windows, pair it with--create-no-windowfor a console child: the detached runner has no console to lend it, so the OS gives the child one of its own. Seedocs/exit-codes.md, "Detached runs".--env-clear/--env-remove <KEY>/--env-file <file>/--env <KEY=VALUE>give the adapter control over the child's environment, applied in that fixed order — clear, then remove, then files, then explicit sets — regardless of flag order on the command line, so an explicit--envalways wins on a duplicated key. SeeREADME.md, "Environment", for the full precedence rule.--run-id-env <KEY>hands the child the run's final id — the--run-idabove, or the generated one when the adapter did not supply an id — in the named environment variable, applied after every flag in the previous bullet. This is the alternative to minting an identity adapter-side and passing it twice (--run-id <id> --env KEY=<id>): one value, no second copy to drift, and it is the only way to give the child a runner-generated id, which is otherwise not knowable until the run has already started. It is opt-in (no key is injected by default) and it composes with--detach— the detached copy is re-spawned with an explicit--run-idfor the id its caller already reported, so the child sees the same value the caller has. An explicit--env <KEY>=…for the same key is refused as aUSAGE(100) parse error rather than silently overridden — "the same key" by the platform's own rule, so on Windows an--enventry differing from<KEY>only in case is that same refusal. Treat the value as correlation data only: it identifies a run, it does not authenticate one, and any process able to set an environment variable can forge it.--max-memory <size>/--max-processes <n>/--cpu-quota <cores>cap the run's whole process tree. Enforcement needs a real container (Windows Job Object or Linux cgroup v2 at the real hierarchy root); where the platform or environment can't apply a cap the run fails fast with alimit_hitevent (§3) andBACKEND(102) rather than running silently unbounded — so an adapter that depends on a cap must treat alimit_hitas a hard failure, not a warning. See the resource-limit platform matrix, which has a separate FreeBSD process-reaper row — normal whole-tree containment, membership, and kill/teardown, but no memory/process/CPU caps orresource_summaryaccounting — and the distinct macOS/non-FreeBSD BSD process-group row; cgroup v2 is often unenforceable under systemd/containers/typical CI, and the Linux--max-processescaveat.- Command-line redaction.
run_started'scommandfield is redacted by default: the raw argv is not recorded, only a one-way SHA-256 fingerprint (argv_sha256) and a classified worker-shapehint(both derived from argv but unable to reveal it) — filled on every run whether or not--argv-rawis given. Pass--argv-rawonly when the adapter's own storage for the resulting JSONL is at least as trusted as the command line itself; do not default to it. Seedocs/schema.md("Command redaction") for the exact fingerprint encoding, which an adapter reproducing the digest independently must match byte for byte. That encoding is defined over each element's canonical bytes, which are platform-specific for an argument that is not valid Unicode (Unix: the argument's own bytes; Windows: the WTF-8 encoding of its UTF-16 code units) — an adapter that hashes a UTF-8-decoded string instead will agree on every ordinary command line and disagree on exactly those. The same case is the one where--argv-raw'sargvarray carries an escaped element rather than the argument verbatim; an adapter that re-runs or compares a recorded argv must decode it. An adapter that stores what it read must also check its sink: an escaped element is the only string value in this schema that can contain U+0000, and while the JSONL line itself stays NUL-free text (the wire form is the ordinary six-character escape), a decoded value handed to a sink that forbids NUL is rejected or truncated — PostgreSQL refuses ajsonbdocument containing it, so an adapter ingesting whole event lines intojsonbloses the entire event rather than one field. All three rules are indocs/schema.md.
run is not shell-free by accident — everything after -- is the literal
<program> <args...>, with no shell to expand or reinterpret it; an adapter
that needs shell features passes the shell as the program explicitly.
3. Reading the JSONL stream
--jsonl accumulates one JSON object per line as the run proceeds; parse it as
newline-delimited JSON, dispatching on each object's event field. A minimal
reader:
import json
with open(jsonl_path, encoding="utf-8") as f:
for line in f:
evt = json.loads(line)
if evt["schema_version"] != 1:
raise IncompatibleSchema(evt["schema_version"])
handle(evt["event"], evt)
Pin schema_version here too (or rely on the probe preflight in §1 to have
already ruled out a mismatch) — never assume a fixed shape without checking it.
Or let the binary read it back for you. events is the first-party reader of
the same stream, so an adapter that only needs to show a run's story — or to
check one — does not have to write the loop above at all:
processkit-cli events --run-id build-42 # rendered for a human
processkit-cli events --run-id build-42 --follow # ... as it happens
processkit-cli events --file "$jsonl" --json # the runner's own bytes
processkit-cli events --file "$jsonl" --validate # conformance check
It resolves the stream through the registry (--run-id, the same jsonl locator
list --json publishes in §5) or reads a path directly (--file, for a stream
whose registry record is already gone — a clean exit deletes its own record — or
one this registry never knew about). Exactly one of the two is required and they
are mutually exclusive; there is no precedence rule, so passing both is a USAGE
(100) error. Like list/wait it is read-only: registry opened read-only, no
control-plane round trip, nothing mutated.
Three properties matter for an adapter:
--jsonis a pass-through, not a re-serialization. Each line is emitted byte for byte as the runner wrote it, so a field a newer runner added survives the trip — the pipelineprocesskit-cli events --file … --json | your-parseris exactly as lossless as reading the file yourself. A line that is not JSON is reported on stderr instead of emitted, so stdout stays parseable JSONL.--followis bounded by the run, never by an invented deadline. It returns at the terminalrunner_exit, or once the registry reports the run over and the stream has stopped growing — the abrupt-death case, explained on stderr rather than passed off as a complete stream. It hands out only complete lines, so a half-written event is never parsed as an event.--validateis a conformance gate. It checks every line against the schema document this binary embeds — the same oneprobe --print-schemaprints in §1 — reports each violation by line number and by what it violated, and exitsEVENTS_INVALID(114) if any line fails,0if none does. An unreadable stream is stillSETUP(111) and a--run-idnaming no single stream is stillCONTROL(103), so a fixture-checking CI job can tell "invalid" from "could not be checked". This is the recommended way for an adapter to keep its own recorded fixtures honest against the runner version it targets.
Ordering (normative: docs/schema.md). A normal run
emits, in order:
run_started— the child was spawned; carriesrun_id,root_pid, containmentmechanism, theabrupt_cleanuptri-state, and the redactedcommand.members_snapshot(reason: "spawn") — the container's members at that point. Exactly one by default; a run started with--snapshot-interval <duration>emits additionalmembers_snapshotevents (reason: "interval") on that cadence, all of them after this one and all of them before step 3 — never inside the teardown pair. Route by event type and treat the count as open-ended: within a schema version an adapter must not assume an event type it knows occurs only once (the full list of what a reader must tolerate within a version is indocs/compatibility.md). Every one of these events carriesread_error; when it istruethe read failed andmembersis an empty fallback, not a confirmed-empty tree — check the flag before drawing a conclusion about the tree from an empty array.- Either the natural-exit path (
root_exited,cleanup_started,cleanup_finished) or a runner-imposed ending's reason event (timeout,cancelled, orkilled) followed by the samecleanup_started/cleanup_finishedpair. Treat a zero/emptycleanup_finishedsnapshot as confirmed only when bothread_errorandkill_errorarefalse: a failed hard kill can be followed by a successful empty member read without establishing that the container itself was left clean. resource_summary— what the tree consumed, inserted between the ending event of step 3 and itscleanup_started. Exactly one, on every run that spawned the child: no flag turns it on and no platform turns it off (alimit_evidence, when a cap was requested, sits immediately before it). Every measurement is independently nullable,nullmeans "this mechanism, at this read point, does not account for it" rather than zero, andread_errormust be checked before concluding anything from anull. The event's presence is guaranteed; its contents are not — on Linux cgroup v2 an ordinary run whose child exited on its own reports all five asnullwithread_error: false. Seedocs/schema.mdand the platform matrix indocs/resource-limits.md.output_captured, only when--capture-dirwas set.runner_exit— always the last line, the terminal event of every run, including a runner failure before the child ever started (in which casespawn_failedorcontainer_failedprecedes it instead, with norun_startedand noresource_summary— there was no tree to measure).
Telling outcomes apart. Two signals distinguish how a run ended, and an
adapter should use both together: the process's own exit code (fastest to
check, no parsing needed) and the terminal runner_exit event's source and
code fields (authoritative — see docs/exit-codes.md,
"Why a band is not enough on its own"):
runner_exit.source | Exit code | Meaning |
|---|---|---|
child_exit | the child's own code (child_code, echoed in code too) | The child ran to completion on its own. |
timeout | 106 | A runner deadline elapsed and the runner tore the tree down — the whole-run --timeout or the --idle-timeout (child silent past the idle window). The preceding timeout event's reason (overall / idle) says which; both reuse this one source and code. |
output_overflow | 113 | A capture stream crossed --capture-max-bytes while --capture-overflow cancel was active, and the runner tore the tree down through the same graceful path a timeout takes. The preceding output_overflow event names the stream that crossed first and the max_bytes ceiling (see docs/schema.md). |
cancelled | 107 | A local stop signal cancelled the run: a Ctrl-C, on Unix a SIGTERM/SIGHUP (an external kill/systemctl stop/cancelled CI job, or a hung-up terminal), or on Windows a Ctrl-Break/console close/logoff/system shutdown. The preceding cancelled event's source (ctrl_c / sigterm / sighup / ctrl_break / ctrl_close / ctrl_logoff / ctrl_shutdown) says which; all reuse this one source and code. |
control_cancel | 108 | A control-plane cancel (§4) cancelled the run. |
control_kill | 109 | A control-plane kill (§4) force-killed the run. |
spawn_error | 101 | The child never started (spawn_failed precedes it). |
container_error | 102 | The container could not be created or joined (container_failed precedes it) — including a requested resource limit (--max-memory/--max-processes/--cpu-quota) the platform could not apply, in which case a limit_hit naming the limit precedes the container_failed (see docs/schema.md). |
internal | 104 | A genuine runner bug — the runner's own logic hit a state it rules out. |
setup | 111 | An ordinary fail-closed setup failure (an unwritable --jsonl/--capture-dir, an unreadable --stdin-file) — distinct from internal, and the caller can usually act on it (bad path, permissions, resources). |
Only source: "child_exit" carries a non-null child_code; every other
source means the child's own exit code was never produced or is not what
code reports, and child_code is null. See the full field reference in
docs/schema.md and the exit-code contract in
docs/exit-codes.md.
Telling the two readings of a reserved-band code apart without opening the
stream. The table above is the authoritative answer, and it costs a file read after
every call: a 106 is the runner's TIMEOUT or a child that happened to exit
106, and only runner_exit.source settles it. There is a cheaper answer for the
common case — and it is why this guide offers no terminal receipt file (§8). Under the
global --error-format json (§7), a runner-owned ending prints exactly one
envelope line on stderr and a child's own exit prints none, so the envelope's
presence — not the numeric code — separates the two readings:
processkit-cli --error-format json run --run-id build-42 \
--jsonl .processkit/build-42.jsonl --no-echo -- ./build.sh 2>build-42.err
rc=$?
# rc=106, no envelope line in build-42.err -> the child itself exited 106
# rc=106, one envelope line in it -> {"error_version":1,"code":106,"kind":"timeout",...}
Test for the envelope's presence, not for an empty file. Even with the echo
suppressed, stderr may carry processkit-cli: warning: … prose lines — a registry
that could not be opened, event logging that switched itself off, a member read that
failed. Those are not envelopes and not failures, and they keep their prose in both
modes (see docs/exit-codes.md,
"What the envelope does not cover"), so an adapter that reads an empty file as "the
child's own code" will misread a genuine 106 the moment any harmless warning
appears. Look for the one line that parses as JSON carrying error_version: there is
at most one per invocation.
The envelope's kind is spelled exactly like the source column of the table above —
every row of it but child_exit, which is by definition the reading that prints no
envelope — so the two channels need no separate vocabulary. --no-echo is what keeps
the child's own bytes off the runner's stderr, and it is the only flag that does —
--capture-dir is an independent axis that files a transcript without suppressing
anything, so pass it alongside --no-echo when the child's output is still wanted
(the pump stays wired either way, so the transcript is complete). With the echo left
on, the envelope is still emitted — it is the runner's own final stderr line — but
the child's stderr is interleaved with it, which is one more reason to match on the
line rather than on the stream as a whole.
This does not make the stream optional, and it is not a second outcome artifact.
--jsonl is required on every run, the envelope reports only the runner-owned
endings, and everything else a supervisor reads — the containment mechanism and
abrupt_cleanup level, root_pid, the tree snapshots, the capture accounting —
lives in the stream and only there.
4. Supervising a live run: inspect / cancel / kill / attest / wait
Once a run has started (its run_id is known — supplied at launch, per §2),
an adapter can query, steer, and wait for it while it is still live. Every
command resolves the target purely by run_id through the per-user registry —
never by PID. This is also the whole supervision story for a run launched with
--detach (§2): a detached run is an ordinary run in the registry, and these
five commands are how an adapter that is no longer its parent steers it —
alongside events (§3), which reads that run's stream back without contacting it
at all:
processkit-cli inspect --run-id build-42 --json
processkit-cli cancel --run-id build-42
processkit-cli kill --run-id build-42
processkit-cli attest --run-id build-42 --json
processkit-cli wait --run-id build-42 --timeout 10m
The first four reach the live runner over the local control plane described
normatively in docs/control-plane.md; wait does not
contact the runner at all and is described in docs/registry.md,
"Waiting — wait".
inspectis read-only: it prints a snapshot (mechanism,root_pid,started_at, the currentmembers) to stdout and changes nothing — as JSON with--json(shown above), or a human-readable rendering by default.cancelends the run through the same soft-stop → grace → hard-kill teardown a--timeoutor a localCtrl-Cdrives, exiting the run withCONTROL_CANCELLED(108).killhard-kills the whole tree immediately — no soft stop, no grace — exiting the run withCONTROL_KILLED(109).attestanswers one question about the calling process: is it inside this run's container? The runner decides it from the kernel's own record of who opened the control connection — there is no--pidand no way to ask about any other process — so amemberanswer is a containment fact rather than a string the caller carried. This is what turns an adapter's "the caller belongs to run X" convention into a runner-checked invariant:verdictmemberexits0,not_a_memberexitsNOT_A_MEMBER(115) — a decided answer, deliberately not aCONTROL(103) — andpeer_identity_unsupportedexits103, the fail-closed refusal a platform that cannot name its callers gives instead of an unproven "ok" (pinattest:peer-identityat preflight, §1, to rule that out up front). The attestation is printed on stdout for every verdict, including the failing ones, and carries its ownattestation_version(§1) — this answer's contract axis, independent ofinspect'ssnapshot_version. The client reads it strictly: a reply declaring any number other than the single one this build implements is refused withCONTROL(103) andkind: "incompatible_contract"(§6) instead of being read as a verdict its sender never promised, because unlike a snapshot this answer is one an adapter gates access on — there is deliberately no read-down range. Readmechanismif you need to know how strong the containment behind amemberanswer is; nested runs, the per-mechanism scope, and why this axis has no read-down range are covered indocs/control-plane.md, "attest" and "Attestation version".waitblocks until the run is no longer live and exits0. It is the answer for an adapter that is not the runner's parent — one that restarted, or that supervises runs another process launched — and so has no child process to wait on. It prints nothing (the exit code is the answer), never touches the run, and needs no control endpoint, so it also works for a run whose transport never came up. Adding--report-outcomemakes the single-run form print one JSON object, while the--allform prints one JSON array in stable snapshot order, naming how each run ended —statusreportedwith the terminal event'scode/source/child_code, orstatusunknownwith all threenullwhen the outcome could not be established — without changing any of the exit codes below. Seedocs/registry.md, "Waiting —wait".
Each of these outputs has a published JSON Schema and golden fixture under
fixtures/schema/cli/: inspect.schema.json (the single snapshot and the
--all array), control-ack.schema.json (the cancel/kill ack and the
--all report array), attest.schema.json (the attestation), and
wait.schema.json (--report-outcome in either single-run or aggregate form).
Both mutating verbs' outcomes are also written to the target run's own
--jsonl stream (a cancelled/killed event with source
control_cancel/control_kill, and the matching terminal runner_exit), so
an adapter watching that stream sees the command take effect even without
reading the cancel/kill client's own ack.
Tearing down everything at once: --all (T-217). cancel --all / kill --all (mutually exclusive with --run-id, one of the two required) are the
aggregate counterpart to the by-run_id form above: instead of one named run
they act on every run confirmed live in a snapshot taken when the invocation
starts, applying the identical per-run mutation to each, and print a single
JSON array on stdout — one {"run_id":...,"accepted":...} entry per snapshot
target — instead of one ack. An adapter driving a full environment teardown
(e.g. before shutting down its own process) typically issues cancel --all
in place of a loop over individually-known run_ids, then wait --all /
list --json / prune to confirm the fleet is actually gone. See
docs/control-plane.md, "cancel --all / kill --all",
for the exit-code and report contract — it differs from the by-run_id form's
103 in the note right below.
Waiting for a run an adapter did not launch. The typical shape — cancel a run, then confirm it is really gone before releasing the resources it held:
processkit-cli cancel --run-id build-42 # 0: the runner acked
processkit-cli wait --run-id build-42 --timeout 30s
case $? in
0) ;; # the run is over; its own exit/JSONL say how it ended
112) ;; # still live at the deadline — the run was NOT touched
103) ;; # ambiguous run id: more than one live run uses it (§6)
esac
0means "not running". It is also what an unknownrun_idreturns, on purpose: a clean exit deletes its own registry entry, so "never registered" and "already finished and cleaned up" are the same observation, and failing on the second would turn the ordinary "it finished while I was starting up" race into an error. The flip side an adapter must respect: a typo'drun_idalso returns0, so never readwait's0as proof the run existed — establish that from the launch itself or fromlist(§5).WAIT_TIMEOUT(112) is the waiter's deadline, not the run's: the run was left running and untouched, and is still going. Do not confuse it with the run's ownTIMEOUT(106) in §3's table, which means the runner tore the tree down. Retrying the samewaitis a reasonable response to a112.CONTROL(103) here means only one thing — an ambiguousrun_id(§6);waithas no runner to fail to reach.- Without
--timeout,waitblocks indefinitely. Prefer an explicit deadline in an adapter, so a supervisor never inherits an unbounded wait.
CONTROL (103) is the one exit code all five of these clients' by-run_id
form can return, and for all five the usual reason is the same: the command could
not be resolved to the single target run. Two further reasons belong to the
read-only verbs, where the target was resolved and reached and did answer. The
first is shared by both of them: a reply declaring a contract version this client
does not read is refused rather than acted on — inspect's snapshot_version (see
docs/control-plane.md, "Snapshot version: a newer runner's reply is refused, an
older one is read") or attest's attestation_version (ibid., "Attestation
version"). The second is attest's alone: it was answered
peer_identity_unsupported — the runner could not name the caller, so it declined
to decide. attest's decided negative is not a 103 at all but NOT_A_MEMBER
(115). See §6 for the concrete situations that produce a 103, those included.
cancel --all / kill --all
reuse the same code for a different reason — one or more snapshot targets failed,
not "no single target run" — see the --all paragraph above and
docs/control-plane.md.
5. Housekeeping: list / prune
list and prune scan the registry directly rather than reaching a specific
live run, and are the tools for an adapter that manages many runs or wants to
clean up after abrupt failures — see the normative "Discovery" and "Reaping"
sections of docs/registry.md.
processkit-cli list --json # every registered run, whatever its health
processkit-cli prune --json # reap only the confirmed-stale entries
list --jsonprints one JSON object per registry entry (run_id, health,started_at,hint,argv_sha256,endpoint), sorted deterministically.argv_sha256andhintare the same redaction-safe command identification therun_startedevent carries (§3) — the full 64-character digest here, so an adapter can join a registry entry to the events of the run that wrote it, or group several live entries by "same command" without ever handling a command line. Both arenullon a record written before those fields existed, andhintisnullfor the common case of a command matching no known worker shape. Health islive,stale(confirmed dead — no live holder found), orunprobed(the liveness lock could not even be opened, e.g. permission denied — a distinct, additive value: liveness is unknown, never printed as the confirmed-deadstale). All three are listed, never hidden — a stale entry (a leftover from a runner that died abruptly) is exactly what an operator or adapter wants visible here, and an unprobed one is exactly the case where guessing would mislead.prune --jsondeletes only entries it can confirm are stale, printing a tally:{"pruned":N,"live":N,"unprobed":N,"orphaned_locks":N}. A live run is never touched, and an entry whose liveness could not even be probed is left in place rather than guessed at — see "The reaping safety invariant" indocs/registry.md. On unix each reaped entry also takes with it the private control-socket directory that record published, so an abruptly-killed run leaves nopkc-…litter in the temp directory either; the tally fields are unchanged (that socket is counted by its own entry'spruned). Worth scheduling if your adapter starts many runs — see "Reaping the control socket" indocs/registry.md.
Both are read-only with respect to any live run's control transport; neither carries the "could not reach the target run" failure modes of §4.
Their machine-readable shapes are published too: fixtures/schema/cli/list.schema.json
(one entry object per line) and fixtures/schema/cli/prune.schema.json (the plain
tally and, as #/$defs/dryRunReport, the --dry-run form with its candidates
list), each with a golden *.jsonl fixture beside it.
6. Typical errors
Every distinction in this section is available as a machine-readable value, not
only as prose: run any of these commands with the global --error-format json and
the failure prints one bounded JSON object on stderr whose kind names exactly the
case below (stale, unprobed, ambiguous_run_id, incompatible_contract, …).
The kind column is noted per bullet; §7 has the full contract.
- Stale registry entry. (
kind: "stale") The runner behind arun_iddied abruptly (crash,SIGKILL, a parent's Job Object terminate); its record is left behind but its liveness lock is released.inspect/cancel/kill/attestdetect this before connecting and report it as aCONTROL(103) failure with an explanatory message on stderr — never a hang, and never silently treated as live.liststill shows the entry (markedstale);pruneis what removes it. An ordinary UnixSIGTERM/SIGHUP, or a WindowsCtrl-Break/console close/logoff/system shutdown, is not in this class: the runner catches those signals/events and runs the full cancel teardown (acancelledevent, the cleanup pair,runner_exitcancelled/107, and removal of the registry entry), so stopping a run withkill <pid>(Unix) or a closed console (Windows) leaves neither a stale entry nor a surviving descendant. - Unprobeable registry entry. (
kind: "unprobed") The entry's liveness lock could not be probed at all (permission denied, a rejected symlink/reparse point, a non-regular file in its place), so nothing about the run is confirmed either way. This is the sameCONTROL(103) refusal —inspect/cancel/kill/attestact only on a confirmed-live entry — but it is reported honestly asunprobed, not as a gone runner;listshows the same entry asunprobedandpruneleaves it in place. Investigate the registry directory rather than deleting the record by hand (seedocs/troubleshooting.md). - Died mid-conversation. (
kind: "control_unreachable", or"ipc_deadline"when a bounded window elapsed instead) The registry entry read as live, but the runner exited between the liveness check and the reply reaching the client — the connect fails, or the connection closes before a complete response. Also a boundedCONTROL(103) failure, never a wedge: every wait in the control plane (connecting, and the request/response exchange) is deadline-bounded. - Ambiguous
run_id. (kind: "ambiguous_run_id") The registry does not enforcerun_iduniqueness; if more than one live entry matches, every by-run-idcommand — the read-onlyinspect,attest, andwaitincluded — fails closed withCONTROL(103) rather than guessing which entry the scan happened to return first. Keeprun_ids unique among an adapter's own concurrently-live runs (§2) to avoid this entirely. - An unreadable contract version (
inspectandattest). (kind: "incompatible_contract") The runner was reached and answered, but the reply declared a contract version this client does not read, so it was refused instead of being acted on under semantics its sender never promised. Both read-only verbs can hit this, each on its own version axis: aninspectreply carrying a control-planesnapshot_versionoutside the range this client reads — newer than the version it implements, or older than the version it still decodes — and anattestreply carrying anattestation_versionother than the single one this client reads (that axis is read strictly, with no range, since a misread membership verdict is a security answer rather than a diagnostic; §4). Also aCONTROL(103), with a message naming the version that arrived and the version — or range — this build reads. Unlike the four above it says nothing about the run's liveness: the target is registered, live, reachable, and healthy, and the run stays fully controllable, sincecancel/killacks carry no version andwait/listask the runner for nothing. Do not treat it as a lost runner or retry it; re-run the same command with a build that implements the runner's version of that contract — forinspect, its snapshot version (for a newer runner, a build at least as new as the binary that started the run); forattest, its attestation version. Seedocs/control-plane.md, "Snapshot version: a newer runner's reply is refused, an older one is read" and "Attestation version", anddocs/compatibility.md, "Machine-output schemas". - A caller the runner cannot name (
attestonly). (kind: "peer_identity_unsupported") The runner was reached and refused to decide membership, because its transport could not supply a kernel-authenticated identity for the connecting process. Also aCONTROL(103), and for the same reason as the bullet above: an answer that cannot be trusted is withheld rather than guessed — here in the safe direction, since the alternative would be reporting an unprovenmember. It says nothing about the run's liveness or about membership. Rule it out at preflight by requiringattest:peer-identity(§1); meeting it at runtime means that check was skipped or the runner is a different build. - A decided non-membership is not in this class. (
kind: "not_a_member", exitNOT_A_MEMBER115) Whenattestreports that the caller is not in the run's container, nothing failed: the target was resolved, reached, and answered. It has a code of its own precisely so an adapter can tell "the runner says no" (deny the request) from "no runner said anything" (investigate, or retry). Never fold it into the103handling above. CONTROL-class exit codes are not run outcomes. A103from the by-run_idform ofinspect/cancel/kill/attest/waitdescribes a failure on the client's side of the exchange — it could not resolve or reach a single target, or (the two read-only cases above) could not read or obtain the answer it asked for — and says nothing about how the target run itself ended (or is still running). Do not conflate it with the run-outcome codes in §3's table (106–109, or the child's own code); those come only from the run's own process exit and itsrunner_exitevent. The same separation applies toWAIT_TIMEOUT(112): it is the waiting client giving up, never the run being stopped (§4).cancel --all/kill --all's own103is the one exception where the code can coincide with some targets having genuinely been acted on — see the--allparagraph in §4.- A
--detachexit code is not a run outcome either.run --detach's0means "the run started", not "the child succeeded", and its non-zero codes mean "the run never started" — carrying the same reserved code the failure would have produced in the foreground. An adapter that branches on a detached launch's exit code as if it were the child's result will read every long-running failure as a success; the child's outcome is in the terminalrunner_exitevent (§3), reached afterwait(§4). Seedocs/exit-codes.md, "Detached runs". SETUP(111) vs.INTERNAL(104). (kind: "setup"versus"internal"; an unreadable registry narrows further to"registry") Arunthat could not write its--jsonl/--capture-dir, or open a--stdin-file, fails closed withSETUP(111) — an ordinary, usually-actionable environment problem (bad path, permissions), not a runner bug.INTERNAL(104) is reserved for a genuine invariant violation in the runner's own logic. See "Setup failures vs internal faults" indocs/exit-codes.md.
7. Machine-readable failures: --error-format json
Everything in §6 is a real distinction the CLI already makes — but by default an
adapter can only read it as English on stderr, because the exit code is coarse: one
CONTROL (103) covers six of those bullets at once — eight kind values in all,
since the "died mid-conversation" bullet is two of them (control_unreachable and
ipc_deadline) and not_found, a run id the registry names nowhere, gets no bullet
of its own above. The global, opt-in
--error-format json publishes the distinction instead:
processkit-cli --error-format json inspect --run-id build-42
# stderr, exactly one line:
# {"error_version":1,"code":103,"kind":"stale","operation":"inspect",
# "run_id":"build-42","retryable":false,"message":"cannot inspect run `build-42`: …"}
- Opt in wherever it is convenient. The flag is global: it parses before or
after the subcommand, and every subcommand honors it. Pin it in the preflight
like any other flag —
--require-surface inspect:--error-format(§1). - Branch on
kind(andcode), never onmessage.error_version,code,kind,operation,run_id, andretryableare the contract;messageis free text that may be reworded in any release. kindmaps onto §6.stale,unprobed,ambiguous_run_id,control_unreachable,ipc_deadline,not_found, and the two refusalsincompatible_contractandpeer_identity_unsupported— those eight are the ones that exist to split the singleCONTROL(103) — plusnot_a_member(the decided verdict,115),host_unqualified(the other decided verdict,116, and the one about the host rather than a run — §1),registry/setup/internal,wait_timeout,events_invalid,probe_incompatible, and — for a failingrun— the terminalrunner_exitevent's ownsourcespellings (spawn_error,container_error,timeout,cancelled,control_cancel,control_kill,output_overflow). Unrecognized value? Fall back tocode; the vocabulary grows additively.- stdout is untouched. The envelope is on stderr, so an adapter can leave the
flag on for every invocation without any risk to the JSON it parses from stdout —
including for a command that prints a report and then fails, such as
probe --jsonexiting 110 orinspect --all --jsonexiting 103. - The default is unchanged. Without the flag, stderr is byte-for-byte the prose every earlier release printed.
- One documented gap. clap's parse-time usage errors (exit
USAGE, 100 — an unknown flag, a malformed duration, a missing subcommand) stay human-readable in v1: they happen before the binary knows what it was asked to do, so there is no operation to name. Use the §1 preflight to establish that a flag exists before using it. Every post-parse failure is covered.
The shape is published like every other machine-readable output in this guide:
fixtures/schema/cli/error.schema.json with a golden error.jsonl beside it. The
normative field-by-field contract, the full kind table, and the retryable rule
are in docs/exit-codes.md.
8. Decided: no terminal receipt file
An adapter reading this guide may reasonably ask for one more thing: a small file the
runner drops at the end of a run — a run --outcome-json <path>, atomically replaced
at terminal completion with the outcome, a cleanup confirmation, and artifact
locators — so the common path costs one tiny read instead of a walk over the lifecycle
stream. It was proposed, evaluated against a real adapter, and declined. This
section records why, so the question does not have to be reopened from scratch; the
durable record, including the one alternative that was deferred rather than rejected,
is ADR 0007.
- It would not remove the read it exists to remove. A supervising adapter does not
read the stream only for the terminal event. The one this decision was measured
against consumes four event types after every call —
run_started(forroot_pid,mechanism,abrupt_cleanup),members_snapshot,output_captured, andrunner_exit— and treats a missingrun_startedoroutput_capturedas a failure reason of its own. A terminal receipt carries terminal facts; the start-time facts exist only in the stream (§3). Such an adapter would gain a second artifact and keep the loop. - The read is seven lines. A foreground run emits seven events — eight with
--capture-dir, and one more again when a requested resource cap (--max-memory/--max-processes/--cpu-quota) adds its post-runlimit_evidence. Six of the seven were the whole stream untilresource_summaryjoined them unconditionally; that one line is the sole growth a caller did not opt into, and it is one line, once, at the end. Every other way the stream grows is a caller asking for more events on purpose —--capture-dir, a cap, and--snapshot-interval's extramembers_snapshotsamples.docs/schema.md, "Ordering", is the full rule. - A cheaper answer already exists, and it is the recipe in §3: the
--error-format jsonenvelope resolves exactly this ambiguity with no path to allocate, no artifact to clean up, and no second durable shape to version. - A receipt could not report its own failure. It would be written after the
child's exit code is already decided. If that write, or its atomic replace, failed,
the runner's options would be to fail the run — rewriting the child's exit code,
which
docs/exit-codes.mdforbids outright — or to say nothing, which would make the receipt's absence mean either "the runner died abruptly" or "the receipt could not be written". The property that made the idea attractive is the first thing its own failure mode would take away. The lifecycle stream is not exposed this way: it carries the whole run, so a write failure truncates it visibly rather than turning one expected artifact into a silent nothing.
What this does not claim: that every terminal read is already as cheap as it could
be. There is no first-party bounded terminal read over an arbitrary stream file today.
events --json (§3) is a whole-stream pass-through, and wait --report-outcome (§4)
reports an outcome only for a run that invocation observed live — a finished
foreground run has already deleted its own registry record, so it answers
status: "unknown" for one. If that ever becomes a measured cost rather than an
anticipated one, ADR 0007 records why the answer would be a read-side flag over
--file, reusing the shape wait --report-outcome already publishes, rather than a
second write path out of the runner.
See also
docs/agent-workflows.md— a policy and execution strategy for automation agents that launch external tools through the runner.docs/schema.md— the normative JSONL event schema (every field, every event, versioning rules).fixtures/schema/cli/README.md— the JSON Schema documents and golden fixtures for every machine-readable output in this guide (probe,list,inspect, thecancel/killacks,prune,wait --report-outcome,attest,doctor, and the--error-format jsonfailure envelope), and the versioning decision behind them (probe,inspect,attest,doctor, and the envelope carry their own version field; the other four deliberately carry none).docs/compatibility.md— the compatibility surfaces, the pinning procedure, and the upgrade/downgrade checklists.docs/exit-codes.md— the normative reserved exit-code band and the child-fidelity rule.docs/control-plane.md— the normative local transport, wire protocol, andinspect/cancel/kill/attestbehavior.docs/registry.md— the normative registry location, record format, and staleness/reaping rules.docs/architecture.md— the map of this repository's own modules, for a contributor rather than a consumer.docs/troubleshooting.md— symptom-to-cause diagnosis for an operator, organized by what you observe rather than by call sequence.
Compatibility and upgrades
ProcessKit CLI has three public compatibility surfaces:
- command names and flags;
- the reserved runner exit-code band;
- JSONL
schema_version.
Three, and only three — a versioned payload is not a fourth surface. Several
machine-readable outputs carry a version field of their own: probe --json's
probe_version, inspect's snapshot_version, the --error-format json
failure envelope's error_version, and attest --json's attestation_version.
Each of those rides on the list above — on the
command and flag that produce it (surface 1), and, for the failure envelope and the
attestation, on the
reserved code it reports (surface 2) — and pins its own shape inside the payload
rather than adding a further thing to pin before launching. (Count them separately:
"three compatibility surfaces" and "how many version fields this project publishes"
are different questions with different answers.) The registry record's
registry_version is not on the list either, for a different reason: the per-user
registry is a private contract between this binary and itself, not something a caller
reads off an invocation (see docs/registry.md). Which outputs carry a
version field, and why the rest deliberately do not, is "Machine-output schemas"
below.
The human CLI surface is guarded by through-binary golden snapshots for the root
help and every public subcommand in fixtures/cli-help/. An intentional flag,
value-name, default, or help change must be regenerated with
UPDATE_CLI_HELP_GOLDEN=1 cargo test --test cli_help and the fixture diff reviewed.
The test normalizes only the Windows .exe suffix and line endings; all contract
text and ordering remain exact.
Breaking any of those three surfaces requires a major release. An adapter should verify the exact pieces it uses before launching a payload rather than discovering an incompatible binary after work has started.
Fail-closed preflight
processkit-cli probe --json \
--require-schema-version 1 \
--require-exit-code-band 100-119 \
--require-surface run \
--require-surface run:--jsonl \
--require-surface run:--capture-dir \
--require-surface inspect:--json \
--require-surface cancel
probe is side-effect-free. It launches no child, creates no container, and
does not touch the registry. A missing requirement exits
PROBE_INCOMPATIBLE (110) and reports all mismatches.
That answers a question about the binary. The matching question about the
host — can this machine actually create the registry, contain a process, and
round-trip the control plane? — is what doctor answers, by doing all three in a
bounded scratch run and reporting what it observed; an unmet host requirement exits
HOST_UNQUALIFIED (116). It is a setup-time check rather than a per-launch one,
because unlike probe it has real (self-cleaning) side effects. See
docs/integration.md §1 and
docs/troubleshooting.md, "Qualifying a host: doctor".
Surface tokens
A surface token takes one of three forms — a subcommand, subcommand:--long-flag,
or subcommand:capability:
run
run:--jsonl
run:--idle-timeout
inspect
inspect:--json
cancel:--all
attest:peer-identity
run:resource-summary
The first two forms name a spelling the parser accepts, and are derived from the
live clap definition so they cannot drift from the real one. The third names a
capability instead — "can this binary do the thing", which no parser can
answer — and is told apart by carrying no --. The missing -- is what keeps the
two categories from being read as one
another, so an adapter that validates this array with a pattern of its own must
admit the ---less form; the published grammar is the surface pattern in
fixtures/schema/cli/probe.schema.json.
There are two capability tokens, and they differ in why they might be absent:
attest:peer-identity— a platform capability. Published exactly where this build can obtain a kernel-authenticated identity for a control-plane client, which is what makesattestable to return a membership verdict at all (seedocs/control-plane.mdanddocs/integration.md, §1). A given target may lack it.run:resource-summary— a build capability. Published by every build whoserunemits the terminalresource_summaryevent (seedocs/schema.md). No platform removes it; only an older binary lacks it. That is exactly why it needs a token: an event's presence is otherwise undiscoverable until a run has already finished without it, which is after the work the number was wanted for.
A consumer requires either with the same --require-surface and does not need to know
which kind it is holding.
A capability token's presence is a guarantee, and it is worth being precise about
what is guaranteed in each case. attest:peer-identity guarantees this target names
the peer, so a negative membership answer from it is a real verdict rather than a
missing capability in disguise. run:resource-summary guarantees the event will be in
the stream — not that any particular measurement in it is populated: which axes
carry numbers is a property of the containment mechanism (run_started.mechanism) and,
for memory and CPU on Linux cgroup v2, of how the run ended. The normative matrix is in
docs/resource-limits.md. A single token
could not honestly carry five per-platform facts, and splitting it into five would
publish as capabilities what are really documented properties of the mechanism — which
is also why no token could have promised numbers a read point decides.
A capability's absence withholds its guarantee rather than predicting failure —
attest on a build without the token still answers from whatever the kernel actually
provides, and fails closed with peer_identity_unsupported when that is nothing.
Requiring it therefore turns "this platform cannot prove membership" into an ordinary
PROBE_INCOMPATIBLE (110) at preflight, instead of a refusal in the middle of a job.
Require only the features the adapter will actually use. This permits additive
CLI releases while preventing an invocation from reaching a binary that lacks
a needed flag. That is why the preflight example above pins no capability token: it
shows an adapter that uses run, inspect, and cancel and nothing else. An
adapter that will gate work on containment membership adds
--require-surface attest:peer-identity to its own invocation — an adapter that will
not must leave it out, since requiring an unused capability would fail preflight on a
platform whose missing capability could not have affected it.
Schema pinning
Every event carries schema_version. An adapter should reject an unknown value
before interpreting event-specific fields. The current schema is documented in
JSONL event schema and available from the installed binary:
processkit-cli probe --json --print-schema > schema.json
--print-schema is mutually exclusive with --require-*: printing the
document and checking compatibility are separate operations, so neither can be
silently skipped.
What a reader must tolerate within one version
Within one schema version, a reader must tolerate every one of the following. Removing a field, renaming it, or changing the meaning or type of an existing one is what requires a new schema version — nothing below does.
-
New event types — including ones that appear in every run. Route by the
eventdiscriminator and ignore a type you do not know, rather than failing on it or assuming the stream is corrupt. Note the scope of this obligation carefully: a new type need not be gated behind a flag.resource_summary(seedocs/schema.md) is the worked example — it is emitted by every run that spawned a child, so a defaultrun's stream is one line longer than an earlier v1 build's. Nothing existing changed, which is what makes it additive; a reader that pinned the exact set of event types a run emits, rather than routing by tag, is the only kind that notices. -
More occurrences of an event type you already know, including a type that previously occurred at most once. Within a version you may not assume any event type is unique, or that its position in the stream is fixed relative to other types beyond what the ordering contract states.
members_snapshotis the worked example: it appeared exactly once per run untilrun --snapshot-intervalmade a run emit it on a cadence. -
New fields on an event you already parse — including always-present ones, not only optional ones. "Additive" here means "no existing field changes", not "the new field may be absent":
members_snapshotgained an always-presentreasonand an always-presentread_error,timeoutgained an always-presentreason, andcleanup_started/cleanup_finishedgained always-presentread_errorflags, andcleanup_finishedgained an always-presentkill_errorqualifier, all within version 1. A reader must therefore consume the fields it uses rather than pin an event's exact field set — validating withadditionalProperties: falseagainst a copy of a published document will fail on the next additive release (see "Machine-output schemas" below). The newlimit_evidenceevent is likewise additive: readers should route byevent, ignore the new type when they do not use resource-limit attribution, and keepschema_versionpinned at1.A requested cap that fails during
ProcessGroupcreation remains the pre-spawnlimit_hitpath. Since no group exists on that path, it has nolimit_evidenceevent; readers must not synthesize anunknownevidence record for it.The
resource_summaryevent carries the same obligation in a stronger form, because every one of its measurements is nullable: anullmeans "this mechanism, at this read point, does not account for it" and must not be read as, or replaced by,0. Check itsread_errorflag before drawing any conclusion from anull— an all-nullsummary is a correct reading on a mechanism with no whole-tree accounting, and equally on a flagless Linux cgroup v2 run whose child exited on its own; only that flag distinguishes either from a read that failed. The per-axis platform matrix is indocs/resource-limits.md— read it before relying on an axis, because two of them are governed by the read point and not by the platform alone — and its two IO counters are explicitly not comparable across platforms. -
New values in an open-ended descriptive string field — a new
cancelledsource, a newrunner_exitsource, a newhintlabel. Treat an unrecognized value as "some other trigger" and keep routing by event type. -
New values in the growing
mechanismvocabulary.process_reaperis an additive value within schema v1: no existing mechanism changed meaning, and readers must treat an unfamiliar mechanism as an unsupported containment choice rather than reject the complete event or machine-output record.unknownis an intentional contract value emitted by the CLI's conservative fallback for a futureprocesskit::Mechanismvariant, not a projection error. The published schemas enumerate the current vocabulary, so an older frozen schema may reject a newer value; strict validation must use the producer-matching schema, and an adapter's runtime parser must not treat the old enum as exhaustive. -
An escaped element in
--argv-raw'sargvarray. The field's type is unchanged (array of string) and every element that is valid Unicode is still the argument verbatim, but an argument the platform accepts and Unicode cannot express — ill-formed bytes on Unix, an unpaired surrogate on Windows — is now recorded losslessly in an escaped form opened by U+0000, instead of the U+FFFD-mangled reconstruction earlier v1 builds wrote. A reader that displays or logsargvneeds no change, and neither does one that stores or relays the JSONL line: U+0000 goes on the wire as the ordinary six-character JSON escape, so the line stays NUL-free text. The correction adds two reader obligations, both confined to that one element shape:- A reader that reconstructs an argv (to re-run, compare, or match against one
it holds) must decode that form — the grammar is in
docs/schema.md, "Raw argv that is not valid Unicode" — and must not take an escaped element literally. - A reader that stores or forwards a decoded element must check that its sink
accepts U+0000; this is the only place in the schema where a string value can
carry that code point, so a sink that rejects it will have accepted every
stream earlier builds produced. PostgreSQL refuses a
jsonbdocument containing it (an adapter that ingests whole event lines intojsonbloses the entirerun_startedevent, not just the field), a C-string API truncates the element at its leading NUL and is left with nothing, and some YAML and log transports refuse a NUL in a scalar. Such a reader decodes the escape and re-renders it for its sink — or escapes or drops the marker itself — instead of passing the decoded string through.
argv_sha256adds no obligation at all, since every digest an earlier build produced for an ordinary command line is unchanged. - A reader that reconstructs an argv (to re-run, compare, or match against one
it holds) must decode that form — the grammar is in
-
Unknown fields anywhere in the envelope or event body.
The normative source is docs/schema.md, not this page: its
"Ordering" section (and specifically "Multiplicity of members_snapshot") states
how many of each event a stream may carry and where, and its "Versioning" section
defines exactly which changes are additive and which are breaking. This section
restates those obligations for an adapter author; where the two ever disagree,
docs/schema.md wins and the disagreement is a bug in this page.
How the borrowed vocabularies are kept in step
Several of the enumerations above are not this project's own inventions: the
mechanism, abrupt_cleanup, limit_evidence verdict, soft_stop_scope,
soft_signal, and outcome values are projections of closed enums in the
processkit library this runner is built on. Those enums grow — process_reaper
above is one such growth — and every projection here ends in a deliberate
fallback (unknown, none, failed) so that a build which meets a value it
predates degrades honestly instead of guessing.
A fallback is a safe answer, not a good one, so the project does not rely on
noticing upstream growth by hand. processkit publishes a machine-readable
dictionary of its stable identifiers inside its own package, and a gating test
tier (tests/spec_drift.rs; see CONTRIBUTING.md, "Upstream identifier drift")
holds every projection and every published schema enum against the dictionary
belonging to the exact library version this binary is built with. A value that
would land in a fallback arm fails the build, naming the enum and the identifier.
What that means for an adapter is narrow and worth stating exactly. It is a
promise about this project's process — that a borrowed vocabulary cannot grow
without someone deciding what the new value means here — and not a promise that
your pinned schema copy stays complete: a decision may still add a value in a
later release. Points 4 and 5 above are unchanged obligations. It also covers
only the vocabularies borrowed from processkit; the ones this CLI mints itself
(a cancelled source, a runner_exit source, a hint label) have no
upstream dictionary to be checked against and remain open-ended by design.
Machine-output schemas
The JSONL stream is not the only machine-readable output. The discovery and
control commands print machine-readable JSON to stdout too, and each of those
shapes has a published JSON Schema (draft 2020-12, self-contained) plus a golden
fixture under
fixtures/schema/cli/:
| Output | Schema document | Golden fixture |
|---|---|---|
probe --json | fixtures/schema/cli/probe.schema.json | probe.jsonl |
list --json | fixtures/schema/cli/list.schema.json | list.jsonl |
inspect --json, inspect --all --json | fixtures/schema/cli/inspect.schema.json | inspect.jsonl |
cancel/kill ack, cancel --all/kill --all report | fixtures/schema/cli/control-ack.schema.json | control-ack.jsonl |
prune --json, prune --dry-run --json | fixtures/schema/cli/prune.schema.json | prune.jsonl |
wait --report-outcome, wait --all --report-outcome | fixtures/schema/cli/wait.schema.json | wait.jsonl |
the --error-format json failure envelope (stderr, any subcommand) | fixtures/schema/cli/error.schema.json | error.jsonl |
attest --json | fixtures/schema/cli/attest.schema.json | attest.jsonl |
doctor --json | fixtures/schema/cli/doctor.schema.json | doctor.jsonl |
The failure-envelope row is the odd one out in two ways, both deliberate. It is
printed on
stderr, not stdout — that is what keeps stdout reserved for successful output,
so a command that prints a report and then fails leaves its stdout untouched — and
it describes a failure rather than a success, which is exactly why the eight
success shapes beside it could never have covered it. It is opt-in: without
--error-format json, a failure prints the same free-text prose it always did.
Two of the table's rows are verdict shapes, and both are routinely printed
alongside a non-zero exit, with no flag involved in either. Two of
attest --json's three
verdicts make the command fail, and the attestation is printed for all three (see
docs/control-plane.md, "attest"); doctor --json exits
HOST_UNQUALIFIED (116) whenever a phase failed or a --require-* expectation about
the host went unmet, and prints the same report either way (see
docs/integration.md §1 and
docs/troubleshooting.md, "Qualifying a host: doctor"). In
both cases the verdict is the answer and the exit code only says what to do about it,
so neither channel replaces the other: do not treat a non-zero exit as "there is no
stdout to parse". (probe --json behaves the same way but only when the caller
asked for a --require-* expectation the binary did not meet, and inspect --all's
and cancel/kill --all's report arrays precede a CONTROL (103) that reports what
could not be done rather than what was decided — a different fact, the same
reading discipline. wait --report-outcome is neither: it prints only when the wait
succeeded.)
events --json is deliberately absent from that table: it passes the runner's own
JSONL lines through byte for byte, so the document that describes it is the event
schema above (fixtures/schema/v1/schema.json), not a second one of its own. That
is also what events --validate checks a stream against.
tests/machine_output.rs validates the real binary's output for each of these
against its document on every test run, so an accidental shape change fails CI
instead of reaching an adapter. A document whose family has more than one output
form has a root oneOf over named $defs, so a consumer can validate against the
exact form it invoked — for example inspect.schema.json#/$defs/snapshot.
Five rows of that table carry a version field; the other four deliberately do
not. probe --json carries probe_version, inspect --json carries
snapshot_version — the same field the runner puts on the control-plane wire —
the failure envelope carries error_version, attest --json carries
attestation_version, and doctor --json carries doctor_version.
probe.schema.json, error.schema.json, attest.schema.json, and
doctor.schema.json pin their value
with const;
inspect.schema.json admits the
range of snapshot versions this build renders (see the snapshot_version bullet
below), because that field reports the runner's contract, not the invoked
binary's. The shape follows the reader's tolerance rather than a sibling's
precedent: attest's value also comes off the wire, and is pinned anyway, because
its client refuses any version but its own outright — a membership verdict read
under unpromised semantics is worse than no verdict. Either way a bump is visible in
the payload itself. Pin those five on
their own version field, not on the CLI version alone: ignoring a
snapshot_version bump across an upgrade is exactly the class of mistake this
section exists to prevent. All five
are versioned for the reason the project's other two versioned contracts (the
durable JSONL stream's schema_version and the registry record's
registry_version) are: each can be read by a party that did not invoke the
binary — the snapshot and the attestation cross a process boundary to a runner that
may be a different build, the probe report's whole job is to be read before the
binary's version is known, a failure envelope is routinely read out of its
invoking context altogether (captured stderr in a CI log, read back later by a
different tool than the one that ran the binary), and a doctor report is meant to
be kept: a failed qualification writes it into the diagnostics directory it leaves
behind, precisely so it can be read later, elsewhere, by whoever is debugging the
host rather than by whoever ran it.
The remaining four — list --json, the cancel/kill ack and --all report,
prune --json, and wait --report-outcome in both its forms — carry no version
field, deliberately. Each is a synchronous stdout rendering read by the caller
that just invoked this exact binary. That includes the printed ack, whose content
does arrive over the wire but is re-serialized by the client from the three
fields it parsed and verified, so its field set is the client's own (see
fixtures/schema/cli/README.md,
"Versioning"). Such a caller already knows the version and can pin the shape
through the probe preflight above — the reported version, plus one
--require-surface token per subcommand and flag it will actually use. A
per-output version field would be a second, redundant pinning axis, so none was
added.
Consequently:
- The unversioned four ride on the first compatibility surface (command names and flags), not on a version integer of their own. A breaking change to any of them — removing a field, renaming it, changing its type or the meaning of a value — is a major release, announced in the changelog.
probe --json,inspect --json, the failure envelope,attest --json, anddoctor --jsonadditionally bump their own field. A breaking change to either of the first two shapes bumpsprobe_version/snapshot_versionrespectively, and that field is what a consumer should check. The envelope'serror_versionworks the same way: removing or re-typing one of its stable fields, or changing what an existingkindmeans, bumps it, while a new field or a newkindvalue is additive and does not (seedocs/exit-codes.md, "Machine-readable failures", and the "Stability" section there). For the snapshot, this binary's owninspectclient checks it too, and does so asymmetrically: a runner answering with asnapshot_versionnewer than this build implements is refused withCONTROL(103) rather than rendered under semantics its sender never promised, while an older one is still read for as long as this build genuinely decodes it (today: version 1, the version every release so far writes). Seedocs/control-plane.md, "Snapshot version: a newer runner's reply is refused, an older one is read", for the rule, the floor, and what moves it. Two consequences for an upgrade: a bump is a hard boundary for older clients in a mixed deployment, not merely a signal to read; and thesnapshot_versionon stdout is the runner's number, soinspect.schema.jsonadmits the range this build renders instead of pinning a single value.attest --json'sattestation_versionis checked too and is the strict case of the same rule — any version but this build's own is refused, because a membership verdict must never be read under semantics its sender did not promise, and because that contract has had only one version so far, so strictness refuses nothing that ever existed. Whether a wire-supplied version pin is a range or a single value follows what its reader actually decodes, never what a sibling document does.- Every document under
fixtures/schema/cli/is updated in place. There is novN/directory there (unlikefixtures/schema/v1/, whosev1is the JSONLschema_version), because a version here, where there is one, lives in the payload rather than in a path. If a future release gives one of the unversioned four a version field of its own, that is the point to revisit the layout. - Additive changes remain minor/patch, exactly as they are for the JSONL stream, and a reader that consumes the fields it knows is unaffected.
The published documents set additionalProperties: false so that this repository's
own tests fail when a field is added without publishing it. An adapter that copies a
document into its own pipeline and wants to tolerate a future additive field should
relax that keyword on its copy rather than pin a field set the project treats as
additive.
Exit-code compatibility
Normal foreground runs forward the child code. Runner-owned outcomes occupy
100–119. Because a child may itself return a number inside that band, the
terminal runner_exit.child_code / runner reason remains the authoritative
machine distinction.
Verify the whole reserved band, not only the individual codes your current version knows. This protects space for additive runner outcomes without making an old adapter classify them as child exits.
Detached launch is the documented exception: launcher 0 means the run
started, and the child's eventual code is in terminal JSONL.
Upgrade procedure
- Download and verify the new archive without replacing the active binary.
- Run the new binary by absolute path with the adapter's full
proberequirements. - Compare
probe --print-schemawith the adapter's pinned schema version. - Exercise one harmless run through the same I/O, capture, and control-plane flags used in production.
- Confirm
run_started.mechanismin the actual deployment environment. - Atomically replace the installed binary.
- Keep the previous binary until terminal events from the smoke run have been consumed successfully.
Reading release notes
The project changelog uses Keep a Changelog categories. Look for:
Added: optional flags/events/features an adapter may adopt;Changed: behavior that remains inside the current compatibility contract;Fixed: corrections that may alter previously buggy observations;Removed: major-version migration work.
The manifest version, v<version> tag, crates.io artifact, and GitHub Release
are produced by one release workflow and should identify the same release.
Rolling upgrades with live runs
The control plane lives inside each running process. Replacing the executable on disk does not upgrade a runner already in memory. A new client must therefore remain compatible with the live run's registry/control/schema contract until that run finishes.
For a conservative rolling upgrade:
- stop launching new work through the old binary;
- let or cancel old live runs;
- wait for the registry to become empty;
- preview and prune stale entries;
- replace the binary and launch new work.
Do not use recorded PIDs to bridge versions.
Downgrades
Run the target older binary's probe by absolute path before replacing the
newer one. An older binary may lack an additive flag even when the JSONL schema
version is unchanged. Surface-token verification catches that case.
Preserve event files across a downgrade; they are durable observations from the
binary that produced them and must be decoded according to their own
schema_version, not the currently installed executable.
The rolling-upgrade and downgrade procedures above are not only described here.
A scheduled, non-gating workflow (.github/workflows/interop.yml) downloads the
latest published release and runs both directions of them against the current
build every week — new clients over an old runner and the reverse, an abandoned
record reaped from either side, probe pinning both ways, and each binary's
JSONL stream read under the other's schema. See CONTRIBUTING.md,
"Cross-version interop".
Adapter acceptance checklist
- exact supported
schema_version; - exact reserved exit-code band;
- every command and long flag the adapter invokes;
- required containment
mechanism; - required
abrupt_cleanupstrength; - I/O mode/capture assumptions;
- resource-limit applicability in the real environment.
The last four are properties of the deployment, not of the binary, so probe
cannot answer them: confirm them on each host with doctor
(--require-mechanism, --require-abrupt-cleanup, --check-resource-controller --require-resource-controller), which reports what the machine actually did rather
than what its platform can do in principle.
See also
- Integration guide — complete adapter lifecycle.
- Exit-code contract.
- JSONL event schema.
- Platform support.
JSONL event schema (v1)
This is the normative description of processkit-cli's JSONL lifecycle-event
contract. It is part of the project's public compatibility surface — CLI flags +
exit-code ranges + schema_version (see AGENTS.md) — because adapters, in
particular the processkit-py CLI, pin schema_version and reimplement these
shapes. Treat every field below as public API.
- The in-code source of truth is
src/events.rs. - The golden sample stream is
fixtures/schema/v1/events.jsonl; the golden test (events::tests::golden_stream_matches_the_fixture) keeps this document, the code, and the fixture in lockstep. - A machine-readable JSON Schema (draft 2020-12) is published at
fixtures/schema/v1/schema.json— one variant per event type plus the shared envelope, transcribed from this document. This prose document remains the normative source of truth; the JSON Schema is a mechanical mirror of it, kept honest bytests::golden_fixture_validates_against_the_schema(tests/events.rs), which validates the golden fixture (and, in several other tests in that file, live streams emitted by the binary) against it — so a discrepancy between the schema and the fixture/code fails CI rather than drifting silently. On any disagreement between the schema and this document, trust this document and treat the schema as needing a fix. The schema's version is synchronized withschema_version: it lives underfixtures/schema/v1/alongside the fixture, and a breaking change that bumpsschema_version(see "Versioning") moves both to a newfixtures/schema/vN/directory together, never one without the other.
Getting the schema without a git checkout
fixtures/schema/v1/schema.json lives in the repository, so a consumer who
only has an installed binary (cargo install) or an unpacked release
archive — no clone, no tag to check out — could otherwise only get the exact
schema for their version by guessing which git tag matches. Two offline
alternatives avoid that:
processkit-cli probe --json --print-schemaprints the schema document embedded into that specific binary at build time (src/probe.rs'sSCHEMA_JSON, viainclude_str!— the file above stays the single source of truth, this is a verbatim, byte-for-byte copy of it, never a hand-maintained second one). It replaces the usual probe report, and cannot be combined with any--require-*flag: that combination is rejected at parse time as an ordinary usage error (exit100), not silently accepted with the requested checks skipped — aprobeinvocation that asks for expectations to be verified must never exit0without verifying them (seedocs/integration.md, "Fail-closed preflight").--print-schemaitself is an ordinary, additive CLI surface token (probe:--print-schema) like any otherprobeflag.- Every release archive (
.github/workflows/release.yml) bundles aschema/directory alongside the binary,completions/, andman/, containingschema.jsonand the goldenevents.jsonlfixture exactly as checked out for that release.
And to check a stream against it without a checkout — or a JSON Schema
validator of your own — processkit-cli events --file <stream.jsonl> --validate
validates every line against that same embedded document, reporting each
violation by line number and exiting EVENTS_INVALID (114) when any line does
not conform (see docs/exit-codes.md, "Checking a stream:
events --validate"). It is the recommended way for an adapter to keep its own
recorded fixtures honest against the runner version it targets. The checker is
in-binary and adds no runtime dependency: it interprets the embedded document
over the keyword subset that document uses and refuses to run on anything it
does not implement, and the test tier holds its verdict against a real JSON
Schema engine's, line for line, over the golden fixture and a generated mutation
corpus.
Transport
- Events are written to the file named by
run's--jsonloption, never to stdout. The child's stdout and stderr pass through untouched; runner diagnostics go to the--jsonlfile or to stderr, never interleaved into the child's stdout (AGENTS.md, "Streams are strictly separated"). - The file is one event per line (JSON Lines): each line is a single,
complete JSON object followed by
\n. Lines are UTF-8. - The
--jsonlfile is created or truncated at the start of a run, so it holds exactly that run's stream. Each line is flushed as it is written, so the stream is durable even though a completed run forwards the child's exit code via an immediate process exit. - If the
--jsonlfile cannot be created, the run fails closed before the child is spawned (a runner-band exit code) rather than running the child with no event stream. A write failure after the child has started is best-effort: the runner warns once on stderr and continues, because the child's exit-code fidelity outranks diagnostics.
Envelope
Every line shares a common envelope, always in this order:
| Field | Type | Notes |
|---|---|---|
schema_version | integer | Always 1 for this version. See "Versioning". |
time | string | Emission time, RFC 3339 UTC, millisecond precision (…Z). |
event | string | The event type tag (snake_case); selects the remaining fields below. |
The time field is the moment the runner emitted the event. For root_exited it
therefore doubles as the child's exit timestamp — the moment the runner observed
the child leave.
Events
Fields marked nullable are always present; when a value is unknown or does not
apply they are the JSON literal null (explicitly absent), never omitted.
run_started
The run has begun: the child is spawned into the container.
| Field | Type | Notes |
|---|---|---|
run_id | string | The --run-id value, or a generated run-<pid>-<unix_nanos>. |
labels | object | Operator labels from run --label; empty when none were supplied. Keys are sorted for deterministic JSON. |
root_pid | integer, nullable | The root child's PID; null if the backend exposed none. |
mechanism | string | Containment mechanism: job_object, cgroup_v2, process_group, process_reaper, or unknown. The FreeBSD process reaper is the whole-tree procctl(2) backend; unknown is the conservative fallback for a future ProcessKit mechanism this build does not recognize. |
abrupt_cleanup | string | Cleanup surviving abrupt runner death: whole_tree, direct_child_only, or none. |
cwd | string, nullable | The child's absolute working directory; null if it could not be resolved. |
command | object | The command, redacted by default — see "Command redaction". |
abrupt_cleanup is distinct from mechanism and from ordinary teardown. It is
whole_tree on Windows because closing the runner's last Job Object handle kills
all members; direct_child_only on Linux because the runner enables ProcessKit's
parent-death signal for the root child while cgroups themselves persist; and
none on FreeBSD, macOS, and other Unix because the current public API has no
parent-death primitive there. Normal completion, timeout, and cancellation still invoke the
reported container mechanism's regular teardown.
members_snapshot
A point-in-time snapshot of the container's members. It is a snapshot, not a census: a listed PID may exit immediately afterward, and a process spawned during the read may be missing.
| Field | Type | Notes |
|---|---|---|
reason | string | What asked for this snapshot: spawn or interval (below). |
read_error | boolean | true when the member read itself failed; see "Honest degradation on a failed sample" below. |
members | array of member | Each entry is a member (below). |
A member object:
| Field | Type | Notes |
|---|---|---|
pid | integer | The process id. |
ppid | integer, nullable | Parent pid — see "Enriched member fields". |
name | string, nullable | Executable name — see "Enriched member fields". |
start_time | string, nullable | Opaque, platform-specific start-time token, as a decimal string — see "Enriched member fields". |
How many, and when (reason). Every run emits one snapshot immediately after
run_started, carrying reason: "spawn". A run given run --snapshot-interval <duration> additionally re-emits the same event on that cadence for as long
as the child runs, each carrying reason: "interval" — recorded observability for
how the tree evolved (when the worker fleet grew, whether helpers lingered, what
the tree looked like just before a deadline fired), for the long, quiet, or
detached runs where nobody is likely to have been watching live with inspect at
the interesting moment.
reasonandread_errorare always present, including on the singlespawnsnapshot a run without the flag emits.reasonis the only difference between a post-spawn snapshot and a periodic one: both are produced by the same code through the samemembers_info()enrichment, so a periodic snapshot'smemberscannot drift in shape from the first one's.- The periodic snapshots stop the moment the run's ending is decided, so they
appear only between
run_startedand the ending's own event (root_exited, or thetimeout/cancelled/killedreason event) — never interleaved into thecleanup_started/cleanup_finishedteardown pair. See "Ordering". - Repeating an existing event is additive within schema v1 — no field is
renamed, retyped, or given a new meaning, and a reader that pins a version must
already tolerate additional events (see "Versioning"). The cadence is also
opt-in: without
--snapshot-intervala run emits exactly the one snapshot, at exactly the point, it always did. The event's wire form, however, changed for every run including the flagless one:reasonandread_errorare new, always-present fields, additive within v1 but not "unchanged" (see Compatibility and upgrades for the reader obligations that follow). - Snapshots are read from the container's own member list, not from the runner's
output pump, so
--snapshot-intervalcomposes with every I/O mode —--inherit-stdioincluded, unlike--idle-timeout.
Honest degradation on a failed sample. When the members_info() read itself
fails, the snapshot is still emitted, with read_error: true and an empty
members array — the same convention cleanup_started/cleanup_finished use for
their own failed reads (see "Honest degradation on a teardown read failure").
members is then a fallback, not an observation: a consumer must check
read_error before reading an empty array as a confirmed-empty tree. The runner
also warns on its stderr, but that warning is not the contract: a --detached
runner's stdin/stdout/stderr are null (see
Detached runs), so in the long, quiet, detached
runs this cadence exists for, the flagged event in the JSONL file is the only
report of the failure that reaches anyone. A failed sample therefore never appears
as a gap in the cadence; a genuine gap means the run ended, the interval had not
elapsed yet, or the stream itself stopped (see "Stream size" below).
Stream size. The cadence is the first thing in this stream whose event count scales with a run's duration, and it is deliberately unbounded — see Running commands for the recorded decision, the sizing arithmetic, and how to choose an interval.
root_exited
The root child exited on its own.
| Field | Type | Notes |
|---|---|---|
outcome | string | exited, signalled, timed_out, or unknown. |
code | integer, nullable | The exit code for exited; null otherwise. |
signal | integer, nullable | The signal number for a Unix signalled death; null otherwise. |
On Windows a killed process reports exited with a platform code (there is no
signal abstraction), so signalled is Unix-only.
cleanup_started
Container teardown is beginning.
| Field | Type | Notes |
|---|---|---|
members_before | integer | The tree size (member count) about to be reaped. |
read_error | boolean | true when the pre-cleanup member read itself failed; see "Honest degradation on a teardown read failure" below. |
cleanup_finished
Container teardown finished (after the hard kill).
| Field | Type | Notes |
|---|---|---|
remaining | integer | Count of remaining_pids. |
remaining_pids | array of integer | Post-kill member snapshot; normally empty. |
soft_terminate | string, nullable | The soft-stop tier for a runner-imposed ending (below); null on the natural-exit path. |
shutdown | object, nullable | Pre-attempt capability plus ProcessKit ShutdownReport observations; null when no soft stop was attempted. |
read_error | boolean | true when the post-kill member read itself failed; see "Honest degradation on teardown confirmation failures" below. |
kill_error | boolean | true when the hard kill itself returned an error; even a successful empty member read is then non-conclusive. |
remaining_pids is a snapshot: on the Job Object and cgroup mechanisms a process
leaves membership on exit, so it is empty after the kill; on the POSIX
process-group fallback an unreaped just-exited child can still be listed until it
is reaped. soft_terminate is one of:
signalled— a soft stop really was delivered to the tree: on Unix aSIGTERMbroadcast; on Windows, where a Job Object has no POSIX signal, ProcessKit's best-effort soft close — aWM_CLOSEto every top-level window owned by a live member plus a consoleCTRL_BREAKto a child opted in with--windows-graceful-ctrl-break.unsupported— nothing in the tree could receive a soft stop, so none was sent and the runner does not pretend otherwise. Windows-only in practice, and the ordinary case there for a plain console child: no windowed member and no console-CTRL leader means the soft tier has nothing to trigger. The grace window still elapsed before the atomic Job Object kill.failed— the soft stop could not be delivered; the hard kill ran regardless.
shutdown is null for natural exits and immediate kill endings. Otherwise it
contains:
| Field | Type | Notes |
|---|---|---|
soft_stop_scope | string | Pre-attempt capability: whole_tree, opt_in_members, or none. |
soft_signal | string | Observed delivery: sent, unsupported, or failed. |
members_before / members_after | integer, nullable | ProcessKit's point-in-time counts; null on read failure. |
drained_within_grace | boolean, nullable | Whether every member exited before escalation. |
escalated | boolean, nullable | Whether ProcessKit hard-killed survivors. |
elapsed_ms | integer, nullable | Actual stop-driver duration in milliseconds. |
The last three fields are null only when ProcessGroup::stop itself failed and
could not return a ShutdownReport; the owning container's hard-kill backstop still
runs. This is additive to schema v1 and leaves soft_terminate in place for existing
consumers.
Honest degradation on teardown confirmation failures. members_before/remaining/
remaining_pids are read from the live container (ProcessGroup::members()),
which can itself fail (an OS-level enumeration error). Rather than let a read
failure masquerade as a confirmed 0/empty observation, each event carries an
explicit read_error flag: false on every successful read (the common case,
unaffected by this), true when the read failed, in which case the numeric
field(s) fall back to 0/an empty array — not a fabricated fact, only the
absence of one. A consumer that treats cleanup_finished.remaining == 0 as
"the tree is confirmed empty" must first check read_error is false.
cleanup_finished.kill_error qualifies a separate operation: it is true when
ProcessGroup::kill_all() itself returned an error. A subsequent member read can
still succeed and return an empty list in that state — notably when ProcessKit drained
the tree but could not thaw the container — so remaining: 0, remaining_pids: [],
and read_error: false are not conclusive on their own. Confirmed-clean teardown
requires kill_error: false, read_error: false, and remaining: 0. A successful
hard kill followed by the same empty snapshot keeps both flags false. The field is
always present and additive within schema v1; no existing field changed meaning.
The runner also warns on stderr whenever this happens, though — as for
members_snapshot's own flag above — that warning reaches nobody in a detached
run, so the flags in the stream, not the warning, are the contract. This mirrors
output_captured's write_error flag (both are explicit failure markers, never
inferred from the accompanying data alone).
limit_hit
A requested ProcessKit resource limit (--max-memory, --max-processes, or
--cpu-quota) could not be applied.
| Field | Type | Notes |
|---|---|---|
limit | string | Which limit could not be applied: memory, processes, or cpu. |
detail | string, nullable | Human-readable detail; null if none. |
When it is emitted. Enforcement of a whole-tree cap needs a real container — a Windows Job Object or a Linux cgroup v2. Where none can carry the request, the run fails fast rather than running silently unbounded, and this event records it. The emission is deliberately narrow:
- Only the "could not be applied" branch. ProcessKit signals a limit failure
only at group creation (pre-spawn — the child never started); it exposes no
separate "the tree was killed mid-run for exceeding a cap" runtime signal (the OS
reaps an offender itself — a Job Object/cgroup OOM or CPU throttle — without the
crate translating that into an event). So
limit_hitcovers the unenforceable / unsupported case, not a live overrun:memory/cpu/processeswhere the platform cannot provide the requested whole-tree cap (macOS and non-FreeBSD BSD process groups, plus the Linux process-group fallback, have no whole-tree cap container; FreeBSD's process reaper provides whole-tree normal containment, membership, and kill/teardown semantics but no memory, CPU, or process-count resource-limit support), or a Linux cgroup v2 whose controllers can't be enabled (not the real hierarchy root — under systemd, an ordinary container, or typical CI; seeREADME.md, "Resource limits"). This is the pre-spawn admission failure only; post-run evidence for successfully applied caps is carried by the additivelimit_evidenceevent below and follows the platform matrix indocs/resource-limits.md. - Nonsense values never reach it. A degenerate value (
--max-memory 0, a non-positive/non-finite--cpu-quota) is aUSAGE(100) form error rejected at argument-parse time, solimit_hitnever carries an "invalid value" reason. - Ordering.
limit_hitis emitted first, then the samecontainer_failed(phase: "create") →runner_exit(source: "container_error", codeBACKEND= 102) tail every other group-creation failure takes. The dedicatedlimit_hitevent — not the exit code — is the authoritative signal that the ending was a resource limit (docs/exit-codes.md, "Why a band is not enough on its own"). A run whose caps were applied emits nolimit_hitat all and proceeds normally. Its post-run evidence, when requested, is the separatelimit_evidenceevent below.
limit_evidence
Post-run, per-axis evidence for a run that requested at least one resource cap
and whose ProcessGroup::with_options call successfully created the container.
The runner reads ProcessGroup::limit_evidence() while that group still exists,
after the ending event (root_exited, timeout, cancelled, killed, or
output_overflow) when one exists, and before cleanup_started consumes the
group. If group creation returns ProcessKit's ResourceLimit error, the group
does not exist: the runner emits the pre-spawn limit_hit event and its existing
backend-error tail, with no limit_evidence event.
| Field | Type | Notes |
|---|---|---|
memory | string | tripped, not_tripped, or unknown. |
processes | string | tripped, not_tripped, or unknown. |
cpu | string | tripped, not_tripped, or unknown. |
tripped is emitted only for authoritative kernel evidence that the cap
engaged; not_tripped is authoritative evidence that it did not engage (and is
also ProcessKit's result for an uncapped axis); unknown means a successfully
created group's mechanism cannot provide a post-run answer. Linux cgroup v2 can
report all three states. A successfully created Windows Job Object reports
unknown for capped axes. FreeBSD's process reaper provides whole-tree normal
membership and kill/teardown semantics but no resource-limit primitive, so a cap
request fails before a capped group exists. The POSIX process-group fallback and
macOS/non-FreeBSD BSD process groups likewise fail before a capped group exists,
so they emit limit_hit and no
limit_evidence. The event is absent when no cap was requested. This is an
additive event within schema_version = 1.
resource_summary
What the contained tree actually consumed. The runner takes one ProcessGroup::stats()
reading after the ending is decided and before cleanup_finished hard-kills the group —
the same read point, and for the same reason, as limit_evidence: what it reads lives in
the container, so it is gone with it. What that reading can cover is per-mechanism, and
on Linux cgroup v2 memory and CPU depend on the read point itself (see the platform matrix
linked below, consequence 5).
| Field | Type | Notes |
|---|---|---|
read_error | boolean | true when the stats() read itself failed; every measurement below is then null as a gap. See "Honest degradation, and why the flag is load-bearing". |
peak_memory_bytes | integer, nullable | Peak whole-tree memory in bytes. On Linux cgroup v2 this is summed over the members live at the read point, so it is null after a natural exit — see the matrix. |
total_cpu_ms | integer, nullable | Total CPU time (user + kernel) in milliseconds, truncated toward zero. On Linux cgroup v2 it covers the members live at the read point, so it is null after a natural exit — see the matrix. |
io_read_bytes | integer, nullable | Bytes the tree read. |
io_write_bytes | integer, nullable | Bytes the tree wrote. |
peak_process_count | integer, nullable | High-water mark of processes held at once — not the count now. |
Emitted on every run that spawned the child. Exactly once, no flag, no cap, every
platform, every ending. A run whose child never started (spawn_failed, a
container_failed with phase create or attach, a pre-spawn limit_hit) emits
none: there was no tree, and on the create path no container to read.
Decision: always, not behind a flag. This is deliberate and recorded here rather than left implicit. Three reasons:
- It is one synchronous read of accumulators the kernel already maintains, not a
sampling cadence, so an opt-in would save one syscall's worth of work and one line
of output. Contrast
--snapshot-interval, which is opt-in: that adds unbounded volume on a timer, which is a real cost worth a flag. - The platform that needs these numbers most would be the one left silent. On Windows,
limit_evidencecan only ever answerunknownfor a capped axis (a Job Object keeps no post-mortem record that a cap fired), and on the POSIX fallback there is nolimit_evidenceat all. The measuredpeak_memory_bytesis the only honest resource fact available there — and a caller who had to know to pass a flag would not get it. - An opt-in event costs more contract, not less: a flag, a
probetoken for the flag, and a conditional in the ordering rule, all to gate a single line.
The cost is paid honestly: this event does grow every run's stream by one line, so the
foreground-run event count in docs/integration.md and
ADR 0007 is now seven rather than six, and
it is the one growth that is not a caller opting in. A reader that routes by event type — which
docs/compatibility.md
requires within a version — is unaffected.
Honest degradation, and why the flag is load-bearing. A failed stats() does not
skip the event: it is emitted with read_error: true and every measurement null.
That flag is not ceremony. An all-null summary is also a perfectly correct
success: it is what a mechanism with no whole-tree accounting reports (FreeBSD's
process reaper, macOS/non-FreeBSD BSD process groups, and the Linux process-group
fallback), and it is equally what a plain flagless run on
Linux cgroup v2 reports when its child exited on its own — memory and CPU have no
live member left to sum at the read point, and no io/pids controller is enabled to
answer for the rest. So the numbers alone cannot distinguish "this mechanism measured
nothing here" from "we could not measure", on any platform. Only read_error can, and a
consumer must check it before concluding anything from a null.
A foreground run also gets a stderr warning, but that reaches nobody on a --detach
run (whose stderr is Stdio::null()), which is why the fact is recorded in the stream.
Nothing here is improved, and null never means zero. Each axis is independently
nullable, and null always means this mechanism, at this read point, does not account
for it. The runner does not substitute 0, and does not take a maximum over its own
periodic reads to fill a gap — that would describe when the runner looked, not what
the tree did. A caller who wants a sampled series over the run wants
--snapshot-interval's own event, knowingly — noting that it samples the tree's
membership, not its consumption, so no event in this stream is a consumption series.
The normative platform matrix — which axis is null where, why the read point decides
two of them, and why two platforms'
IO counters are not comparable with each other — is in
docs/resource-limits.md, together with
what this event does not prove about a limit. Three consequences worth stating here
because they are easy to misread as bugs:
peak_process_countisnullon all of Windows. A Job Object counts how many processes are in it now and how many were ever assigned to it; neither is a peak.- On Linux cgroup v2,
peak_memory_bytesandtotal_cpu_msarenullafter a natural exit. They are not cgroup counters: they are summed from/procover the members live at the read point, and a child that exited on its own has left none. The same two axes are populated on a runner-imposed ending (timeout,cancelled,killed,output_overflow), whose read happens while the tree still runs. Windows, whose Job Object accounting outlives its processes, is unaffected. This is the ordinary case on Linux, not an edge one — consequence 5 of the matrix states it normatively. total_cpu_mstruncates, so a run using under a millisecond of CPU reports a measured0.nullis the only value that means unknown.
active_process_count — which ProcessKit's snapshot does carry — is deliberately
not on this event. It is a "how many right now" reading, and this read happens
after the ending is decided, so it would report the moment the runner looked rather
than anything about the run. The tree size at teardown already has an honest home in
cleanup_started.members_before.
This is an additive event within schema_version = 1.
timeout
A runner deadline elapsed while the child was still running: either the whole-run
--timeout, or the --idle-timeout (the child produced no output for the idle
window). Both share this event, the reserved TIMEOUT (106) terminal code, and the
soft-stop → grace → hard-kill teardown described by the following cleanup_started /
cleanup_finished events (see docs/exit-codes.md); the always-present reason
field tells them apart.
| Field | Type | Notes |
|---|---|---|
timeout_ms | integer | The deadline that elapsed, milliseconds — the whole-run window for overall, the idle window for idle. |
grace_ms | integer, nullable | The --grace window, ms; null if unset. |
reason | string | Which deadline fired: overall (--timeout) or idle (--idle-timeout). |
--idle-timeout re-arms its deadline on every chunk of the child's output, so a
child that keeps producing output is never reaped no matter how long it runs — only
one that goes silent past the window is. It reuses TIMEOUT (106) rather than
minting a new exit code (the same class of ending — a deadline the runner enforced —
distinguished by the more specific reason on this earlier event) and its terminal
runner_exit source stays timeout, exactly as for --timeout. It requires the
runner's output pump, so it conflicts with --inherit-stdio at parse time (like
--capture-dir); it does compose with --capture-dir, whose tee re-arms the same
one timer.
output_overflow
A capture stream exceeded its configured per-stream ceiling while
--capture-overflow cancel was active. The event is emitted before the same
soft-stop → grace → hard-kill teardown used by a timeout.
| Field | Type | Notes |
|---|---|---|
stream | string | stdout or stderr, whichever crossed the ceiling first. |
max_bytes | integer | The active per-stream --capture-max-bytes ceiling (or its 8 MiB default). |
grace_ms | integer, nullable | The --grace window, ms; null if unset. |
The terminal runner_exit has source: "output_overflow", code
OUTPUT_OVERFLOW (113), and child_code: null. output_captured still follows
cleanup and reports the transcript's final counters and truncation flags. Without
the opt-in cancel policy, crossing the same ceiling emits no output_overflow and
continues the run, preserving the default truncate-only behavior.
cancelled
The run was cancelled and torn down through the shared soft-stop → grace → hard-kill
path. source names the trigger, and the terminal runner_exit carries the matching
reserved code:
ctrl_c— a local interactiveCtrl-C; terminal codeCANCELLED(107).sigterm— Unix only: the runner receivedSIGTERM, the standard external stop (kill <pid>,systemctl stop, a cancelled CI job, a supervisor's shutdown timeout); terminal codeCANCELLED(107).sighup— Unix only: the runner receivedSIGHUP— its controlling terminal went away (a closed terminal, a dropped SSH session); terminal codeCANCELLED(107).ctrl_break— Windows only: the runner caughtCTRL_BREAK_EVENT; terminal codeCANCELLED(107).ctrl_close— Windows only: the runner caughtCTRL_CLOSE_EVENT(the console window is being closed); terminal codeCANCELLED(107). Windows gives the handler only a short window (about 5 seconds) before terminating the process regardless — see "Timeouts, cancel, and grace" inREADME.mdfor how the effective--graceis bounded so this event's own teardown can fit inside it.ctrl_logoff— Windows only: the runner caughtCTRL_LOGOFF_EVENT(the user is logging off); terminal codeCANCELLED(107).ctrl_shutdown— Windows only: the runner caughtCTRL_SHUTDOWN_EVENT(the system is shutting down); terminal codeCANCELLED(107).control_cancel— acancelcommand that reached the live runner over its control plane (seedocs/control-plane.md); terminal codeCONTROL_CANCELLED(108).
They all share this event because they share the teardown; the source and the
terminal code tell them apart. The local signals/events deliberately share the one
CANCELLED code — they are the same class of ending (a local signal stopped the run) —
so a consumer that needs to know which one arrived reads this source, one event
before the terminal runner_exit. The Unix signal and Windows console-control-event
values are additive: a run that is never signalled emits exactly the stream it did
before, and a consumer that only knows ctrl_c/control_cancel still sees a
well-formed cancelled event with the same fields.
Catching SIGTERM/SIGHUP (Unix) and CTRL_BREAK/CTRL_CLOSE/CTRL_LOGOFF/
CTRL_SHUTDOWN (Windows) is what makes this teardown happen at all on those paths:
their default disposition terminates the runner outright, which would skip the
cancelled / cleanup_started / cleanup_finished / runner_exit events and leave the
run's registry entry behind stale until prune — the ending would go unreported to
any observer of the event stream or registry, even though the tree itself is not left
orphaned on every platform: the abrupt-owner-death reap covers only the direct child
on Linux, nothing at all on FreeBSD, macOS, or other BSDs, and the whole tree on Windows (closing the
runner's last Job Object handle; see cleanup_finished and docs/registry.md).
One exception, deliberate: a Unix signal whose disposition is already SIG_IGN when
the runner starts (what nohup does to SIGHUP) is left ignored rather than
un-ignored behind the operator's back, so no cancelled event is produced for it —
and none is owed, since an ignored signal would not have ended the run either.
Known limitation, Windows only: a repeat Unix signal arriving mid-teardown is
silently absorbed (the OS keeps the disposition installed for the process's whole
lifetime, independent of listener state), but a second Windows console-control
event arriving after teardown has already begun is not — it falls through to the
OS's default handling and terminates the runner outright, before the terminal
events above are written. See README.md, "Timeouts, cancel, and grace", and the
#[cfg(windows)] arm of wait_for_cancel_signal in src/run/signals.rs for the full
reasoning behind this accepted trade-off.
| Field | Type | Notes |
|---|---|---|
source | string | ctrl_c, sigterm (Unix), sighup (Unix), ctrl_break (Windows), ctrl_close (Windows), ctrl_logoff (Windows), ctrl_shutdown (Windows), or control_cancel. |
grace_ms | integer, nullable | The effective --grace window, ms; null if unset. For a Windows ctrl_close this may be less than the requested --grace (capped to fit the OS's own termination window — see README.md, "Timeouts, cancel, and grace"); every other trigger echoes the request unchanged. |
killed
The run was killed by a control-plane kill command: an immediate hard kill of
the whole tree, with no soft stop and no grace (unlike cancelled, which waits out
the grace window first). The teardown it triggers is described by the following
cleanup_started / cleanup_finished events — where soft_terminate is null,
because no soft stop was attempted — and the run's terminal code is the reserved
CONTROL_KILLED (109). See docs/control-plane.md.
| Field | Type | Notes |
|---|---|---|
source | string | control_kill. |
spawn_failed
The program could not be started (not found, not executable, bad --cwd): the
child never ran.
| Field | Type | Notes |
|---|---|---|
code | integer | The runner-band exit code (SPAWN, 101). |
message | string | Human-readable failure reason. |
container_failed
Creating the container, joining the child to it, or handing the terminal to an interactive child failed.
| Field | Type | Notes |
|---|---|---|
phase | string | create (the container could not be created), attach (the launch into it failed), or foreground (handing the terminal to an interactive child failed after it had already spawned). |
code | integer | The runner-band exit code (BACKEND, 102). |
message | string | Human-readable failure reason. |
runner_exit
The terminal event of every run: the exact code the runner process returns. It
is always emitted, including on the runner's own failure, so a child's exit code
is never lost or aliased even when the process returns a runner-band code
(AGENTS.md, "Exit-code fidelity"; docs/exit-codes.md).
| Field | Type | Notes |
|---|---|---|
code | integer | The exit code the runner process returns (child's code, or a runner-band code). |
source | string | Why the runner exited: child_exit, timeout, output_overflow, cancelled, control_cancel, control_kill, spawn_error, container_error, internal, or setup. |
child_code | integer, nullable | The child's own exit code when it exited on its own; null for a runner-imposed ending or a child that never produced one. |
When source is child_exit, code equals child_code. For a runner-imposed
ending (timeout / cancelled / control_cancel / control_kill) or a pre-run
failure (spawn_error / container_error / setup), child_code is null and
code is the runner-band value. setup names a fail-closed setup failure — a
required output (--jsonl / --capture-dir) or --stdin-file input that could
not be opened — and carries the reserved SETUP code (111), distinct from internal
(a genuine runner fault) so a consumer never reads a bad path as a runner bug (see
docs/exit-codes.md).
This source vocabulary has one mirror, and it is deliberately a mirror rather
than a fork: a run that fails under the global --error-format json reports a
kind spelled with these same words (timeout, cancelled, control_cancel,
control_kill, output_overflow, spawn_error, container_error, setup,
internal — everything but child_exit, which is not a failure). The table above
remains their single source of truth; the envelope exists for the case where there
is no stream to read at all (a run started without --jsonl, or a --detach that
never got far enough to write one), and a test holds the two spellings together so
they cannot drift. See docs/exit-codes.md, "Machine-readable failures:
--error-format json".
The "runner process" here is always the process that ran the child. Under
run --detach that is the detached copy, not the invocation the caller waited on:
the caller's own exit code reports only whether the run started, which is exactly
why this event — unchanged in shape, source, or meaning for a detached run — is
where a detached caller reads the child's real result (see docs/exit-codes.md,
"Detached runs").
output_captured
Bounded stdout/stderr capture finished. Emitted only when run was given
--capture-dir <dir>: the child's stdout and stderr are teed into
<dir>/stdout.log and <dir>/stderr.log alongside the live echo — suppressed
when --no-echo is also given, which does not change what is captured — and
this event records, per stream, what was captured. A run without --capture-dir
does not emit it (the stream is otherwise byte-for-byte identical).
--inherit-stdio conflicts with --capture-dir, because direct child output
does not pass through the runner's tee, and therefore never emits this event.
| Field | Type | Notes |
|---|---|---|
stdout | object | Capture result for standard output (below). |
stderr | object | Capture result for standard error (below). |
A capture object (one per stream):
| Field | Type | Notes |
|---|---|---|
path | string | The file the stream was written to (<dir>/stdout.log or <dir>/stderr.log). |
bytes | integer | Full byte counter — every decoded byte the stream produced; exceeds the file size when the stream was truncated or a write failed. |
sha256 | string | Lowercase-hex SHA-256 of the bytes actually written to path — verify the file against it. Same digest primitive as argv_sha256. |
truncated | boolean | Explicit flag: true when the stream outran the per-stream capture ceiling and the tail was deliberately not written. Never inferred from the file's size. |
write_error | boolean | Explicit flag: true when a file write failed part-way through the stream, after which capture stopped writing to the (broken) file. Signals a disk-level problem, distinct from a ceiling clip. Never inferred from the file's size. |
The two explicit flags exist so a consumer distinguishes "captured in full" from
"clipped at the limit" from "cut short by a disk write error" from the flags alone —
not by comparing the file's size against a ceiling it would have to know. The stream
was captured in full exactly when both truncated and write_error are false;
then bytes equals the file's size and sha256 covers the whole stream. When
truncated is true, bytes is the full amount produced while the file holds (and
sha256 covers) the first ceiling's worth. When write_error is true, a write
failed mid-stream: bytes remains the full byte counter, but the file holds — and
sha256 covers — only the prefix that reached disk before the failure, so bytes
exceeds the file's size. The two flags are independent and may both be set (a stream
that outran the ceiling and then also hit a write error). The two streams are likewise
independent: one may be truncated or write-errored while the other is complete. On a
runner-imposed ending (timeout / cancelled) the event reports whatever was captured
before the teardown.
Ordering
A normal run emits, in order: run_started, members_snapshot
(reason: "spawn"), then either
- natural exit —
root_exited,resource_summary,cleanup_started,cleanup_finished,runner_exit; or - runner-imposed ending — the reason event (
timeout,cancelled, orkilled),resource_summary,cleanup_started,cleanup_finished,runner_exit.
resource_summary appears exactly once in every run that spawned the child —
there is no flag and no platform that removes it — between the ending event and
cleanup_started. A run whose child never started emits none (see
"resource_summary").
When any resource cap was requested and ProcessGroup creation succeeded, insert
limit_evidence immediately before that resource_summary, i.e. still after the
ending event and before cleanup_started. A run
without a cap emits no limit_evidence; a pre-spawn limit_hit path also emits
none because no group exists to query.
Neither container read ever moves inside or
after the cleanup_started/cleanup_finished pair, and the reason is the same for
both: cleanup_finished hard-kills the group, and both the limit evidence and the
usage counters live in that group. Their fixed relative order (limit_evidence,
then resource_summary) keeps the conditional event adjacent to the ending event
whose caps it describes.
The reason event names which ending it was: timeout (with reason overall or
idle) for a --timeout or a --idle-timeout, cancelled (with source ctrl_c,
sigterm/sighup (Unix), ctrl_break/ctrl_close/ctrl_logoff/ctrl_shutdown
(Windows), or control_cancel) for a local stop signal or a control-plane cancel, and
killed (source control_kill) for a control-plane kill.
Multiplicity of members_snapshot. The post-spawn members_snapshot above is
the only one a run emits by default, and it always appears exactly once,
directly after run_started — unconditionally, because a failed member read is
recorded as a read_error sample rather than omitting the event (see
"members_snapshot"). A run given run --snapshot-interval <duration>
emits additional members_snapshot events (reason: "interval") on that
cadence, all of them after the post-spawn one and all of them before the
ending's own event — root_exited for a natural exit, or the
timeout/cancelled/killed reason event for a runner-imposed one. None is ever
interleaved into the cleanup_started/cleanup_finished teardown pair or emitted
after it: the cadence stops the instant the ending is decided. Every other event's
position above is unchanged. This is an additive extension within schema v1 (see
"Versioning"): a consumer that reads the first members_snapshot and routes the
rest of the stream by event type is unaffected, and one that wants only the
start-of-run shape can select reason == "spawn" instead of relying on position.
When --capture-dir is set, an output_captured event is inserted after
cleanup_finished and before the terminal runner_exit, on every ending that ran
the child (natural exit, timeout, cancel, and kill alike). Without --capture-dir it
is absent.
A failure before the child is spawned emits its error event (container_failed
with phase create or attach, or spawn_failed) and then runner_exit, with
no run_started (and no output_captured — the child never produced output, and no
resource_summary — there was no tree to have consumed anything). When
that pre-spawn failure is a resource limit that could not be applied, a limit_hit
is emitted first, immediately before the container_failed (phase create) →
runner_exit (source container_error) pair — the resource-specific record of
why the container could not be created (see limit_hit). A
container_failed with phase foreground comes later: the child had already
spawned, so the same resource_summary → cleanup_started → cleanup_finished tail
tears the container down before the
terminal runner_exit — the child ran, so it has consumption to report like any other
ending. run_started is still never written (the handoff fails
first), and the interactive mode this path only occurs in cannot set
--capture-dir, so there is still no output_captured.
Command redaction
Command lines can carry secrets, so run_started's command object is redacted
by default (AGENTS.md, "Argv is redacted by default"):
| Field | Type | Notes |
|---|---|---|
redacted | boolean | true by default; false only under --argv-raw. |
argv | array of string, nullable | The raw argv, present only when redacted is false; null otherwise. Losslessly encoded — see "Raw argv that is not valid Unicode". |
argv_sha256 | string, nullable | Lowercase-hex SHA-256 fingerprint of argv — see "Fingerprint". Filled on every run. |
hint | string, nullable | Worker-shape hint for a recognized argv, else null — see "Hint classifier". |
The redaction is deliberately one-directional: argv_sha256 and hint are
derived from argv but cannot reveal it (a one-way hash and a fixed category
label), so they are filled on every run — redacted or not. --argv-raw adds
the raw argv array; it never changes the fingerprint or the hint. A consumer can
therefore correlate and classify a run without ever seeing its command line.
Artifact paths follow a separate disclosure rule. The public lifecycle stream does
not add them as event fields: the caller already chose its --jsonl and optional
--capture-dir destinations. The private, owner-only run registry does publish
those locations as absolute paths so a different supervisor can discover a detached
run's observability artifacts. Paths are not derived from argv, but may contain a
project or user name; treat list --json and inspect --json as private operational
metadata and do not copy them into public logs. See
docs/registry.md.
Fingerprint (argv_sha256)
argv_sha256 is the SHA-256 of a canonical encoding of argv, rendered as
lowercase hex (64 characters). The canonical encoding is each argv element's
canonical bytes, joined by a single NUL byte (0x00) — with no leading or
trailing separator and no terminator. A NUL cannot occur inside a real argv
element on any supported platform, so element boundaries are unambiguous:
["ab", "c"] and ["a", "bc"] fingerprint differently. An adapter that re-emits
this schema reproduces the exact digest by hashing the same encoding. (The
reference implementation is events::argv_sha256_hex.)
An argv element is an OS string, not a Unicode string, so "its canonical bytes" is defined per platform — the platform's own exact representation, never a lossy Unicode rendering:
| Platform | An argv element is | Canonical bytes |
|---|---|---|
| Unix (Linux, macOS, BSD) | an arbitrary NUL-free byte sequence, with no encoding attached | those bytes, verbatim |
| Windows | an arbitrary sequence of UTF-16 code units, which may include unpaired surrogates | the WTF-8 encoding of those code units: a surrogate pair is encoded as the 4-byte UTF-8 form of the scalar it denotes, and any unpaired surrogate as the 3-byte form of that code unit |
For an element that is valid Unicode — every ordinary command line — both rows yield exactly its UTF-8 bytes, so the digest is identical on both platforms and identical to what earlier releases produced. The rows differ only for an argument Unicode cannot express, which earlier releases could not fingerprint faithfully at all (see "Versioning"). Two such arguments that differ in their ill-formed bytes fingerprint differently, as two different commands must.
Raw argv that is not valid Unicode
--argv-raw's argv array is JSON, and a JSON string is Unicode text, so an
element whose canonical bytes are not valid UTF-8 cannot be written verbatim. It
is written in an escaped form instead — the raw argv is encoded, never lossily
approximated and never silently dropped — so two arguments that differ only in
their ill-formed bytes stay distinguishable in the field that exists to disclose
them. The type is unchanged: every element is still a JSON string.
The two forms are mutually distinguishable with no extra field or in-band flag, because U+0000 cannot occur inside a real argv element (the same invariant the fingerprint's NUL join rests on):
- An element not beginning with U+0000 is verbatim — the argument itself. This is every element of every ordinary command line, unchanged from earlier releases.
- An element beginning with U+0000 is escaped. Strip that marker; in what
remains,
\\denotes a single\,\xNN(exactly two lowercase hex digits) denotes the byte0xNN, and every other character denotes its own UTF-8 bytes. Concatenating those in order recovers the element's canonical bytes exactly.
The producer escapes as little as possible: only \ (the escape introducer), a
NUL byte, and each byte that is not part of a well-formed UTF-8 sequence are
rewritten, so the readable part of a mangled argument stays readable beside the
bytes that broke it. A Unix argument whose bytes are --path=/tmp/logs followed by
the single byte 0xE9 (an ISO-8859-1 é in a UTF-8 world) is therefore recorded
as the JSON string "\u0000--path=/tmp/logs\\xe9", while the well-formed
--path=/tmp/logsé is recorded as "--path=/tmp/logsé", exactly as it always was.
An escaped element is a well-formed JSON string like any other, and only argv the
local platform accepts but Unicode cannot express ever takes that form, so a reader
that merely displays or logs argv needs no change. Two kinds of reader do:
- One that reconstructs an argv to re-run or compare it must decode as above rather than take an escaped element literally.
- One that stores or forwards a decoded element must check that its sink accepts U+0000. This is the only place in this schema where a string value can carry that code point — every other string is either produced by the runner itself or a rendering of an OS string, and an OS string cannot contain a NUL on any supported platform — so a sink that rejects it will have accepted every stream this runner produced until now.
The emitted line itself is unaffected, and the distinction matters: U+0000 is
written as the ordinary six-character JSON escape \u0000, so the JSONL line is
NUL-free text and storing, archiving, copying, or re-serving the line is exactly
as it was. The obligation begins where the escape is decoded into a string:
- PostgreSQL rejects a
jsonbdocument containing the escape outright (unsupported Unicode escape sequence) and errors on text extraction (->>) from ajsonone — so an adapter that ingests whole event lines intojsonbloses the entirerun_startedevent, not merely theargvfield. - A C-string API (or any layer that passes the decoded value into one) truncates the element at the first NUL. The marker is the element's first character, so the whole argument disappears rather than arriving mangled — strictly less than the U+FFFD reconstruction earlier builds wrote, which at least kept a readable prefix.
- Some YAML and log transports refuse a NUL in a scalar when re-encoding.
Such a reader must decode the escape and re-render the element in a form its sink
accepts — or escape or drop the marker itself — rather than pass the decoded string
through untouched. Note that the marker cannot be moved to a printable character to
avoid this: any printable marker would itself need escaping in verbatim elements,
changing how every ordinary command line is recorded (a Windows path's \ most
visibly), which is a far wider break than the one case above.
The escaped text is a rendering, not the fingerprint's input: argv_sha256 hashes
the canonical bytes themselves, so escaping never perturbs a digest.
Hint classifier
hint names a recognized worker shape — a process form worth identifying (for
example a build worker left running after a build) without disclosing its command
line. It is one of a small, documented catalog of category labels, or null when
the argv matches no known shape (the common case). A rule matches when all of
its marker substrings appear somewhere in the argv, compared case-insensitively;
the first matching rule in catalog order wins. Matching runs over the same text
rendering --argv-raw would record (see "Raw argv that is not valid Unicode"),
whether or not that flag was given. Unlike argv_sha256, the hint makes no
identity claim — it is a category, and two different commands may legitimately
share one — so nothing about it depends on that rendering being unambiguous; every
marker in the catalog is ASCII, so an escaped element still matches on whatever of
it is readable.
hint | Markers (all must be present) | Shape |
|---|---|---|
msbuild_node_reuse | MSBuild.dll, /nodemode:1, /nodeReuse:true | An MSBuild reusable worker node (/nodeReuse:true) — the long-lived build-node process that lingers after a build. |
Adding a shape. The catalog is plain data — the HINT_RULES table in
src/events.rs. Add one entry (a new hint label plus the marker substrings that
identify the shape) and mirror it as a row in the table above; no control-flow
change is needed. Choose a stable, snake_case hint label: consumers may match on
it, so an existing label is part of this contract (changing or removing one is a
breaking change — see "Versioning"). Keep the label to ASCII lowercase letters,
digits, and _ — the per-user run registry publishes the same label in its records
and validates that shape when reading one back
(docs/registry.md, "Reading a record"); a label outside it would be
dropped there. An in-tree test asserts every catalog label satisfies this, so a rule
that violates it fails the build rather than surfacing as an empty column in list.
Enriched member fields
ppid, executable name, and start_time are filled from ProcessKit's
ProcessGroup::members_info() — built strictly on the public processkit API
rather than a local process-enumeration path (AGENTS.md, "Build strictly on the
public processkit API"). Each field stays independently nullable because
members_info() itself reports a field null wherever the platform can't read
it: on Windows, Linux (cgroup or the process-group fallback), and macOS every
field is populated; on the "bare" BSDs (no wired-up per-process reader) every
enriching field is null while pid is still reported — a correct result, not
an error. A member that exits between enumeration and metadata read is omitted
from members entirely rather than reported with fabricated fields.
start_time is not a wall-clock timestamp — it is an opaque, platform- and
unit-specific token (Windows: 100 ns since 1601-01-01 UTC; Linux: clock ticks
since boot; macOS: microseconds since the Unix epoch) whose only documented
purpose is telling a recycled pid apart from the process that previously held
it. It is rendered as its decimal string, matching the field's string, nullable
type, and must never be parsed as a timestamp or compared across platforms.
The same enrichment backs the control-plane inspect snapshot (see
docs/control-plane.md): both members_snapshot and
inspect's members are queried through members_info(), so the two "container
member" views never drift.
Versioning
schema_version is a single integer. Any breaking change to an event's shape
— renaming/removing a field, changing a field's type, or changing the meaning of a
value — is a major bump of schema_version (and a matching Cargo.toml
version bump; docs/exit-codes.md and AGENTS.md treat the surface as a whole).
A future version lands under a new fixtures/schema/vN/ directory. Additive,
backward-compatible clarifications that do not change any existing shape do not bump
the version. Adding a new event type (as output_captured was added) is
likewise additive: it introduces no change to any existing event's shape, and a
consumer that pins the events it knows simply ignores one it does not.
resource_summary is the case that makes the boundary of that rule explicit: it is an
unconditional new type, so it appears in every run that spawned a child rather
than only under a flag, and the stream a default run writes is therefore one line
longer than an earlier v1 build's. That is still additive under the definition above —
no existing event was renamed, retyped, or given a new meaning — and it is the same
distinction members_snapshot's new always-present fields draw below: "additive"
bounds what may change, never what may appear. A reader that pinned the exact set
of event types a run emits, instead of routing by the event tag and ignoring the
rest, will notice; that reader was already outside the contract (see
Compatibility and upgrades,
obligation 1). Adding a new
field to an existing event — always present, and leaving every other field's name,
type, and meaning intact — is additive in the same way: a consumer that reads the
fields it knows is unaffected and simply ignores the new one. The output_captured
per-stream write_error flag was added this way within v1, as was the timeout
event's reason field (when --idle-timeout joined --timeout on that event) and
the members_snapshot event's own reason and read_error fields (when
--snapshot-interval gave that event a second trigger, and a failed sample a way to
report itself), and cleanup_finished.kill_error (when a failed hard kill needed to
stay distinguishable from a successful empty member read). Note what that does not
mean: reason and read_error appear on every
members_snapshot, including the single one a run without the flag emits, so the
default stream's members_snapshot line is not byte-identical to what an earlier
version wrote. That is precisely the additive case above — a reader consuming the
fields it knows is unaffected — but a reader that pinned the exact field set of an
event, rather than the fields it uses, will notice.
Emitting an existing event more times than a
stream previously carried it is additive for the same reason a new event type is:
no existing event's shape changes, and a consumer pinned to a version must already
tolerate events it did not expect at that point in the stream — within a version it
may not assume an event type it knows occurs at most once. members_snapshot was
extended this way within v1 by the opt-in run --snapshot-interval cadence, which
also leaves the default stream (no flag) emitting exactly the events, in exactly the
order and the number, that it did before; the normative statement of how many such
events a stream may carry, and where they may appear, lives in "Ordering", and the
reader obligations these two additive shapes create are collected for adapter
authors in Compatibility and upgrades.
Adding a new value to an open-ended
descriptive string field — a new cancelled source, for instance, as sigterm and
sighup were added within v1 when the runner started catching those signals — is
additive too: no existing value changes meaning, every other field keeps its name,
type, and meaning, and a consumer that switches on the values it knows sees a
well-formed event it can still route by event type (treat an unknown source as "some
other trigger", not as a parse error). Filling a
field that was reserved-as-null is not a breaking change: the field already
exists and its type is unchanged. The argv_sha256 and
hint fields were filled this way — they now carry values on every run instead of
always null; the enriched member fields (see "Enriched member fields" above) were
filled the same way once ProcessKit shipped members_info(). Adding a new hint label to the classifier
catalog is likewise additive, but renaming or removing an existing hint label, or
changing the fingerprint's canonical encoding, changes the meaning of a value and
so is a breaking change.
Defining the canonical encoding for an input it never covered is not such a
change, and the per-platform "canonical bytes" table in "Fingerprint" is that case.
The encoding this document previously specified — "each element as its UTF-8
bytes" — defines a digest only for an argv element that is valid Unicode, and for
every one of those the bytes hashed today are exactly the bytes hashed before, on
both platforms: no value that this document ever gave a meaning to changed meaning.
What changed is an element that has no UTF-8 bytes at all (a Unix argument with
ill-formed bytes, a Windows argument with an unpaired surrogate). An adapter
implementing the old wording could not reproduce those digests, because the runner
was not implementing that wording either — it silently substituted U+FFFD, which
gave different commands the same fingerprint. The same reasoning covers the raw
argv array's escaped form (see "Raw argv that is not valid Unicode"): the element
type and every representable element's rendering are unchanged, and the one case
that changed was previously specified as "the raw argv" while in fact delivering a
lossy reconstruction of it. Both are corrections within schema_version = 1.
They are not free of reader obligations, and the two differ in exactly that.
argv_sha256 keeps its type, its shape, and every digest a reader could already
have recorded, so a reader that treats it as an opaque correlation token needs no
change at all. The raw argv array keeps its type (array of string) and every
representable element's rendering, but a reader may now encounter one element shape
it could not before — the escaped form, and only for argv the local platform allows
but Unicode cannot express. A reader that only displays or logs argv is
unaffected; one that reconstructs an argv from it must decode that form, where
before it would have reconstructed a U+FFFD-mangled argv and could not have detected
that it had; and one that stores or forwards a decoded element must first check
that its sink accepts U+0000, which no stream this runner produced before could
contain. Both obligations, and why the emitted line itself is unchanged, are stated
in "Raw argv that is not valid Unicode".
The mechanism field is the deliberate exception to the usual closed-enum reading of
the JSON Schema. Its vocabulary grows additively within schema_version = 1: adding
ProcessKit's process_reaper value changes no existing value's meaning, and readers
must route by event/output type while treating an unfamiliar mechanism as unsupported
rather than rejecting the entire record. unknown is a real contract value, not a
projection defect: it is the conservative result of mechanism_str's
#[non_exhaustive] fallback when a newer ProcessKit mechanism reaches an older CLI.
The current schemas enumerate all values known by this release, including both
process_reaper and unknown; a frozen older schema may reject a newly added enum
value, so strict validation must use the schema matching the producer and adapters
must not use an old enum as an exhaustive runtime parser.
Exit-code contract
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 core rule: child fidelity
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.
Runner-own failures
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, cancel, and kill: runner-imposed outcomes
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.
A waiter's deadline is not a run's deadline
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.
Preflight probe: a pre-launch verdict, not a run outcome
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".
Checking a stream: events --validate
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.
A membership verdict is not an unreachable target
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.
Qualifying a host: doctor
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 failures vs internal faults
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).
Resource limits reuse BACKEND (102)
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.
Detached runs: the code reports the start
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.
Why a band is not enough on its own
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.
Machine-readable failures: --error-format json
--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.
The fields
| 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. |
The kind vocabulary
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
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.
What the envelope does not cover
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.
Invariants
- 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.
Stability
- 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.
Threat model
This document states, in one place, what processkit-cli treats as untrusted
input, who the trusted principal is, where the project deliberately draws its
security boundary, and which concrete threats within that boundary are closed
— and by what mechanism. It does not restate the mechanisms' own normative
text (each closed threat below links to the module or document that owns it);
treat it as the map for a security reviewer or auditor, not a substitute for
reading the cited code.
Untrusted inputs
Five input surfaces are treated as untrusted or semi-trusted and are handled accordingly (bounded parsing, validation before use, no blind trust in shape):
-
Registry bytes. Every record file under the per-user run registry (
src/registry/mod.rs) is parsed defensively — a corrupt or malformed record is skipped, never trusted to abort a scan or to smuggle a path outside the registry directory (see "Closed threats" below for thelock_filecase). -
Control-plane wire strings. The one request-verb line a client sends, and the one JSON reply line the server sends back, over the local
inspect/cancel/kill/attesttransport (src/control/mod.rs), are read as untrusted bytes from whichever local process holds the socket/pipe. -
A control-plane client's claimed identity — which is why there is none. The
attestverb answers whether the connecting process is inside the run's container (docs/control-plane.md, "attest"), and a caller's own account of who it is would be the most obviously untrusted input on this list: any local process can hold any string and name any pid. So none is accepted. The command exposes no--pidand the verb carries no argument at all; the identity is read from the transport itself (unix peer credentials,GetNamedPipeClientProcessIdon Windows) while the connection is open, and checked against the run's own live container membership. The two facts that follow are the reason this is listed here rather than under "closed threats": the input surface for this verb is empty by construction, and the answer is therefore a property of the connection rather than of anything parsed. A platform that cannot supply that identity is answeredpeer_identity_unsupported— a refusal — rather than being allowed to fall back to what the caller says. -
The child's argv and output. The command line passed to
runis attacker-influenceable in the sense that it ends up in a diagnostic artifact (the JSONL stream) an operator or automated tooling later reads; the child's own stdout/stderr are unbounded, potentially adversarial or merely pathological, byte streams (src/events.rs,src/capture.rs). -
The events file read back. The JSONL lifecycle stream is this project's own output while a run writes it, and untrusted input the moment anything reads it back: it sits at an operator-chosen path (
run --jsonl), any local process can write one, and a reader will read whatever it is pointed at. Two commands read one.wait --report-outcomereads a bounded head/tail window of the file a registry record names (src/wait.rs).eventsis the larger of the two surfaces — it reads a whole stream, andevents --file <path>reads an arbitrary caller-specified path, including a file this registry never knew about, such as an adapter's own fixture;events --run-id <id>reads the locator a registry record publishes, itself untrusted deserialized data under "Registry bytes" above. Everything on that path is hand-rolled and treats the bytes as hostile:- an incremental line reader that hands out only complete lines and refuses
to buffer one past
MAX_LINE_BYTES(1 MiB), so a file with no newline in it cannot decide this process's memory use; invalid UTF-8 is replaced and reported, never fatal (src/events_cmd/mod.rs); - under
--validate, an interpreter of the embedded JSON Schema document run over each parsed line (src/events_cmd/schema.rs) together with the small anchored matcher that document'spatternkeywords need, which runs against untrusted string content (src/events_cmd/pattern.rs); - and, at the terminal boundary, every operator-facing fragment — a rendered
field, the notice about a line that would not parse, a schema violation, and
the stream's own locator — passed through
text::terminal_safe_bounded(src/text.rs) before it is printed, so neither a stream's content nor a registry-published path naming it can forge or overwrite what an operator sees.events --jsonis the one deliberate exception and not a terminal rendering: it passes the runner's own bytes through byte for byte (a line that is not JSON is reported instead of emitted), relying on JSON's own escaping exactly as this project's other machine-readable outputs do — the linesrc/text.rsdraws explicitly between the two. The--error-format jsonfailure envelope (src/error_envelope.rs) sits on the same side of that line, and it is worth being explicit because it prints to stderr, where a terminal may be watching: itsmessageis the very string the prose mode prints, but serialized byserde_json, so a control sequence or bidi override that reached a diagnostic (through an OS error text, say) is escaped rather than emitted, and the object is always exactly one line. The fragments that are sanitized at construction — the events locator above, for instance — stay sanitized in both modes, because both render the same message.
See "Supply-chain compromise" below for what the fuzz tier does and does not currently exercise on this surface.
- an incremental line reader that hands out only complete lines and refuses
to buffer one past
Trusted principal and boundary
The trusted principal is the same OS user that invokes processkit-cli:
every security mechanism below defends that user's own runs against a
different OS user (or an unprivileged remote party with no local account),
never against that user's own other processes.
Explicit boundary. processkit-cli does not defend a run against a
malicious process already running as the same OS user. A same-user process
that can read the registry directory, connect to the control-plane transport,
or otherwise act with that user's own privileges is, by definition, already
inside the trust boundary this project draws — the owner-only restrictions
below exist to keep other principals out, not to isolate one same-user
process from another.
Closed threats
Each entry names the threat, the mechanism that closes it, and the exact code/docs it is implemented and described in.
- A different OS user reading or connecting to the registry/transport.
The per-user registry directory's permissions are guaranteed on every
mutating open, not merely assumed:
0o700re-applied viachmodon Unix (bypassing umask); on Windows the directory is created carrying its protected owner-only DACL and, on a subsequent open, that DACL is compared ACE for ACE against the target and rewritten whenever it does not match (src/registry/mod.rs,Registry::open/open_in,platform::create_owner_only_dir). A pre-existing directory whose permissions were widened out of band is repaired on both platforms. The Windows comparison is deliberately exact and fail-closed — an unreadable descriptor, an extra ACE, a missing protected bit, or a non-directory all route to the unconditional write — so the skip can only ever elide a write whose result is already in place; no weaker signal an attacker could forge (the directory existing, a marker file, a cached flag) is accepted in its stead. Neither platform touches ownership, then or now. The control-plane transport is deliberately not derived from that directory: each run atomically reserves its own short-lived0o700directory under/tmp(falling back to the platform temp directory) and binds the Unix socket inside it, with the socket file itself given0o600on a best-effort basis afterward (src/control/mod.rs,imp::ControlServer::bind,create_private_socket_dir); the path is kept independent of the registry directory specifically so a long registry path cannot push the socket path pastsockaddr_un::sun_pathon macOS (seedocs/control-plane.md, "Local transport"). On Windows, the control-plane's named pipe is built with its own non-inheritable owner-only DACL, sharing only the FFI-glue modulesrc/win_security.rs(SecurityDescriptor,to_wide) with the registry directory's DACL construction — that sharing is Windows-only; the Unix socket has no DACL and no relationship tosrc/win_security.rs. - The command line leaking into diagnostics.
run_started'scommandfield is redacted by default: the raw argv is not recorded, only a one-way SHA-256 fingerprint (argv_sha256) and a categorical worker-shapehintfrom a static classifier table, both derived from argv but unable to reveal it (src/events.rs,argv_sha256_hex,classify_hint/HINT_RULES). Recording the raw argv requires an explicit opt-in (--argv-raw); it is never the default. The fingerprint is computed over argv's canonical bytes rather than a lossy Unicode rendering of it (docs/schema.md, "Fingerprint"), so two commands that differ only in bytes Unicode cannot express are two fingerprints, not one — a correctness property of the diagnostic, not a disclosure one: the digest stays one-way either way. The per-user registry record publishes that same one-way pair (and only it) solistcan tell several live runs apart — the raw argv is not even an input to the registry'sregister, which takes anevents::CommandFingerprint, so no flag (--argv-rawincluded) can put a command line into a registry record. The values are shape-checked when read back, like every other record field (seedocs/registry.md, "Reading a record"). - An unbounded or malformed control-plane wire line. Both the server's
request-line read and every client's reply-line read are capped at
MAX_LINE_BYTES(64 KiB) via a shared bounded-read helper (src/control/mod.rs,read_bounded_line) — an oversized or unterminated line fails deterministically rather than growing an in-memory buffer without bound. Separately, a registry record'slock_filefield is validated as a simple file name before it is ever joined onto the registry directory path — control characters, NUL, path separators, Windows reserved device names (with or without an extension, including superscript-digit aliases), and symlink targets (rejected at open time viaO_NOFOLLOWon Unix / a reparse-point check on Windows) are all refused (src/registry/mod.rs,is_simple_lock_file_name,is_windows_reserved_device_name,platform::open_lock_file). - A record steering a deletion outside its own leftovers.
prunereaps the Unix control socket a confirmed-stale record published, which means aremovecall driven by that record'sendpoint— untrusted deserialized data likelock_fileabove. The value is refused unless it is exactly the form the control server publishes (absolute, no./../empty segment as written, final componentc.sock, parentpkc-plus an alphanumeric/-token, sitting directly inside one of the temp bases the server binds in), and even then no symlink is followed: the directory is openedO_NOFOLLOW | O_DIRECTORYand the socket is unlinked relative to that handle, only if it really is a socket, with the directory itself removed by an empty-onlyrmdir. A value failing any of that deletes nothing at all — the record and its lock are still reaped (src/registry/mod.rs,platform::control_socket_dir_to_reap,platform::reap_control_socket_dir; rationale indocs/registry.md). - Launching an incompatible or unusable runner binary uncontained. The
side-effect-free
probesubcommand is a fail-closed preflight contract: it spawns no child and touches no registry, reports this binary's version,schema_version, reserved exit-code band, and live CLI surface as one JSON line, and — given--require-*expectations — exitsPROBE_INCOMPATIBLE(110) with the concrete mismatches on any unmet expectation, rather than ever letting an adapter silently proceed with an incompatible binary (src/probe.rs; consumer walkthrough indocs/integration.md, "Fail-closed preflight"). - A host qualification that is itself a way in.
doctoris the one subcommand besidesrunthat spawns anything, so what it spawns and where it writes are part of this model rather than an implementation detail. It contains only this binary's own code — the child isprocesskit-cli doctor --scratch-child, a mode that sleeps for a bounded duration and does nothing else — rather than a shell or any program found on the host, so a qualification introduces no new argv, no new parsing, and no new attack surface from the environment it is qualifying. Its scratch directory is created fresh and owner-only (0700at creation on unix, the per-user%TEMP%on Windows) with a non-recursive create, so an existing path — including one a different local user pre-created under a guessed name in a world-writable/tmp— is a hard failure rather than a directory it adopts and writes into; that is the same stance the control transport takes for its own private socket directory. Everything it creates in the real per-user registry is a single scratch record it also removes, and the report says whether the removal was confirmed (src/doctor.rs; operator walkthrough indocs/troubleshooting.md, "Qualifying a host:doctor"). - Resource exhaustion from a pathological child output stream. The
--capture-dirtee enforces a hard per-stream byte ceiling (CAPTURE_MAX_BYTES, configurable via--capture-max-bytes) with an explicittruncatedflag rather than growing the capture file without bound, and--idle-timeouttears the run down if the child goes silent past a configured window (a sharedIdleClockre-armed by any non-empty write on either the default echo path or the--capture-dirtee), closing the case of a child that neither exits nor produces bounded output (src/capture.rs). - Supply-chain compromise of the build or release pipeline. Every
third-party GitHub Actions step in
.github/workflows/ci.ymland.github/workflows/release.ymlis pinned to a full commit SHA (not a floating tag) except the toolchain selector,dtolnay/rust-toolchain@stable/@master, which both workflows leave intentionally unpinned (each occurrence carries an explicit "intentionally unpinned" comment) so CI and releases keep tracking the rollingstable/MSRV toolchain; that one exception means trust indtolnay/rust-toolchain's owner is accepted, not eliminated (see "What is not closed" below). Everywhere else, a compromised or re-tagged action cannot silently change what CI or a release build runs.cargo deny check advisories bans licenses sourcesruns on every pull request and push tomain(deny.toml,.github/workflows/ci.yml), failing the build on a known RustSec advisory, a yanked crate, a wildcard version requirement, a disallowed dependency license, or a dependency sourced from outside crates.io. Released artifacts carry a SHA-256 checksum and a signedactions/attest-build-provenanceattestation (.github/workflows/release.yml) a consumer can verify against the exact commit and workflow that produced them. That workflow writes outside this repository in two places. The long-standing one is thereleasejob'scargo publish --lockedstep: every release uploads the crate to crates.io underCARGO_REGISTRY_TOKEN(secrets.CRATES_IO_TOKEN), a standing credential that lives in this repository's own Actions secrets, publishing to a registry users are pointed at today (docs/installation.md, "Install from crates.io"). The second ispublish-package-repos, the only job that pushes into another git repository: it pushes the generated Homebrew formula — and, independently, the Scoop manifest — into the tap/bucket repositories this project owns, which is what would make abrew install/scoop installwork at all. Several properties bound it. It does nothing unless an operator has set that channel's token secret (HOMEBREW_TAP_TOKEN/SCOOP_BUCKET_TOKEN) — with none set it skips with a notice rather than failing, and each channel is enabled on its own. Its ownGITHUB_TOKENis narrowed tocontents: read(a job-levelpermissions:block replaces the workflow's top-levelcontents: writerather than merging with it), so it cannot write to this repository, and every write it does perform is authenticated by the channel's own token instead. That token is required to be scoped to the tap/bucket alone (a fine-grained PAT holdingContents: read and writeon it, or a GitHub App credential); the built-inGITHUB_TOKENcannot stand in, as it reaches no other repository. That scoping is an obligation on whoever mints the secret, though, not a property the workflow can check — see "What is not closed" below. The operator-set target variables (HOMEBREW_TAP_REPOSITORY/SCOOP_BUCKET_REPOSITORY) are accepted only in an exact, whole-string-anchoredowner/nameshape before they can reach a clone URL or the step's own outputs. The job adds no third-party action, so it widens no pinning surface, and it runs after crates.io, the tag, the Release, and every asset upload undercontinue-on-error: true, so it can neither gate a release nor alter one that already happened (seedocs/release-process.md, "What thepublish-package-reposjob does"). Neither the tap nor the bucket is configured for this repository today — neither repository exists, so nothing has been published through them yet (docs/installation.md, "Publishing to a tap or bucket"), unlike the crates.io publication above, which happens on every release; what this arrangement accepts once a tap or bucket is configured is stated under "What is not closed" below. A dedicated fuzz tier (fuzz/) exercises four of the parsers that sit closest to the untrusted inputs above, undercargo-fuzz: the registry's byte-to-record parser, the control-plane's request/reply decoders, the CLI's own value parsers, andwait --report-outcome's bounded head/tail read-back of a run's JSONL events file (a path any local process can write, so its content is untrusted the same way). It does not reach theeventsreader described under "Untrusted inputs" above — the larger of this project's two events-file readers, and the only one that opens an arbitrary caller-given path: neither its incremental line reader, nor the schema interpreter and anchored pattern matcher behind--validate, is fuzzed. That stack is covered by unit and through-the-binary tests, and its--validateverdict is held line for line against a real JSON Schema engine (tests/events.rs), but it is not under coverage-guided fuzzing: a fifth target over that reader is the way to close the gap, and until one exists this document claims no fuzz coverage for it.
What is not closed
The boundary above is deliberate, not an oversight; the following are explicitly out of scope for this project's own security mechanisms:
-
Confidentiality of data inside the child process. Whatever the child program reads, writes, or holds in memory is entirely its own concern;
processkit-cliobserves only what the child writes to its own stdout/stderr (and, if requested, the process tree's membership) — it does not attempt to protect the child's internal state from anything. -
Isolation from another process of the same OS user. As stated under "Trusted principal and boundary" above, a same-user malicious process is inside the trust boundary, not outside it — this project provides no mechanism against it (no additional sandboxing, no cross-process capability restriction beyond the owner-only ACLs that already keep out other users).
-
Authentication between mutually hostile peers — including through
attest. Theattestverb reports a containment fact (is the connecting process in this run's container?) established from kernel-supplied peer identity, and it is genuinely unforgeable in the sense that matters: a process cannot make the runner name a pid other than its own. What it is not is an authentication mechanism. It runs entirely inside the same-user boundary above — a process able to reach the control plane at all is already that user — so it neither adds a boundary nor is one. Two consequences worth stating outright: amemberanswer says the caller is contained, not that it is trustworthy (a compromised process inside the container attests positively, correctly); and the answer is scoped to the connection it was made on, so it is not a token, not transferable, and says nothing about any later instant (hencechecked_at). A consumer needing a boundary between distrusting parties needs OS-level isolation — separate users, containers, sandboxes — andattestis a check within one such boundary rather than a substitute for it. Seedocs/control-plane.md, "The boundary: containment, not authentication". -
Trust in the
dtolnay/rust-toolchainaction owner. Both workflows deliberately leave that one action unpinned (a floating@stable/@mastertag rather than a commit SHA) so CI and releases keep tracking the rolling stable/MSRV toolchain; a compromise of that action's owner or repository could change what CI or a release build runs, and the project accepts that residual risk rather than freezing the toolchain version. -
A package channel published from this repository's own secrets. Automatic publication to a Homebrew tap or Scoop bucket requires a standing credential that can push to that other repository to sit in this repository's Actions secrets for as long as the channel is enabled. So an enabled channel is only as isolated as this repository's secrets and the set of actors who can run its release workflow: whoever can read that secret, or dispatch a release carrying it, can put content into the repository users install from. That much is not new with the tap: crates.io is already published on every release from a
CRATES_IO_TOKENsitting in these same secrets. What a tap or bucket adds is a cross-repository git write capability, which is what the automation fundamentally is, and the project accepts that residual risk as the price of a channel that publishes itself instead of by hand. The alternative it rejects is not "a safer token"; it is publishing every release manually.The two bounds stated on that capability above are not equally strong, and the weaker one is the residual risk this entry accepts. That the publishing job holds no write access to this repository is enforced: its
GITHUB_TOKENis narrowed by a job-levelpermissions: contents: readblock, and every write it performs is authenticated by the channel's own token instead. That the channel token reaches the tap/bucket and nothing else is not enforced anywhere — it is a requirement this project documents for the operator who mints the secret, and nothing here verifies it or could detect its violation: theResolve publication targetsstep tests the token only for emptiness, and a classic PAT carryingreposcope over every repository its owner can reach would clone, commit, and push exactly the same way, on an otherwise green release. The anchoredowner/namevalidation bounds the target the job writes to, not what the credential is permitted to do. So the blast radius of an enabled channel is whatever scope the operator actually granted, not the scope asked for here — enabling a channel accepts that gap along with the capability itself. -
Build-provenance verification of a package installed from that channel. The formula/manifest such a channel serves is not covered by the attestation described above. Homebrew and Scoop trust a formula because of the repository it came from;
gh attestation verify, which a consumer can run against a release archive downloaded directly, has no place in thebrew installpath, and neither the formula nor the manifest bundle is itself attested. The only thing tying an install through that channel to the attested release artifacts is the per-archivesha256the generated formula/manifest pins, taken from the Release's own.sha256sidecars (scripts/generate_package_manifests.py) — a formula pushed with some other URL/digest pair would install whatever it names. A consumer who needs provenance should run that verification against a downloaded archive itself (seeREADME.md, "Prebuilt binaries"), rather than infer it from the channel. Neither the tap nor the bucket is configured today, so this particular gap is a property of that mechanism accepted in advance rather than a live exposure through those two channels — which says nothing about crates.io, a live channel whose published source crate this attestation does not cover either. -
Denial of service through the operating system itself. Beyond the opt-in, best-effort
--max-memory/--max-processes/--cpu-quotacaps on the child's own process tree (platform-limited: real Windows Job Object or Linux cgroup v2 enforcement only, fail-fast rather than silently unenforced — seeREADME.md, "Resource limits"),processkit-clidoes not defend against exhaustion of system-wide resources (memory, file descriptors, process table slots) by other workloads on the same machine; that remains the operating system's and the operator's own concern.
See also
SECURITY.md— how to report a vulnerability, and the automated supply-chain scanning this document's "Supply-chain compromise" entry summarizes.docs/architecture.md— the module map and data flow this document's closed-threat entries point into.docs/integration.md— the consumer-facing preflight and redaction walkthrough (probe, command redaction) referenced above.docs/registry.mdanddocs/control-plane.md— the normative registry and control-plane documents the owner-only and bounded-read mechanisms above are drawn from.
Architecture overview
This document is the map that ties the per-area normative documents together:
the module layout, how one run moves data from spawn to exit, how the
control-plane clients (inspect/cancel/kill/attest) reach a live runner, where
this repository's responsibility ends and the processkit crate's begins, and
how the test suite is layered. It does not restate the normative contracts
themselves — each links to its own document below — and it is not a substitute
for reading the source; treat it as the entry point for a new contributor,
sketched from the code as of this writing rather than from memory.
Module map
processkit-cli is a thin binary (src/main.rs) over an internal library
(src/lib.rs): every other src/*.rs file is a mod of the library, and the
binary only parses argv and dispatches into it (see "Target structure: library
and binary" below for why the split exists and what stays stable).
Responsibilities, in the order data flows through a run:
| Module | Responsibility |
|---|---|
src/lib.rs | The internal library crate root: declares every src/*.rs module and re-exports nothing publicly-supported. It carries the "not a stable public API" disclaimer and marks each module #[doc(hidden)], so the library is only a foundation for the crate's own tooling — never a semver-stable Rust surface. |
src/main.rs | The thin binary entry point. Parses Cli, dispatches into the library's subcommand module, and maps the result onto a process exit code: run owns its own exit path (it hard-exits with the child's code and never returns here); every other subcommand's Result<(), RunnerError> is mapped through RunnerError::code(), and a clap parse failure is mapped onto the runner's own USAGE code rather than clap's default. It also resolves the one global option, --error-format, into the stderr rendering every failure path shares (src/error_envelope.rs). |
src/cli/ | The CLI-flags half of the compatibility surface: the clap-derived Cli/Command types for run, inspect, cancel, kill, attest, wait, events, list, prune, probe, and doctor, plus the one global option every subcommand shares (--error-format, whose rendering lives in src/error_envelope.rs). Parsing and shape validation only — each subcommand's behavior lives in its own module. A directory of eleven files, split by subcommand family rather than by size so a new flag or subcommand lands in a focused file: mod.rs holds the top-level Cli/Command shapes and re-exports everything below it, so crate::cli::<Item> stays the single path to every CLI type and parser; run.rs holds RunArgs/CaptureOverflowPolicy; control.rs holds InspectArgs and the TargetArgs shared by cancel/kill; attest.rs holds AttestArgs — a file of its own rather than a third user of control.rs's shared target form, because attest deliberately takes only --run-id: no --all (it asks about one named run, never "am I inside any run") and no --pid or other way to name a process (the identity comes from the control connection itself, so the only question it can pose is "am I inside this run?"), which is the opposite of the target shape cancel/kill share; wait.rs, events.rs (whose implementing module is src/events_cmd/, the plain events name being taken by the emitter), list.rs, prune.rs, probe.rs, and doctor.rs hold one argument struct each; and parse.rs owns the hand-written value parsers they share (durations, byte sizes, process counts, CPU quotas, exit-code bands, KEY=VALUE entries, bare environment keys, run ids) — the functions the fuzz tier drives directly. Cross-argument rules that compare two flags' values rather than their presence, which clap cannot express declaratively, live in Cli::validate/RunArgs::validate and are checked by src/main.rs between parsing and dispatch, so they refuse as ordinary USAGE (100) parse errors. |
src/run/ | The run subcommand itself: spawns the child into a processkit::ProcessGroup this module owns, selects either default pipe-and-echo I/O or direct inherited stdio, temporarily hands a POSIX terminal to a separate child process group when required, races the child's exit against --timeout/--idle-timeout/a local stop signal (Ctrl-C, on Unix SIGTERM/SIGHUP, and on Windows Ctrl-Break/console close/logoff/system shutdown)/a control-plane command, and drives the shared teardown tiers — the graceful soft stop → grace → hard kill for timeout (whole-run or idle)/a signal cancel/cancel, and the immediate hard kill (no soft stop, no grace) for a control-plane kill. Exit-code fidelity — the child's exact code on a normal completion, a reserved-band code for every runner-imposed ending — is enforced here. A directory of four submodules under one entry point, split by responsibility rather than by size: mod.rs is the entry point (run::execute) plus the shared ending vocabulary (Ending/Termination/CancelSignal/TimeoutTrigger/SoftTerminate); launch.rs owns the run itself (run_async: container creation, spawn, I/O-mode selection, the terminal handoff, and the race); signals.rs owns the runner-imposed deadlines and the platform stop-signal listeners (wait_for_cancel_signal, effective_grace_for); teardown.rs owns the two teardown tiers, the JSONL emitters, and the ending-to-exit-code mapping; and detach.rs is the --detach wrapper (start_detached), which re-spawns this binary detached and returns once the run has provably started, without duplicating any of the above. |
src/events.rs | The versioned JSONL lifecycle-event schema and its emitter — this repository's normative, golden-tested public event contract (see docs/schema.md). Also owns argv redaction: the default SHA-256 argv_sha256 fingerprint and the HINT_RULES worker-shape classifier, exposed as one shared CommandFingerprint so the registry record publishes the identical pair (see docs/registry.md, "Which run is which") instead of deriving its own. |
src/capture.rs | --capture-dir bounded per-stream stdout/stderr capture to files, riding the same tee run already echoes through (no second output-reading path). Records, per stream, a full byte counter, a SHA-256 of the bytes written, and independent explicit truncated/write_error flags, surfaced in the output_captured event. |
src/hash.rs | The one hand-rolled incremental/one-shot SHA-256 (FIPS 180-4) both events (argv fingerprint) and capture (streamed transcript hashing) build on, so the project has a single digest primitive and rendering style. |
src/text.rs | Shared human-output primitives: terminal-safe normalization for untrusted text and the column-aligned table renderer used by both list and inspect. |
src/registry/mod.rs | The per-user run registry: one record per in-flight run in an owner-only-restricted directory, found by scanning and matching run_id (never a PID), carrying the run's redaction-safe command fingerprint/hint for discovery, with staleness detected via an OS advisory lock the live runner holds (see docs/registry.md). The first brick of the control plane. |
src/control/ | The live-run control plane: a stable facade plus platform transports, bounded rendering, and isolated tests for its line-oriented inspect/cancel/kill/attest protocol (see docs/control-plane.md). The platform transports additionally own the kernel-supplied peer identity attest rests on (unix peer credentials, GetNamedPipeClientProcessId), read at accept time because it belongs to the concrete transport type rather than to the shared protocol. |
src/list.rs | The list subcommand: a thin, read-only CLI wrapper over Registry::entries that renders every registry entry — whatever its health (live/stale/unprobed) — as a table or JSON Lines, for a caller that has lost (or never had) a run_id. |
src/prune.rs | The prune subcommand: an equally thin wrapper over Registry::prune, which owns the whole confirm-before-delete reaping safety rule; this module only opens the registry and reports the tally. |
src/wait.rs | The wait subcommand: blocks until a run is no longer live, for a supervisor that is not the runner's parent. Registry-only in both of its modes — it never contacts the runner. --run-id polls Registry::probe_run and has three outcomes (finished, its own --timeout elapsing, an ambiguous run_id); --all (T-216) instead polls a Registry::entries snapshot taken at the moment it starts and has only two — there is no aggregate ambiguity outcome, since --all never resolves an id at all. Both are decided entirely from the registry (see docs/registry.md, "Waiting — wait"). |
src/events_cmd/ | The events subcommand: the read-back counterpart to src/events.rs's emitter. Resolves a stream by --run-id through Registry::entries (or takes --file), hands out only complete lines as the file grows, and renders them (render.rs, through src/text.rs's terminal barrier), passes them through verbatim (--json), or checks them against the embedded schema document (--validate: validate.rs runs the pass and owns the exit verdict, schema.rs interprets the document over the keyword subset it uses — refusing to run on anything it does not implement rather than skipping it — and pattern.rs is the tiny anchored matcher its pattern keywords need; tests/events.rs holds that checker's verdict against the jsonschema dev-dependency's, so the binary links no JSON Schema engine of its own). Read-only in the same sense list/wait are: no control transport, no mutation. |
src/probe.rs | The side-effect-free probe subcommand: reports (and, with --require-*, verifies) this binary's version/schema_version/exit-code band/CLI surface as one JSON line. |
src/doctor.rs | The side-effecting doctor subcommand: the runtime qualification of the host, where probe qualifies the binary. It performs a bounded scratch run of this very executable against its own harmless --scratch-child mode, then drives that run as an ordinary control-plane client (inspect, cancel, terminal wait) and reports what it observed — registry creation and owner-only protection, containment mechanism and abrupt-cleanup level, the transport round-trip, a confirmed-empty teardown, optionally the resource controller, and per-phase timings. Reimplements none of it: every phase drives the same production code a caller's own run and control clients take. Requirement flags gate only the exit code (exit::HOST_UNQUALIFIED, 116); the report carries the observed facts either way. Cleans up on success, keeps a named diagnostics directory on failure. |
src/exit.rs | The reserved runner-own exit-code band (100–119) constants — the exit-code half of the compatibility surface (see docs/exit-codes.md). |
src/error_envelope.rs | The machine-readable rendering of those same codes: the versioned, bounded JSON object the global --error-format json prints on stderr for any post-parse failure, and the ErrorKind taxonomy behind it — a finer axis over src/exit.rs's codes (it splits CONTROL eight ways and SETUP two), never a competing set of them. Not a compatibility surface of its own: it rides on the flag that turns it on and on the code it carries, and pins its own shape in the payload with error_version, exactly as the other machine-readable outputs do (see docs/compatibility.md, "Machine-output schemas"). Shared by src/main.rs's report_result and run::execute, so the two cannot drift; the run-family kinds reuse runner_exit.source's own spellings rather than forking that vocabulary (see docs/exit-codes.md, "Machine-readable failures"). |
Target structure: library and binary
The crate builds two targets from one source tree:
- an internal library (
src/lib.rs, Cargo targetprocesskit_cli) that owns every module undersrc/, and - a thin binary (
src/main.rs, Cargo targetprocesskit-cli) that does nothing but parse argv with clap and dispatch each subcommand into the library.
The library exists as a foundation for the crate's own tooling, not as a
reusable dependency. Keeping the runner's internals in a [lib] target lets:
- unit and property tests live in each module's
#[cfg(test)] mod testsand run as the library's--libtier under a plaincargo test, reaching module-private helpers directly — retiring thecargo test --binworkaround the old bin-only layout required; - fuzz targets (
cargo-fuzz,fuzz/, T-186) link the library and drive parsers of untrusted/semi-trusted input — the registry's bytes → parse/ validate path, the control plane's request-line classifier and response-line decode, the CLI's--timeout/--grace/--require-exit-code-band/--env/--run-idvalue parsers, andwait --report-outcome's terminal-outcome read-back over a run's JSONL events file (src/wait.rs, bounded head/tail scan for a terminalrunner_exit, T-301); theeventsreader's own parsers (src/events_cmd/) are not in this tier — a boundary thatCONTRIBUTING.md's "Fuzzing" section anddocs/threat-model.mdboth state outright, rather than leaving it to be inferred from this list. A[lib]target is a hard prerequisitecargo-fuzzcannot work without (seeCONTRIBUTING.md, "Fuzzing"); - benchmarks (criterion,
benches/, T-187) reach internal primitives — the hand-rolled SHA-256 hasher (src/hash.rs), bounded-captureabsorb(src/capture.rs), and the argv-redaction hint classifier (src/events.rs) — directly, to measure them in isolation (seeREADME.md, "Benchmarks").
Because the crate is published to crates.io, the library surface is deliberately
not a stable public Rust API: every module is #[doc(hidden)], the crate-root
rustdoc says so in as many words, and no item carries a semver guarantee. The only
supported compatibility surface stays the binary's contract — the CLI flags
(src/cli/), the reserved exit-code band (src/exit.rs,
docs/exit-codes.md), and the JSONL schema_version
(src/events.rs, docs/schema.md) — exactly as before this split. The
--error-format json envelope (src/error_envelope.rs) adds no fourth item to that
list: it is a machine-readable rendering built over the first two, versioned in its
own payload by error_version like the other machine-output schemas (see
docs/compatibility.md, "Machine-output schemas").
This document records only the target structure; the fuzz and benchmark tiers
themselves (T-186, T-187) are documented in CONTRIBUTING.md ("Fuzzing") and
README.md ("Benchmarks") respectively.
Data flow of one run
A processkit-cli run moves through the same sequence on every platform,
implemented in run::execute (src/run/mod.rs) and run::launch::run_async
(src/run/launch.rs):
- (
--detachonly) Hand the run to a detached copy.run::detach::start_detachedre-spawns this binary on the caller's own argv with--detachremoved (plus--run-id/--no-echowhere the caller left them out) — in a new session on Unix (setsid), as aDETACHED_PROCESSon Windows, withnullstdio either way — then waits until that copy'srun_startedline is readable in--jsonland exits, reporting only whether the run started (seedocs/exit-codes.md, "Detached runs"). The detached copy enters step 1 below and runs every subsequent step unchanged: detaching is a wrapper around this sequence, never a second implementation of it. - Spawn. The child is built from
processkit::Commandand spawned into aProcessGroupthis process owns — not a shared or global one — so the group's kernel-backed kill-on-drop (Windows Job Object, Linux cgroup/POSIX-group, FreeBSD process reaper, macOS process group) reaps the whole tree on every exit path this process lives to observe (normal completion, timeout, a local stop signal —Ctrl-C, on UnixSIGTERM/SIGHUP, or on WindowsCtrl-Break/console close/logoff/system shutdown — and a control-planecancel/kill). That guarantee does not extend to this process's own abrupt death (crash/SIGKILL/TerminateProcess), which skipsDropentirely: reaping a leaked grandchild after the runner itself dies abruptly is a platform-derived tri-state —whole_treeon Windows (Job Object survives the owner's abrupt death),direct_child_onlyon Linux (kill_on_parent_death/PR_SET_PDEATHSIG, direct child only),noneon FreeBSD, macOS, and other BSDs — surfaced per run asrun_started'sabrupt_cleanupfield (see K-005). Arun_startedevent (run id, root PID, containment mechanism, abrupt-cleanup tri-state, working directory) opens the JSONL stream. - Select the I/O path. By default,
processkit's line pump concurrently reads the child's stdout/stderr and drives two things off the same read: the live echo to this process's own stdout/stderr (src/run/launch.rs), and — when--capture-diris set — the per-stream tee insrc/capture.rs.--no-echoswaps only the echo sink for a discarding one (tokio::io::sink()in place oftokio::io::stdout()/stderr()); the pump, the--capture-dirtee, and the--idle-timeoutclock's re-arming on every observed chunk are all unaffected — seesrc/run/launch.rs's sink-selection block and K-050.--detachreuses that same swap rather than adding a second suppression path: the detached copy is started with--no-echo(step 0).--inherit-stdioinstead gives the child the runner's three handles directly; it conflicts with capture (and with--no-echo, which has no pump to act on in this mode, and with--detach, which has no terminal to hand over), so there is no pump or tee. When ProcessKit selects POSIX process-group containment and stdin is a terminal,runtemporarily assigns the terminal's foreground group to the child and restores the original group during cleanup. Amembers_snapshotevent records the container's member list — enriched withppid/executablename/start_timevia ProcessKit'smembers_info()wherever the platform can report them (docs/schema.md, "Enriched member fields") — in either path. With--snapshot-intervalthe same event is re-emitted on that cadence (reason: "interval") until the ending is decided, from an extra never-resolving arm of the run's existingselect!race rather than a thread or a spawned task, and reading the container's member list rather than the pump — which is why it composes with--inherit-stdiotoo. - Capture and hash.
src/capture.rs'sCaptureTeemirrors every byte the pump observes into a bounded capture file per stream — independent of whether that byte is also echoed (--no-echodoes not change what is captured) — hashing what actually reached disk withsrc/hash.rs's incremental SHA-256 and recording an explicit ceiling-truncation flag and write-error flag — never inferred from the file's size. This stage is a no-op, with no capture files and nooutput_capturedevent, unless--capture-dirwas passed; clap rejects--capture-dirtogether with--inherit-stdiobefore the child starts. - Teardown. Runner-imposed endings split into two tiers, not one shared
path.
--timeoutelapsing, a local stop signal (interactiveCtrl-C, on Unix a caughtSIGTERM/SIGHUP, or on Windows a caughtCtrl-Break/ console close/logoff/system shutdown), and a control-planecancelreaching the live runner oversrc/control/mod.rsall drive the same graceful path: a soft stop (SIGTERMto the tree on Unix; on Windows, which has no POSIX signal, a best-effortWM_CLOSEto any windowed member — nothing at all for the ordinary console child, in which case the grace window still elapses honestly with no soft stop delivered), a--gracewait, then the owningProcessGroup's kernel-backed hard kill-on-drop. A control-planekillis not part of that tier: it skips the soft stop and the grace window entirely and hard-kills the whole tree immediately via the same kill-on-drop mechanism — documented as immediate on purpose, not a shorter grace. A normal completion instead reaps via the same drop once the child's own exit is observed (root_exited, thencleanup_started/cleanup_finished). Every ending, forced or natural, first reads whatever the mechanism accounts for into aresource_summaryevent — and, where a cap was requested, thelimit_evidencethat precedes it — because both facts live in the container the teardown is about to destroy. The read point is therefore load-bearing for the numbers, not only for their availability: a forced ending reads a tree that is still running, a natural exit one that has already gone (see resource limits, consequence 5). runner_exit. The terminal JSONL event closes the stream, always carrying the outcome — the child's own exit code on a normal completion, or the reserved code for whichever runner-imposed ending fired (timeout/cancelled/killed, or a control-planecancelled/killed). The process's own exit code (seedocs/exit-codes.md) mirrors that same outcome, so a shell that never reads the JSONL stream still gets a faithful, distinguishable signal.
Control-plane contour
inspect, cancel, kill, and attest (src/control/mod.rs) never address a live run
by PID; they resolve it through the run registry (src/registry/mod.rs):
- Registry scan.
registry::Registry::entrieslists every record in the per-user registry directory and classifies each by probing the record's advisory liveness lock — a dead runner's leftover record is detected this way, not by mere file existence — as [registry::Health::Live], confirmed-dead [registry::Health::Stale], or (when the probe itself could not run, e.g. permission denied) [registry::Health::Unprobed]; this control-plane contour only ever acts onLive, soStaleandUnprobedare equally unactionable here — but not equally reportable: the refusal names a stale entry as a gone runner and an unprobeable one asunprobed(liveness unknown), the same distinctionlist/prune/waitdraw (seedocs/registry.md). - Endpoint resolution.
control::resolve_live_endpointmatches the requestedrun_idagainst the live entries only. More than one live match is an ambiguous run id — a hardCONTROL(103) failure for every verb, never a guess at which entry the scan happened to return first. The mutating verbs (cancel/kill) additionally re-run this resolution immediately before writing the verb (mutate_async), narrowing — though not fully closing — the TOCTOU window against a duplicate registering mid-flight (seedocs/control-plane.md, "Ambiguous run id"). - Verb over transport. The client connects to the resolved endpoint — a
unix domain socket or a Windows named pipe, both owner-restricted — and
speaks the shared line-oriented wire protocol: one request-verb line out,
one JSON reply line in.
inspectis read-only and prints aSnapshot;attestis read-only too and prints anAttestation— the runner's verdict on whether the process that opened this connection is inside its container, taken from the transport's own kernel-reported peer identity and answered on its ownattestation_versionaxis;cancel/killare mutating and reuserun's own teardown tiers exactly as described above —cancelthe graceful soft-stop → grace → hard-kill tier,killthe immediate hard-kill tier — replying with aControlAckbefore the run ends.
See docs/registry.md for the registry's location, record
format, and staleness signal, and docs/control-plane.md
for the full wire protocol and all three unreachable-runner cases (stale entry
vs. unprobeable entry vs. died mid-conversation).
Boundary with processkit
processkit-cli is a thin, standalone wrapper: the processkit
crate is the single source of truth for
containment, teardown, PID-reuse discipline, and process-tree lifecycle
semantics, and this repository builds strictly on its public API rather than
reimplementing any of that — a genuine gap becomes an additive request in
ProcessKit-rs's own backlog, never a local fork of the semantics (this is a
settled, repository-wide decision the module docstrings cite verbatim).
Concretely:
- What
processkitowns: the kernel-backed container (ProcessGroup) and its kill-on-drop teardown, the async child-output line pump (with a byte-cappedOutputBufferPolicy), directStdioMode::Inherit, and the environment builder (Command::env/env_remove/env_clear)run's--env*flags map onto directly. - What this runner owns: the CLI surface (
src/cli/), the versioned JSONL event contract and argv redaction (src/events.rs), the reserved runner-own exit-code band and its machine-readable failure envelope (src/exit.rs,src/error_envelope.rs), bounded diagnostic capture with hashing (src/capture.rs,src/hash.rs), the per-user run registry (src/registry/mod.rs), and the live-run control plane (src/control/mod.rs)/preflight probe (src/probe.rs) built on top of it, plus the temporary POSIX foreground-terminal handoff required by ProcessKit's separate process-group mechanism — none of whichprocesskititself provides.
README.md's introduction states the same division for a project outsider:
processkit-cli "runs one program inside ProcessKit's kernel-backed
containment boundary and reports the run lifecycle," while "ProcessKit-rs
remains the sole owner of containment, teardown, PID-reuse discipline, and
lifecycle semantics." Out of scope entirely: IPC-to-child protocols beyond the
control plane above, scheduling/pooling/retries beyond what processkit::Command
offers, a shell mode, and PTY support (deferred in the core crate).
Test tiers
Four tiers, increasing in weight and decreasing in how often they run:
- Unit. Each module under
src/carries its own#[cfg(test)] mod tests(for example the SHA-256 vector tests insrc/hash.rs, or theProcessGroup/Emitter-driven helper tests insrc/run/teardown.rs). The modules now live in the library (src/lib.rs), so these run as its--libtier under a plaincargo test— nocargo test --bin processkit-cliworkaround, which the earlier bin-only layout needed to reach module-private helpers. Internal helpers are tested against realprocesskit/Emitterobjects, never a mock layer. - Integration.
tests/drives the built binary (env!("CARGO_BIN_EXE_processkit-cli")), not the library, because the value this crate adds over ProcessKit-rs's own suite is the binary plus its contracts:tests/run.rs,tests/events.rs,tests/registry.rs,tests/probe.rs,tests/doctor.rs, andtests/integration.rscover through-the-binary scenarios, sharing fixtures/helpers fromtests/common/mod.rs. This is the defaultcargo testtier.tests/doctor.rsis the one case in it that is side-effecting by design — the command under test contains a real process and round-trips a real control plane — so it isolates both the registry (PROCESSKIT_CLI_REGISTRY_DIR) and the temp directory the command works in (TMPDIR/TMP/TEMP), which is what lets its cleanup claims be checked from outside the report that makes them. - End-to-end (
e2e, feature-gated).tests/e2e.rsis heavier still: it spawns real multi-level process trees, observes liveness from outside the runner (an OS process-table probe, not the container's own member list), and stresses concurrent runs, nested Windows Job Objects, PID-reuse storms, abrupt runner death, and inherited stdio through a real Windows console or POSIX pseudo-terminal. It is gated behind thee2eCargo feature (with itssrc/bin/e2e_helper.rsworker binary) so it stays off in the defaultcargo testand runs explicitly viacargo test --features e2e --test e2e -- --nocapture; CI runs it as a separate job. SeeCONTRIBUTING.md, "End-to-end tests". - Concurrency stress (
stress, feature-gated).tests/stress.rstargets what the tiers above cannot reach by construction: the invariants that only break when many runs contend for the two resources every run shares — the per-user registry (src/registry/mod.rs) and the per-run control plane (src/control/). Where thee2etier scripts a fixed handful of processes, this one launches dozens of simultaneousruninvocations against one registry directory and drives parallellist/prune/wait/inspect/cancel/killclients at them, asserting thatprunenever reaps a live entry (including one still inside its reservation window), that a registry scan never loses or duplicates a record under concurrent writes and deletions, that a control client aimed at an unreachable or dying runner refuses withCONTROL(103) inside a bounded deadline rather than hanging, and thatwaitnever misses — or invents — a completion. Every scenario carries a positive control, so none of those "never" assertions can pass vacuously. Gated behind thestressCargo feature and run viacargo test --features stress --test stress -- --nocapture; CI runs it as a separate, non-gating scheduled workflow (.github/workflows/stress.yml), not on every PR. SeeCONTRIBUTING.md, "Stress tests".
The property-based (proptest), fuzz (cargo-fuzz), mutation (cargo-mutants),
and benchmark (criterion) tiers cut across this ladder rather than sitting on a
rung of it — each re-examines code the tiers above already cover, from a
different angle. They are documented in CONTRIBUTING.md ("Fuzzing", "Mutation
testing") and README.md ("Benchmarks").
One further tier sits outside the ladder altogether, because its subject is not
this runner's behaviour but its agreement with the library underneath it.
Several of the vocabularies this CLI publishes (mechanism, abrupt_cleanup,
the limit_evidence verdicts, soft_stop_scope, soft_signal, outcome) are
projections of closed processkit enums, each ending in a conservative fallback
for a value the build predates — a safe answer that is also a silent one. The
upstream identifier drift gate (tests/spec_drift.rs, gated behind the
spec-drift Cargo feature) holds every one of those projections, and every
published schema enum that carries them, against the stable-identifier
dictionary processkit ships inside its own package, for the exact version
Cargo.lock resolves. A new upstream value fails the build instead of reaching a
wire as a fallback string. CI runs it as a separate gating job, and the scheduled
upstream canary runs the same tier against processkit's main branch. See
CONTRIBUTING.md, "Upstream identifier drift".
Normative documents
Each area of the compatibility surface has its own normative document; this overview only sketches how they connect:
docs/registry.md— the per-user run registry: location, record format, staleness signal.docs/control-plane.md— the local transport, the wire protocol, and theinspect/cancel/kill/attestclients.docs/exit-codes.md— the reserved runner-own exit-code band and the child-fidelity rule.docs/schema.md— the versioned JSONL lifecycle-event schema.docs/threat-model.md— untrusted inputs, the trusted principal and boundary, and which security threats the mechanisms sketched above (owner-only registry/transport, argv redaction, bounded control-plane reads, fail-closedprobe, bounded output capture, supply-chain scanning) actually close.docs/ROADMAP.md— the delivery status and the remaining ProcessKit-rs dependencies (this document describes the implementation).
Architecture decision records
Architecture decision records (ADRs) preserve why a durable project choice was made, including rejected alternatives and accepted trade-offs. They complement the current architecture description and changelog: an ADR is thematic and remains useful when the code around the decision moves.
Index
| ADR | Decision | Status |
|---|---|---|
| 0001 | Keep child streams and runner diagnostics separate | Accepted |
| 0002 | Redact argv by default | Accepted |
| 0003 | Keep control in the live runner | Accepted |
| 0004 | Scope cleanup to owned containers | Accepted |
| 0005 | Keep command execution shell-free | Accepted |
| 0006 | Poll the registry for detached waits | Accepted |
| 0007 | No terminal receipt file for a run's outcome | Accepted |
| 0008 | Do not expose external PID adoption in the CLI | Accepted |
Adding a decision
Use the next four-digit number and a short kebab-case title. Link the new record
from this index and docs/SUMMARY.md. Record supersession in both the old and new
ADR rather than rewriting history.
# NNNN: Decision title
- Status: Proposed | Accepted | Superseded by ADR-NNNN
- Date: YYYY-MM-DD
## Context
What forces a choice, including relevant constraints.
## Decision
The durable rule in imperative, testable terms.
## Alternatives considered
The credible alternatives and why they were not selected.
## Consequences
Benefits, costs, operational effects, and follow-up obligations.
0001: Keep child streams and runner diagnostics separate
- Status: Accepted
- Date: 2026-07-29 (retrospective)
Context
The CLI is both a transparent command runner and a structured lifecycle-event producer. Mixing either JSONL or runner diagnostics into child stdout would corrupt payload protocols and make faithful stream forwarding impossible.
Decision
Forward child stdout only to runner stdout and child stderr only to runner stderr.
Write lifecycle events only to the required --jsonl file. Write runner-owned
human diagnostics to stderr, never to child stdout. Keep capture files per stream.
The normative event contract remains the JSONL schema; I/O modes and capture behavior are documented in Standard I/O and capture. The implementation is split between the event writer, capture tee, and run launch path.
Alternatives considered
- Emit JSONL on stdout. Rejected because it aliases machine events with arbitrary child output.
- Merge child stdout and stderr into one diagnostic stream. Rejected because it destroys ordering-independent stream identity and breaks consumers.
- Prefix relayed output. Rejected because pass-through bytes must remain unmangled.
Consequences
Consumers can parse child stdout without filtering runner records and can tail the JSONL file independently. The CLI must maintain separate pumps and capture metadata, and every new diagnostic path must be reviewed for its destination.
0002: Redact argv by default
- Status: Accepted
- Date: 2026-07-29 (retrospective)
Context
Command lines routinely contain tokens, connection strings, paths, and customer data. Lifecycle files and registry records outlive terminal output and are natural inputs to automation, so copying raw argv into them by default widens secret exposure.
Decision
Publish a SHA-256 argv fingerprint and a fixed, non-secret worker-shape hint by
default. Publish raw argv only under explicit --argv-raw. Reuse the same
fingerprint in lifecycle events and registry discovery so the two views cannot
drift. Keep environment-file values out of argv and events.
See Command redaction and the registry's command identity. The shared fingerprint and hint construction lives in the event model used by both artifacts.
Alternatives considered
- Record raw argv by default. Rejected because convenience does not justify routine secret persistence.
- Drop all command identity. Rejected because operators still need to correlate and classify runs.
- Apply heuristic substring redaction. Rejected because an allow/deny list cannot recognize every secret format and creates false confidence.
Consequences
Default diagnostics support equality and known-worker classification without disclosing arguments. Operators who opt into raw argv accept its storage risk. Hint rules and fingerprint canonicalization are public schema behavior and require tests when changed.
0003: Keep control in the live runner
- Status: Accepted
- Date: 2026-07-29 (retrospective)
Context
inspect, cancel, and kill need access to the exact ProcessKit group owned by a
specific live run. A PID can be reused, and named kernel objects would expose a
second lifecycle and security surface with platform-specific ownership semantics.
Decision
Host the control server inside the live run process. Resolve a run_id through an
owner-only per-user registry, then connect to the recorded Unix-domain socket or
Windows named pipe. Reconfirm the exact registry record around dispatch. Treat a
dead runner as stale; never reconstruct control from a PID.
Protocol and snapshot details live in the control-plane guide, with lifecycle discovery in the registry guide. The stable implementation entry points are the control facade and registry facade.
Alternatives considered
- Address the root PID directly. Rejected because PID reuse can redirect a command to an unrelated process and cannot recover the owned container handle.
- Publish globally named Job Objects or other kernel containers. Rejected because semantics and ACLs diverge across platforms and outlive the runner differently.
- Run a permanent daemon. Rejected because the one-command runner does not require a new service lifecycle or privileged broker.
Consequences
Only the process that owns the container can answer or mutate it. IPC and registry artifacts need owner-only permissions and bounded conversations. Abrupt runner death makes control unavailable but leaves a detectable stale record and the platform's documented abrupt-cleanup guarantee.
0004: Scope cleanup to owned containers
- Status: Accepted
- Date: 2026-07-29 (retrospective)
Context
Build tools commonly reuse generic executable names, and unrelated users or runs may host identical workers. Name-based cleanup and recursive PID walking are both racy and can terminate processes outside the invocation that requested containment.
Decision
End only members of the current run's ProcessKit container. Let ProcessKit remain the source of truth for membership, graceful stop, escalation, PID-reuse discipline, and kill-on-drop. Never clean up by executable name or by an externally supplied PID.
The ownership boundary is summarized in Architecture and operational teardown in Timeouts and cancellation. The CLI delegates the lifecycle to the run path and its ProcessKit-backed teardown.
Alternatives considered
- Kill every process with a known worker name. Rejected because it cannot distinguish this run from unrelated builds.
- Walk descendants from the root PID in the CLI. Rejected because membership races process exit/reuse and duplicates core ProcessKit semantics.
- Reimplement missing containment behavior locally. Rejected because two sources of teardown truth would inevitably disagree.
Consequences
Cleanup is narrow, auditable, and portable through the public ProcessKit API. A core capability gap must be requested upstream rather than patched with an unsafe local fallback. Diagnostics may mention unrelated lookalike processes, but never act on them.
0005: Keep command execution shell-free
- Status: Accepted
- Date: 2026-07-29 (retrospective)
Context
Implicit shell parsing changes quoting across platforms, expands variables and globs, and introduces an injection boundary between an automation adapter's argv and the program that receives it.
Decision
Interpret every token after -- as <program> <args...> and pass it directly to
ProcessKit. Do not provide a shell mode. A caller that needs shell language must
name the shell explicitly (sh -c, cmd /c, or equivalent) and therefore owns the
shell's quoting and security boundary.
See Running commands for platform examples. The boundary is declared by the CLI parser and passed to the child without a shell by the launch path.
Alternatives considered
- Add
--shell. Rejected because its quoting semantics cannot be portable and it makes unsafe interpolation deceptively convenient. - Join argv into one command string. Rejected because round-tripping arbitrary arguments through a shell is lossy and injection-prone.
- Auto-detect shell metacharacters. Rejected because implicit behavior is harder to reason about than an explicit shell executable.
Consequences
Adapters can construct argv without another parser rewriting it, and Windows/Unix behavior stays aligned. Shell pipelines require a deliberate extra program token; examples must make that boundary visible rather than implying expansion by the CLI.
0006: Poll the registry for detached waits
- Status: Accepted
- Date: 2026-07-29 (retrospective)
Context
A detached caller is not the runner's parent and cannot wait on its process handle. The control channel is deliberately tied to a live runner and may be busy, while the registry already owns the authoritative live/stale/unprobed classification needed for discovery.
Decision
Implement wait as bounded polling of the owner-only registry, without contacting
the runner or mutating registry state. For --run-id, require one unambiguous live
record. For --all, snapshot the exact confirmed-live record paths once and poll
only that finite set; registrations after the snapshot are outside the wait.
The full state model and timeout distinction are documented under Waiting and in the exit-code contract. The polling and snapshot rules are implemented in wait.rs.
Alternatives considered
- Hold a control-plane connection until exit. Rejected because wait needs no live group operation and would couple observation to the sequential IPC server.
- Wait on runner PIDs. Rejected because the caller owns no process handle and PID reuse would weaken identity.
- Re-scan all matching run ids on every aggregate poll. Rejected because new runs
could extend
wait --allforever and duplicate ids would create false ambiguity.
Consequences
Wait is read-only, restartable, and independent of control-channel availability.
Polling introduces bounded detection latency. Its own deadline returns
WAIT_TIMEOUT without ending any run, and unprobed registry state must remain
honest rather than being treated as confirmed completion.
0007: No terminal receipt file for a run's outcome
- Status: Accepted
- Date: 2026-08-05
Context
Child fidelity makes a foreground run's exit code ambiguous by construction: the
runner exits with the child's own code verbatim, so a 106 is either the runner's
TIMEOUT or a child that happened to exit 106. The exit-code
contract already names the
JSONL stream as the authority that resolves it, which means a supervising adapter
reads the stream after every call.
A proposal asked for a cheaper answer: an opt-in run --outcome-json <path>,
atomically replaced at terminal completion with a versioned subset of runner_exit
plus run_id, a cleanup confirmation, a capture summary, and artifact locators —
never replacing the lifecycle stream, and with its absence after an abrupt runner
death remaining meaningful. This record is the decision on that proposal, taken after
the published machine-output schemas and the events subcommand landed.
Decision
Do not add a terminal receipt file. run keeps exactly one durable outcome artifact,
the required --jsonl lifecycle stream, and an adapter that wants the outcome without
opening it reads the failure envelope instead: under the global --error-format json, a runner-owned ending prints one bounded JSON object on stderr whose kind is
spelled exactly like the terminal runner_exit event's source, and a child's own
exit prints none. The envelope's presence, not the numeric value, is what separates
the two readings of a reserved-band code.
The adapter-facing form of this rule is in the integration guide, "Telling outcomes apart" and "No terminal receipt file".
Alternatives considered
-
run --outcome-json <path>, the proposal. Rejected, on four grounds.- It would not remove the read it exists to remove. The supervising adapter this
decision was measured against — a foreground driver of this binary — consumes
four event types after each call:
run_started(root_pid,mechanism,abrupt_cleanup),members_snapshot,output_captured, andrunner_exit. It treats a missingrun_startedoroutput_capturedas its own named failure reason. A terminal receipt carries terminal facts; the start-time facts exist only in the stream. To actually retire that reader the receipt would have to grow into a second, full serialization of the run. - The read is seven lines. A foreground run emits seven events, eight with
--capture-dir, and nine if a requested resource cap (--max-memory/--max-processes/--cpu-quota) also contributes its post-runlimit_evidence. It was six when this decision was taken; the seventh is the unconditionalresource_summary(T-317), the one growth that is not a caller opting in — and one extra line does not change this argument, which turns on the read being a bounded handful either way. Every other way the stream grows is a caller asking for more events on purpose — those two axes plus--snapshot-interval's extramembers_snapshotsamples. The full ordering rule is indocs/schema.md, "Ordering". - A cheaper answer already exists. The
--error-format jsonenvelope resolves exactly the ambiguity the proposal names, with no path to allocate, no artifact to clean up, and no new schema family to version. - A receipt has no honest failure mode. It would be written after the child's
code is already decided, so a failed write or a failed replace leaves three bad
options: fail the run, which rewrites the child's exit code and breaks the
contract's core rule; succeed silently, which makes the receipt's absence
ambiguous — "the runner died abruptly" or "the receipt could not be written" —
and so destroys the one property the proposal wanted to preserve; or record the
failure in the stream, which only a reader of the stream would see. This is the
project's existing principle applied to a new channel: a
--jsonlwrite failure is already outside every event's reach, because no event can report the failure of the channel that would carry it. Unlike the stream, a receipt carries nothing else, so its failure is total rather than partial.
A fifth, smaller cost is worth recording: the stream is created or truncated at the start of the run and its
run_startednames therun_id, so a reader can confirm the file belongs to the run it asked about. A receipt that must never appear early or partially cannot be truncated at start, so one left at the same path by an earlier run survives the current run's abrupt death and reads as valid. It is mitigable by comparing arun_idfield — but that comparison is the adapter-side logic the receipt was supposed to save. - It would not remove the read it exists to remove. The supervising adapter this
decision was measured against — a foreground driver of this binary — consumes
four event types after each call:
-
A bounded terminal read over an arbitrary stream (
events --outcome --file). Deferred, not rejected. The primitive already exists —wait --report-outcomegates on arun_startednaming the id, then scans a bounded 64 KiB tail in reverse for the last well-formedrunner_exit(wait.rs) — but it is reachable only for a run the invocation itself observed live, and a finished foreground run has already deleted its own registry record, sowait --report-outcomehonestly answersstatus: "unknown"for it.eventsreads a stream a different way on purpose and offers no terminal-only mode. If reason 2's seven-line read is ever measured to matter, the answer is to expose that existing read-side primitive over--file— one flag on a read-only subcommand, reusing the already-publishedwait --report-outcomeshape — rather than to add a second write path. Recorded so a revisit starts from the smaller option.
Consequences
run has one durable outcome channel, and --jsonl stays required, so there is no
configuration in which a receipt would be an adapter's only artifact. Adapters get a
documented stream-free disambiguation that costs one flag they can leave on for every
invocation. The residual gap is named rather than papered over: there is still no
first-party bounded terminal read over an arbitrary stream file, and closing it is a
read-side change if it is ever justified. Reopening this decision should rest on a
measured cost, not an anticipated one.
0008: Do not expose external PID adoption in the CLI
- Status: Accepted
- Date: 2026-08-07
Context
ProcessKit 3.3.0 adds the public
ProcessGroup::adopt_external
method. It accepts only a pid and lets a caller place an already-running
external process into a ProcessGroup. This is useful to a supervisor that has
the process number but does not hold a tokio::process::Child.
The CLI's existing ownership model is different. A run invocation starts its
child, keeps the live runner as the owner of the ProcessKit container, and
forwards the child's exit code. Other commands discover that run by run_id
through the owner-only registry and reach the endpoint published by that live
runner. ADR 0003 therefore says that
control is hosted in the live runner and is never reconstructed from a PID;
ADR 0004 limits cleanup to the current
run's owned container.
adopt_external is deliberately a lower-level capability, not an ordinary
child-launch operation:
- A PID is used as a one-time lookup, not retained as the identity. ProcessKit
captures its own identity anchor: a process object held after
OpenProcesson Windows; a start-time token around the migration on Linux cgroup v2; and a start-time token for tracked entries on POSIX process-group backends. Later probes, signals, and teardown are bound to that anchor, so PID reuse after the adoption call does not redirect those operations. - That anchor cannot close the caller's pre-adoption TOCTOU window. A PID read
from a pidfile, registry, or another supervisor may already identify a new
process by the time the caller passes it to
adopt_external. A token read by the caller earlier is not an improvement: it is older than the anchor taken by ProcessKit and would require every caller to duplicate platform-specific identity rules. ProcessKit's own documentation calls this residual window out explicitly. - The API never receives a
Child, never waits for the adopted process, and never exposes its exit status. It can list and signal membership, including hard-kill and drop cleanup, but it cannot produce the child's exit code. An adopted process that exits during the adoption call can also produceOkwith nothing left to contain. These are not the semantics of the CLI's ordinaryrunpath, whose exit-code andrunner_exit.child_codecontract is defined around an actual child. - Adoption is not neutral toward an existing supervisor. Only the target process is moved; descendants it already spawned keep their old containment, while future forks follow the new mechanism. The target's existing containment can also be changed by the platform.
The question is therefore not whether the ProcessKit method is technically available. It is whether this CLI should turn a destructive, PID-selected ownership transfer with no exit-status channel into a public command contract.
Decision
processkit-cli will not expose external PID adoption in its current public
CLI. In particular, this repository will not add:
- an
adopt --pid <pid>subcommand; - a
run --pid <pid>orrun --adopt-pid <pid>mode; or - PID-targeted variants of
inspect,cancel, orkill, or registry records keyed byroot_pid.
The existing boundary remains normative: a CLI control operation resolves a
run_id, then uses the endpoint in that run's registry record to reach the live
runner, and the live runner acts only on its own ProcessKit container. The
registry remains PID-free for addressing, and the JSONL lifecycle schema and
exit-code fidelity contract remain unchanged.
This is a refusal of a CLI feature, not a rejection of the ProcessKit API. A
caller that explicitly owns the cross-supervisor agreement may use
adopt_external directly through ProcessKit. That caller must also own the
missing wait/exit-status semantics and the consequences of changing the target's
existing containment. Reconsidering a CLI feature requires a separate ADR and a
new implementation task; it must not be smuggled into the existing run or
control-plane contracts.
Alternatives considered
Separate adopt --pid subcommand
This is the clearest of the positive CLI shapes, because it could make the
ownership transfer and the lack of a child exit code explicit. It still accepts
a destructive PID from outside the CLI's registry, cannot eliminate the
pre-adoption TOCTOU window, and would need a second lifecycle contract for a
process whose parent and exit status belong elsewhere. It would also need to
answer whether the resulting group is a normal run for list, inspect,
cancel, kill, wait, members_snapshot, and runner_exit; answering those
questions consistently would create a second run kind rather than a small
adapter around ProcessKit. Rejected.
A flag on run
run --pid <pid> or run --adopt-pid <pid> would reuse the existing command
name, registry entry, control endpoint, JSONL stream, and teardown code. That
reuse is misleading: there is no command to spawn, no Child to wait for, no
child exit code to forward, and no truthful ordinary runner_exit with a
child_code. It would also make the meaning of stdin/stdout capture, timeout,
root_pid, and the initial members_snapshot depend on whether a hidden
alternative input was selected. Rejected.
PID arguments on the control plane or in the registry
Adding --pid to inspect, cancel, or kill, or publishing root_pid as a
target key, would bypass the live-runner endpoint and turn a run-control
interface into a process-table interface. It would directly contradict the
registry's No PID addressing rule and make a
PID reuse mistake an action against an unrelated process. Rejected even if
adoption itself were otherwise available.
Expose the capability now and document the caveats
Documentation would not make the pre-adoption window, missing exit status, or foreign-container side effects disappear. A public CLI would invite scripts to treat a best-effort process lookup as an ownership boundary that the CLI cannot prove. Refusal is the only option that preserves the already-published control-plane and cleanup contracts without inventing a second, weaker one. Chosen.
Platform-specific risks
The API's identity anchor makes later operations PID-reuse-safe within its limits; it does not make cross-supervisor adoption portable or ownership-neutral:
| Platform/mechanism | ProcessKit 3.3 behavior | CLI consequence |
|---|---|---|
Windows JobObject | OpenProcess obtains a handle with the rights needed for assignment and termination, then the handle's process object is assigned to the job. A target already in another job may cause this job to nest under the outer job. On Windows 11, adoption of an outer-job member succeeded while the new group was empty, while the same operation after this group had spawned its own member was observed to fail with ERROR_ACCESS_DENIED. | A successful adoption can make an unrelated outer supervisor's termination and limits reach this CLI's future members. A refusal can depend on call order and foreign job state, not merely on the PID. |
| Linux cgroup v2 | ProcessKit reads /proc/<pid>/stat, writes the PID to this group's cgroup.procs, then reads the start-time again. cgroup membership is exclusive: the target leaves its previous cgroup, so that supervisor's limits and teardown no longer apply. Existing descendants are not moved. If a recycle is detected after the write, ProcessKit attempts a best-effort move-out; if that fails, the target remains in this group's teardown scope. | Adoption can change another supervisor's containment and can leave the target owned by this group's teardown after a failed rollback. The post-call identity check detects a race; it cannot undo every kernel-level side effect or restore the unknown cgroup the target originally occupied. |
| Linux process-group fallback and macOS | ProcessKit captures a start-time identity and normally tracks an external target individually because setpgid is not permitted for a process this caller did not start. Later signals are identity-gated, but descendants already present remain outside and future forks are not included. The token's platform resolution is finite, so same-tick occupants remain an upstream caveat. | The CLI could kill the adopted process without containing its already-existing tree, while a normal run promises container-scoped tree cleanup. On the process-group fallback, an unreaped external process can remain a zombie through the grace period because this API cannot reap it. |
| FreeBSD and other BSDs | No start-time reader is wired into the public adoption path, so adopt_external returns ErrorReason::Unsupported rather than tracking a bare number. FreeBSD's process reaper covers descendants of this process, not an external supervisor's process. | A supposedly portable CLI feature would either fail on these targets or tempt a caller-side PID-only fallback, which the project must not provide. |
These differences are material even before considering permissions: Windows may
deny opening a process owned by another user, integrity level, or protection
class; Linux may deny the cgroup write or identity read; and a POSIX identity
read may be unavailable under a restricted /proc or proc_pidinfo policy.
Consequences
The CLI keeps one ownership model and one addressing model. run continues to
mean "start and supervise this child"; inspect, cancel, kill, and wait
continue to resolve a run_id; and cleanup continues to be delegated to the
ProcessKit container owned by that run. No new CLI flag, registry field, JSONL
event, snapshot version, exit-code kind, or compatibility surface is required.
The cost is deliberate: a process started by another supervisor cannot be
handed to processkit-cli for tree cleanup. Integrators that need that workflow
must either make the CLI the process's original supervisor or use ProcessKit's
lower-level API with an explicit agreement about containment transfer, waiting,
and exit-status ownership. The CLI will not infer that agreement from a PID.
No roadmap delivery item is changed by this decision: the roadmap has no
scheduled external-adoption feature, so no docs/ROADMAP.md edit is needed.
If this decision is revisited, the follow-up must be a distinct feature design with cross-platform tests for PID reuse and pre-call races, foreign Job Object and cgroup membership, missing identity readers, descendants that predate adoption, zombie behavior, and an outcome contract that never pretends an adopted process has a reapable child exit code. It must also specify how a future JSONL/control-plane contract represents "membership is observable but exit status is not" before any production code is written.
Release process
This is the single maintainer-facing reference for how a release happens: what
you click, what .github/workflows/release.yml does step by step, what repo
configuration it needs, and how to recover from a failure partway through. It
supersedes the old root-level release-token-bypass.md (folded in below as
its own section).
Triggering a release
The workflow is workflow_dispatch-only — it has no push:/tag trigger, so it
never runs on its own. In the Actions UI, run Release and pick a version
bump (patch / minor / major). It always runs from main (an explicit
"Require main" step rejects any other ref, even if you pick one from the
dropdown). The version number is never typed by hand: the bump you pick is
applied to whatever Cargo.toml currently says, and that derived version then
drives the commit, the tag, and the GitHub Release, so the three can never
drift apart. The very first release (no v* tag exists yet) ignores the
chosen bump and ships the current Cargo.toml version as-is.
What the release job does, step by step
- Mint GitHub App token (conditional). If repo variable
RELEASE_APP_IDis set, mints a short-lived GitHub App installation token, used further down to push as the App instead of the defaultGITHUB_TOKEN. See "GitHub App bypass for a protectedmain" below for why and how to set this up. Skipped entirely when the variable is empty. - Checkout with full history (
fetch-depth: 0, needed for tag-based version math and for git-cliff to walk commits) and the token from step 1 (orGITHUB_TOKENas a fallback) so the later push carries the right identity. - Require main — fails fast if the workflow was dispatched from anything
other than
refs/heads/main. - Preflight — require
CRATES_IO_TOKEN— fails fast, before any of the slower work below, if theCRATES_IO_TOKENrepo secret isn't set. - Determine version — parses the current
versionfromCargo.toml. If a priorv*tag exists, applies the chosen bump (major/minor/patch) to it; otherwise (first release) keeps the currentCargo.tomlversion unchanged. Exposesversion,tag(v<version>) andprev_tagas step outputs. - Verify tag does not exist — refuses to proceed if
v<version>is already tagged. - Bump version —
cargo set-versionwrites the computed version intoCargo.toml/Cargo.lock. A no-op on the first release. - Auto-fill empty
[Unreleased]from git log — manualCHANGELOG.mdentries always win. Only when the## [Unreleased]section inCHANGELOG.mdhas no real bullets does this step generate one viagit-cliff --config cliff.toml, walking commits sinceprev_tag(or full history on the first release) and bucketing them by commit-message prefix percliff.toml's rules (feat/add→ Added,fix/bug→ Fixed,remove/delete/drop→ Removed,refactor/change/update/... → Changed,doc/chore/test/style→ skipped, everything else falls back to Changed). Fails the run if there is nothing release-worthy to put there. - Extract release notes — curates the (now non-empty)
[Unreleased]body down to only the### Headersections that have at least one real bullet, dropping placeholder-lines, and writes the result to$RUNNER_TEMP/release-notes.md— deliberately outside the working tree so it neither dirties it (which would abortcargo publish) nor ends up packaged into the crate. - Promote
[Unreleased]inCHANGELOG.md— renames the curated## [Unreleased]heading to## [<version>] - <date>, leaves a fresh empty[Unreleased]above it, and rewrites the Keep-a-Changelog reference links (compare link on subsequent releases, tag link on the first one). - Commit version bump + changelog — commits
Cargo.toml,Cargo.lockandCHANGELOG.mdlocally (not pushed yet, so the next step can verify against a clean tree). - Verify the crate publishes (dry run) —
cargo publish --locked --dry-run, catching build/packaging/metadata errors before the irreversible step below. - Publish to crates.io —
cargo publish --locked, retried up to 3 attempts on transient failures. An "already uploaded"/"already exists" response from cargo (a prior run that published but failed before tagging) is treated as success, so a re-run can still proceed to tag + Release. - Tag and push — only after the crate is live: tags
v<version>and pushes the commit + tag tomainatomically (git push --atomic), so a rejected push can never advance the branch while dropping the tag (or vice versa). - Publish GitHub Release — creates (or, on retry, edits) the GitHub
Release for the tag, using the curated notes file from step 9. Retried up
to 3 attempts; if it still fails, the job error tells you to finish it by
hand with
gh release create <tag> --notes-file <notes>and explicitly not to re-run the workflow, since a re-run would bump to the next version from the now-updatedmainand strand this release.
What the build-artifacts job does
Strictly downstream (needs: release) of the job above — it never bumps,
publishes to crates.io, tags, or creates the Release; it only builds and
attaches assets to the Release the release job already created. It fans out
across a fail-fast: false matrix of seven targets:
x86_64-pc-windows-msvc,aarch64-pc-windows-msvc(Windows)x86_64-unknown-linux-gnu,aarch64-unknown-linux-gnu(Linux glibc; the aarch64 leg cross-compiles withgcc-aarch64-linux-gnu)x86_64-unknown-linux-musl(static Linux, dependency-free binary; built onubuntu-latest)aarch64-unknown-linux-musl(static Linux, dependency-free binary; built natively on theubuntu-24.04-armhosted runner instead of cross-compiling, since apt has noaarch64-linux-muslcross-gcc package)aarch64-apple-darwin(macOS, Apple Silicon)
For each target, the job:
- Checks out the exact tagged commit (
ref: needs.release.outputs.tag), so the binary embeds the released version. - Builds
cargo build --release --locked --target <triple>(installing a cross linker first for the aarch64-glibc leg; the two musl legs each installmusl-toolsfor their own native architecture instead). - Packages the binary,
build.rs's generated shell completions and man pages, and aschema/directory (schema.json+events.jsonl, copied verbatim from the trackedfixtures/schema/v1/— notbuild.rsoutput, so the fixture stays the single source of truth) into a per-target archive namedprocesskit-cli-v<version>-<triple>(.zipon Windows via7z,.tar.gzelsewhere viatar). - Computes a
<archive>.sha256checksum right next to the archive (sha256sumon Linux/Windows-Git-Bash;shasum -a 256fallback on macOS, which ships BSD tools withoutsha256sum). - Uploads the archive + checksum to the Release with
gh release upload --clobber— idempotent, so a re-run of this job replaces rather than duplicates the assets. - Records a signed SLSA build-provenance attestation for the archive via
actions/attest-build-provenance, kept last in the leg since the archive and checksum are already on the Release before it runs; a downloader verifies it withgh attestation verify <archive> --repo ZelAnton/ProcessKit-CLI.
Because contents: write/id-token: write/attestations: write are
re-declared at the job level here (a job-level permissions: block replaces,
rather than merges with, the top-level contents: write), the release job
above is unaffected and keeps only the top-level permission it already had.
What the package-manifests job does
This job waits for the release and every archive-matrix leg to settle. It still runs when a leg failed only in its final provenance-attestation step, because the archive and checksum were already uploaded; a missing required checksum instead fails generation honestly. The job never publishes to an external package repository. It:
- Checks out the exact release tag and downloads the Release's archive
.sha256sidecars. - Runs
scripts/generate_package_manifests.py, which accepts only a SemVer release and verifies that every sidecar names the exact archive whose URL is being embedded. - Produces the three-file
ZelAnton.ProcessKitCLIwinget manifest, an architecture-aware Scoopprocesskit-cli.json, and a Homebrewprocesskit-cli.rbformula for macOS Arm64 plus Linux x86_64/Arm64. The Linux formula deliberately uses the static musl archives rather than inheriting the release runner's glibc floor, for both architectures. - Syntax-checks the JSON and Ruby output, packages the complete directory as
processkit-cli-v<version>-package-manifests.tar.gz, and checksums that bundle. - Attaches the individual manifests, bundle, and bundle checksum to the
existing GitHub Release with
--clobberidempotence.
Winget retains its external microsoft/winget-pkgs review. Scoop and Homebrew
receive ready-to-copy files for an account-owned bucket/tap; this job itself
mutates nothing outside the repository and needs no external credentials.
Pushing those two files into the tap/bucket is the separate, optional job
below, which keeps all external channel availability out of the
crate/tag/Release critical path either way.
What the publish-package-repos job does
This final job publishes the formula and Scoop manifest package-manifests just
attached to the Release into the package repositories the project owns — the
step that turns a release asset into an installable
brew install <owner>/tap/processkit-cli. It is the only job in the workflow
that pushes into another git repository — the release job already writes
outside this repository too, publishing the crate to crates.io — and it is off
until an operator provisions the target repositories:
- Resolve publication targets — a channel is enabled by the presence of its
token secret (
HOMEBREW_TAP_TOKEN,SCOOP_BUCKET_TOKEN); the token is only tested for emptiness, never printed or forwarded. The target defaults to<owner>/homebrew-tap/<owner>/scoop-bucketand can be renamed with the repository variablesHOMEBREW_TAP_REPOSITORY/SCOOP_BUCKET_REPOSITORY, a value the step accepts only in the exactowner/nameshape since it ends up in a clone URL. An unconfigured or rejected channel is skipped with a notice or warning naming what to create. (secretsis unavailable toif:expressions at either job or step level, which is why the check happens in a shell step and is republished as a plain step output.) - Download the published manifests — fetches
processkit-cli.rbandprocesskit-cli.jsonback from the Release rather than regenerating them, so a tap serves exactly the bytes the Release advertises and the job stays re-runnable on its own. - Publish each enabled channel: clone the target with its own token, write
the file to
Formula/processkit-cli.rb/bucket/processkit-cli.json, commit asprocesskit-cli <version>and push to the target's own default branch. Identical bytes push nothing (a re-run is a no-op, not an empty commit). Both channels run the same staged publisher script, so they cannot drift apart, and each iscontinue-on-erroron its own so a broken tap does not also skip the bucket. - Report publication outcome — always runs, and states per channel whether
it published, is not configured, or failed (as an
::error::annotation plus a run-summary line), since a swallowed failure is easy to miss on an otherwise green release.
The job declares permissions: contents: read — narrower than the top-level
contents: write, which a job-level block replaces rather than merges with (the
same rule build-artifacts documents above). It only reads this repository's
release assets; every write goes to an external repository authenticated by that
channel's own token. The built-in GITHUB_TOKEN cannot serve that purpose: it
is scoped to this repository, which is why a separate secret is required.
The job is continue-on-error: true at job level as well, so a configured
channel that fails — repository deleted, token expired or revoked, protected
branch on the tap — cannot turn the release red. It runs after crates.io, the
tag, the Release, every archive, and every manifest upload, so at that point
there is nothing left it could strand.
winget is deliberately not automated here; docs/installation.md ("Why winget
is submitted by hand") records the reasoning: there is no project-owned winget
repository, publication is a reviewed pull request into microsoft/winget-pkgs,
and a green automated wingetcreate submit would still not mean the version is
installable.
Required repository configuration
CRATES_IO_TOKEN(secret) — required. Publishing to crates.io fails the preflight check immediately if it's missing.RELEASE_APP_ID(variable) +RELEASE_APP_PRIVATE_KEY(secret) — optional. Only needed oncemainis protected by a ruleset that would otherwise reject the release commit/tag push (see the next section). UntilRELEASE_APP_IDis set, the "Mint GitHub App token" step is skipped and the push falls back to the defaultGITHUB_TOKEN— fine whilemainis unprotected.- The
GITHUB_TOKENused to create/edit the GitHub Release and to upload build artifacts and package manifests is the default one GitHub Actions provides; no setup needed beyond the job-levelpermissions:blocks already in the workflow. HOMEBREW_TAP_TOKEN(secret) — optional. Enablespublish-package-reposto pushFormula/processkit-cli.rbinto the tap repository. Use a token that can push to the tap and nothing else: a fine-grained PAT withContents: read and writescoped to that repository, or a GitHub App installation token. Until it is set, the Homebrew half is skipped with a notice.SCOOP_BUCKET_TOKEN(secret) — optional, same shape, forbucket/processkit-cli.jsonin the Scoop bucket repository. Independent of the Homebrew half.HOMEBREW_TAP_REPOSITORY/SCOOP_BUCKET_REPOSITORY(variables) — optional. Override the default<owner>/homebrew-tap/<owner>/scoop-buckettargets. They only rename the target; the token secret is what enables publication. Seedocs/installation.md, "Publishing to a tap or bucket", for the operator walkthrough and the naming constraint on a Homebrew tap.
Recovering from a failure partway through a release
The steps are ordered specifically so that the one truly irreversible action —
publishing to crates.io — happens before anything is pushed or tagged, and
so build-artifacts only ever adds optional, re-buildable assets on top of an
already-complete release:
- Failure before "Publish to crates.io" (version bump, changelog
generation/promotion, dry-run publish, etc.): nothing has left the runner.
Just re-run the workflow from the same
bumpinput; the earlier local commit is discarded with the job. - Failure during/after "Publish to crates.io" but before "Tag and push":
the crate version is live on crates.io but
mainhasn't moved and no tag exists yet. Re-run the workflow — the "Publish to crates.io" step treats an "already uploaded"/"already exists" response as success and proceeds to tag and push, so this is safe and does not attempt a duplicate publish. - Failure during "Tag and push": the atomic push means either both the
commit and the tag landed on
main, or neither did — never a half state. If neither landed, re-run as above. If it actually did land but the step still reported failure (e.g. a flaky follow-up check), inspectmainand thev*tags before re-running to avoid a wasted crates.io no-op. - Failure during "Publish GitHub Release": crates.io is published and the
tag is pushed —
cargo install processkit-cliandcargo add processkit-clialready work. Per the job's own error message: finish the Release by hand withgh release create <tag> --notes-file <notes>and do not re-run the workflow, since a re-run would bump from the already-advancedmainand ship the next version, stranding this one. - Failure in
build-artifacts(one or more matrix legs): the release itself (crate + tag + GitHub Release) is unaffected — this job never bumps/publishes/tags/creates the Release.cargo install processkit-cliand the GitHub Release page both already work; only the prebuilt archive for the failed target(s) is missing. Either re-run just that job (the upload is--clobber-idempotent and the attestation is regenerated), or build it by hand (cargo build --release --target <triple>, package it with the completions/man/schema/trees + checksum it the same way the job does — see "What thebuild-artifactsjob does" above for the exact contents — thengh release upload <tag> <archive> <archive>.sha256 --clobber). - Failure in
package-manifests: the crate, tag, Release, and prebuilt archives are already published. Repair or upload any missing checksum sidecar, then re-run this job; generation is deterministic from the tagged script plus those sidecars, and every upload uses--clobber. Do not trigger a new release merely to repair these distributor inputs. - Failure in
publish-package-repos: the release is complete and green — this job iscontinue-on-errorand only the tap/bucket is behind. Its "Report publication outcome" step names the channel and target repository that failed. Fix the cause (create the missing repository, refresh the token, allow the release identity to push to a protected branch), then re-run just this job: it re-downloads the same published files and pushes nothing if the target already holds them. Copying the file in by hand achieves the same thing. Never trigger a new release to repair a tap.
GitHub App bypass for a protected main
.github/workflows/release.yml pushes the release commit (the version bump +
promoted CHANGELOG.md) and the v<version> tag straight to main. Once you
protect main with a rule that requires pull requests, that direct push is
rejected — for every actor except those on the rule's bypass list.
You cannot put the built-in github-actions[bot] on a bypass list (it is a
system actor, not an addressable App), and a personal access token expires and
ties the push to a human. The supported path is a GitHub App: the workflow
mints a short-lived installation token (auto-revoked, no rotation), pushes as
the App, and the App sits in the ruleset's bypass list.
When main is not protected, none of this is needed — the workflow falls
back to the default GITHUB_TOKEN and the push just works. Set this up only
once you turn on PR-required branch protection.
One-time setup
-
Create a GitHub App (Settings → Developer settings → GitHub Apps → New GitHub App). Minimal config:
- Repository permissions → Contents: Read and write (to push the commit
- tag). Nothing else is required.
- No webhook needed (uncheck Active).
- It can be private to your account/org; it does not need to be public.
- Repository permissions → Contents: Read and write (to push the commit
-
Generate a private key for the App (App settings → Private keys → Generate a private key) and download the
.pem. -
Install the App on the target repository (App settings → Install App → pick the repo).
-
Add the credentials to the repo (repo Settings → Secrets and variables → Actions):
- Variable
RELEASE_APP_ID= the App's numeric App ID. - Secret
RELEASE_APP_PRIVATE_KEY= the full contents of the.pem(including the-----BEGIN/END-----lines).
The workflow's "Mint GitHub App token" step is guarded by
if: ${{ vars.RELEASE_APP_ID != '' }}, so until the variable exists the step is skipped and the push uses the default token. - Variable
-
Add the App to the branch-protection bypass list. Use a repository ruleset (repo Settings → Rules → Rulesets), which — unlike the older "branch protection rules" screen — supports a bypass list:
- Target branch
main, enable Require a pull request before merging. - Under Bypass list, add your App (it appears once installed).
The App can now push directly to
main; everyone else still goes through a PR. - Target branch
Verifying
Dispatch the release workflow (Actions → Release → Run workflow → pick a
bump). The Mint GitHub App token step should run (not skip), and the Tag
and push step should push the Release v<version> commit and tag to main
without a protection error. If the push is rejected, re-check that the App is
installed on the repo and is actually listed in the ruleset's bypass list, and
that RELEASE_APP_ID / RELEASE_APP_PRIVATE_KEY are set on the repo (not the
org, unless the App is org-owned).
This is ordinary setup documentation: it applies only if
mainis protected by a ruleset that would otherwise reject the release workflow's tag push.
Roadmap
Delivered in v0.2.0
- Runnable containment shell.
processkit-cli runexecutes one shell-free command through the publicprocesskitAPI, echoes child stdout/stderr, and preserves the child exit code. Timeouts and cancellation use a distinct, documented runner-owned exit-code band. - JSONL schema v1. The normative event schema and golden fixtures cover
lifecycle events, cleanup, runner failures, and terminal exit. Events are
written to
--jsonl, never stdout, and argv is redacted by default. - Bounded diagnostic capture.
--capture-dirwrites separate bounded stdout/stderr transcripts with full byte counts, hashes, and truncation metadata while preserving live echoed output. - Live-run control plane. The per-user registry and local IPC back
inspect,cancel,kill,list, andprune; stale entries are visible and safely reaped without addressing a process by PID. - End-to-end proof. Through-the-binary tests cover leaked descendants,
nonzero roots, inherited pipe handles, concurrent runs, control-plane
cancellation, and platform-specific teardown behavior. The heavier
e2etier additionally covers abrupt runner death, nested Windows Jobs, PID reuse, and Ctrl-C. - Distribution. Releases publish six prebuilt archives: Windows x86_64 and
aarch64, Linux x86_64 glibc and musl plus aarch64 glibc, and Apple Silicon
macOS. Source installation remains available through
cargo install.
Delivered in v0.2.1
- Explicit stdin sources.
--inherit-stdinshares the runner's input handle with the child, while--stdin-file <file>streams a checked file through ProcessKit and closes stdin at EOF. Closed/null stdin remains the safe default.
Current development
- Interactive inherited stdio.
--inherit-stdiopasses stdin, stdout, and stderr directly to the child, preserving an existing console or terminal while retaining ProcessKit containment, lifecycle JSONL, the control plane, cleanup, and exit-code fidelity. The default remains pipe + echo; transcript capture and no-console mode intentionally conflict with direct inheritance. - Cross-platform terminal proof. Through-the-binary tests cover piped I/O, a real Windows console, and a POSIX pseudo-terminal, including input, terminal detection, JSONL completion, and descendant cleanup.
Remaining ProcessKit-rs dependencies
The processkit 3 graceful-shutdown contract is now fully consumed. Windows console
children can opt into CTRL_BREAK with --windows-graceful-ctrl-break; every
runner-imposed graceful ending probes ProcessGroup::soft_stop_scope() before the
attempt and records the resulting ShutdownReport in
cleanup_finished.shutdown. ProcessGroup::members_info() is likewise consumed
for members_snapshot/inspect enrichment (see
docs/schema.md, "Enriched member fields").
Whole-tree cleanup after an abrupt runner death is also a core dependency on
Unix. The current public primitive kills only the direct child on Linux and is a
no-op on FreeBSD, macOS, and other BSDs; cgroups, process reapers, and process
groups do not disappear with their owner.
Until ProcessKit exposes an additive, identity-safe whole-tree owner-death
primitive, the CLI reports direct_child_only or none in run_started and
does not claim the Windows guarantee on those platforms. Any stronger contract
requires additive, identity-safe ProcessKit-rs support and cross-platform
abrupt-death proof.
Runtime resource-limit attribution is now consumed through processkit 3.2.0.
The existing limit_hit event still covers only the pre-spawn "could not be
applied" branch. Runs with a requested cap additionally emit the additive
limit_evidence event with tripped, not_tripped, or unknown per axis,
read before teardown consumes the group. The evidence is authoritative on
Linux cgroup v2 only: Windows Job Objects, the FreeBSD process reaper, and POSIX
process groups (macOS, other BSDs, and the Linux process-group fallback) do not provide the same successful
Linux path. A successfully created Windows Job Object reports unknown for
capped axes; a POSIX limit request fails before a capped group exists and
therefore emits the existing pre-spawn limit_hit with no limit_evidence.
This closes the runtime-attribution gap on Linux cgroup v2 only; it does not
claim post-run limit attribution on Windows or POSIX fallback runs.
Whole-tree resource measurement — as distinct from that limit attribution — is now
consumed through processkit 3.3.0's ProcessGroup::stats(). Every run that spawned a
child emits the additive resource_summary event exactly once, from the same read point
as limit_evidence and immediately after it, with a slot for peak memory, total CPU, IO
bytes read and written, and peak process count, each independently nullable. This is
deliberately the answer to a different question than limit_hit/limit_evidence — how
much the tree used, not whether a cap engaged — and it is what partially closes the gap
the paragraph above leaves open on Windows: post-run limit attribution there remains
unknown by mechanism, but the measured peak_memory_bytes from the Job Object's own
accounting block is now available as an honest substitute, which is the reason this was
worth taking. The closure is specific to that mechanism and no wider. Axes stay
unavailable by mechanism rather than by omission — peak_process_count on Windows (a Job
Object keeps no peak-concurrency counter), both IO counters on FreeBSD, macOS, other BSDs,
and the Linux
process-group fallback, and on Linux the IO counters additionally need the cgroup v2 io
controller, which this CLI does not enable. On Linux cgroup v2 memory and CPU are also
bounded by the read point rather than by the controller set: stats() sums them from
/proc over the members live when it runs, so a run that ended by its child exiting
reports both as null, and only a runner-imposed ending (read while the tree still runs)
populates them. The normative matrix, including that consequence and why the two
platforms' IO counters are not comparable with each other, is in
docs/resource-limits.md, "What the tree consumed".
That last point is an upstream limitation, not a CLI one, and is recorded here so a
revisit starts from the right place: cgroup v2 does keep whole-tree accumulators that
outlive their members (memory.peak, cpu.stat), but processkit's Linux backend
deliberately folds per-member /proc counters instead, because the cgroup it creates
enables no controller unless a cap asks for one. Closing the gap means asking processkit
for a memory/CPU reading sourced from those controller files (and enabling the controllers
to make them exist), not adding sampling here — a runner-side maximum over its own reads
would report when this CLI looked rather than what the tree did, which the event's contract
rules out.