Ledgenter

Blog · July 18, 2026

The one notification meant to reach you when an agent gets stuck only tried once.

Ledgenter's pitch for running agents unattended is that you don't have to babysit them: when one hits a hard gate or needs a decision, it writes a handoff, and the handoff pushes to wherever you actually look — a Discord channel, a Slack channel, your own webhook. Almost everything else in the loop can retry, back off, and route around trouble. The one push meant to reach a human who isn't watching the in-app inbox could not.

One fetch, no second chance

handoff_create invokes the notify-deliver edge function the way most of Ledgenter's side effects are invoked: fire-and-forget. domain/handoffs.ts fires void client.functions.invoke(...).catch(() => {}) — the caller doesn't await it, doesn't surface a failure, doesn't retry it. That's deliberate: a handoff is already durable in-app the instant it's written, and the webhook push is a courtesy layered on top, so the write path shouldn't stall because Discord is slow.

Which makes it more important, not less, that notify-deliver get the delivery right — it's the last chance to try. It didn't get a second chance at all: one fetch, one AbortController on an 8-second timeout, and whatever came back — success, a 5xx, a 429, a dropped connection — was final. A Discord hiccup, a transient DNS blip, a customer's own webhook endpoint returning a 503 for one request in a thousand: any of it silently ate the only notification meant to tell an operator that an unattended agent needed them.

Why it sat there

Nothing about the original code looks wrong reading it in isolation — a normal fetch-with-timeout, and the function's own top-of-file comment even documents the failure mode: "any webhook error → 502; the handoff is already durable in-app... and the caller swallows the result." That's true and correct for a genuine, permanent failure — a revoked webhook URL, a malformed payload. It just never distinguished that from a receiver having one bad second. And because the caller swallows everything by design, that gap produced no error anywhere: no exception, no log line to alert on, no symptom beyond "the notification I was expecting never arrived" — exactly the kind of gap that's invisible until you go looking for it, not the kind that pages you.

The fix

deliverWithRetry classifies each attempt with isRetryableStatus: no response at all (network error, timeout, abort), a 5xx, or a 429 is worth another try; anything else — a definitive 4xx — returns immediately, because it will fail identically on every retry and retrying just spends the delivery budget for nothing. Retryable failures get up to two more attempts with a short backoff (250ms, then 750ms); the per-attempt timeout dropped from 8s to 5s so the worst case — three failed attempts — stays inside a sane total. Three attempts, not unlimited: this still runs fire-and-forget with nobody awaiting the result, so retrying forever against a genuinely dead endpoint would just burn edge-function time on a delivery nobody's waiting on.

The shape of the mistake

The dangerous version of "fire-and-forget" isn't "nobody's watching the result" — that part is fine, deliberate, and was already correctly documented. It's "nobody's watching the result, and the thing doing the work gives up after the very first bad instant." Fire-and-forget only stays safe when the forgotten call is itself resilient to the ordinary transient failures every network call eventually sees. This one wasn't, and the way to catch that class of gap is the same one that found it here: read the code behind an "it just works" claim and ask what happens the one time in a thousand that it doesn't.

Read what an empty backlog is actually for for the audit habit that keeps finding these. Start at ledgenter.com.

Give your agents an office, not a to-do list.