Rolling Back a Half-Finished Agent: Compensating Transactions for Partial Failure
When you let an Antigravity agent run work that spans several external systems, a failure in the middle leaves the world half-rewritten. Retrying doesn't fix that. Here is how to fold it back safely with compensating transactions (the Saga pattern), with TypeScript and real operational numbers.
Running six apps by myself, a release is never a single button. Update the AdMob mediation config, swap the App Store Connect metadata, flip a Remote Config flag, and finally draft a short announcement post. This was the sequence I'd been doing by hand since 2014, and this spring I started handing the whole chain to an Antigravity agent.
The first few runs felt great. Then, late one Thursday, it broke. The AdMob update landed, the Remote Config flag went up — and the App Store Connect metadata update hit a rate limit partway through the fourth app. The agent honestly reported "failed" and stopped. Correct behavior. But what I was left holding was a state that should never exist anywhere: the ad config was new, the store listing was half-new, and the flag had already turned a new feature on.
Telling the agent "just run it again" here is dangerous. Re-running non-idempotent operations could rewrite the already-successful AdMob config and double-fire billing events. Some kinds of breakage don't heal with a retry. What I needed was a way to deliberately undo side effects that had already succeeded — what distributed systems call a compensating transaction, or the Saga pattern.
Why retries aren't enough
When people talk about agent reliability, retries and idempotency keys come up first. I've written before about suppressing duplicate execution with idempotency keys. But that mechanism is for "send the same operation twice, take effect once." It is not for "fold up a state where some of several different operations succeeded and others didn't."
A multi-step side effect has three states:
All steps succeeded — do nothing.
All steps failed (never took the first step) — a retry is enough.
Partial success — this is the real problem. You must decide whether to push forward to completion or roll back to nothing.
Retries only protect you in case 2. Case 3 — partial success — cements a "half-new world" into production if you ignore it. The agent can report that it stopped, but it won't clean up the side effects that happened before the point where it stopped. You have to fill that gap in the design.
The four rules a compensating action must satisfy
The heart of a Saga is giving each step a pair: a forward operation (do) and an undo operation (compensate). A compensating action is more constrained than an ordinary function. I refuse to accept anything as a compensation unless it satisfies these four rules.
It must be idempotent. If the compensation itself fails midway and reruns, it must not undo twice. "Set the flag to false" is idempotent; "decrement a counter by one" is not.
It must not depend on the forward step having fully completed. Don't assume do finished. The compensation must be safe to call even if do got partway and then failed.
It must have an observable completion condition. You should be able to confirm the compensation "took," not guess — re-fetch the flag, re-read the metadata.
It must not create a new partial failure itself. If a compensation has multiple side effects, then that compensation is also a Saga.
The fourth rule is the harshest in practice. The compensation for "revert App Store metadata" turns out to be a two-step "update metadata + roll back screenshots."
✦
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
✦Build a Saga orchestrator in TypeScript that converges the dreaded 'step 3 failed but steps 1 and 2 already happened' partial failure, using two distinct strategies — forward recovery and backward compensation
✦Learn from a real incident running six apps in parallel where an iOS release left AdMob updated but App Store metadata half-applied, plus the four rules a compensating action must satisfy and how to tell reversible operations from irreversible ones
✦Decide compensation idempotency, where to place the point-of-no-return, and partial-failure detection time (90s down to 8s in my own operation) using real numbers that don't bloat your runtime with over-engineering
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.
Here's the skeleton: a step definition and a runner. Each step carries do and compensate, and only the steps whose do succeeded get compensated, in reverse order.
type StepResult<T> = { ok: true; value: T } | { ok: false; error: Error };interface SagaStep<Ctx> { name: string; // Forward. Store the result in context for later steps and rollback decisions. do: (ctx: Ctx) => Promise<unknown>; // Undo. Called in reverse order, only if its `do` succeeded. compensate: (ctx: Ctx) => Promise<void>; // Crossing this step means no going back (the point of no return, below). pivot?: boolean;}interface SagaReport { status: "completed" | "compensated" | "stuck"; completedSteps: string[]; failedStep?: string; compensationFailures: string[];}async function runSaga<Ctx>(steps: SagaStep<Ctx>[], ctx: Ctx): Promise<SagaReport> { const done: SagaStep<Ctx>[] = []; let crossedPivot = false; for (const step of steps) { try { await step.do(ctx); done.push(step); if (step.pivot) crossedPivot = true; } catch (error) { // The moment partial failure becomes certain. if (crossedPivot) { // We're past the point of no return -> switch to forward recovery (below). return { status: "stuck", completedSteps: done.map((s) => s.name), failedStep: step.name, compensationFailures: [], }; } return await compensate(done, ctx, step.name); } } return { status: "completed", completedSteps: done.map((s) => s.name), compensationFailures: [] };}
Compensation undoes the successful steps in reverse order. The key point: if even one compensation fails, don't swallow it — record it and move on. Stopping midway creates an even nastier intermediate state: "halfway through the rollback."
async function compensate<Ctx>( done: SagaStep<Ctx>[], ctx: Ctx, failedStep: string,): Promise<SagaReport> { const compensationFailures: string[] = []; // Reverse = undo from the last successful step backward. for (const step of [...done].reverse()) { try { await step.compensate(ctx); } catch (e) { // Don't stop on a failed compensation. Record it and escalate to a human. compensationFailures.push(step.name); } } return { status: "compensated", completedSteps: done.map((s) => s.name), failedStep, compensationFailures, };}
A non-empty compensationFailures is the region automatic recovery couldn't reach. I don't leave that to the agent — that part, and only that part, goes to a human (me) review queue. Not automating everything turned out to be the condition for being able to keep automating at all.
Writing the release as a Saga
Let's port the opening incident onto this skeleton. Order matters: put the hard-to-undo operations later and the easy-to-undo ones first. That way, when something fails near the end, the cost of folding back what came before is small.
const releaseSaga: SagaStep<ReleaseCtx>[] = [ { name: "admob-mediation", do: async (ctx) => { ctx.admobSnapshot = await admob.getMediationConfig(ctx.appId); // save current state for rollback await admob.updateMediationConfig(ctx.appId, ctx.newMediation); }, compensate: async (ctx) => { if (ctx.admobSnapshot) await admob.updateMediationConfig(ctx.appId, ctx.admobSnapshot); }, }, { name: "appstore-metadata", do: async (ctx) => { ctx.metaSnapshot = await appstore.getMetadata(ctx.appId); await appstore.updateMetadata(ctx.appId, ctx.newMetadata); }, compensate: async (ctx) => { if (ctx.metaSnapshot) await appstore.updateMetadata(ctx.appId, ctx.metaSnapshot); }, }, { name: "remote-config-flag", // Lighting the flag makes the new feature visible to users = effectively irreversible. pivot: true, do: async (ctx) => { await remoteConfig.setFlag(ctx.appId, "new_feature", true); }, compensate: async (ctx) => { await remoteConfig.setFlag(ctx.appId, "new_feature", false); // idempotent }, },];
The crucial detail is that each dosnapshots the current state into context before rewriting it. Compensation doesn't query the external world for "what the past should be" — it just restores the value it saved a moment ago. An external API might return a different value at compensation time, and trusting that would contaminate the rollback.
The point of no return (pivot) and forward recovery
Not every operation is reversible. Sending a user notification, finalizing a charge, exposing a new feature by flipping a flag — none of these can be un-happened. In a Saga, this line is the pivot (point of no return).
My operating rule is simple: gather pivots near the end of the Saga, and if anything fails after crossing a pivot, switch from compensation (backward) to forward recovery. That's why the code above checks crossedPivot and returns status: "stuck". Stuck means "we can't go back, so the only way out is forward to completion."
Forward recovery retries just the remaining steps idempotently and drives them to done. That's exactly why every step after the pivot is designed to be idempotent, snapshot-free, and independently retryable. Put a pivot early and nearly every step after it becomes a forward-recovery target, and the design burden spikes. The "easy-to-undo first" ordering pays off more than it looks.
Screen out un-compensatable steps up front
The thing that helped most in production wasn't a code trick — it was an ordering trick. Operations that are both "irreversible once done" and "prone to failing" get pulled out ahead of the Saga body as a separate, lightweight validation step.
For example, App Store metadata updates can be rejected by character limits or banned-word checks. Hit that on the third step of the body and you're stuck rolling back AdMob and Remote Config. So before the body runs, I run "metadata dry-run validation only," and if it's rejected there, the Saga body never starts.
In my own numbers: before this up-front check, about 60% of total Saga failures were metadata-related, and most of those failed only after the body was partway through. After adding the up-front validation, post-launch partial failures dropped to a handful per month, by feel. Not causing a partial failure is far cheaper than folding one back — cheaper than any amount of polishing your compensation logic.
Detect partial failure fast
Even with correct compensation, slow detection means the intermediate state lingers in production. Early on, I'd wait for the agent's completion report before reading the logs, so it took about 90 seconds on average to notice a partial failure. Late at night, users step on a mixed old/new screen during those 90 seconds.
Now I stream the success/failure of each do straight to structured logs and raise an alert the instant compensate runs. I cut detection-to-compensation-start to about 8 seconds on average. More than the raw seconds, what matters is catching the fact that "compensation ran" at an external observation point, not from the agent's self-report. If the agent crashes mid-run the self-report never arrives, but the per-step structured logs remain.
Finally, this is less about technique than about stance. At first I tried to run the whole thing — compensation included — fully automatic. But cases where compensationFailures is non-empty — where even the rollback failed — always go to my review queue to close out by hand. It happens only a few times a year, but when it does, pushing through with automation-on-automation only widens the wound.
My grandfathers were temple carpenters, and they always checked the surface by hand after the pieces were joined. They never skipped that final pass with the hand after the machine had cut. When I hand a multi-step side effect to an agent, I keep that last check in my own hands too — and it's precisely because that line exists that I can comfortably hand off the rest.
If you're adding this design next, start by picking one multi-step task you already run and ask, for each step, "can I write a single function that undoes this?" When you find an operation you can't, that's your pivot. Begin by pushing pivots toward the end, and the whole compensation layer gets much lighter. I hope this helps in your own work.
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.