c8ctl-plugin-nano
v1.70.3
Published
c8ctl plugin to start, inspect, and stop a local Nano BPM (nanobpmn) cluster
Maintainers
Readme
c8ctl-plugin-nano
A c8ctl plugin that starts, inspects, and
stops a local Nano BPM (nanobpmn) cluster.
About Nano BPM
A Rust research engine exploring high-performance BPMN execution and Camunda 8 compatibility.
Nano BPM (nanobpmn) is a single self-contained binary that runs BPMN processes
behind a Camunda 8-compatible v2 REST API. It embeds a deterministic,
event-sourced BPMN engine (engine-core), an append-only journal for crash
durability, an SQLite-backed read model, optional multi-node Raft replication,
and a built-in web console — all in one executable with no runtime dependencies.
It is an advanced research prototype: a place to explore what a faster, smaller, faster-to-iterate-on process engine can do, while staying API-compatible with existing Camunda 8 clients and tooling. This plugin is the easiest way to run and manage it — single node or a whole cluster — shipping a prebuilt binary for your platform so there is nothing to compile.
It adds a single nano command:
c8ctl nano start|status|stop|restart|logs|pause|resume|clean|set|config|update
c8ctl nano hire|assign|work # hire/assign manage agent profiles; work runs one as a Nano job workernano start N spawns N nanobpmn node processes wired to talk to each other
on localhost (round-robin partition ownership), tracks them in a state file,
and waits until every node is reachable.
Installation
This is a plugin for the Camunda 8 CLI
(c8ctl). Install the CLI, then load the plugin from npm:
# 1. Install the Camunda 8 CLI (once); requires Node.js 18+
npm install -g @camunda8/cli
# 2. Load this plugin from the npm registry
c8ctl load plugin c8ctl-plugin-nano
# 3. Verify it's available
c8ctl nano --helpThe prebuilt Nano BPM server binary for your platform is pulled in automatically
as an npm optionalDependency, so there is nothing to compile. To pull a newer
release later, run c8ctl nano update (see
Updating to a new release).
Loading from a local checkout instead? Use
c8ctl load plugin --from file:///path/to/c8ctl-nano.
Usage
# Start a single-node cluster (port 8080)
c8ctl nano start
# Start a 3-node cluster (ports 8080, 8081, 8082)
c8ctl nano start 3
# Start a 3-node Raft-replicated cluster (RF=3 enables Raft automatically)
c8ctl nano start 3 --rf 3
# Choose a different base port (nodes -> 9000, 9001, 9002)
c8ctl nano start 3 --port 9000
# Override the partition count (default = node count)
c8ctl nano start 3 --partitions 6
# Show cluster status and per-node health
c8ctl nano status
# Inspect a cluster c8ctl did NOT start (queries /v2/topology on the given port)
c8ctl nano status --port 8080
# Tail a node's log (-f / --follow to stream). Node ids are 0-indexed,
# so a single-node cluster is node 0.
c8ctl nano logs 0 --follow
# Simulate a node failing (freeze it) and recovering (resume it)
c8ctl nano pause 0
c8ctl nano resume 0
# Stop the cluster (engine data is retained)
c8ctl nano stop
# Stop the cluster and delete per-node engine data
c8ctl nano stop --purge
# Stop then start fresh
c8ctl nano restart 3
# Restart from a clean slate (delete engine data, keep models & workers)
c8ctl nano restart --purge
# Wipe journal/data + logs from disk (keeps models & workers)
c8ctl nano clean
# Persist settings
c8ctl nano set bin ~/workspace/nanobpmn/server/target/release/nanobpm-gateway-rest-server
c8ctl nano set model-dir ~/bpmn-workspace
# Clear a custom setting (back to the managed/release binary, or default workspace)
c8ctl nano unset bin
c8ctl nano unset model-dir
# Show current configuration and on-disk locations
c8ctl nano configPersistent assets: models & workers
Nano BPM separates persistent authoring assets (BPMN models and worker code) from ephemeral engine data (journal, snapshots, variable spill):
- Workspace (
NANOBPMN_WORKSPACE_DIR) — holdsmodels/andworkers/. It is the authoring source of truth, shared by every node, and is never deleted bystoporclean. - Engine data (
NANOBPMN_DATA_DIR) — per-node journal/snapshots/spill. Ephemeral; removed bystop --purgeandclean.
The plugin points every node at one shared workspace so a model deployed once is
visible cluster-wide and survives restarts. By default it lives at
<state home>/workspace; change it with:
c8ctl nano set model-dir ~/bpmn-workspaceThis creates ~/bpmn-workspace/models/ and ~/bpmn-workspace/workers/. Restart a
running cluster for a workspace change to take effect.
CLI agent workers: hire / work
Beyond BPMN service-task workers (code in the workspace workers/ dir), the
plugin can turn an interactive CLI agent harness (Copilot CLI, Claude CLI,
pi, "little coder", …) into a Nano job worker.
hire persists an agent profile — a name, a rank
(principal|senior|junior|decider), the command that starts the CLI, a
model name, and a list of capabilities:
# Interactive
c8ctl nano hire
# Or non-interactively
c8ctl nano hire --name reviewer --rank senior --command copilot \
--model gpt-5 --capabilities code-review,testing
# Give the harness command-line switches (e.g. run copilot with --allow-all)
c8ctl nano hire --name coder --rank senior --command copilot --arg --allow-all
# Opt a role into the ACP harness mode (JSON-RPC/stdio) — see "ACP harness mode" below
c8ctl nano hire --name coder --rank senior --command copilot --protocol acp
# List profiles
c8ctl nano hire --listassign <name> [capabilities...] grants new capabilities (roles) to an
existing hire without re-running hire. Capabilities are added to (unioned
with) the profile's current set — assign never removes a role — and the
updated job-type matrix is printed. Restart the profile's workers so they pick
up the new job types:
# Give an existing reviewer two more capabilities
c8ctl nano assign reviewer triage refactoring
# --capabilities works too (comma-separated), equivalent to the positionals above
c8ctl nano assign reviewer --capabilities triage,refactoring
# then restart its workers to service the new job types
c8ctl nano work reviewerwork <name> loads the profile, connects with the c8ctl SDK client, and
registers one job worker per token in the rank × capability matrix, then
polls for work in the foreground until Ctrl-C. For rank senior and
capabilities code-review, testing the matrix is:
| Token | Meaning |
| --- | --- |
| senior | rank alone |
| senior:code-review | rank + one capability (spread) |
| senior:testing | rank + one capability (spread) |
| senior:code-review+testing | rank + all capabilities, sorted (combined) |
so a BPMN service task can target a worker at any granularity by setting its job type to the matching token.
Choosing which engine a worker connects to. By default work (and
supervisor start --worker) connects to your active c8ctl session profile
(c8ctl use profile <name>). To point a single invocation at a different
cluster without switching the active session, pass c8ctl's global
--profile <name> — it is honoured the same way it is for core c8ctl commands:
c8ctl nano work fleet --profile nano-validate # this worker only → nano-validate's engine
c8ctl nano supervisor start --worker fleet --profile nano-validate # the whole fleet → nano-validateThe named profile wins over the active session for that run only; a supervised
fleet is pinned to it (the daemon forwards it to every worker it spawns), and
supervisor status reports the matching ENGINE.
To also service a job type the matrix can't express — for example a code-first
@nanobpm/workflow flow whose
external task type is <flowId>:<taskName>, or any bespoke token — add one or
more --job-type <token> flags. They are serviced in addition to the
rank×capability matrix, so a single hired reviewer can drive both a model-first
senior:pr-review task and a code-first flow's task without re-hiring:
c8ctl nano work reviewer --job-type convergence-loop:review-round
c8ctl nano work reviewer --job-type senior:pr-review --job-type senior:triagec8ctl nano work reviewer # poll for work until Ctrl-C
c8ctl nano work reviewer --recovery-window 300000
c8ctl nano work reviewer --name reviewer-eu # name this worker (else auto ‹host›-‹profile›-‹random›)Zero-config enrolment: --auto (serve every deployed agent job type)
--auto is the "Borland Delphi on your machine" onboarding ramp: skip the
capability wiring entirely and subscribe the worker to all deployed agent
job types, read straight from the engine.
c8ctl nano work coder --auto # serve every agent job type on the engine
c8ctl nano work coder --auto --auto-scope my-app # scope to one app/network (process-id prefix)How it works and why it needs no wiring:
- Engine-read demand. The worker already holds the engine (C8 REST) endpoint
from its c8ctl profile.
--autoenumerates the deployed process definitions (process-definitions/search→/{key}/xml) and scans their<zeebe:taskDefinition type>leaves for the job types the engine matches. The engine is the guaranteed shared rendezvous: if a worker can execute an app's agent jobs at all, it and the app are already on the same engine, so what agent job types exist is answerable from that engine alone — no cross-machine app discovery, no app enrol endpoint, no channel connection. - Agent-task marker filter. Not every service task is an agent task —
connectors and record-keepers (e.g.
pr.record-plan) are plain workers.--autokeeps only leaves whose service task carries the single-convention external-agent marker<zeebe:agentDefinition agentType="external" />(the same marker every external agent task already declares). A task can opt out of--autowith<zeebe:property name="io.nanobpm.agentTask.autoSubscribe" value="false" />— it is then served only by explicit--job-type/profile subscription. - One poller per agent job type, reconciled on change. It opens one poller per agent job type and re-reads the engine periodically, adding pollers for newly deployed agent processes and draining pollers for undeployed ones — the same in-place reconcile the profile watch uses, sourced from the engine instead of the profile.
- Raw job-type grammar. The job type the engine matches (
senior:plan) is advertised verbatim — colon-named types are not forced through any dot-grammar.
--auto is mutually exclusive with capability-resolved serving: it bypasses
the rank×capability matrix (any --job-type extras are still added). The prompt
a worker needs already rides the job header (io.nanobpm.agentTask.task.prompt)
plus per-instance context, so a generic --auto worker needs no baked
specialisation.
Trust. Engine-read has no capability gate — an
--autoworker will serve any deployed agent job on its engine. That is the accepted trade for the local/zero-config target; capability-gated serving is the specialised (capability-declared) enrolment path. Use--auto-scope <process-id | prefix>to narrow the blast radius to one app/network.
Dual-stack / IPv6-first engine hosts. The engine client races IPv4 and IPv6 (Happy-Eyeballs, RFC 8305) at connect time, so a worker whose engine host resolves to an unreachable IPv6 address first — common with macOS mDNS (
merlin.local → fe80::… (dead) → 192.168.x.x), a stray/misorderedAAAA, or an IPv6-advertised host — transparently falls back to IPv4 instead of failing the activation /--autoengine read (fetch failed/write EPIPE/UND_ERR_CONNECT_TIMEOUT). Without this, the--autoautoscaler would read0job types and silently scale the fleet to zero (workers vanish from the Nano console) even thoughcurl/fetchreach the same host fine (jwulf/c8ctl-plugin-nano#139). No configuration is needed; if you still want to pin a family you can setNODE_OPTIONS=--dns-result-order=ipv4firstor point the engine URL at the IPv4 literal.
The optional --name sets this worker's name — the workerName it
registers under at the broker (‹name›:‹jobType›) and how it shows up in
supervisor status/logs. Omit it and a distinct ‹host›-‹profile›-‹random›
name is generated, so two work reviewer processes never collide at the
broker. (--name names the worker; the profile to run is always the
positional argument.)
Live agent visibility: the /agentic channel + live terminals
A running worker can appear live on the Workforce visibility page and stream
its agents' terminals to an operator's cockpit. This rides the app's agentic
channel (ADR 0056), served same-port on the app's own HTTP base URL at path
/agentic — not a sidecar, so there's no extra port to open.
Connecting — on by default (LAN-first). Nano is designed to run on a trusted
network, so visibility is on by default. Run a worker against a Nano app and it
appears live with zero configuration — it joins the channel with a well-known
token in LOCAL mode (no secret). The worker presents this token to whatever
NANO_AGENTIC_URL you point it at; the hub honours the well-known LOCAL token from
any origin (matching the open trusted-LAN posture of the engine itself), so a
worker on another box on the LAN appears live too — exposure is governed by the
server's bind address, not by a shared secret:
# LOCAL mode (default): appears live with no secrets
export NANO_AGENTIC_URL=http://localhost:8080 # app base URL; channel is served at /agentic
c8ctl nano work reviewer
# agentic channel (local): announcing presence as ‹worker› on ws://localhost:8080/agenticZero-config hub auto-discovery. You usually don't even set NANO_AGENTIC_URL.
When nwf runs embedded, the engine (:8080) serves the console but the
/agentic channel is served by the embedded app on its own port (e.g.
:3000); the engine's console proxy deliberately refuses WebSocket upgrades
(nanobpmn ADR 0057 §3 → 501), so the channel is unreachable via the engine URL.
With no agentic target configured, work therefore auto-discovers it: it
reads GET <engine>/console/api/projects and, for each running app that
advertises an agentic UI port (appUi.enabled === true and appUi.port), probes
that app's direct ws://<engine-host>:<port>/agentic. Discovery runs against
the engine's own host — a local engine keeps probing 127.0.0.1, while a
remote/LAN engine (e.g. merlin.local:8080) steers the probe back at itself
(merlin.local:<port>), never at the worker's own loopback services. It is
time-bounded (≤2s) and never meaningfully delays job polling. The discovered
host and port are printed for debugging. (An IPv6 literal engine host is bracketed
in the URL authority, e.g. ws://[2001:db8::1]:3000/agentic.)
- Exactly one app → the worker connects directly to
ws://<engine-host>:<appUi.port>/agentic(bypassing the WS-incapable console proxy) and appears live with zero configuration — including cross-machine on a trusted LAN. - Two or more apps → the worker does not guess: it prints an
ambiguouserror naming each discoveredproject → :portand stops. Pin the one you want and re-run:export NANO_AGENTIC_URL=http://<engine-host>:<port>(or persistagenticUrl). - Nothing discoverable (e.g. pointed at Camunda, or an API-only gateway) → the
worker prints a one-line advisory naming
NANO_AGENTIC_URLand continues doing real work with the channel simply absent — discovery never fails the worker's actual job.
Setting NANO_AGENTIC_URL (or persisted agenticUrl) skips discovery and is
used verbatim — so an explicit target always wins, and it's also how you
disambiguate when several apps are running. NANO_AGENTIC=off disables the
channel entirely and attempts no discovery.
Seeing it work — supervisor status. Supervised workers report both the
engine they poll and their agentic-channel state to c8ctl nano supervisor
status (and the interactive console), so you don't have to read raw worker logs
to tell whether presence actually reached the Workforce hub. The table gains an
ENGINE column (the engine's host:port) and an AGENTIC column whose value is
one of:
| AGENTIC | meaning |
| --- | --- |
| starting | transient: the worker just spawned and hasn't resolved its channel target yet (pre-connecting) |
| connected | presence is live on the hub — you should see this worker in the Cockpit |
| connecting | resolved a hub, socket not open yet (or the hub is unreachable) |
| disconnected | an established channel dropped (hub restart/outage) — it auto-reconnects, and a worker-side liveness watchdog force-re-discovers the hub if it stays down (see below); also set if the channel failed to start (bad URL/refused socket), which is not auto-recovered by the watchdog (it only guards a channel that has connected) — fix the target and restart the worker |
| advisory | nothing discoverable at the engine — not in the Cockpit; set NANO_AGENTIC_URL |
| off | visibility disabled (NANO_AGENTIC=off) |
| ? | a live worker not yet reporting, or an older build predating these fields |
If workers show advisory (or stay connecting) while jobs still run, that's the
"connected to the engine but empty Cockpit" case: point them at the app with
export NANO_AGENTIC_URL=http://<engine-host>:<appUi.port> (e.g. :3000).
settlement-pending in the JOB column. A worker's JOB cell normally
shows the job key it is running (or idle). If an agent finished its external
side effect but the worker then lost its activation lease around
completeJob/failJob — so the fenced settle failed and the engine may still
project the job as CREATED — the cell instead shows
<jobKey> settlement-pending (<age>), where <age> is how long settlement has
been pending (measured from the failed settle, not the job's start). This is
observational only: recovery is
already handled by the lease-fence + transcript-resume path, and such a job is
not counted as busy/in-flight (it doesn't hold up a drain). The state lets you
see a job caught between side effect and settlement rather than inferring it
from a silently-idle worker plus a stuck CREATED job; it clears when the job is
re-activated on this worker, self-expires after a bounded TTL (so a re-activation
that lands on a different worker can't leave a ghost lingering here), or the
worker restarts.
Liveness watchdog (auto-recovery from a wedged channel). If the nano server
restarts, crashes, or a network partition drops the connection without a clean
close (a half-open socket), a worker's channel client can sit disconnected
forever — the worker vanishes from the Nano Workers view / Cockpit and, before
this, only a supervisor restart brought it back. Each worker now runs a
belt-and-suspenders watchdog: once a channel that had connected stays down past a
threshold (the client library's own reconnect never recovered it), the worker
tears the wedged channel down and re-runs full hub discovery + reopen — no restart
needed. The thresholds are tunable via env (sensible defaults; you rarely need
these):
export NANO_AGENTIC_STALE_MS=60000 # force re-discovery if a drop hasn't recovered within 60s (default)
export NANO_AGENTIC_WATCHDOG_MS=15000 # how often the watchdog checks channel liveness (default)Lossy-link hardening (reconnect churn that never re-lands presence). A
distinct failure mode shows up on a lossy/roaming WiFi (or NAT) link: the drop
is detected (an abnormal closure, code 1006), the client does reconnect and
re-announce — yet presence never re-lands on the hub, so the worker stays absent
even though its log shows it re-announcing. The sustained-drop watchdog above does
not catch this, because each brief reconnect keeps resetting its drop clock. Two
extra safeguards close the gap:
- Presence-keyed watchdog trigger. A reconnect only counts as recovered once
the socket holds for a short grace window (so a
1006blip that immediately re-drops does not mask an unrecovered presence). When presence has not been confirmed within a threshold — regardless of the reconnect flapping — the watchdog forces the same full re-discovery + reopen. - Jittered reconnect backoff. Reconnect attempts are spread with equal-jitter backoff so a fleet dropped on the same link does not reconnect in lockstep and re-congest it.
export NANO_AGENTIC_PRESENCE_STALE_MS=60000 # force re-discovery if presence isn't re-confirmed within 60s (default)
export NANO_AGENTIC_PRESENCE_GRACE_MS=5000 # how long a reconnect must hold before presence counts as landed (default)Secure mode (opt-in). For a deployment where you want the visibility channel
authenticated (rather than open on the LAN), start the server and every worker
box with the same NANO_AGENTIC_SECRET — same env-var name, same value on both
sides (Tab A → Slot A). The worker presents it as its identity token and the hub
verifies it against its own NANO_AGENTIC_SECRET. Setting it switches the worker
into SECURE mode:
# SECURE mode: same NANO_AGENTIC_SECRET on the server and every worker box
export NANO_AGENTIC_URL=http://localhost:8080
export NANO_AGENTIC_SECRET=<shared-secret> # must equal the server's NANO_AGENTIC_SECRET
c8ctl nano work reviewer
# agentic channel (secure): announcing presence as ‹worker› on ws://localhost:8080/agenticThe shared secret can also be persisted (via config) as agenticSecret, so it
need not be exported into the environment on every run; env still wins over the
persisted value.
The legacy NANO_AGENTIC_TOKEN env var (and persisted agenticToken) is still
accepted as a deprecated alias for the shared secret. The capability
credential (NANO_AGENTIC_CREDENTIAL) is no longer required — it was removed from
the hub contract (it was accept-any, pure friction). It remains optional and is
forwarded only if still configured, for forward-compatibility with a future per-peer
capability verifier.
To opt out entirely, set NANO_AGENTIC=off (or persisted agentic: false) — the
worker then runs with no visibility, no relay, nothing else changed. In secure
mode a matching secret connects; a wrong secret is rejected (unauthorized).
How presence appears. On connect the worker announces its identity, its
host, and the set of jobKeys it is currently running, then heartbeats to
stay live and re-announces after a reconnect so its row survives a hub
restart. When work stops it deregisters cleanly, so the worker disappears
from the visibility page on exit.
Opting a role into a live terminal (PTY vs pipe). Each role chooses whether
its agent harness runs on a full PTY (a real terminal — streamed on the relay
lane and steerable: an operator's keystrokes reach the running agent) or a
plain pipe (streamed, not interactive). It's a per-role opt-in, set on
the hire profile and defaulting to pipe:
# Hire a role whose harness runs on a full, steerable PTY
c8ctl nano hire --name coder --rank senior --command copilot --terminal pty
# Override the mode for a one-off worker without re-hiring
NANO_AGENTIC_TERMINAL=pipe c8ctl nano work coderA PTY needs the optional native node-pty dependency; when it is
unavailable (not installed, or Windows) a pty role gracefully falls
back to a pipe that still relays. (If node-pty is present but a PTY can't be
spawned at runtime, that job fails rather than falling back.) Each job's terminal
streams on its own relay stream
named job:‹jobKey›, so output and steer-in are routed from the jobKey alone.
Surviving a hub outage. A worker that starts before the app, or survives
a hub restart, buffers its outbound frames in a bounded local ring and
drains them in order on reconnect (no loss or reorder within the bound). The
bound is operator-tunable for a long expected outage; raise it with
NANO_AGENTIC_BUFFER_CAPACITY (frames). When the bound is hit the worker warns
rather than silently shedding.
ACP harness mode (opt-in)
Alongside the pipe/pty terminal choice above, a role can opt into driving its
harness over the Agent Client Protocol (ACP)
— JSON-RPC 2.0 over stdio — instead of the default stdin/scrape pipe. ACP is
additive, not a switch: pipe stays the default floor, so every CLI harness
(and non-agent record-keepers) keeps working unchanged, and YOLO stays the
default via auto-approve. You turn ACP on per role, exactly like --terminal:
# Drive a role's harness over ACP (JSON-RPC/stdio) instead of the pipe
c8ctl nano hire --name coder --rank senior --command copilot --protocol acp
# Override protocol/permission for a one-off worker without re-hiring
NANO_AGENTIC_PROTOCOL=pipe c8ctl nano work coder
NANO_AGENTIC_PERMISSION=escalate c8ctl nano work coder--protocol pipe|acp(defaultpipe) selects the harness protocol.NANO_AGENTIC_PROTOCOLoverrides it at work time (mirroringNANO_AGENTIC_TERMINAL).--permission yolo|escalate|filter(defaultyolo) selects the ACP permission policy.NANO_AGENTIC_PERMISSIONoverrides it at work time.
Protocol must match the command. The command and the protocol have to agree:
a command that runs the harness in ACP mode (an --acp / acp token or a
*-acp adapter binary) hired with protocol: pipe would have plain JSON piped
to an ACP-speaking binary that rejects it and exits 0 empty — and the worker
would silently complete every job it took with no work done (issue #275). To
prevent that, hire refuses an ACP-mode command on protocol: pipe, and
work refuses to start a worker with the same mismatch (a pre-existing
or hand-edited profile). Re-hire with --protocol acp (or drop the ACP selector
from the command). protocol: acp with no selector on the command is fine — the
worker appends --acp at spawn time.
Container workers are pipe-only. A --sandbox docker|podman worker runs the
harness over the pipe transport regardless of --protocol, so an ACP-mode
command is a mismatch there even with --protocol acp (the container would
still pipe plain JSON to an ACP-speaking harness). Both hire and work
evaluate the effective protocol, so such a container profile is rejected at
hire time and refused at work time — drop the ACP selector from the command for a
container role.
An empty job is failed, not completed. If a harness exits 0 but produced nothing — no result vars, no output, no transcript turns, no commits and no push — the worker fails the job (preserving its retries, so it reactivates and surfaces as an incident) instead of completing it. Completing an empty job would silently drop whatever it carried (an escalation answer, a review verdict) because downstream gateways see no status and fall through to their default. A run with any evidence of real work completes exactly as before.
What ACP unlocks. Because the harness speaks a structured protocol rather than
a scraped terminal, the ACP path gives you a structured turn/tool event stream
(today serialized to text chunks on the relay lane — a minimal mode, not yet
typed turn/tool envelopes), native permission handling, and PTY-free
steering — an operator's
steer text is delivered as a session/prompt and an interrupt as a
session/cancel, with no keystroke injection. The ACP path therefore does not
need the optional native node-pty dependency at all.
Permission policies — mind the status. Only yolo is enforced today:
yolo(default, the only enforced policy today) — auto-allows every permission request the agent raises: full speed, no human in the loop. This is the same auto-approve posture the pipe/PTY paths already run with.escalateandfilterare RESERVED / not-yet-active in this build. They are accepted and persisted for forward-compatibility, but they are not yet enforced: at work time they fall back to a safe interim policy (currently auto-allow, likeyolo) and the CLI emits a warning so an operator is never misled into thinking destructive operations are gated. Their intended future behavior —escalateblocking a permission request until a human answers it, andfilterauto-allowing reads/edits while escalating destructive operations — is not available yet; it lands once the companion permission-event + escalation bridge (nanobpm/nano-workforce#559) ships.
Per-CLI hire examples. Some CLIs speak ACP natively; others ride a thin
adapter binary. In every case the profile's command (plus any --args) assembles
the ACP invocation; a default --acp switch is appended only when the assembled
command line doesn't already select ACP, so a native/adapter invocation is never
doubled. All of these run with the enforced default yolo policy:
# Copilot CLI — native ACP (assembles `copilot --acp`)
c8ctl nano hire --name coder --rank senior --command copilot --protocol acp --permission yolo
# OpenCode — native ACP server (assembles `opencode acp`)
c8ctl nano hire --name coder --rank senior --command opencode --arg acp --protocol acp --permission yolo
# Qwen — native ACP behind a HIDDEN flag (`qwen --experimental-acp`; present in
# the shipped cli.js but not in `qwen --help`). Detection recognises the
# switch, so no second `--acp` is appended.
c8ctl nano hire --name coder --rank senior --command qwen --arg --experimental-acp --protocol acp --permission yolo
# Claude Code — via the `claude-code-acp` adapter (npm @zed-industries/claude-code-acp)
c8ctl nano hire --name coder --rank senior --command claude-code-acp --protocol acp --permission yolo
# Pi — via the `pi-acp` adapter
c8ctl nano hire --name coder --rank senior --command pi-acp --protocol acp --permission yolo
--permission yolois the default, so you can omit it. You may hire with--permission escalateor--permission filtertoday — the value is persisted — but it is reserved / not-yet-active (pending nanobpm/nano-workforce#559) and currently behaves as the safe interim policy with a warning, so do not rely on it to gate destructive operations yet.
Non-goals. ACP does not replace anything: the pipe/PTY surface stays the default floor and every existing harness keeps working unchanged (ACP is enforced on the host executor; container sandboxes remain pipe-only for now). There is no change to the Camunda-8 worker⇄engine job protocol — ACP governs only how a worker drives its local agent harness, not how it talks to the engine.
Selecting the model under ACP. --model only exports AGENT_MODEL into the
harness environment; it is not injected into the harness argv. And structured
--arg values are POSIX single-quoted by the plugin, so a literal $AGENT_MODEL
in an --arg does not expand (only the --command string is
shell-interpolated, since the harness is spawned with shell: true). The
deterministic, supported way to pin a model for an ACP hire is therefore to
bake it into --command, where the shell does interpolate:
# The --command string IS shell-interpolated, so $AGENT_MODEL (or a literal name) works here:
c8ctl nano hire --name coder --rank senior --command 'copilot --acp --model gpt-5.4' --protocol acp(Equivalently, set a harness-specific env var with --env when the harness reads
the model from its environment.) This is the seam the nano-workforce install
script uses to route a detected model to each harness deterministically.
Live profile reload (no restart on assign)
A running c8ctl nano work <name> watches the profile it is servicing. When
you extend or reduce that profile's capabilities in another terminal —
c8ctl nano assign reviewer fix-ci # add a capability to the live profile— the supervisor reconciles its pollers in place: it starts pollers for the
newly added rank×capability job types and gracefully drains the pollers for
removed types — best-effort: each draining poller is given a bounded grace
window (STOP_GRACE_MS) for its in-flight jobs to finish before
it is stopped, so long-running work exceeding that window may still be
interrupted. Unchanged job types keep running undisturbed, so there is no need
to stop and restart the worker.
Only job types (rank + capabilities, plus any --job-type extras) reconcile
live. Changes to the profile's command, model, sandbox/image, or env
still require a restart to take effect. If the profile is deleted or the config
file is mid-write when the reload fires, the running workers are kept (never
torn down) and a warning is logged.
Each activated job runs the profile's command once (one-shot): the job is serialized to JSON and piped to the CLI's stdin —
{ "jobKey": "...", "jobType": "senior:code-review", "processInstanceKey": "...",
"prompt": "<variables.prompt ?? variables.task>", "variables": {},
"profile": { "name": "reviewer", "rank": "senior", "model": "gpt-5",
"capabilities": ["code-review", "testing"] } }and the profile/model are also exported as AGENT_PROFILE, AGENT_RANK,
AGENT_MODEL, AGENT_CAPABILITIES, AGENT_JOB_TYPE env vars. On exit 0 the
job is completed with { output: <stdout>, exitCode: 0 } (captured output is
capped at 1 MiB, with a truncated flag when exceeded); any other exit fails
the job with a decremented retry count. Profiles are stored in the plugin's
config.json (see c8ctl nano config).
Self-managing activation lock (no hardcoded job timeout). An agent job's duration is unpredictable, so the worker does not ask you to pick a fixed timeout up front. It keeps the broker's job-activation lock a bounded
--recovery-window(default300000ms) ahead of now, refreshing it every ~1/3 of that window for as long as the harness is alive and producing output. Consequences:
- Long jobs never lose their lock. A run that takes hours keeps going — the lock is continuously extended, so the broker never re-activates the job while you're still working on it (which would start a second agent and get the stale
complete/failrejected with a 409 "job cannot be failed in the current state").- Fast recovery on death. Because each refresh sets the deadline to now+window (the
UpdateJobTimeoutcontract is a duration-from-now, not a cumulative delta), the moment the worker stops refreshing — the process dies, the node is lost, the harness is idle-killed, or it hits the hard cap — the lock lapses within one--recovery-windowand the broker reclaims the job. This is deliberately optimised for quick reclaim, not for holding a stale lock.--idle-timeout(default300000ms) is the liveness gate — but it is progress-aware, not a blunt silence timer. When the window elapses the worker probes the harness's process subtree: if a descendant is still doing work (aggregate CPU time across the tree is advancing — e.g. a long, quiet./mvnw -qbuild that emits nothing for minutes) the agent is treated as live and the kill is deferred. Only a genuinely quiescent tree — no live descendant, or descendants burning no CPU across the window — is killed as wedged, so a hung agent still can't hold a job forever while a silent-but-busy one keeps the cheapest, zero-token build pattern. Reclaim timing: because a single CPU sample can't reveal progress, the first elapse with live descendants only baselines and re-probes — the kill-vs-defer decision then lands on the following--recovery-window, so a quiescent-but-live subtree ages out roughly--idle-timeoutplus one recovery window after the last output, not the instant the idle window first elapses. (A tree with no live descendants is killed immediately when the idle window elapses.) A task can widen this per-task class via the envelope'stask.idleTimeoutMs/task.recoveryWindowMs(see the task envelope below) instead of a global flag change.--job-timeoutis now an optional absolute hard cap on total harness runtime (default0= unlimited), for when you want a ceiling regardless of output.--lock-graceis deprecated and ignored — the lock is auto-managed.
Long-poll window.
--poll-timeout(default30000ms) is how long the broker holds eachactivateJobsrequest open waiting for work before returning empty. A longer window keeps an idle worker on one connection for that whole window instead of reconnecting every few seconds — cutting the number of connection establishments, and thus the chances of hitting a transient connect error (ECONNREFUSED/ connect-timeout) on a flaky link. It maps straight to the SDK'spollTimeoutMs→ the broker'srequestTimeout:0selects the broker's own default (~5s) and a negative value returns immediately when no job is available.
Trust boundary. The profile
commandis run through a shell so you can write a full invocation (args, pipes, multi-word commands). It is operator-authored — only what you put in your ownconfig.jsonis shell-interpreted. Untrusted job data reaches the harness solely as stdin JSON andAGENT_*env vars, never interpolated into the command line, so process variables cannot inject shell commands.
Resume on re-activation — a continuation, not a duplicate. When the broker re-activates an agent job (its lock lapsed after a worker death, node loss, or idle-kill — see the activation-lock note above), the new worker does not cold-rerun from scratch. Instead it resumes from the previous agent's state, so at-least-once delivery stops being harmful — a re-activation becomes a continuation rather than a duplicate of the agent's external side effects. On re-activation, before spawning the harness, the worker:
- Fetches the prior engine-native
AgentInstancetranscript for thiselementInstanceKey(the durable, append-onlyAgentHistoryminted while the previous instance ran). Because it is engine-backed it is cross-machine — the resumed agent can pick up on a completely different worker box, unlike a local same-host journal.- Seeds the harness prompt with a rendered continuation of that transcript, so the new agent continues from where the prior one left off and does not repeat already-completed steps or re-perform side effects (comments, pushes, PRs).
Recovery scope (what survives a re-activation):
- Committed work is durable only when this activation re-checks-out the exact branch the prior run pushed onto — and only when the envelope alone can prove that. One envelope shape qualifies:
repository.refnames a stable non-base branch andbranch.createnames that same branch (create === ref). The clone lands the workspace onref(prior commits present) andprovisionRepo's honoredgit checkout -B <create>is a no-op that keeps it there and pushes it back, so the resumed agent can inspect it for the last pushed commit (git log/ the open PR for what landed). The ref-only shape (nobranch.create, the PR-based review/fix-ci/rebase shape) is deliberately transcript-only even thoughprovisionRepooften does push the checked-out PR head: that decision hinges onprovisionReporesolving the remote default (a checked-out branch that is the default is base-like and gets fallback-branched, and an unresolvable default fails closed), and resume gating runs before the clone with no network, so it can never prove a barerefis not the repo's (possibly custom, e.g.trunk) default — it fails closed to transcript-only rather than over-claim recovery for a fallback-branched run. Every other shape is likewise not durable and classified transcript-only: abranch.createthat differs fromrefdoescheckout -B <create>off the freshly re-cloned base and never fetches the existing remote<create>; and a base-like ref/create (including a conventionalmain/masterhead, or a ref-only job with no configured base), a bare-URL / base-only clone,branch.push=false, or arepository.sha-detached checkout likewise recover nothing. For every transcript-only run the resume preamble points at the transcript (VERIFY-first) rather than promising a branch to check out. (The #270 provisioning fix — a ref-only PR head is pushed to, not diverted onto a per-run fallback — is unaffected; only the resume prompt wording is conservative here.)- Uncommitted working-tree changes are not recovered by default — the throwaway workspace does not persist across activations, so any delta the previous run had not committed is lost and the resumed agent re-derives it. The resume preamble states this explicitly. WIP checkpoints (below, on by default for pushing jobs) recover them.
Resume is best-effort and gated to external agent jobs (the only ones with a durable transcript). A read failure, an SDK without an AgentInstance read surface, no prior work to continue, or the
NANO_AGENT_RESUME=offkill switch all fall through to the legacy cold rerun with no behaviour change.Agent plans. When the harness publishes an ACP
plan(e.g. rusty-harness'splan_*tools), each distinct plan is recorded in the AgentInstance history as an assistant checklist turn. A resumed run gets the latest plan restated in the resume prompt (VERIFY-first, like the transcript), and a harness that advertisesagentCapabilities._meta.planSeedalso receives it insession/new_meta.planso it can keep working the same plan.NANO_AGENT_PLAN=offdisables both.Durable transcripts need an ACP harness and a recent host c8ctl. The engine-native
AgentInstancetranscript is built from the harness's ACPsession/updatestream, so only a harness running in ACP mode records a body — a non-ACP harness (e.g.copilot --allow-allwithout--acp) emits no update stream, so its instance carries just the openingCONFIGURATIONturn. Recording also needs the host c8ctl's SDK to expose the AgentInstance verbs (createAgentInstance/updateAgentInstance):@camunda8/climust bundle@camunda8/orchestration-cluster-api>= 10.0.0-alpha.45. When it doesn't, every job silently records nothing — sonano worknow fails loudly at startup on an agentic profile, naming the host SDK versions and the fix (the plugin version is irrelevant; upgrade c8ctl). SetNANO_AGENT_INSTANCE=offto disable transcripts intentionally. Note that token metrics (inputTokens/outputTokens) are 0 for ACP-mode runs: ACP reports context-window occupancy, not per-turn token counts (a protocol limitation), so the producer never fabricates them.WIP checkpoints (
NANO_AGENT_CHECKPOINT=auto|on|off, defaultauto). The worker periodically snapshots the agent's workspace — uncommitted changes and unpushed local commits — torefs/nano/wip/<elementInstanceKey>on the job's remote, without touching the agent's HEAD, index or branch (a "shadow commit" built through a temporary index).autoenables this only for jobs that already publish their work: a provisioned, authenticated clone on a working branch withbranch.pushnotfalse(read-only, anonymous, detached and repo-less jobs are skipped).onforces it for any provisioned job;offdisables it.
- What is left out:
.gitignored paths; secret-looking paths (.env*,*.pem,id_*,.npmrc, …); files overNANO_AGENT_CHECKPOINT_MAX_FILE_BYTES(5 MiB); and any changed file whose content contains one of the job's own injected secret values, the repo token, or a common credential format (private keys, GitHub / AWS / Slack / OpenAI-style tokens).- When: after a completed ACP tool call or plan change (at most one per
NANO_AGENT_CHECKPOINT_MIN_INTERVAL_MS, default 60s), on a safety-net timer (NANO_AGENT_CHECKPOINT_INTERVAL_MS, default 300s,0disables — the only trigger for pipe/PTY harnesses), and once more when a run fails or is force-stopped. Each checkpoint also sets a best-effort localagentCheckpoint {ref, sha, head, at, reason, branch}variable on the element instance.- Restore: a later activation of the same element instance restores the snapshot into the fresh clone as uncommitted changes (recovering local commits too) and tells the agent to inspect
git status/git difffirst.- Cleanup: the ref is deleted once the engine acknowledges
job.completefor a run whose branch push succeeded. Until that ack a lost lease or failed settle redelivers the job, so the ref stays restorable. Orphans (cancelled instances, failed acks, exhausted retries) are reclaimed by a background sweep (at most hourly per remote per worker) that deletes WIP refs older thanNANO_AGENT_CHECKPOINT_TTL_MS(7 days), or older than 1h whose element instance the engine reportsCOMPLETED/TERMINATED.- Safety: pushes use
--force-with-lease, so a superseded worker can't clobber a newer run's checkpoint. A remote policy refusal (repository rules, secret push protection) or a permission error stops checkpointing for that job with a warning; network errors are retried on the next trigger.Caveats: WIP refs are visible to anyone who can read the repo; the worker can't pause the agent, so a snapshot may catch a half-written file. Everything is best-effort — a failure degrades to transcript-only resume.
Agent outcomes. A harness may report an explicit end-of-task outcome in its
session/promptresult as_meta.outcome—{ "status": "completed" | "blocked", "summary": "…" }(rusty-harness'sreport_outcometool). It is recorded inio.nanobpm.agentResult.outcome. If the agent returned no result of its own, ablockedoutcome becomes the result{ "status": "blocked", "summary", "question" }, so the job escalates instead of taking a re-emit nudge turn. A result written to$AGENT_RESULT_FILE(or the::nano:result::sentinel) always wins, and agents that send no outcome are handled exactly as before.
Task envelope, sandboxes & disk hygiene
For agentic jobs (an agent that clones a repo, works a task, pushes a
branch) the job carries a structured task envelope under the reserved
io.nanobpm.agentTask namespace. It is assembled from the job's static
customHeaders (model-authored defaults) deep-merged with per-instance
variables (overrides win), then normalized to schema v1 and included in the
stdin payload as task:
{
"io.nanobpm.agentTask.repository.url": "https://github.com/o/r.git", // header
"io.nanobpm.agentTask.repository.ref": "main",
"io.nanobpm.agentTask.branch.push": "true",
"io.nanobpm.agentTask.task.allowPr": "false"
}Element templates emit flat dotpath header keys (strings); the plugin expands
them into a nested object and coerces "true"/"false" → bool and numeric
strings → int. The normalized shape is
{ schemaVersion, repository{provider,url,ref,sha,depth,singleBranch,filter,baseRef,baseSha,cloneTimeoutMs,submodules,authRef}, branch{base,create,push}, setup{commands,env,secretRefs}, task{prompt,promptFile,maxIterations,timeoutMs,idleTimeoutMs,recoveryWindowMs,allowPr,prBase} }.
Per-task liveness overrides. task.idleTimeoutMs and task.recoveryWindowMs
let a specific task class (e.g. an implementation job on a large monorepo) widen
the idle-liveness / broker-lock recovery window without touching the worker's
global --idle-timeout / --recovery-window flags, and task.timeoutMs raises
the absolute hard cap (--job-timeout). Precedence is envelope override →
worker flag → built-in default, each clamped to a sane maximum so a task can't
request an unbounded window; absent fields leave the worker-flag behaviour
unchanged.
Prompt = base + optional verbatim append. The agent's prompt resolves to
task.prompt (typically a model header filled at deploy time), falling back to a
plain prompt/task variable. Because a header-delivered base prompt can't be
composed in FEEL, a task may supply per-instance context via task.appendPrompt
(reserved) or a plain appendPrompt variable — it is concatenated onto the base
verbatim, with no injected separator (the model's ioMapping owns any leading
separator/preamble), so a null/empty append leaves the base untouched. This lets the
static prompt live in a model header/side-car while the dynamic tail (e.g. plan-revision
feedback, a per-task brief) is built per instance.
Live prompts via linked resources. A service task can declare a Zeebe/Camunda-parity linked resource for its prompt instead of baking it into a model header:
<zeebe:linkedResources>
<zeebe:linkedResource resourceId="plan.md" bindingType="latest" linkName="prompt"/>
</zeebe:linkedResources>At job activation the engine resolves the resourceId to the latest deployed key and
delivers a linkedResources custom header ([{resourceKey, resourceType, linkName}]). The
header carries the key, not the content — the worker fetches the bytes over the broker
REST API (GET /v2/resources/{resourceKey}/content/binary, reusing the same nano endpoint
the worker already talks to; override with NANO_REST_URL/NANO_REST_TOKEN) and uses the
UTF-8 content as the base prompt. The entry whose linkName is prompt wins over the
header-baked task.prompt chain; appendPrompt still composes onto it. Redeploying just
the resource updates the prompt for the next activation — no process redeploy, no
worker restart. Jobs without linkedResources behave exactly as before (fallback chain).
A declared prompt resource that can't be fetched fails the job (retryable provisioning
error) rather than running an agent with an empty prompt, and the resolved resourceKey is
logged and echoed on the output envelope as promptResourceKey for audit (the engine keeps
only latest, so the key is the reproducibility handle).
On completion the plugin writes an output envelope back under
io.nanobpm.agentResult ({schemaVersion, status, sandbox, image, output, truncated, stderrTruncated, exitCode, signal, error, outcome?, promptResourceKey?}). When a repository was
provisioned (below) it also carries {repository, branch, baseSha, headSha, commits[], pushed, pushError?, pushFailed?, strandedCommits?, branchMismatch?, scanError?, gitError?, pr?}. pushFailed is the explicit "push failed" flag — set for a non-zero git push whose result could not be confirmed as landed at the remote (a non-fast-forward rejection, or an auth, hook, or network error), not only a server rejection. pushFailed does not by itself prove a remote push was attempted or rejected: finalizeGit also sets it (with strandedCommits and a branchMismatch {expected, actual}) when it refuses to push at all because the harness moved HEAD off the provisioned work branch — or left commits abandoned on another local branch — so pushing the work branch would publish stale work and strand those commits (no git push runs, so pushError is absent in that case). As a guard against false strands, a non-zero push is re-checked with ls-remote: if origin/<branch> already points at headSha — or the remote tip is a descendant of headSha (another actor pushed a further commit after ours landed) — the push is treated as a transport hiccup that landed after the ref was accepted (pushed: true, no pushFailed/pushError); otherwise pushFailed is set and strandedCommits lists the SHAs of the new commits left UNPUSHED in the throwaway workspace — together they are the recovery handle for a failed push, so consumers must not treat pushed: false alone as the only signal. On such a failure (a rejected push or a branch-mismatch refusal) the throwaway workspace is preserved best-effort (even under the default --keep-runs=false) so those SHAs stay recoverable, but this is a recovery window, not a durable archive: the run-directory reaper still ages it out by mtime and worker shutdown removes the namespace — copy the stranded commits out promptly (or run with --keep-runs). scanError flags a best-effort/PARTIAL strandedCommits list: a commit or remote-reachability scan did not complete, so a consumer should recover the whole preserved workspace rather than trust the list exactly. It arises two ways: (a) a pre-push commit-enumeration scan failed, so finalizeGit refused to push on an incomplete graph — no push was attempted (pushError is absent) and strandedCommits is a partial surfacing; or (b) a push was ATTEMPTED and rejected but the remote-reachability FILTER that trims already-published commits from the strand set itself failed — so scanError is present alongside pushError, marking the rejected-push strand list as best-effort (it may falsely include an already-landed commit). Either way pushFailed stays authoritative; scanError is how a consumer knows the strand list is inexact (recover the whole workspace) versus an exact list (a rejected push with no scanError).
Git provisioning (host). When --sandbox none (the default) and the envelope
carries a repository.url, the plugin provisions a workspace on the host around
the harness:
- resolve the optional repo credential (
repository.authRef, orGITHUB_TOKENfor GitHub) — absent ⇒ anonymous clone; git clone(honouringdepth/submodules, andrepository.ref/branch.baseas the checkout target).refis always a branch/tag name (there is no hex heuristic, so a legitimately hex-named branch likedeadbeefis cloned via--branch, never misread as a commit); to pin a raw commit use the dedicatedrepository.shafield, which clonesref/branch.base(if any) then fetches + checks the commit out as a detached HEAD. The clone lands in a throwaway workspace under<state>/agent-runs/run-*. For a huge monorepo the clone envelope can be scoped so it finishes inside the clone timeout:singleBranchadds--single-branch(fetch onlyref, not every branch — a plainclone --branchstill pulls all branches/history);filter(e.g."blob:none") adds--filter=<spec>for a partial/treeless clone (full commit graph, lazy blobs — somerge-base/git diff base...headstill work);baseRef/baseShaadditionallygit fetchthe base (respectingdepth/filter) so a single-branch/shallow clone can still diffbase...head(exported asAGENT_REPO_BASE).baseRefis always treated as a branch/tag name (even one that looks hex-like) and is mapped intorefs/remotes/origin/<baseRef>;baseShais the field for a raw commit SHA (fetched by id, exposed as the SHA itself).baseRefandbaseShaare mutually exclusive — setting both is ambiguous, so the base fetch is skipped and a non-fatalbaseFetchErrorrecords the misconfiguration. A--depth 1 --single-branchof only the head otherwise has NO base and NO merge-base, so a naivegit diff mainfails. A failed base fetch is non-fatal — the head clone still succeeds and the failure is logged.cloneTimeoutMsoverrides the clone/fetch timeout per envelope (default 120s, or the--clone-timeoutworker flag) as a backstop for repos big enough to approach the cap even when shallow; a timeout is now reported as a timeout rather than an opaqueexit 128;- create
branch.create(if set) off that target — or, whenbranch.createis absent (or names the effective base itself) and push is enabled, cut a generated fallback work branchnano/agent-work/<base>-<runId>so commits are never made directly on the base branch; the branch actually used (configured or generated) rides back in the result envelope (branch) and is exported to the harness asAGENT_REPO_BRANCH. Exception — a detached tag/SHA checkout with nobranch.create: with no symbolic branch to name a fallback from, it staysbranch: nulland its commits are not pushed even when push is enabled — this holds for any detached no-branch.createcheckout, including when abranch.baseis configured (the base name is not used to synthesize a fallback here). Supplybranch.createto publish work committed off a tag/SHA base; - set a committer identity on the workspace, preferring the operator's own
(
GIT_AUTHOR_*env → globalgit config user.name/email→ thegh-authenticated GitHub user), and only falling back tonano-agentwhen none resolve — so autonomous commits are authored by the human running the fleet (who has signed any CLA/DCO), not an anonymous bot; - run the harness in the workspace (
cwd), withAGENT_WORKSPACE,AGENT_REPO_URL,AGENT_REPO_BRANCH,AGENT_REPO_REF,AGENT_REPO_BASEexported and the job envelope on stdin; - on success, enumerate new commits,
git pushthe branch whenbranch.push(default true), and — whentask.allowPr— reconcile the PR the agent opened for the branch (gh pr list --head <branch>;openedByreports the PR's actual author login, ornullwhen none is found), then post a one-time attribution comment recording that the change was agent-generated (marker- guarded so convergence rounds don't repeat it; disable withNANO_AGENT_ATTRIBUTION=0, rename the agent withNANO_AGENT_NAME).
The token is delivered to git via GIT_ASKPASS (env), never on argv or in the
remote URL, and is redacted from all logs/results. Credential helpers are
always disabled for the clone/fetch/push (-c credential.helper=), even when
a token is present, so a helper like store/keychain can never persist the
job's token to disk — GIT_ASKPASS supplies the secret directly. When no
token is resolved the clone is additionally anonymous for HTTPS remotes:
inherited GIT_ASKPASS/SSH_ASKPASS are cleared, and the operator's global git
config is neutralized (GIT_CONFIG_GLOBAL → the platform null device,
/dev/null or NUL on Windows) so knobs like http.*.extraHeader
or url.*.insteadOf can't silently inject operator credentials. (An SSH
remote — git@…/ssh://… — can still authenticate via the host's SSH
agent/config; use HTTPS URLs if you need a guaranteed-anonymous clone.)
Token-backed jobs keep global config (e.g. http.proxy). pushFailed is the
authoritative "work is stranded, workspace preserved" flag (the job still
completes, so a later BPMN step can drive the merge); a clone/checkout failure
sheds the job (retryable). pushError is present only for a push that was
ATTEMPTED and rejected/unconfirmed — when finalizeGit refuses to push at all (a
branch mismatch, or a partial/incomplete commit scan), it sets pushFailed
(with branchMismatch or scanError) but no pushError, so consumers must
key recovery off pushFailed, not pushError. scanError flags a
best-effort/PARTIAL strandedCommits list — a commit/reachability scan did not
complete, so the list may omit or (after a rejected push) falsely include commits
and a consumer should recover the whole preserved workspace rather than trust the
list exactly. It arises two ways: (a) a pre-push commit-enumeration scan failed, so
finalizeGit refused to push on an incomplete graph (no git push ran, so
pushError is absent); or (b) a push was ATTEMPTED and rejected but the
remote-reachability FILTER that trims already-published commits from the strand set
itself failed — so scanError is present alongside pushError, marking the
otherwise-complete rejected-push strand list as best-effort. Either way pushFailed
stays authoritative. Workspaces are
deleted after each job (keep them with --keep-runs) — except a job whose
git push could not be confirmed as landed (a non-fast-forward rejection, or
an auth/hook/network error whose ls-remote re-check did not find origin/<branch>
at or ahead of headSha — pushFailed + pushError), or one where
finalizeGit refused to push (no git push ran, so pushError is absent)
because HEAD moved off the provisioned work branch — or left commits on another
local branch or an abandoned detached HEAD reachable only via the reflog (a
branchMismatch strand) — or because a critical local commit scan did not
complete (a scanError strand, with a best-effort PARTIAL strandedCommits),
whose workspace is preserved best-effort so its
strandedCommits stay recoverable; that preservation is still age-gated by the
reaper and cleared on worker shutdown, so recover the SHAs promptly.
# The harness sees a cloned repo at $AGENT_WORKSPACE; branch/push/PR are handled for it.
c8ctl nano work coder # sandbox=none: repository-bearing jobs are provisioned on the hostRepo-less jobs run in a throwaway temp cwd (host). When --sandbox none and
the envelope carries no repository block at all, there is nothing to clone,
but the job still gets a fresh, empty run-* workspace under
<state>/agent-runs/ as its cwd (tracked and auto-reaped by the same run-dir
lifecycle as a provisioned clone). This makes "each job runs in a throwaway
per-job workspace" true by default, not only for repo-bearing tasks: the
worker's launch dir is never polluted with scratch files or a stray git init,
and an agent that must clone the repo itself gets a clean base. This is hygiene,
not a sandbox — the harness runs as your user with full filesystem access, so an
empty cwd does not confine it (an agent can still cd to a known absolute
path); true confinement remains the container increment, and a provisioned
repository envelope stays the preferred path (it also avoids a full-monorepo
self-clone, defeating the singleBranch+filter shaping above). A job whose
envelope carries a repository block that declares intent (any repository.*
field set) but whose url is absent or not a usable clone target is treated
as an orchestrator bug and fails closed (retryable) rather than silently
degrading to the temp cwd — surfacing the half-specified envelope loudly instead
of running an agent with no repo where one was expected.
Sandbox. By default the command runs on the host (--sandbox none). Pass
--sandbox docker (or podman) with an --image to run each job in a
throwaway container instead:
c8ctl nano hire --name coder --rank senior --command "agent-harness" \
--sandbox docker --image ghcr.io/acme/agent:1
c8ctl nano work coder # uses the profile's sandbox/image
c8ctl nano work coder --sandbox docker --image ghcr.io/acme/agent:1 # or overrideContainers are labelled (nano.managed=1, nano.worker, nano.jobKey,
nano.run=<uuid>), log-capped (max-size=10m max-file=3), run with --rm, and
a run that is idle-killed (or that outlives an optional --job-timeout hard cap)
is force-removed. The envelope is piped on
the container's stdin exactly as on the host. (Container-side git provisioning —
strong isolation — is a later increment; container jobs don't clone yet.)
Host workers inherit your credentials. A host worker (--sandbox none, the
default) runs as your user and inherits your full environment and $HOME, so
your existing gh CLI login (from gh auth login) or a GH_TOKEN/GITHUB_TOKEN
env var is available to the harness with no extra setup — handy when the
agent command shells out to gh. A container sandbox is isolated and does
not inherit that host login; provide the token explicitly via
setup.secretRefs / --secret-resolver host (see Secrets below) instead.
Refreshing a gh login while workers run. When no GITHUB_TOKEN is
exported, a worker falls back to gh auth token and caches it (a synchronous
CLI read that can't run on the per-job path). The cache is not
process-lifetime: it is re-read on a short TTL (NANO_GH_TOKEN_TTL_MS, default
5 minutes) off the job path — the timer DEFERS a tick while a job is active — and
a clone/push that fails with an auth or OAuth-scope error (401/403, "refusing to
allow an OAuth App … without workflow scope") forces a fresh re-read and
retries once before failing — the re-read is non-destructive (it does not
drop the cache first
