
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. |
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. |
| macOS / other 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 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 and the reserved runner failure band. |
| 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
# 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, or a reported process-group fallback | Explicit abrupt_cleanup: whole_tree on Windows, direct_child_only on Linux, none on 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, and macOS. |
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 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 or when Linux cgroup delegation is unavailable. In that case the CLI does
not claim a stronger boundary: mechanism is process_group, resource-limit
requests fail rather than becoming no-ops, and the documented escape and abrupt
death limitations apply. 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 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. |
| Scoop | processkit-cli.json for x86_64 and Arm64 | Ready for bucket/processkit-cli.json in an account-owned bucket; 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; 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.
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 → set.
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.
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.
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. |
| 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
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_treeon Windows,direct_child_onlyon Linux, andnoneon macOS/other Unix. - 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. Four 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 =. |
Application order is fixed, regardless of flag order: clear, remove, env-file,
explicit set. Repeated files are applied in argument order, and an explicit
--env therefore wins over every file or removal for the same key. A file read,
UTF-8, or syntax failure is SETUP (111) before the child starts.
For either --env or a file entry, the key must be non-empty and contain 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.
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, or wait.
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, andkill.
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.
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
run still reports cgroup_v2 or process_group, and the platform-specific
abrupt_cleanup value still applies if the detached runner itself is killed
before it can perform teardown.
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
cleanup_started
cleanup_finished
runner_exit
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. |
| macOS / 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 currently proves only that a requested limit could not be
applied before launch. It does not prove that a successfully installed limit
later fired.
The processkit version this repository resolves from crates.io
(Cargo.lock, currently 3.1.0) does not yet expose portable post-spawn
evidence for a cgroup OOM/pids event or a Windows Job Object notification. A
child terminated by an enforced live limit may therefore be indistinguishable
from another nonzero or signalled child outcome. Do not claim runtime
attribution from the exit code alone.
ProcessKit-rs has since implemented such a primitive on its main branch
(ProcessGroup::limit_evidence(), per-axis LimitVerdict::{Tripped, NotTripped, Unknown}), but it is not yet in a published release — it
ships when a new version reaches crates.io and this project's Cargo.lock is
updated to consume it, which this document does not do. The design carries
constraints worth recording now, before anything is wired against it:
- Three-valued, never a boolean. A future reader of this evidence — and
any JSONL surface built on it — must represent
Tripped/NotTripped/Unknownas three distinct states.Unknownmust never collapse 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 (macOS, the BSDs, the Linux process-group fallback), every capped axis instead reportsUnknownas a measured result, not an omission — those mechanisms keep no post-mortem record that a cap fired. Windows is a first-class platform for this CLI, and runtime limit attribution will not become available there even once this primitive is wired in; only the Linux cgroup v2 gap can close. - Readable only while the container still exists. The evidence lives in
the container itself, so it must be read before
ProcessGroupis dropped or consumed by shutdown. That constrains where a future reader could sit relative to this runner's teardown andcleanup_finished/cleanup_startedordering — it has to run ahead of, not after, whatever drops or shuts down the group.
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).
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. - Keep a separate outer-runtime signal for runtime OOM/CPU/pids attribution.
See also
- Platform support — mechanism selection.
- Running in containers — cgroup delegation in images and orchestrators.
- JSONL event schema — normative event fields.
- Exit-code contract.
Platform support
ProcessKit CLI exposes one command surface across Windows, Linux, and macOS, 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 |
| macOS / other 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.
macOS and other 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 | POSIX process group |
|---|---|---|---|
| Normal whole-tree hard teardown | Yes | Yes | Group members only |
| Whole-tree abrupt runner-death reap | Yes | No | No |
| Enriched member snapshots | Yes | Yes | Backend-dependent |
| Memory limit | Yes | With controller access | No |
| Process-count limit | Yes | With controller access | No |
| CPU quota | Yes | With controller access | No |
| Soft-stop request | Window close plus opt-in console CTRL_BREAK | SIGTERM | SIGTERM |
| Direct inherited terminal | Yes | Yes | Yes |
| PTY emulation | No | No | No |
CI coverage
The repository's GitHub Actions matrix builds and tests Windows, Linux, and macOS, including Arm runners where available. 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.
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
These are separate policies. A Linux cgroup gives strong normal teardown but not Windows-equivalent abrupt cleanup.
See also
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 / 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 three clients — inspect (read-only) and the mutating
cancel / kill — including their behavior when the runner cannot be reached.
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, andkill. (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). |
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.
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, or process_group (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.
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)), thecleanup_started/cleanup_finishedteardown pair, and a terminalrunner_exitwithsourcecontrol_canceland code108.killwrites a dedicatedkilledevent withsourcecontrol_kill, the cleanup pair (withsoft_terminatenull— no soft stop was attempted), and a terminalrunner_exitwithsourcecontrol_killand code109.
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, and kill — 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.
One inspect-only refusal shares this code without being a lost runner. 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"). That is what sets it 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. (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 it — their ack carries no version.
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.
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 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 (run's path) 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.
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). |
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-onlyinspect, 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, 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 uses, 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 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, 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/killstill 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) [--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 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, restricted to
--run-id, instead prints exactly one JSON object 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. Aggregate
reporting is deliberately out of scope until it has an explicit per-target shape;
clap therefore rejects wait --all --report-outcome. 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 inspect/cancel/kill, which act only on
[Health::Live] and so refuse on Unprobed exactly as they refused on the old
collapsed 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>] 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, exactly like --run-id.
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.
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. macOS, the
BSDs, and the Linux process-group fallback have no whole-tree container at
all, so any cap request fails the same way there too. See README.md,
"Resource limits", for the full platform matrix, 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) 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 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 of three reasons applied — 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.
inspect has a fourth reason of its own, and it is not a lost runner. If
the message says the runner answered with a control-plane snapshot version
outside the range this client reads, the runner is reachable and healthy: the
exchange completed, and it is the answer that was rejected, because it declares
a snapshot contract this binary does not read — in practice a runner newer than
the client you are running. cancel/kill cannot hit this, and neither can
list/wait/prune, so the run itself is still fully controllable. Retrying
will not help: inspect that run with a build that speaks its version — for a
newer runner, one at least as new as the binary that started the run. The message
quotes the version that arrived and the range this build reads; 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 version — that number only arrives in its reply). See
docs/control-plane.md, "Snapshot version: a newer runner's
reply is refused, an older one is read".
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 / 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 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/killbehavior.docs/integration.md— the consumer/adapter walkthrough, organized by call sequence rather than by symptom.
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, and
docs/registry.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
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. - 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 these outputs carry no version field of their own (the probe report's
probe_version and the inspect snapshot's snapshot_version are the two 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".
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/killlater 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 <KEY=VALUE>give the adapter control over the child's environment, applied in that fixed order — clear, then remove, then set — 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.--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. SeeREADME.md, "Resource limits", for the platform matrix (macOS/BSD and the Linux process-group fallback are unsupported; 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.
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. 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_started).
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. |
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.
4. Supervising a live run: inspect / cancel / kill / 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
four 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 wait --run-id build-42 --timeout 10m
The first three 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).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-outcome(single-run only) makes it print one JSON object naming how the 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), and wait.schema.json (--report-outcome).
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 four of these clients' by-run_id
form can return, and for all four the usual reason is the same: the command could
not be resolved to the single target run. inspect has one further reason of its
own, where the target was resolved and reached and did answer — its reply declared
a control-plane snapshot version this client does not read, so the answer was
refused rather than rendered (see docs/control-plane.md, "Snapshot version: a
newer runner's reply is refused, an older one is read"). See §6 for the concrete
situations that produce a 103, that one 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
- Stale registry entry. The runner behind a
run_iddied abruptly (crash,SIGKILL, a parent's Job Object terminate); its record is left behind but its liveness lock is released.inspect/cancel/killdetect 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. 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 same
CONTROL(103) refusal —inspect/cancel/killact 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. 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
bounded
CONTROL(103) failure, never a wedge: every wait in the control plane (connecting, and the request/response exchange) is deadline-bounded. - Ambiguous
run_id. The registry does not enforcerun_iduniqueness; if more than one live entry matches, every by-run-idcommand — the read-onlyinspectandwaitincluded — 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 snapshot version (
inspectonly). The runner was reached and answered, but its reply declared a control-planesnapshot_versionoutside the range this client reads — newer than the version it implements, or older than the version it still decodes — soinspectrefuses the answer instead of rendering it under semantics its sender never promised. Also aCONTROL(103), with a message naming the version that arrived and the range this build reads. Unlike the four above it says nothing about the run's liveness: the target is registered, live, reachable, and healthy, andcancel/kill/wait/listagainst it are unaffected (an ack carries no version). Do not treat it as a lost runner or retry it; inspect that run with a build that speaks its version — for a newer runner, one at least as new as the binary that started the run. Seedocs/control-plane.md, "Snapshot version: a newer runner's reply is refused, an older one is read", anddocs/compatibility.md, "Machine-output schemas". CONTROL-class exit codes are not run outcomes. A103from the by-run_idform ofinspect/cancel/kill/waitdescribes a failure on the client's side of the exchange — it could not resolve or reach a single target, or (theinspect-only case above) could not read the answer it got — 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). 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.
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), and the versioning decision behind them (probeandinspectcarry 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/killbehavior.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.
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 them 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.
Surface tokens
A surface token is either a subcommand or subcommand:--long-flag:
run
run:--jsonl
run:--idle-timeout
inspect
inspect:--json
cancel:--all
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.
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. Route by the
eventdiscriminator and ignore a type you do not know, rather than failing on it or assuming the stream is corrupt. - 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, 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). - 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. - 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.
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 | fixtures/schema/cli/wait.schema.json | wait.jsonl |
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.
Two rows of that table carry a version field; the other four deliberately do
not. probe --json carries probe_version and inspect --json carries
snapshot_version — the same field the runner puts on the control-plane wire.
probe.schema.json pins its 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. Either way a bump is visible in the payload itself. Pin those two 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. Both
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 crosses a process boundary to a runner that may be a
different build, and the probe report's whole job is to be read before the
binary's version is known.
The remaining four — list --json, the cancel/kill ack and --all report,
prune --json, and wait --report-outcome — 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 --jsonandinspect --jsonadditionally bump their own field. A breaking change to either shape bumpsprobe_version/snapshot_versionrespectively, and that field is what a consumer should check. 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.- 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.
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:probe").--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, or process_group. |
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 macOS/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 a teardown read failure" below. |
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 a teardown read failure. 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; 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 flag in the stream, not the warning, is 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 has no whole-tree container at all (macOS/the BSDs, the Linux process-group fallback), 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 reflects theprocesskitversion this repository currently consumes; an additive, not-yet-released post-spawn evidence primitive and the tri-state design constraints it will impose on any future JSONL surface are tracked indocs/resource-limits.md, not in this schema. - 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.
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 macOS/BSD, 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).
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,cleanup_started,cleanup_finished,runner_exit; or - runner-imposed ending — the reason event (
timeout,cancelled, orkilled),cleanup_started,cleanup_finished,runner_exit.
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). 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 cleanup_started/cleanup_finished tear the container down before the
terminal runner_exit. 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. |
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 the argv elements
joined by a single NUL byte (0x00) — each element as its UTF-8 bytes, 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.)
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.
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. 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). Note what that does not mean: those two fields 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.
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.
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), 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 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. inspect adds one reason of its own that is not an unreachable runner: the target answered, and its answer was rejected — a reply declaring a snapshot_version outside the range this client reads (newer than it implements, or older than it still decodes) is refused rather than rendered under semantics its sender never promised (see docs/control-plane.md, "Snapshot version: a newer runner's reply is refused, an older one is read"). 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. |
Codes 115–119 are reserved for future runner-own conditions. --help
and --version are not failures: they print to stdout and exit 0.
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.
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/ control (cancel/kill) reply that cannot be serialized. 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 (macOS/BSD and the Linux process-group fallback have no such 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 115–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.
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.
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.
EVENTS_INVALID(114) is the most recent, taking the next free 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" below);WAIT_TIMEOUT(112) did the same before it, 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); codes115–119remain reserved.
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
Four 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/killtransport (src/control/mod.rs), are read as untrusted bytes from whichever local process holds the socket/pipe. -
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.
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 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:probe"). - 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. 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).
- 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. - 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) 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. |
src/cli/ | The CLI-flags half of the compatibility surface: the clap-derived Cli/Command types for run, inspect, cancel, kill, wait, events, list, prune, and probe. Parsing and shape validation only — each subcommand's behavior lives in its own module. A directory of nine 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; wait.rs, events.rs (whose implementing module is src/events_cmd/, the plain events name being taken by the emitter), list.rs, prune.rs, and probe.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, run ids) — the functions the fuzz tier drives directly. |
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 protocol (see docs/control-plane.md). |
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/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). |
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.
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, 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 macOS/BSD — 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). 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, and kill (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;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 (src/exit.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, andtests/integration.rscover through-the-binary scenarios, sharing fixtures/helpers fromtests/common/mod.rs. This is the defaultcargo testtier. - 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").
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/killclients.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 |
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.
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 final 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, but those separate
repositories are not mutated and need no credentials in this project. This
keeps all external channel availability out of the crate/tag/Release critical
path.
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.
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.
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 macOS/BSD; cgroups 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 also a core dependency. limit_hit
(see docs/schema.md) today covers only the pre-spawn
"could not be applied" branch — a requested cap the platform has no container
for, or a Linux cgroup v2 whose controllers can't be enabled. It does not, and
currently cannot, cover a cap that was applied and then actually fired during
the run (a Linux cgroup OOM-kill or pids fork refusal, a Windows Job Object
memory/active-process limit): today's processkit 3.1.0, the version this
repository resolves from crates.io (Cargo.lock), exposes no portable
post-spawn evidence primitive for that case, so a live limit kill remains
indistinguishable from the child failing on its own.
The cross-repo request for that primitive
(msg-send-401e87d4625e22218e50a11de4a7f122) has since been answered and
implemented upstream: ProcessGroup::limit_evidence() landed on ProcessKit-rs
main (ProcessKit-rs task T-243) with a three-valued
LimitVerdict::{Tripped, NotTripped, Unknown} per axis — never a boolean —
so a future JSONL surface for this must represent "no authoritative evidence"
as its own state and must never collapse it into "did not fire" (see
docs/resource-limits.md
for the platform-by-platform breakdown and the read-before-drop constraint on
where a future reader could sit). It is not yet in a published release —
the latest tag remains v3.1.0 — so nothing is consumable today and this
dependency stays open in practice. This roadmap does not bump or repoint the
dependency; the scheduling trigger is the upstream release notification
arriving in this project's inbox, at which point wiring limit_evidence()
into the JSONL stream — an additive schema change, exact shape (a limit_hit
discriminator field vs. a separate event) to be decided when it is planned —
gets scheduled. Note that even once wired, this closes the gap on Linux
cgroup v2 only: Windows Job Object and POSIX process groups (macOS, the BSDs,
the Linux process-group fallback) report Unknown as a measured result, not
an unfinished one, so runtime limit attribution will not become available on
Windows despite it being a first-class platform for this CLI.