Four Ways a Batch Runner Can Believe It Already Finished A developer detailed how their batch runner for an AI content pipeline, despite having resumability built in from day one, still failed silently for three months due to four distinct bugs. The fixes, which landed in a single day, included centralizing rate-limit error detection and correcting a case where quota errors were treated as successful results. On 16 March 2026 the batch runner for our content pipeline landed in a single commit. That commit already contained a resume flag, a state log with per-item status, error classification, a quota pause, and a lock file. Resumability was not bolted on after it hurt. It was there on day one, and it still did not work for another three months. That gap is the article. When people write about resumable AI systems, they usually mean an agent remembering a conversation across turns: threads, checkpointers, replaying a graph from a saved node. This is the other thing. A batch pipeline that has to know which work is already finished, across separate runs and across two different machines. The order in which we built it is not flattering, so I will put it first. Eleven days before the runner existed, we had a circuit breaker https://www.bestaiweb.ai/glossary/circuit-breaker/ stop after three consecutive quota failures and a shutdown path that kills child processes when the parent is interrupted instead of orphaning them. That is survival of one call, not resumability. Four days after the runner, we shipped a retry that continued a half-written file instead of regenerating it, which is the classic confusion between the two: retry patterns https://www.bestaiweb.ai/glossary/llm-fallback-and-retry-patterns/ are about surviving a call that failed, resume is about not paying again for a call that already succeeded. We built them in that order, and for a while I assumed the second came free with the first. Two axes, kept deliberately separate. An entity for us: one topic that owns a set of articles is in exactly one of five states: | State | Meaning | |---|---| pending | not started, or reset after a dead run | running | claimed by the current run | done | all planned output exists | failed | stopped for a reason that waiting will not fix | paused | stopped for a reason that waiting will fix | A failure additionally carries one of six categories: timeout , quota , validation , phase error , planning error , unknown . Keeping the category off the state axis is what lets paused and failed be different verbs at all. Quota exhaustion is not a defect. It is the system being told to come back later, which is exactly the distinction that separates pausing from failing https://www.bestaiweb.ai/glossary/agent-error-handling-and-recovery/ in any batch that talks to a metered API. A validation error is the opposite: waiting a thousand years will not change a deterministic verdict, so it stops the run for a human. One detail that only shows up once a run has actually been killed: it leaves its entities frozen in running , and nothing will ever move them on its own. So a resumed run sweeps stale running back to pending before it schedules anything. Note where that reclaim stops: it covers entity state, not the lock file the dead run also left behind. Those needed two different owners, and we found that out the hard way. An audit had turned up four independent ways the resumable runner could be silently wrong, and all four fixes landed in one day. Not one bug with four symptoms. Four bugs. a A quota error came back as an empty result instead of raising. The parallel aggregator treated it as a value, so the batch reported success, missing content was recorded as produced, and the run kept going. This one gets its own section below. b Three separate places in the stack each recognised rate-limit https://www.bestaiweb.ai/glossary/rate-limiting/ errors their own way, with their own string matching. What one layer flagged, another missed, and which one you hit depended on where the error happened to surface. The fix was not to add the missing phrases in three places. It was to delete two of the matchers and have everything ask the same one, which is the same move as killing a whole class of error rather than the instance in front of you https://www.bestaiweb.ai/anatomy-of-a-flaky-ai-agent/ . We had learned the general shape of this before, when a single output contract had to hold identically across three different model backends https://www.bestaiweb.ai/llm-structured-output-three-backends/ : if a rule can drift between implementations, it eventually will. c A corrupted state file was read as "fresh start". Not as an error. As zero progress, cheerfully, and then we paid for everything again. d The lock had a check-then-write window between "is anyone holding this" and "I am holding this", wide enough for two runners to both walk through. Hole a is the one worth stealing, because the shape is everywhere. Our generation step launches many agents in parallel for one topic and waits for all of them, collecting outcomes instead of letting the first failure escape. Every language has this primitive, and using it is correct here: one agent dying should not orphan its seventeen-odd siblings mid-flight. But our runners, on permanent quota exhaustion, returned an empty result rather than raising. An empty result is a value. Values pass through a gather-all aggregator as data, not as alarms. php before: run prompt - Result exhausted quota yields Result text="" batch = gather all runs every entry is fulfilled = batch reports success = caller records each item as produced = run continues into the next topic, against a closed window after: run prompt raises QuotaExhausted phase re-raises it past the aggregator per-topic loop recognises the signature = entity moves to paused, not failed = the whole run stops and notifies The bug was not "we forgot to check for errors". We checked. The check ran against a result object that had been handed a successful-looking shape by a layer that had already given up. If a failure has to travel through an aggregator, decide explicitly how it survives the trip. Raise, or wrap it in something the aggregator cannot flatten into a value. Deciding by accident means deciding "it becomes data". The table we now actually operate by: | What happened | Category | What the runner does | |---|---|---| | Quota exhausted | quota | Pause the whole run, notify a human, resume later. Waiting is the fix. | | Deterministic check failed | validation | Stop immediately. No amount of waiting changes a verdict. | Timeout, or a bare exit code 1 with no explanation | timeout / unknown | Back off and probe. Often this is a filled quota window wearing an unhelpful error. | | Crashed run left a lock behind | none | The runner reclaims stale entity state but not its own lock file; the supervising wrapper clears a lock whose owner process is gone. | That last row deserves honesty. File-based locking on one machine is not distributed coordination, and I am not going to pretend otherwise. What the next section buys us is narrower than it may sound: sequential runs on two machines agree on what is already done, as long as each one pulls the committed output before it starts. Two runs started at the same time are not coordinated at all, and nothing in this design stops them from generating the same work twice. The real fix was not a better flag. It was deleting the authority of the state file. Before: "is this topic finished?" was answered by reading a local, untracked progress file. After: it is derived from two signals that are committed to the repository. done topic : planned = the slugs the content plan promises for this topic return planned is non-empty and every slug in planned has a file at content dir/