Chapter 5 of 7 · 17 min read
A stuck run is one where nothing new entered the context
On this page
The loop detector from the previous chapter hashes a tool call: it normalises the tool name and the arguments, takes a digest of the pair, and terminates when the same digest recurs inside a recent window. That is a check on the request — on the thing the model asked for. Whether the run is making headway is a fact about the reply, and about the world the reply came from. Those are two different quantities, and nothing forces them to move together.
Because they can move independently, a test on one of them misclassifies the other in both directions rather than in one. An agent polling a build status sends a byte-identical request every few seconds while the world underneath changes on every reply: the key holds still and the thing worth watching moves. An agent rewording the same failing search sends a different request every time while the world returns the same nothing: the key moves and the thing worth watching holds still. A detector reading only the key will call the first one a loop and the second one healthy, and it will be exactly wrong about each. The fix is not a better hash. It is a second instrument pointed at the reply.
Two traces the hash reads backwards
Both of the traces below come from the same week, both from agents behaving exactly as the previous chapter's detector predicts, and the detector is wrong about both.
deploy agent — detector says LOOP, reality says healthy
step 07 get_build_status {"build_id":"b_88231"} -> {"status":"queued","jobs":0/4}
step 08 get_build_status {"build_id":"b_88231"} -> {"status":"running","jobs":1/4}
step 09 get_build_status {"build_id":"b_88231"} -> {"status":"running","jobs":1/4}
docs agent — detector says fine, reality says stuck
step 11 search_docs {"q":"refund policy"} -> 0 results
step 12 search_docs {"q":"refund policy annual plans"} -> 0 results
step 13 search_docs {"q":"annual plan refund rules"} -> 0 results
step 14 search_docs {"q":"what is the refund policy for annual subscriptions"} -> 0 resultsThere is a third shape that also slips through, from a code-fixing agent: edit_file adds a null check at line 40, the test run fails for an unrelated reason, edit_file reverts it, the test fails again, and the null check goes back in. Four distinct keys alternating in pairs, no repeat inside the window, and the file is byte-identical to where it started after six steps. What the deploy agent has that the other two do not is that something changed between calls, and what the docs agent and the code agent have in common is that nothing did, whatever the arguments say.
Progress is a property of results, not of calls
The measurable version of "getting somewhere" is that each step must put something into the run that was not there before. That gives four signals, in rough order of how cheap they are to compute.
The first is the result digest: normalise the tool result the way the previous chapter normalised arguments, hash it, and compare against the last result from the same tool. Step 8 of the deploy trace changed queued to running and 0/4 to 1/4, so it advanced; step 9 is identical to step 8 and did not. All four search_docs calls returned zero results and none of them advanced anything, which is the signal the argument hash could not see.
The second is external state: did the run cause a write, a file change, a record created, a message sent. This is the one signal that is not a matter of inference, since it is your own dispatch layer recording that a mutating tool ran and succeeded.
The third is plan advance, available when the agent maintains an explicit checklist — a todo list, a set of sub-goals, a scratchpad the harness parses. An agent that has closed no item in six steps is not progressing through its plan regardless of how varied its calls look.
The fourth is goal distance, which only exists when the task has a measurable target: tests passing out of a suite, rows migrated out of a total, fields filled on a form. It is the strongest signal and available on a minority of agents, so it is worth wiring where you have it and not worth inventing where you do not.
The order those four arrive in is an order of cost, and it is worth reading it as an order of strength running the other way. The result digest needs nothing but the bytes you already have in hand, and it licenses the weakest conclusion available: something in the reply is different from last time. Different is not better. The external-state signal costs a hook in your dispatch layer and licenses something firmer, because a write that succeeded is a change in the world rather than a change in a string. Plan advance costs an agent that keeps an explicit checklist, which most do not, and licenses a statement about the task rather than about the tools. Goal distance costs a task with a countable target, which fewer still have, and is the only one of the four that says anything about direction. The residual is the same at every level and it is the whole limitation of this chapter: none of them can distinguish motion from progress towards the right thing, and the cheaper the signal, the wider that gap.
Which is why they are combined disjunctively rather than being ranked. A step counts as progress if any of the four fired. That looks lax and is the correct choice, because each signal is available on a different subset of agents and steps, and requiring agreement between two of them would mean a run is only ever judged by the weakest signal it happens to have. The strictness lives in the counter rather than in the test.
A no-progress counter over those signals is the whole mechanism. It increments on any step that produced none of them and resets on any step that produced one, and the threshold is a small number — four works for the agents here, chosen because legitimate exploratory sequences on this fleet rarely exceed three consecutive steps with no result change and never exceed four outside of waiting.
@dataclass
class Progress:
last_digest: dict[str, str] = field(default_factory=dict)
barren: int = 0
def observe(self, tool: str, result: str, wrote: bool,
plan_advanced: bool) -> None:
digest = hashlib.sha256(
json.dumps(normalise(strip_volatile(result)),
sort_keys=True).encode()).hexdigest()[:16]
changed = self.last_digest.get(tool) != digest
self.last_digest[tool] = digest
self.barren = 0 if (changed or wrote or plan_advanced) else self.barren + 1
def stuck(self, limit: int = 4) -> bool:
return self.barren >= limitThree details in that dataclass are carrying the design. sort_keys=True is there so that two structurally identical results serialised with their keys in different orders produce one digest rather than two — the same reason the previous chapter normalised arguments before hashing them, applied to the reply instead of the request. last_digest is keyed by tool rather than being a single value, because an agent alternating between two tools would otherwise show a changed digest on every step purely from switching tools. And the assignment to self.barren is the entire state machine: reset to zero when anything fired, add one when nothing did. The detector's whole memory is one integer per run plus one digest per tool.
The per-tool keying has a limit that follows directly from it holding exactly one value. An agent whose results from a single tool alternate between two states — A, then B, then A again — compares each result only against its immediate predecessor, so every step reports a change and the counter never leaves zero. That is the edit-and-revert shape from the top of this chapter arriving one layer down, and it is why the row for it in the table below is resolved by digesting the file rather than by digesting what edit_file returned. Widening the comparison to a set of recent digests per tool would catch the alternation and would also flag a legitimate two-state poll, which is the trade that keeps it at one.
strip_volatile is doing the same job on the way in that normalise did on the way out, and skipping it breaks the detector completely: a tool whose response envelope carries "served_at": "2026-03-17T09:12:44.181Z" makes every result a new digest, every step looks like progress, and the counter never leaves zero. On the agents here the fields worth stripping were timestamps, request IDs, an ETag (the version tag an HTTP response carries so a client can tell one revision of a resource from another), and a latency_ms — four keys, and until they were stripped the progress check reported a perfectly healthy fleet with no stuck runs at all.
Run the four shapes from the top of this chapter through both detectors and the disagreements line up in a way that says which instrument to trust where.
| Shape | Argument hash | Progress signal | Right action |
|---|---|---|---|
| Build poll, status advancing | Repeat | Result changed | Continue |
| Build poll, status unchanged for 4 minutes | Repeat | Nothing changed | Continue, if the wait budget allows it |
| Reworded search, zero results each time | All distinct | Nothing changed | Warn once, then stop |
| Edit and revert of the same file | Distinct, alternating | Writes happen, file returns to its start | Stop; a write is not automatically progress |
| Paging through a result set | Distinct, cursor advances | New rows each page | Continue |
The fourth row is the one that shows why the write signal has to be qualified. A mutating tool that ran successfully is strong evidence of progress right up until the mutations cancel out, and catching that means comparing the state after the write against the state before it rather than trusting that a write occurred. On the code agent this was a two-line change — digest the file contents rather than record that edit_file returned 200 — and it moved that trace from undetectable to caught on the second cycle.
Waiting is a state, not a loop
The deploy agent's poll is the case that breaks a naive progress check as thoroughly as it broke the hash, because step 9 genuinely produced nothing new and the correct response is to do it again. A build that sits at running 1/4 for four minutes is not stuck; it is a build. The distinction that resolves it is that waiting is a legitimate state as long as the thing being waited on is still in a state that can change, and as long as the waiting has a deadline.
Both of those are declarations you can make per tool, in the same tool manifest your dispatch layer already reads to build the tools array it sends the model, rather than inferences the detector has to draw.
{
"name": "get_build_status",
"kind": "wait",
"progress_field": "status",
"waiting_values": ["queued", "running"],
"terminal_values": ["success", "failed", "cancelled", "timed_out"],
"max_wait_seconds": 1200,
"min_interval_seconds": 5
}With that manifest entry, repeated identical calls are exempt from both the hash check and the no-progress counter while status is in waiting_values and the wait budget has time left. When status is terminal and the model calls it again, the exemption does not apply, because asking a finished build how it is doing is exactly the loop the detector exists for. When max_wait_seconds is exhausted, the wait ends and the run is told the build did not reach a terminal state in twenty minutes, which is information the model can act on. And max_wait_seconds is not an independent number — it is drawn from the run's wall-clock budget from chapter 3, so the 1,200 seconds fits inside the deploy agent's 45-minute run with room for the merge and the report afterwards.
That is the substantive difference from the whitelist. A whitelist entry says the tool is exempt; the manifest says this tool may repeat, under these conditions, for this long, and every one of those clauses is a place where an infinite wait gets caught. A build ID that was never valid and returns queued forever hits the 1,200-second ceiling. A CI provider that starts returning unknown — not in waiting_values, not in terminal_values — stops being exempt on the first occurrence, which is when a human should be told.
A whitelist is worth taking seriously as a proposal, because the reasoning behind it is correct as far as it goes: this tool repeats legitimately, the detector fires on repeats, so exempt the tool. The flaw is in what the exemption is conditioned on. Membership of a whitelist is a property of the tool's name, and the tool's name is the same on the healthy poll and on the poll of a build that finished forty minutes ago. It holds still. What varies is whether there is still anything to wait for, and the whitelist has no expression for it. Each clause in the manifest attaches the exemption to something that does vary: waiting_values to the reply's own state, max_wait_seconds to elapsed time, terminal_values to whether the thing being watched has finished. An exemption granted on a constant is permanent by construction, which is another way of saying it is not a control.
The wait ceiling is also not a number somebody liked. Twenty minutes of build wait has to fit inside the deploy agent's 45-minute run with the merge and the report still to come afterwards, so it is a slice of the wall-clock budget from chapter 3 rather than a limit of its own. That relationship is the thing to preserve when the numbers change. A wait ceiling set independently of the run's deadline produces the case where a run is inside its wait budget and outside its run budget at the same moment, and the two limits then disagree about whether to stop.
The hash sits at the front of that pipeline as a filter rather than a verdict, which is the arrangement to keep. It is two microseconds and it eliminates the great majority of steps from any further consideration, and the progress check — which needs the tool result, a digest, and the write log — only runs on the small remainder. Reversing them, or dropping the hash now that something better exists, costs a digest on every result of every step for a signal you mostly do not need.
A better answer than polling in the loop
The deploy agent's build wait, done as 240 polls at five-second intervals, costs 240 model round trips. At roughly 9,000 input tokens each by the end of the run, that is on the order of two million tokens and several dollars for the privilege of watching a progress bar, and it puts 240 opportunities in front of the model to decide something creative about a build that has not finished.
Both figures in that sentence come out of the manifest rather than out of nowhere, and following them makes the size of the waste concrete. The poll interval is five seconds and the wait ceiling is 1,200 seconds, so a build that uses its whole allowance is polled 1,200 divided by 5 times, which is the 240. Each of those polls is a full model round trip, because the model is what decides to poll again, and a round trip resends the conversation. Multiply 240 round trips by the roughly 9,000 input tokens a late-run conversation carries and the result is the two million. The multiplication is worth doing slowly because the intuition it corrects is that a poll is cheap. One poll is cheap. What is expensive is that the price of a round trip is set by the length of the transcript rather than by the size of the question, and the transcript is at its longest exactly when the agent is waiting near the end of a run.
Collapsing the wait into a single blocking tool call removes all of it.
def await_build(build_id: str, budget: Budget) -> str:
deadline = min(1200.0, budget.remaining_seconds() - 120.0)
end = time.monotonic() + deadline
interval, last = 5.0, None
while time.monotonic() < end:
state = ci.get_build(build_id)
if state["status"] in TERMINAL:
return json.dumps(state)
last = state
time.sleep(interval)
interval = min(interval * 1.5, 30.0)
return json.dumps({"status": "wait_timeout", "waited_s": round(deadline),
"last_seen": last})One step, one entry in the trace, one deadline that came out of the run's own remaining time with two minutes held back for the work that follows. The loop detector never sees the repetition because there is no repetition in the loop, the backoff on the interval means a twenty-minute build costs about forty HTTP requests rather than 240, and the timeout returns a structured result the model can reason about instead of an exception. The general form of the rule is that anything the model does not decide should not cost a model round trip, and polling is the clearest case of a decision the model is not making.
The forty requests are worth checking rather than accepting, because the backoff makes them non-obvious. The interval starts at five seconds and is multiplied by one and a half after each sleep, capped at thirty. So the gaps run five, then seven and a half, then a bit over eleven, then a bit under seventeen, then a bit over twenty-five, and from there thirty seconds for the rest of the wait. Those first few gaps together account for roughly a minute of the twenty, at the cost of about half a dozen requests. The remaining nineteen minutes are covered at thirty seconds apiece, which is thirty-eight more. Add them and the total lands in the low forties — call it forty — against 240 for a fixed five-second poll. The ratio comes almost entirely from the cap: the growth phase is over inside the first minute, and everything after that is a fixed interval six times longer than the original one.
Notice what the backoff is trading and what it is not. It costs latency on detection — a build that finishes one second after a poll is now noticed up to thirty seconds later instead of up to five — and it buys a sixfold reduction in requests against a system you may not own. That trade is defensible for a build that runs for twenty minutes and indefensible for one that runs for fifteen seconds, which is why the starting interval and the cap are both worth setting per tool rather than globally.
The in-loop poll still needs the manifest treatment, because tools you do not own will sometimes only expose a status endpoint, and an agent that is genuinely waiting on a human — an approval, a reply — cannot be collapsed into a blocking call that holds a worker for four hours.
What progress detection gets wrong
It is more expensive and more fragile than the hash, and both costs land in the same place: someone has to decide, per tool, what counts as new. Get the volatile-field list wrong and the detector reports nothing forever; declare a wait tool without terminal values and a stuck build becomes exempt again through a different door; treat any write as progress and an agent that rewrites the same scratchpad file every step looks productive while going nowhere. The counter is only as good as the signal definitions behind it, and those definitions are per-tool work that does not generalise across a fleet.
It is also blind to the run that makes steady, well-formed, entirely wrong progress. Four searches that each return different documents, none of them relevant, produce four changed digests and a counter that never leaves zero, and the run proceeds confidently to an answer built on nothing. Progress is a measure of motion and says nothing about direction, which is a limitation with no clean solution in this layer and is the subject the last chapter of this topic takes up.
The instrument that answers "is this run stuck" therefore sits on the reply, and the two traces at the top of this chapter are the reason it has to. The deploy agent repeated itself and was fine, because its results moved. The docs agent varied itself and was stuck, because its results did not. Neither fact is visible in a call. What the reply-side signals give you is a counter that resets on any evidence the run acquired something — a changed result, a write that stuck, a plan item closed, a countable step towards the target — and terminates when nothing has for a small number of steps in a row. What they give you second, and it is the part that pays for the per-tool work, is a way to keep the poll you legitimately need. A wait declared with the states it may sit in and the time it may sit there is bounded by construction, which is a control, where a tool exempted by name is bounded by nothing.
Whatever fires — the wait timeout, the no-progress counter, the loop hash, or one of the four budgets — the run is being cut off in the middle of something. The deploy agent that gives up after twenty minutes may have already pushed a commit and opened a pull request; the email agent that hits its money cap may have already written to the CRM. Ending the run is the mechanical part, and what the run hands back is where the real work is.
Error recovery — read the current version at https://daviddominguez.dev/learn/en/error-recovery/a-stuck-run-is-one-where-nothing-new-entered-the-context