Never Miss a Managed Agent Completion: Pairing a Serverless Receiver with Polling Reconciliation
A cloud Managed Agent can finish while you are not watching, and the webhook that should tell you can quietly fail. Here is a serverless receiver on Cloudflare Workers paired with polling reconciliation, and a state machine that recovers every completion within minutes.
The week Managed Agents landed in public preview on the Gemini API, my first reaction was not excitement but a small worry. A long-running job on the cloud finishes while I am not looking at the screen. So how, exactly, does that fact of "it finished" travel back to me?
As an indie developer who runs unattended work across several sites on Cloudflare Workers, this was not somebody else's problem. Because I have decided not to keep an always-on server, my completion receiver has to be serverless too. And push-style notifications — webhooks — are convenient, but they fail quietly. Not even noticing that one was lost is the scariest part.
What follows builds the receiver-side design for never missing a Managed Agent completion, together with code that actually runs. The core idea is to pair push (webhook) and pull (polling) from the start, so that even if one channel goes silent, the final state still converges.
Where "it finished but I never knew" actually happens
Let me break down the failure modes concretely. The path from a cloud Managed Agent to your receiver has more gaps than you might expect.
The first is plain delivery loss. Your receiver returned a 5xx for a moment, or it was down for a few hundred milliseconds during a deploy, or the network gave up on the delivery. Even platforms that promise at-least-once have a retry cap. Exceed it, and the notification vanishes silently.
The second is duplicate delivery. If your receiver succeeds but the connection drops just before it returns a response, the sender concludes "that failed" and resends. You end up receiving the same completion event twice. If you naively run a side effect like a charge or a file publish twice, real damage follows.
The third is reordering. Two transition notifications, running then succeeded, can arrive in the opposite order. When network paths differ, overtaking is ordinary. Write "adopt the state of the last notification that arrived" and a finished job rolls back to running.
The fourth is the race that actually bit me hardest. You start a job, and the completion webhook arrives before you have written its job_id to KV. When the cloud side is fast, "it finished" arrives before your own "record the start" write completes. The receiver sees an unknown job_id and discards it as "a job I don't know." This is neither load nor an outage, just an ordering problem, which makes it hard to reproduce and hard to explain.
Fixing these one bug at a time turns into a pile of patches. I have come to believe it is more robust, in the end, to duplicate the path itself so the structure becomes "as long as one of them carries the truth, we're fine."
The premise: make push and pull two wheels from the start
Prepare two receiving paths.
One is the push path. The instant the Managed Agent finishes, it fires a webhook, and your Cloudflare Worker receives it. Fast, but as noted, it drops.
The other is the pull path. You periodically ask the cloud, "is that job done yet?" and reconcile the state. Slow, but it does not drop. As long as you keep asking, you will eventually read the correct state.
The place where these two merge is the job record you keep in KV. Whether via push or pull, both aim at the same state transition on the same record. Which one wins when they conflict is decided by the state machine's rules. The key is to preserve monotonicity: adopt "the more advanced state," not "the one that arrived last."
Put differently, the webhook is a fast shortcut, not the sole truth. Polling holds the ultimate guarantee of truth, and the webhook is positioned as an optimization that makes that truth arrive sooner. Decide this division of roles up front, and the implementation that follows will not wobble.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦You can eliminate the fragility of a cloud Managed Agent finishing in the background without its notification reaching you, by pairing a webhook receiver with polling reconciliation
✦You'll build an idempotent receiver on Cloudflare Workers and KV that survives duplicate deliveries, out-of-order events, and the race where a callback arrives before your job record is committed
✦As an indie developer with no always-on server, you'll design a state machine that reliably recovers unattended job completions within minutes
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Build an idempotent receiver on Cloudflare Workers
First, the push receiver. Three design goals: verify the signature, reject duplicates, and never drop even an unknown job.
// worker: receiver for the Managed Agent completion webhook// KV bindings: JOBS (job records) / SEEN (delivery-dedup records)interface Env { JOBS: KVNamespace; SEEN: KVNamespace; WEBHOOK_SECRET: string;}export default { async fetch(req: Request, env: Env): Promise<Response> { if (req.method !== "POST") return new Response("nope", { status: 405 }); const raw = await req.text(); // 1) Verify signature on the RAW body (recompute HMAC, constant-time compare) const ok = await verifySignature(raw, req.headers.get("x-agent-signature"), env.WEBHOOK_SECRET); if (!ok) return new Response("bad signature", { status: 401 }); const ev = JSON.parse(raw) as { deliveryId: string; // unique per delivery (used for dedup) jobId: string; // unique job key state: "running" | "succeeded" | "failed"; seq: number; // monotonic version number stamped by the sender finishedAt?: string; }; // 2) Reject duplicate delivery (never process the same deliveryId twice) if (await env.SEEN.get(ev.deliveryId)) { return new Response("duplicate", { status: 200 }); // 200 stops the resend } // 3) Apply the transition monotonically (create a record even for unknown jobs) await applyTransition(env, ev); // 3 days of retention outlasts the sender's retry window await env.SEEN.put(ev.deliveryId, "1", { expirationTtl: 60 * 60 * 24 * 3 }); return new Response("ok", { status: 200 }); },};
What matters here is returning 200, not a 4xx, when you detect a duplicate. To the sender, 200 signals "already accepted," which stops the retries. Return an error and the more dutiful the counterpart, the longer it keeps resending and loading you. In production I once returned 409 for duplicates, and the sender's retries spun endlessly; ever since, I pin this to 200.
And do not discard an unknown jobId. The next applyTransition creates the record if it is missing, then applies the transition. That is the answer to the race from the previous section, where receipt outruns the start record.
Keep transitions monotonic to protect the single truth
Here is the body of applyTransition. This is the merge point of the two channels; both push and pull pass through this same function.
type JobState = "unknown" | "running" | "succeeded" | "failed";const RANK: Record<JobState, number> = { unknown: 0, running: 1, succeeded: 2, failed: 2 };interface JobRecord { jobId: string; state: JobState; seq: number; // max seq already adopted finishedAt?: string; claimedAt?: string; // marker that the side effect ran (see below) createdAt: string;}async function applyTransition(env: Env, ev: { jobId: string; state: JobState; seq: number; finishedAt?: string }) { const key = `job:${ev.jobId}`; const cur = (await env.JOBS.get(key, "json")) as JobRecord | null; // Start from unknown if there is no record (prevents dropping unknown jobs) const base: JobRecord = cur ?? { jobId: ev.jobId, state: "unknown", seq: -1, createdAt: new Date().toISOString() }; // Monotonicity: adopt only a newer seq AND a non-regressing state const forward = ev.seq > base.seq && RANK[ev.state] >= RANK[base.state]; if (!forward) return; // ignore stale or rollback notifications const next: JobRecord = { ...base, state: ev.state, seq: ev.seq, finishedAt: ev.finishedAt ?? base.finishedAt, }; await env.JOBS.put(key, JSON.stringify(next)); // Run the side effect exactly once, only on first arrival at a terminal state if ((next.state === "succeeded" || next.state === "failed") && !base.claimedAt) { await runSideEffectOnce(env, next); }}
RANK ranks the states, and combined with seq (the sender's monotonic version number) it guarantees "no regression." Even if ordering flips and a stale running arrives after succeeded, forward becomes false and it is ignored. A finished job rolling back to running cannot happen by construction.
Side effects — publishing a file, kicking off a downstream job, sending a notification — run only on first arrival at a terminal state. Even if push and pull both carry succeeded at once, whichever reaches first sets claimedAt, and the other sees it and holds back. Once you duplicate the path, this exactly-once guarantee is mandatory.
Make polling reconciliation the backstop
The reason the state still converges when push goes silent is that pull exists. Run it periodically with Cron Triggers, and query the cloud only for jobs that have not reached a terminal state.
// scheduled(): reconcile unfinished jobs against the cloud at a fixed intervalexport async function reconcile(env: Env) { const list = await env.JOBS.list({ prefix: "job:" }); const now = Date.now(); for (const k of list.keys) { const rec = (await env.JOBS.get(k.name, "json")) as JobRecord; if (rec.state === "succeeded" || rec.state === "failed") continue; // terminal: no reconcile // Fetch the authoritative state from the cloud (Managed Agents get API) const remote = await fetchJobStatus(env, rec.jobId); if (!remote) continue; // Feed it through the SAME applyTransition (keep the merge point single) await applyTransition(env, { jobId: rec.jobId, state: remote.state, seq: remote.seq, // assume the get API returns the same seq finishedAt: remote.finishedAt, }); // Catch "started but stuck in unknown/running for too long" as an anomaly const ageMin = (now - Date.parse(rec.createdAt)) / 60000; if (rec.state !== "succeeded" && rec.state !== "failed" && ageMin > 30) { await alertStuckJob(env, rec.jobId, Math.round(ageMin)); } }}
Feeding polling through the same applyTransition as the webhook is the crux of this design. Keeping the merge point single prevents the accident where the "webhook-sourced record" and the "polling-sourced record" hold separate truths. Both channels can only write results that have passed the monotonicity rule.
Calibrate the interval by working back from the job's expected duration. For jobs that finish in minutes, a 1-to-2-minute interval works; for ones that take tens of minutes, 5 minutes is enough. A KV list gets heavier as keys grow, so evict terminal records to a separate prefix the next day, or let them expire via TTL, to keep the reconciliation set from bloating. I run unattended jobs for six sites, and after switching to folding terminal records away at 24 hours, the cost of one reconcile pass settled to nearly constant.
Operating it as an indie developer: thresholds and monitoring
Duplication is meaningless as "just buying insurance"; you feel safe only once you can see that the insurance is working. Here are the metrics I actually watch.
Metric
Meaning
Guideline / action
Webhook-first rate
Share of terminal arrivals delivered by push first
Healthy at 95%+. A sharp drop means the receiver or network is unwell
Reconcile rescues
Count where polling confirmed the terminal state first
Ideally 0 at all times. Rising means inspect the webhook path
Stuck detections
Jobs unfinished past 30 minutes
Even one warrants suspecting a cloud-side failure or queue jam
Duplicate-delivery rate
Share of resends rejected by SEEN
0.1-0.5% is normal. Extremely high suggests missing 200 responses
"Reconcile rescues" in particular is an excellent leading indicator. When this normally-zero value starts to climb, it is the sign that the webhook has begun to fail quietly, and you can act before users or you notice real harm. A push-only setup cannot even observe this symptom. The real value of duplication, I believe, is not the speed guarantee but exactly this: making one channel's silence visible.
Set thresholds by your own operational feel. In my case, I hold an internal SLA that unattended job completion means "my record reaches a terminal state within 5 minutes of the start." Just reviewing weekly how many times I broke that bar already reveals where along the path things are weakening.
Where implementations tend to trip
A few pitfalls I actually stepped on in production.
Do not blindly trust the sender's seq. Some platforms stamp an independent version number per state, so a contiguous seq across running and succeeded is not guaranteed. In that case it was safer to prioritize the state RANK and demote seq to a tiebreaker within the same state.
Do not overtrust KV's eventual consistency. A read in another region right after SEEN.put can return a stale value, and for a very short window a duplicate slips through. That is precisely why duplicate detection is only a first line of defense; guarantee the ultimate exactly-once with the claimedAt check inside applyTransition. Splitting duplicate defense and side-effect exactly-once into separate layers is a lesson I learned the hard way.
Do not defer signature verification until after parsing the body. Verifying after JSON.parse lets a malformed body throw and gives an attacker room to probe the receiver's behavior. Verify on the raw body and never break the order of parsing only after it passes. For the fundamentals of this receiver, the companion piece a practical pattern for building webhook receivers that never drop events is also worth a look.
Protect side-effect exactly-once with idempotency keys. The claimedAt marker alone still leaves a gap if you crash between the KV write and the side-effect execution. If the side-effect side (payment, publish APIs) accepts an idempotency key, use the jobId as that key and defend it doubly with the receiver's marker. Designing a whole unattended batch to survive re-runs is covered in keeping Managed Agents' unattended batches unbroken across re-runs: idempotency and checkpoints.
First, add just the reconcile polling to one unattended job you already have. Even without a webhook yet, the pull path alone establishes the "never miss a completion" foundation. Then layer push on as an optimization, and once you confirm that "reconcile rescues" stays pinned at 0, your duplication is working correctly.
Mechanisms that run asynchronously on the cloud, like Managed Agents, will surely become ordinary in indie development from here. I am still learning as I operate it, but I feel the idea of designing "the fast path" and "the path that never drops" separately applies just as well to other asynchronous work. Thank you for reading.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.