DEV Community

yureki_lab
yureki_lab

Posted on

How I Stopped My AI Agent From Doing the Same Thing Twice After a Crash

TL;DR

My autonomous coding agent once opened the same pull request three times because it crashed mid-task and, on restart, had no idea it had already done the work. Here's the idempotency pattern I built to fix that — check-before-act, stable keys, and a local ledger — plus the five lessons that came out of chasing down duplicate side effects.

The Problem

My agent runs long stretches unattended: multi-step tasks, several tool calls deep, sometimes minutes between the first action and the last. That's fine until something interrupts it mid-flight — a network blip, an out-of-memory kill, me closing my laptop lid at the wrong moment. When that happens, the process comes back and does the only thing it knows how to do: pick up the task and retry it from the top.

The first time this actually bit me, the agent was partway through a task that ended with opening a pull request. It successfully created the branch, pushed the commit, and opened the PR — and then the process got killed before it recorded that the task was finished anywhere. On the next run, it saw a task with no "done" marker, retried it end to end, and opened a second PR for the same change. Two days later, a slightly different crash in the same code path gave me a third one.

By the time I noticed, I had three open PRs for one change, two stray branches nobody would ever clean up, and a nagging question: how many other "invisible" duplicates had already merged somewhere without anyone noticing, because a duplicate file write or a duplicate comment is a lot less obvious than a duplicate PR sitting in the list?

None of this was a logic bug in the traditional sense. Every individual step worked correctly. The problem was a gap that doesn't show up if you only think about "success" and "failure" as the two outcomes: there's a third state, "the action happened but I don't yet know that it happened," and a crash can land you exactly there. My retry logic assumed a crash meant nothing had happened yet. It should have assumed nothing until proven otherwise.

How I Solved It

The fix was to stop treating retries as "run the task again" and start treating them as "figure out what's already true, then do only what's left."

That splits into two pieces:

1. Check before you act, not just after. Before creating anything — a branch, a PR, a file, a comment — the agent now queries for whether that thing already exists, using a stable identifier tied to the task, not a random one generated at retry time. If a PR tagged with this task's key already exists, skip straight to "done," don't open another one.

2. Keep a local ledger of intent, separate from the side effect itself. Some side effects don't give you a clean way to query "did I already do this" (a Slack message doesn't have a natural dedup key you can search on). For those, the agent writes a small record before attempting the action — "about to do X for task Y" — and marks it complete only after confirming success. On restart, it reads the ledger first: a task marked "attempted, not confirmed" gets its actual state checked before anything is retried.

Here's the flow simplified:

flowchart TD
    A[Resume task after restart] --> B{Ledger entry exists?}
    B -- No --> C[Proceed normally, write ledger entry, then act]
    B -- Yes, confirmed done --> D[Skip — already completed]
    B -- Yes, attempted/unconfirmed --> E[Query reality: does the effect already exist?]
    E -- Yes --> F[Mark ledger confirmed, skip re-run]
    E -- No --> G[Safe to retry — perform the action]
Enter fullscreen mode Exit fullscreen mode

And a simplified version of the check-before-act wrapper I use for anything that creates an external artifact:

import hashlib
import json
from pathlib import Path

LEDGER_PATH = Path(".agent/idempotency-ledger.json")

def task_key(task_id: str, step: str, content: str) -> str:
    # stable across restarts — no randomness, no timestamps
    digest = hashlib.sha256(f"{task_id}:{step}:{content}".encode()).hexdigest()[:16]
    return f"{task_id}-{step}-{digest}"

def with_idempotency(task_id: str, step: str, content: str, check_exists, do_action):
    key = task_key(task_id, step, content)
    ledger = json.loads(LEDGER_PATH.read_text()) if LEDGER_PATH.exists() else {}

    if ledger.get(key) == "done":
        return {"status": "skipped", "key": key}

    # crash could have happened after do_action() but before we marked "done" —
    # so always check reality before assuming a retry is safe
    existing = check_exists(key)
    if existing:
        ledger[key] = "done"
        LEDGER_PATH.write_text(json.dumps(ledger))
        return {"status": "already_existed", "key": key, "ref": existing}

    ledger[key] = "attempting"
    LEDGER_PATH.write_text(json.dumps(ledger))

    result = do_action(key)

    ledger[key] = "done"
    LEDGER_PATH.write_text(json.dumps(ledger))
    return {"status": "created", "key": key, "ref": result}
Enter fullscreen mode Exit fullscreen mode

The check_exists function is the part that actually varies per integration — for a PR it's "search open/closed PRs for a branch or label matching this key," for a file write it's "does the file already contain this exact content," for a notification it might just be "is this key in the ledger as done," since there's no external system to query. That asymmetry is fine as long as something gets checked before the action fires again.

It's worth being honest that this adds real overhead. Every mutating step now costs an extra read (the existence check) and an extra write (the ledger update) before the actual work happens. For a task that fires once a day, that's free. For an agent doing hundreds of small file edits in a tight loop, wrapping every single one would be wasteful and would slow down the exact fast path you don't want to slow down. So I only apply this pattern to steps that are hard to undo or visible outside the agent's own working directory — opening a PR, posting a comment, creating a branch, calling an external API. Purely local, easily-repeatable edits inside a repo don't need a ledger entry, because re-running them produces the same file either way; there's no duplicate to create.

That distinction turned out to matter as much as the ledger mechanism itself. The first version of this system wrapped everything, including trivial local writes, and the ledger file itself became a bottleneck — a single JSON file getting read and rewritten dozens of times per task. Scoping it to only the steps that produce an externally visible or hard-to-undo effect cut that overhead back down to nothing noticeable.

Lessons Learned

  1. Design for the gap between "did it" and "recorded it," not just for failure. Most retry logic only accounts for two states — succeeded or failed — and treats a crash as "failed." A crash can just as easily happen a few milliseconds after success, before the outcome was ever written down. If your retry logic can't tell those apart, it will eventually redo a completed action.

  2. A retry should be a question, not a command. "Retry the task" implicitly means "do the thing again." What you actually want on restart is "check what's true, then do only what's missing." That reframe is the whole fix — everything else is implementation detail.

  3. Idempotency keys must survive the crash that triggers the retry. A UUID generated fresh each attempt is useless for this — it doesn't match anything from the previous attempt, so "check if this key already exists" always comes back empty. The key needs to be derived from something stable: a task ID, a content hash, a deterministic slug — computed the same way every time, restart or not.

  4. Not every side effect is queryable, and that's not an excuse to skip dedup. APIs that support natural idempotency keys (many payment and messaging APIs do) make this easy. Ones that don't — like posting a comment with no search-by-tag support — need the ledger to carry the weight instead. Write the intent down before you act; that's the whole trick.

  5. "It's just a retry" is the mindset that causes this bug. The safe default isn't "assume nothing happened, try again" — it's "assume something might have happened, prove it didn't before retrying." That single assumption flip is the difference between a robust retry system and a background job quietly triple-posting the same thing.

What's Next

Right now I hand-write the check_exists function for every new integration, which works but doesn't scale — every new tool the agent gets means another bespoke dedup check. The next step is a generic idempotency middleware that sits in front of any side-effecting call the agent makes, keyed automatically off task ID + step name + a content hash of the arguments, so "did I already do this" stops being something I implement per-integration and becomes something the framework guarantees for free.

Wrap-up

If your agent runs unattended long enough, it will eventually crash at the worst possible moment — right after the side effect, right before you recorded it. Treat every retry as "prove nothing happened yet" instead of "assume nothing happened yet," and a whole category of duplicate-PR, duplicate-message bugs disappears before you ever have to debug them.

If this was useful, follow me here on Dev.to for more of what I'm learning running an autonomous Claude Code setup day to day — and if you're building anything that retries on failure, it's worth an hour to check whether it can tell "failed" apart from "succeeded, but I didn't hear about it."

Top comments (0)