Step 6 of a 9-step agent job times out. The run is holding a checkpoint, an open HTTP call that may or may not have landed, and a retry counter that nobody wired up. The next decision is the expensive one — and most codebases make it with a bare except: retry().
That default is wrong in two directions at once. It hammers dead endpoints, and it re-fires steps that already wrote something into the world.
This post is a glossary, a decision tree, and four worked leaves, plus a dependency-free classifier you can run in under a second. The scenario above is illustrative, not a customer story; the code is the artifact you can actually check.
Terms worth pinning down first
Failure class. Whether the fault is in the transport (timeout, 429, 5xx, connection reset) or in the task (wrong output, wrong field, wrong decision). These need different budgets, not the same for loop.
Side-effect boundary. The first line in a step after which the outside world has changed: a row written, an email sent, a payment authorized. Everything before it is safe to throw away.
Checkpoint. A stable identifier for the last known-good state, granular enough that replay resumes at a step rather than at the job.
Retry. Same input, same prompt, same parameters. Cheap to reason about, only valid before the side-effect boundary.
Repair. Changed input: a narrowed instruction, an added schema, one failing example. Expensive in tokens, appropriate when the output parses but is wrong.
Replay. Re-run from a checkpoint behind an idempotency key, so a duplicate request returns the original result instead of creating a second one.
Poison step. A step whose failure fingerprint is unchanged after the repair budget is spent. Retrying it is a way to convert a bug into an outage.
Reconciliation. The out-of-band read that proves what actually happened downstream. Triage decides what to try; reconciliation decides what is true.
The three branches
Every recovery decision in an agent step reduces to three questions, asked in this order.
- Is the fingerprint stable and non-transient after repair? If yes, stop. This is a poison step and no branch below improves it.
- Are there side effects? If yes, the only safe action is replay from a checkpoint with an idempotency key — or abort if no checkpoint exists.
- Is the transport healthy? Transient and under budget means retry. Transient and over budget means abort: a dead endpoint is not a prompt problem. Non-transient means repair, once or twice, then abort.
Order matters more than the rules themselves. Side effects must be checked before the retry branch, and poison must be checked before replay, or you will replay a step whose model output was garbage in the first place.
Decision table
| transient | side effects | checkpoint | repair attempts | action |
|---|---|---|---|---|
| yes | no | — | 0 | retry (budget open) |
| yes | yes | ckpt-17 |
0 | replay behind idempotency key |
| yes | yes | none | 0 | abort, reconcile by hand |
| no | no | — | 1 | repair once, record prompt hash |
| no | no | — | 2 | abort, quarantine |
| no | yes | ckpt-17 |
2 | abort — poison beats replay |
The classifier, runnable
Save as triage.py. Standard library only, CPython 3.10+ for the union syntax.
#!/usr/bin/env python3
"""triage.py - pick a recovery action for a failed agent step."""
from __future__ import annotations
from dataclasses import dataclass
from enum import Enum
class Action(str, Enum):
RETRY = "retry"
REPAIR = "repair"
REPLAY = "replay"
ABORT = "abort"
RETRY_BUDGET = 3
REPAIR_BUDGET = 2
@dataclass(frozen=True)
class Failure:
step: str
transient: bool = False # timeout, 429, 5xx, reset
schema_ok: bool = True # did output match the contract?
side_effects: bool = False # did this attempt already write outside?
checkpoint: str | None = None # last known-good state id
attempts: int = 1
repair_attempts: int = 0
signature: str = "" # normalized error fingerprint
def triage(f: Failure) -> tuple[Action, str]:
if not f.transient and f.repair_attempts >= REPAIR_BUDGET:
return Action.ABORT, "poison step: fingerprint survived the repair budget"
if f.side_effects:
if f.checkpoint:
return Action.REPLAY, f"re-run from {f.checkpoint!r} behind an idempotency key"
return Action.ABORT, "side effects with no checkpoint: reconcile by hand"
if f.transient and f.attempts < RETRY_BUDGET:
return Action.RETRY, "transport fault, retry budget open"
if f.transient:
return Action.ABORT, "transport budget spent; a prompt edit will not fix a dead endpoint"
if f.repair_attempts < REPAIR_BUDGET:
return Action.REPAIR, "transport fine, output wrong: narrow the instruction once"
return Action.ABORT, "no leaf matched: escalate with the trace"
CASES = [
("transient read", Failure("fetch", transient=True, attempts=1), Action.RETRY),
("transport exhausted", Failure("fetch", transient=True, attempts=3), Action.ABORT),
("write with checkpoint",
Failure("charge", transient=True, side_effects=True, checkpoint="ckpt-17"), Action.REPLAY),
("write without checkpoint", Failure("charge", side_effects=True), Action.ABORT),
("contract violation", Failure("extract", schema_ok=False), Action.REPAIR),
("repair exhausted", Failure("extract", schema_ok=False, repair_attempts=2), Action.ABORT),
("poison beats replay",
Failure("charge", side_effects=True, checkpoint="ckpt-17", repair_attempts=2), Action.ABORT),
]
def main() -> int:
failures = 0
for name, failure, want in CASES:
got, why = triage(failure)
ok = got is want
failures += 0 if ok else 1
print(f"{'ok ' if ok else 'FAIL'} {name:<24} -> {got.value:<6} {why}")
return 1 if failures else 0
if __name__ == "__main__":
raise SystemExit(main())
Run python3 triage.py. Expected output, with the last line being the one that catches the classic mistake — replaying a step whose model never produced valid output:
ok transient read -> retry transport fault, retry budget open
ok transport exhausted -> abort transport budget spent; a prompt edit will not fix a dead endpoint
ok write with checkpoint -> replay re-run from 'ckpt-17' behind an idempotency key
ok write without checkpoint -> abort side effects with no checkpoint: reconcile by hand
ok contract violation -> repair transport fine, output wrong: narrow the instruction once
ok repair exhausted -> abort poison step: fingerprint survived the repair budget
ok poison beats replay -> abort poison step: fingerprint survived the repair budget
If a line differs, the test fails and the tree disagrees with the table above. Fix the tree, not the table.
Four leaves, one worked example each
Leaf 1 — Retry
A geocoding lookup times out on attempt 1 of 3. transient=True, no side effects, no writes. Action: retry with backoff (200ms, 800ms, 3200ms), log the attempt counter, and let the budget run out. The thing you must not do: reuse this leaf for the enrichment step that posts a normalized address to an internal service. That step has a side-effect boundary.
Leaf 2 — Replay
The charge step times out after the provider accepted the request. Whether the charge landed is unknown, which is exactly the state replay exists for. You have ckpt-17, so you re-submit the same request with the same idempotency key and the provider returns the original response instead of a second charge.
class ReplayLedger:
"""Append-only claim file: first writer wins, replayers get False."""
def __init__(self, path: str) -> None:
self.path = path
@staticmethod
def key(job_id: str, step: str, checkpoint: str) -> str:
return f"{job_id}:{step}:{checkpoint}"
def claim(self, k: str) -> bool:
with open(self.path, "a+", encoding="utf-8") as fh:
fh.seek(0)
if k in {line.strip() for line in fh if line.strip()}:
return False
fh.write(k + "\n")
return True
The checkpoint is not optional here. With side_effects=True and checkpoint=None, the classifier aborts — deliberately, because the honest action is a human reading the provider's list endpoint, not a script guessing.
Leaf 3 — Repair
The extract step returns valid JSON that is missing due_date. The transport is fine; the contract is not. Repair means the input changes: append the required schema, add the one failing example, bump repair_attempts, and record a hash of the new prompt so the next failure is comparable to the last. Two repairs maximum. After that the fingerprint is stable and the leaf flips to abort.
Leaf 4 — Abort
The charge step returns 402 three times, transient=False, repair_attempts=2, and it has side effects. Every branch below the poison check would do something plausible and wrong. Action: quarantine the job, keep the trace, page a human, and run reconciliation against the provider before any resubmission. The cost of this leaf is one interrupted job. The cost of skipping it is a duplicate-charge incident.
Where free capacity actually lands in this tree
The repair leaf is the speculative one. Two repair attempts per failing step, across a nine-step job, means inference on inputs that mostly fail — and every repair changes the prompt, which makes the cost hard to amortize with caching.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
That is the honest place to try MonkeyCode's free model access: point only the repair branch at it, keep retry and replay on your existing infrastructure, and compare the abort rate after a week. The other fit is the replay ledger — a tiny append-only process that benefits from the free server option because it needs uptime, not compute. Free-tier quotas, model lists, and server terms change; read the project's current documentation rather than any number quoted in a blog post, including this one, and check the limits before you put a real queue behind it.
Limitations, and who should skip this
- The ledger above is not concurrency-safe. Single writer only; multi-worker setups need a real store with an atomic conditional write.
- Triage classifies failures, not outcomes. A step that returns HTTP 200 with semantically wrong data lands in repair, and only reconciliation catches it.
- The tree assumes idempotent downstream operations or a written compensation action. If neither exists, replay is a duplicate generator and this whole approach is unsafe.
- If your steps cannot checkpoint — long streaming writes, non-resumable file uploads — the side-effects branch collapses to abort and you gain little beyond a vocabulary.
- Do not put financial, medical, or deletion paths behind an automated replay without a reconciliation step that a human reads.
Test plan before production
- Run
python3 triage.pyand require exit code 0. All seven assertions are regression tests, especially the last one. - Add a fixture for every step in your job, recording real observed values for
transient,schema_ok,side_effects, andcheckpoint. - Point the job at a sandbox downstream that records every write. Inject a timeout after the write and confirm exactly one write appears after replay.
- Force
checkpoint=Noneon a side-effecting step and confirm the job aborts instead of retrying. This is the single most valuable test in the list. - Run 50 jobs with a 5% injected failure rate and compare duplicate-write count against your current
except: retry()baseline. Zero is the target; anything else means the ledger is leaking.
Start with the selftest, not the architecture diagram. If the repair leaf turns out to be where your tokens go, running those speculative attempts on MonkeyCode's free model access for a week is a cheap way to get evidence before you change anything that is currently working.
Top comments (0)