Idempotency Is the Whole Game: Making a Publishing Agent Survive a Mid-Run Kill
A publish run that dies halfway leaves some channels posted and some not. Here is the per-channel ledger design that makes cross-posting resumable, why exit code 0 is not a receipt, and what to do when an API offers no idempotency key.
A scheduled publishing agent is almost entirely I/O against services you do not own, on a clock you do not control. Ours fans out to four destinations per article: a search-engine index ping, a microblog, a federated social network, and a developer community site. That last one rate-limits hard enough that we pace requests 75 seconds apart and back off on 429s. A three-article run therefore spends over four minutes inside the fan-out, and most of those minutes are spent sleeping.
Four minutes is plenty of time to get killed. A CI job hits its wall-clock cap. A container gets evicted mid-sleep. Someone closes a laptop. The run dying is not the interesting part. The interesting part is what the next run does about it.
Retry is easy; knowing what already happened is not
The failure that costs you is not a crash — it is a partial fan-out that leaves no obvious trace. Three articles across four channels is twelve remote calls. The process dies on call seven. Article one is everywhere. Article two reached two of four channels. Article three exists nowhere but your git history.
Now pick a retry strategy. Re-run the whole job and article one gets posted a second time to every channel that has no server-side dedupe. Skip anything carrying a published flag on the article record and article two never receives its remaining two channels — not on the next run, not ever. Both strategies are wrong for the same reason: they track state at a granularity that does not match the work.
We shipped a worse version of this. For a long stretch, our publish command built and deployed the site but never invoked the syndication step at all. 57 articles went live and were never announced anywhere. Nothing threw. Exit code zero, every time. We found it by reading the script, not by reading logs, because there was nothing in the logs to read.
The rule that falls out of this: state belongs to the (item, channel) pair. Not to the run. Not to the item.
Design the ledger before you write the retry loop
Three properties do the real work, and none of them are the retry loop itself.
Write in two phases. Mark the pair in_flight before the remote call, resolve it to done or failed after. A kill that lands between the network write and the ledger write is not a hypothetical — it is the single most likely place to die, because that window contains the network. Without a two-phase record you cannot distinguish never sent from sent but unrecorded, and those two states demand opposite actions.
Store the identifier the remote system gave you. The post ID or URL that came back in the response is what lets you reconcile later without guessing. It also turns an ambiguous in_flight row into a question you can answer with a read.
Keep the ledger outside the run. Not process memory, not the job’s temp directory, not an in-memory queue that dies with the worker. A committed JSON file or a table. Ours lives in the repo, which means the diff shows exactly what shipped and when — the same reason we generate article metadata ahead of build time rather than during it.
With that in place, resume stops being a mode and becomes a filter:
// pending work is a query over the ledger, not a resume cursor
const pending = [];
for (const item of items) {
for (const channel of CHANNELS) {
const row = ledger.get(item.slug, channel.id);
if (!row || row.state === 'failed') pending.push({ item, channel });
else if (row.state === 'in_flight') pending.push({ item, channel, verify: true });
}
}
A resumed run and a fresh run now take the same code path. There is no recovery branch to maintain and no --resume flag anyone has to remember at 2am. That matters more than it looks, because you cannot reliably test a recovery branch: the kill can land anywhere, and the cases you write tests for are the ones you already thought of.
When the API gives you no idempotency, buy it with a read
Publishing APIs rarely ship the Idempotency-Key header that payment APIs standardized years ago. In practice you land in one of three tiers.
| What the channel offers | What resume does | What it costs |
|---|---|---|
| A real idempotency key | Replay the call with the same key | One extra header |
| A queryable natural key, usually the canonical URL | Search the channel for that URL before posting | One read per ambiguous pair |
| Nothing | Scan your own recent posts in a time window, or escalate to a human | Manual review, or accepted duplicate risk |
The canonical URL is the natural dedupe key for anything content-shaped, and most channels let you search your own posts for it. Pay that read only when a row is stuck at in_flight — on a clean run it never fires, so the cost sits at zero in the common case and one request in the case that actually needs it.
One more classification is worth making explicit before you write any of this: decide, per channel, whether a repeat is harmless. Index pings are naturally idempotent, so ping freely and treat them as at-least-once. Social posts are public and permanent, so prefer at-most-once and accept a missed announcement over a duplicate — a missing post can be sent by hand tomorrow, a double post cannot be un-seen. Applying one policy uniformly across both kinds is how the same link ends up in a feed three times.
OpenCode
Open-source terminal coding agent. Useful for the mechanical part of this refactor: splitting a linear publish script into per-channel stages, threading a ledger handle through each one, and generating the fault-injection harness that kills the run after N calls.
Open source, bring your own model API key
Affiliate link · We earn a commission at no cost to you.
The refactor is mostly mechanical once the ledger schema is settled, and it is exactly the kind of repetitive, well-specified edit worth handing to a coding agent while you keep the schema decision for yourself.
FAQ
Can I skip all this by putting the work in a queue with exactly-once delivery?
Where should the ledger live for a small setup?
How do I actually test the resume path?
None of this makes the agent more capable. It makes the agent’s failures cheap, which for anything running on a schedule is the property that determines whether you keep running it.
Tools used in this review
Some links above are affiliate links. We may earn a commission if you sign up. See our disclosure for details.
Related reading
2026-08-13
What a Daily LLM Digest Agent Costs to Run for a Year: $166 and 34 Hours
A full year of running one scheduled LLM digest agent in production: the token bill per stage, the infrastructure line items, the 41 runs that needed a human, and which failures were actually expensive.
2026-08-13
Schema Validation Is Not Enough: When Agent Output Passes Zod and Still Breaks the Build
Zod checks the shape of what your agent returned. Your build checks what that shape means. Four failure classes that survive a clean parse, and the three-layer validation pass we run instead.
2026-08-13
Authenticating AI Agents: API Keys vs OAuth Device Flow vs Scoped Tokens
A practical breakdown of the three credential models for non-human callers — static API keys, the OAuth 2.0 device authorization grant, and short-lived scoped tokens — and when each one actually fits.
2026-08-13
Error Messages as an Agent Interface: Designing API Failures an Agent Can Recover From
An AI agent only sees what your error body contains. A field-by-field guide to API errors agents can act on — stable codes, explicit retryable flags, wait hints, fix examples — and the error shapes that trap agents in retry loops.
2026-08-12
Spec-Driven Development With AI Agents: Writing a Spec an Agent Can Actually Execute
How to structure a spec so a coding agent can run it end to end without babysitting: ground truth files, an interface contract, one acceptance command, and explicit out-of-bounds rules.
Get the best tools, weekly
One email every Friday. No spam, unsubscribe anytime.