Guardrails and outcomes
The stop model — no-progress checks, parked work, budgets and ceilings, and the twelve run states a Loop can hold.
A Loop never runs forever and never lies about how it ended. Its contract carries the stop model: the axes that decide "no progress," the budgets that cap spend, the ceilings the daemon enforces regardless of what a definition asks for, and the fixed set of terminal outcomes. This page is the reference for all of them.
Run states
A run holds exactly one of twelve states — five live, seven terminal. Color carries state, never category; a node's class is never colored.
Live states
| State | Meaning |
|---|---|
queued | A same-Loop start deferred by concurrency: queue. Promoted FIFO when a sibling run terminates. |
running | The coordinator owns an active generation. The initial state at start. |
watching | A dormant run awaiting a watch-source poll tick or a matching watch-events event. |
needs-approval | A dormant run parked on a human gate (or a budget.on_exceeded: escalate). Resumed by an approval decision. |
paused | An operator paused the run at a generation boundary. |
needs-approval and paused are live pauses, never terminal — the run is still alive and will
resume. paused is operator-initiated; needs-approval is system-initiated by a human gate.
Terminal outcomes
| Outcome | Meaning |
|---|---|
done | The goal was verified. The only success outcome. |
no-op | The run executed but found nothing to do. A clean watch tick is no-op, never a fake done. |
blocked | An external dependency made progress impossible — a missing dependency, missing credential, or unreachable resource; a human gate reject; or a loop.gate.pre hook denial. Distinct from stalled. |
failed | An unrecoverable node or gate error, or a loop.generation.pre hook denial. |
canceled | An operator cancel or kill, with cause operator_cancel or operator_kill. |
exhausted | A hard stop limit tripped before the goal — the iteration cap or an authored max_fan_out bound. |
stalled | No progress is being made — the no-progress window elapsed, the failure circuit breaker tripped, the blocker-ID signature repeated, or a watched source went silent past its window. |
ready and awaiting_child are node-level states only (a node's per-run status inside a
generation) — they are never run states. awaiting_child marks a run-loop node in await mode
parked until its child Loop terminates.
While that child is live, the parent remains live, dependent nodes remain pending, and a daemon
restart restores the same child_loop_run_id without submitting another child. A child ending
done or no-op settles the node as succeeded; every other terminal child outcome settles it as
failed. mode: detach remains immediate success and does not establish parent wait ownership.
No-progress checks
"Making progress" is measured from committed work, not elapsed wall time.
Generation progress
The blocker-ID stall signature compares the sorted set of blocking_issues[].id a gate
emits across contract.no_progress.window. The same unresolved blockers repeating means the
Loop is spinning, and the run ends stalled.
Evidence-based liveness
A long-running node stays healthy while the daemon sees stream activity, an in-flight tool, or
transport presence. Silence raises an attention flag; duration alone never ends, pauses, or
cancels the node. Only a watch-source that exceeds its authored silence window ends the run
stalled.
A separate failure circuit breaker ends the run stalled after consecutive node failures
reach the daemon limit, so a Loop that only ever fails cannot burn its whole iteration cap.
Parked work suspends work bounds
Paused nodes, durable waits, approval waits, and quarantined nodes are parked. The coordinator uses one classifier to exclude them from scheduling, rerun sets, due scans, and no-progress arithmetic. Their attempt timeout and deadline clocks stop while parked, as does the run's wall-clock work budget. Token usage still counts whenever tokens are spent.
Parked does not mean hidden or terminal. Waiting, quarantined, attention, and retrying inventories
remain available through compozy loop nodes --state …; a required quarantined producer raises
attention instead of terminating the run. Waits and approvals have no default expiry. Authors may
add an expiry and route, while operators may explicitly resume, cancel, kill, or requeue work.
Re-attempt and rejection semantics
Every new generation records why it exists. Node failures use the configured re-attempt strategy; gate rejection uses the gate's explicit route. They are separate controls.
| Cause | What runs next | Origin |
|---|---|---|
Node failure with failed_only (default) | Failed or pending nodes and their transitive dependents re-run; unrelated successful outputs carry forward. | reattempt |
Node failure with full_body | The whole body re-runs. | reattempt |
In-body gate rejection routed to revise | Producers of every route-causing gate, those gates, and their dependents re-run; unrelated successful outputs carry forward. | gate_revise |
Metric gate revise with an existing best | The same producer-scoped repair runs, but carried outputs seed from the best generation. | ratchet_restore |
In-body gate rejection routed to next_generation | A fresh full-body generation starts. | gate_next_generation |
Definition-of-done rejection routed to next_generation | A fresh full-body generation starts instead of terminating the run. | dod_retry |
revise is targeted repair; next_generation is a fresh pass. Both carry the prior verdicts in
previous.verdicts.* and their ordered causes in previous.route_causes, so the next attempt can
act on typed failure context. Iteration caps, budgets, gate revision limits, no-progress detection,
and the failure circuit breaker apply to every row in the table.
Weak evidence is a route, not a hidden prompt instruction
If evidence quality decides whether work may continue, encode that decision as a gate. A prompt
that says “improve this if the evidence feels weak” leaves the transition inside model prose; the
runtime cannot audit, bound, or reproduce it. A typed criterion plus on_result makes the same
decision visible and deterministic:
- id: evidence
class: control
kind: gate
criteria:
- id: source_quality
type: agent-judge
agent: evidence_reviewer
rubric: >
Approve only when every material claim cites a primary source and the cited evidence
directly supports it. Return stable blocking issue ids for missing or weak support.
verdict_policy: revise_until_clean
on_result:
fail: revise
max_revisions: 4The next producer can read previous.verdicts.evidence.blocking_issues under a guarded
{{ with .previous }} block. Choose revise when the evidence-producing branch should be repaired,
next_generation when the entire body needs a fresh pass, or halt when weak evidence must stop
the run. The gate owns the decision; the prompt consumes its recorded evidence.
Limits, budgets, and ceilings
Every stop limit has a per-Loop default (the definition, seeded by
[loops.defaults.*]). Some also have a hard daemon
ceiling. The fan-out window has no fixed daemon cap; the required positive max_fan_out remains
the author's logical-width bound.
| Limit | Contract key | Delivery default | watch-source default | Daemon ceiling |
|---|---|---|---|---|
| Iteration cap | contract.iteration_cap | 50 | 0 (unbounded, ∞) | none — 0 means unbounded |
| Token budget | contract.budget.tokens | 0 (off) | 0 (off) | none |
| Wall-clock budget | contract.budget.wall_clock_sec | 0 (off) | 0 (off) | none |
| No-progress window | contract.no_progress.window | 3 | 2 | 30 |
| Fan-out window | node max_parallel / fan_out_width | 4 | 2 | none |
| Gate max revisions | gate max_revisions | 10 | — | 64 |
A definition containing watch-source defaults iteration_cap: 0 and renders the cap as ∞.
watch-events retains the delivery default of 50 unless its contract explicitly sets 0; a 0
budget means unlimited (progress-first). Budgets are opt-in — set a nonzero value to cap spend.
Fan-out logical width is bounded by the node's required positive max_fan_out; only the
max_parallel window materializes at once.
When a set budget is exceeded during active work, budget.on_exceeded chooses the response:
halt(default) — end the runexhausted.escalate— park the run inneeds-approvalfor a human decision.
Cost is a meter, not a cap
The run page shows a live cost meter (tokens × price). It is a visibility signal only. No surface accepts a cost cap — the run form and configure sheet have no USD field, and no daemon ceiling is expressed in dollars. Spend is bounded by the token budget and the iteration cap, not by cost.
Where these show up
- The catalog shows each Loop's last outcome and success rate.
- The run page streams the live state, the five meters, and the terminal-outcome banner.
- The DSL reference defines every contract field named here.