Halt Reasons
A loop runs until an external check passes, a guard trips, a budget is spent, or a human is required. Never because an agent reports it is done. Every halt emits a computed reason - not a flag - and a replayable receipt.
This page is the taxonomy as doctrine. It is the page behind the claim: our loops stop, and explain why.
The three properties of every halt
Section titled “The three properties of every halt”A halt is not one fact. It is three, and they are deliberately separated.
- The
halt_reasonstring is the integration contract. It is stable, precise, and the unit other tools key on. One of a closed set of named reasons. The string MUST NOT change meaning within a major spec version. - The exit code is a coarse CI class. A small integer CI can branch on without parsing JSON. Many strings collapse into one code. The code answers “what kind of stop”; the string answers “which one.”
- Every reason is computed from observed state. A reason is the output of a detector reading durable state - the failing-test trajectory, a fingerprint hash, a coverage delta, a manifest hash, a spend ledger, a model-identity tuple, a sequential flake bound. It is never an argument you pass in.
Keep these three straight and the rest of the page follows.
The canonical map
Section titled “The canonical map”The halt_reason string is the stable integration contract; the exit code is a coarse class. Existing codes 0/10/11/12/20/30/40/50 predate this taxonomy; 13-19 are new classes. The map below is the full normative taxonomy; which strings emit today versus stay reserved is tracked in SPEC section 11 - today classes 13, 14, 15, 16, 17, 18, and 19 emit their core reasons (class 18 via inspect-cost, class 15 via the doctor --mutate-cmd adequacy canary), while only class 11’s task-list reasons are reserved. Every reason MUST be computed from observed state - --halt-reason is a hidden test fixture only.
| exit | class | halt_reason strings | owner |
|---|---|---|---|
| 0 | nominal | (loop ran, no halt) | LoopExec |
| 10 | converged | success_condition_met | LoopExec |
| 11 | terminal-blocked | no_actionable_tasks, human_required | human |
| 12 | iteration-cap | max_iterations_reached | LoopExec |
| 13 | integrity | blocked_path_modified, reward_hacking_detected, metric_integrity_violation, credential_scope_invalid, objective_unverified | LoopExec (det.) |
| 14 | oracle-untrusted | check_flaky, check_has_side_effects, check_not_hermetic, hermeticity_violation | LoopExec (det.) |
| 15 | check-inadequate | check_inadequate | LoopExec (det.) |
| 16 | resumable-judgment | escalation_pending, reviewer_rejected | Musketeer / human |
| 17 | no-convergence | no_progress_detected, same_failure_repeated, oscillation_detected, same_test_regressed, unsatisfiable_constraints, infeasible_suspected | LoopExec (det.) |
| 18 | budget | budget_exceeded, cost_anomaly | LoopExec (det.) |
| 19 | liveness/drift | heartbeat_stale, model_drift_detected, comprehension_debt_exceeded, context_budget_unsatisfiable | LoopExec (det.) |
| 20 | invariant | invariant_failed | LoopExec |
| 30 | workspace | workspace_invalid, isolation_unsatisfiable | LoopExec |
| 40 | execution | execution_failure | LoopExec |
| 50 | internal | internal_error | LoopExec |
The (det.) marker means deterministic: LoopExec-owned, reproducible offline, the part of a halt that a replayable verdict can re-verify without re-running the agent.
String versus exit code
Section titled “String versus exit code”The split is intentional, and it earns its keep at 3am.
A CI pipeline does not want to parse JSON to decide whether to page. It reads the exit code. Exit 17 means no-convergence - alert the owner, do not auto-retry blindly. Exit 14 means the oracle itself is untrusted - quarantine the check before believing any verdict. Exit 18 means budget - the loop stopped because it was told to, not because it failed.
The string is for the integration that wants precision. Exit 17 covers six distinct strings. oscillation_detected and infeasible_suspected share a class but demand opposite responses: one is a thrashing loop you can sometimes break by widening context; the other is a target with no reachable green, where more budget burns forever. The exit code routes; the string decides.
Computed, never declared
Section titled “Computed, never declared”The single most important property of this taxonomy: no halt reason is a flag.
In a stub, a loop can be told how it ended. The contract forbids it. --halt-reason is demoted to a hidden test fixture - present so the receipt schema and exit-code map can be exercised, never a path a real run takes. A conformant run reaches a halt reason exactly one way: a detector observes state and computes it.
no_progress_detectedis computed from the failing-test-ID setF_ishowing no strict decrease over K iterations - not from an agent saying it is stuck.metric_integrity_violationis computed from the collected-test-set losing a member, or an assertion count dropping, against an immutable baselinet0- not from a diff againstHEAD~1.check_flakyis computed from a sequential Wilson lower bound on the check’s stability falling belowconfidence_target- a maintained confidence bound, not a one-time probe.model_drift_detectedis computed from the observed model-identity tuple no longer matching the pinned one.
This is what makes “explain why” honest. A computed reason can be re-derived from the receipt offline. A declared one is just a label the loop chose to print.
Who owns a halt
Section titled “Who owns a halt”Three owners, drawn along one line: what a number can decide, what a number cannot, and what only a person can.
| owner | decides | example reasons |
|---|---|---|
| LoopExec | everything a number can decide, reproducible offline | success_condition_met, no_progress_detected, metric_integrity_violation, budget_exceeded, check_flaky |
| Musketeer | what a number cannot express, layered on top of the deterministic gate | reward_hacking_detected, reviewer_rejected |
| human | the irreducible decision and the ack | human_required, resolving escalation_pending |
LoopExec owns the exit codes, the F_i trajectory, a fingerprint hash, a coverage/mutation delta, a manifest hash, a spend ledger, a model-identity tuple, a sequential flake bound. Musketeer owns only the judgments a number cannot capture - semantic reward hacking, a reviewer’s veto - and these sit on top of the deterministic metric_integrity_violation, never in place of it. Humans own the one thing neither layer can: the decision to proceed, and the signed ack that records it.
This is why reward_hacking_detected shares exit class 13 (integrity) with the deterministic metric_integrity_violation. The number-decidable violation fires first and stands on its own; the judge’s semantic veto is an additional, advisory layer over the same class - it cannot rescue a loop the deterministic gate already condemned, and it is not required for that gate to halt.
Reading a halt: three worked examples
Section titled “Reading a halt: three worked examples”How to read the receipt, the string, and the exit code together. JSON below follows the --json contract - one object, documented fields - with the process exit code noted alongside.
1. The green that wasn’t - guards dominate success
Section titled “1. The green that wasn’t - guards dominate success”A check goes green at iteration 7. The loop does not halt success_condition_met.
{ "tool": "loopexec", "version": "0.2.0", "status": "halted", "run_id": "fix-auth-suite", "iteration": 7, "halt_reason": "metric_integrity_violation", "errors": ["collected-test-set lost member: test_token_refresh_expiry"]}Exit code: 13 (integrity).
How to read it. Guards are evaluated against the immutable baseline t0 before any green branch can fire - guards dominate success (G-before-S). The suite passed, but it passed because a test was deleted. The collected-test-set lost a member, so the integrity gate computed metric_integrity_violation and the green was never allowed to become success_condition_met. The exit code routes CI to “integrity - do not ship.” The string names the exact invariant. The errors line names the lost test. You revert to t0; you do not trust the green.
2. Out of iterations - raise the limit, or never retry
Section titled “2. Out of iterations - raise the limit, or never retry”Two runs both stop at the iteration cap. They are not the same halt.
{ "halt_reason": "max_iterations_reached", "iteration": 20, "status": "halted" }Exit code: 12 (iteration-cap). |F_i| was still strictly decreasing at the cutoff.
{ "halt_reason": "infeasible_suspected", "iteration": 14, "status": "halted" }Exit code: 17 (no-convergence). The failing-test set stopped shrinking, with no reachable green in evidence.
How to read it. max_iterations_reached while progress was still being made means the loop ran out of budget, not road - raise the limit and retry is rational. infeasible_suspected means there may be no reachable green; retrying burns budget forever. explain-halt is specified to draw exactly this line, so the operator who reads the halt at 3am knows whether more budget is a fix or a waste. Same family of “it stopped without converging,” opposite correct response - and the exit code (12 vs 17) already separates them before you read a word.
3. The oracle you cannot trust
Section titled “3. The oracle you cannot trust”The loop halts not on the work, but on the instrument.
{ "tool": "loopexec", "version": "0.2.0", "status": "halted", "run_id": "payments-refactor", "iteration": 4, "halt_reason": "check_flaky", "errors": ["stability lower bound 0.91 < confidence_target 0.99 (dimension: test_order)"]}Exit code: 14 (oracle-untrusted).
How to read it. Each in-loop check run is a free Bernoulli sample. The runtime maintains a sequential lower bound on the check’s stability; here it crossed below confidence_target, and the adversarial dimension that broke was test order. The loop refuses to treat an unstable check as a stop condition - a flaky oracle cannot certify convergence. Note the neighbor in the same class: check_has_side_effects is a deterministic-but-non-idempotent check, cured by reset, not by editing the check. Exit 14 says, uniformly, “fix the instrument before believing any verdict it produced.”
Migration: the death of “blocked”
Section titled “Migration: the death of “blocked””The legacy bare "blocked" string was one word with three meanings. The taxonomy splits it, and the split is not cosmetic - each shard has a different owner and a different exit class.
| was | becomes | exit | meaning |
|---|---|---|---|
blocked | no_actionable_tasks | 11 | nothing left to select; terminal, human-owned |
blocked | blocked_path_modified | 13 | a guard tripped; integrity, LoopExec-owned |
blocked | human_required | 11 | a person must decide; terminal, human-owned |
Related
Section titled “Related”- Determinism - the maintained confidence bound behind
check_flaky, and why only verdicts replay. - Loop Engineering - the discipline these halts enforce: bounded, stateless, auditable loops.
- CLI Reference - the
--jsoncontract, the exit-code list, and the capability-status matrix.