Cost, Stall Protection & Health
Long-running loops fail in two expensive ways: they burn money without converging, or they spin in place repeating the same output. autoloop guards against both from the journal itself, and ships two operator commands — doctor and stats — that read the same state.
Cost budget (event_loop.max_cost_usd)
Backends with usage telemetry (the claude-sdk and pi RPC backends today) journal one backend.usage event per iteration with token counts and cost. Set a USD budget and the harness stops the run once accumulated journaled cost reaches it:
[event_loop]
max_cost_usd = 5.0autoloop run autocode --set event_loop.max_cost_usd=5.0 "Refactor the parser"The run stops with reason cost_budget and journals a loop.stop event carrying cost_usd and max_cost_usd. A budget of 0 (the default) disables the check. Backends without telemetry report zero cost, so the budget never mis-fires for them.
Because the check is journal-derived, it covers every continue path — routed events, rejected emits, and plain continues — and survives context reloads.
Stall detection (event_loop.stall_iterations)
A loop that produces byte-identical output N iterations in a row is not converging; it's burning the rest of its iteration budget. Enable stall detection to stop early:
[event_loop]
stall_iterations = 3After stall_iterations consecutive identical backend outputs the run stops with reason stalled, journaling how many identical outputs were seen. Empty outputs never count (backend failures and timeouts already have their own stop reasons), and 0 (the default) disables the check.
Runtime budgets (event_loop.max_runtime, event_loop.max_iteration_runtime)
Iterations that wait on long-running workflows — a CI pipeline, PR review comments — can legitimately take hours or days. Two duration keys bound that time explicitly. Both accept duration strings ("45s", "90m", "12h", "3d", "1h30m") or bare millisecond integers:
[event_loop]
max_iteration_runtime = "2d" # per-iteration cap; overrides backend.timeout_ms
max_runtime = "5d" # wall-clock budget for the whole loopmax_iteration_runtime replaces backend.timeout_ms as the per-iteration timeout when set (per-role backend_timeout_ms overrides and the branch-mode timeout clamp still apply). max_runtime stops the run with reason max_runtime once wall-clock time since the journaled loop.start reaches the budget, journaling elapsed_ms and max_runtime_ms. Each iteration's timeout is also clamped to the remaining loop budget, so a days-long iteration never overshoots the loop's budget; when that clamp is what fires, the run records max_runtime rather than backend_timeout.
Like the cost and stall guards, the check is journal-derived (the loop.start timestamp), so it covers every continue path and survives context reloads. 0 (the default) disables either key; values are capped at ~24.8 days (the Node timer limit), and autoloop doctor warns on unparseable values or an iteration cap that exceeds the loop budget.
Inspecting usage
autoloop inspect usage # per-iteration tokens + cost for the latest run
autoloop inspect usage --run <id> # a specific run
autoloop inspect usage --json # machine-readableInspecting handoffs
inspect handoffs lists the routing evidence one run journaled, in journal order. It needs an explicit run id and reads run-scoped and worktree journals the same way other inspect targets do.
autoloop inspect handoffs <run-id> # terminal rows
autoloop inspect handoffs --run <run-id> # same run, flag form
autoloop inspect handoffs <run-id> --json # one versioned JSON documentThe JSON document has schema_version: 1, run_id, ordering: "journal_input", completeness: "not_established", and an observations array. Each observation has an ordinal, a nullable iteration and timestamp, and a kind:
| kind | Source record | Fields |
|---|---|---|
decision | iteration.start | recent_event, suggested_roles, allowed_events, backpressure_present |
accepted | agent emit | event, source |
validation | event.invalid | emitted, recent_event, suggested_roles, allowed_events |
retry | backend.transient | pause_count, backoff_ms |
usage | backend.usage | token counts, cost_usd |
finish | backend.finish, iteration.finish | scope, exit_code, timed_out, elapsed_s |
terminal | loop.complete, loop.stop | event, reason |
Read the report as recorded observations, not a causal chain:
actor_roleanddecision_makerare always{"status": "unknown"}. The journal does not record which role ran or who chose it.suggested_roleslists candidates.- Rows keep journal order, including duplicates and interleaved parallel records. The report does not pair starts with finishes, link an emit to the next iteration, or sum usage, retries, or durations.
nullmeans the field was absent or malformed. Usage values are copied as journaled, and some backends journal0for a count they did not report.- The journal reader skips unparseable lines, so the report cannot claim to be complete.
- An unknown run id exits with code 2. A known run with no routing records returns an empty
observationsarray.
The report copies identifiers only: topics, role names, stop reasons, counts, and timestamps. It omits prompts, emit payloads, backend output, error text, commands, and backpressure prose. Topic and role names come from your topology, so treat the report as project metadata rather than anonymized data.
autoloop stats
Cross-run analytics grouped by preset, derived from the registry plus journaled usage:
autoloop stats # table: runs, success rate, avg iterations/duration, cost
autoloop stats --json # machine-readableUse it to answer "which presets actually finish, how long do they take, and what do they cost" before reaching for a single run's journal.
Finish notifications ([notify])
Run any command when a loop ends — post to Slack, fire a webhook via curl, ring a bell:
[notify]
command = "curl -s -X POST -d @- https://hooks.example.com/autoloop"
on = "completed,failed" # stop-reason classes: completed, failed, stopped
timeout_ms = 10000The command receives AUTOLOOP_RUN_ID, AUTOLOOP_STOP_REASON, AUTOLOOP_ITERATIONS, AUTOLOOP_PRESET, and AUTOLOOP_PROJECT_DIR in its environment, plus the same fields as a JSON payload on stdin. Delivery is best-effort and journaled as notify.sent / notify.failed — a broken notifier never fails the run.
herdr pane status
When autoloop run or autoloop resume runs inside a herdr pane (HERDR_PANE_ID is set), it reports to that pane automatically — no config needed:
- each iteration sets the pane to working with
iter N · <role> · <model>and titles itautoloop <run-id> · <role>; - a pending
askor durablewaitsets it to blocked (anaskalso raises a notification); review.rejected,build.blocked, andfinalization.failedraise a notification;- when the loop finishes, a successful or limit stop leaves the pane idle (herdr shows it as done) and a failed stop leaves it blocked with the reason. Either way a notification shows the iteration count and cost, and the report stays until something else uses the pane. Stopping the process with Ctrl-C or a signal releases the pane instead.
Reports are fire-and-forget herdr subprocesses and never slow or fail the run. Set AUTOLOOP_HERDR=0 to turn the integration off.
autoloop doctor
Preflight and state-health diagnostics in one command:
autoloop doctor # human-readable report
autoloop doctor --json # machine-readable (for agents and CI)Checks include:
- node / git / backend — runtime version, git availability, and whether the configured backend command resolves on
PATH - state —
.autoloop/existence and writability - registry — malformed
registry.jsonllines - runs — runs marked
runningwhose process is gone - waves — stale
waves/activemarkers that would block future parallel waves - worktrees — orphaned worktrees (suggests
autoloop worktree clean)
Exit code is 1 only when a check fails; warnings exit 0, so it's safe to wire into scripts.