Skip to content
Harness Engineering2026-09-1013 min read

Durable Agent Execution: Resume Without Repeating Effects

A checkpoint that saves state is not the same as a receipt from the outside world. Learn what must persist before an actor is replaced.

Key takeaways

  • A saved checkpoint proves your program remembered something; it does not prove an external supplier received or committed the action.
  • Splitting a send into ready, sent_unknown, and confirmed states lets a successor reconcile instead of guessing whether to retry.
  • A stable operation identifier is what lets a resumed job check reality before acting, not just recall its own prior intention.
  • A conditional claim selects one worker for a job version; lease expiry, stale workers and receiver idempotency still need explicit rules.
  • In-memory fixtures can test selected resumption paths in one process; they do not establish concurrent ownership or durable storage.

Rod Rivera

Author

Durable Agent Execution: Resume Without Repeating Effects

Rod's note — read with a pencil; the margins are for you.

Durable agent execution: the worked case

Consider a hypothetical supplier-order job. A worker process is halfway through placing a supplier order. It has already checkpointed its plan: read the inventory, computed the shortage, decided to send the order. Then the worker dies. A new worker starts up, reads the checkpoint, sees "next action: send order," and sends it.

The problem is that the checkpoint does not tell you whether the first worker's order already reached the supplier. If it did, and the new worker sends again, you now have two orders. The checkpoint recorded the agent's intention faithfully. It said nothing about what happened on the other side of the network call.

This is the gap between durable task state and durable business state. LangGraph's persistence documentation describes checkpoints that preserve a graph's execution state so a run can resume after interruption, but recovery still has to account for effects outside the graph; saving state is not proof that a business operation completed exactly once (LangGraph, Persistence, retrieved 2026-09-10). A successor needs to recover both the intended operation and its known outcome before deciding whether to act.

Why replaying the checkpoint isn't enough

A tempting fix is "just re-run whatever step you were on." That works when the step is pure computation. It fails the moment the step includes a call to something outside your process: a payment API, an email send, a supplier order form. The call might have failed cleanly, failed after committing, or succeeded and lost the response on the way back. From the resumed worker's point of view these three outcomes look identical: no confirmation locally.

Malcolm Featonby's write-up for the AWS Builders Library on idempotent APIs makes the underlying fix explicit: give each logical operation a stable request identity across its attempts, and let the receiving system recognize a retry of the same operation rather than treat it as new work; the design still has to handle a retry that arrives late, after the sender has already moved on (Malcolm Featonby, AWS Builders Library, Making retries safe with idempotent APIs, 2021-01-15, retrieved 2026-09-10). Two things have to be true for a resumed agent job to be safe: the operation needs a name that survives the crash, and the receiving system needs to do something useful when it sees that name twice.

Neither of those is automatic. A Python object holding {"job_id": "order-17", "status": "sending"} gives you the first half only if you also durably record the identifier you used when you called the supplier, and only if the supplier's API actually treats that identifier as a dedup key rather than a log field.

Three states instead of two

Most first attempts at this model the job as pending and done. That's too coarse, because it can't represent "I called the supplier and don't know what happened." Use three states instead:

StateMeaningSafe next action
readyOperation decided, not yet sentSend it
sent_unknownSending was authorized and may have begun; no confirmed outcome is recordedReconcile with the receiver before deciding whether to retry
confirmedReceipt is durably recorded and matches this operationMove on; do not resend

The critical move is writing sent_unknown to durable storage before making the external call, not after. If you write it after, a crash during the call leaves you back at ready, which is exactly the ambiguous case you were trying to eliminate. If you write it before, a crash after the call still leaves a record that says "something may have happened here, go check."

Here is a bounded teaching example using nothing beyond the Python standard library. This is a toy, in-memory simulation meant to expose the state machine, not a production storage or concurrency guarantee — a real system would use a database transaction and a real supplier API rather than a dictionary in one process.

python
from dataclasses import dataclass, field

@dataclass
class JobStore:
    jobs: dict = field(default_factory=dict)

@dataclass
class MockSupplier:
    receipts: dict = field(default_factory=dict)
    send_count: int = 0

    def send(self, operation_id, payload):
        self.send_count += 1  # calls to this mock, not external business effects
        existing = self.receipts.get(operation_id)
        if existing is not None:
            if existing["payload"] != payload:
                raise ValueError("Operation ID reused with a different payload")
            return existing
        receipt = {"operation_id": operation_id,
                   "payload": dict(payload), "confirmed": True}
        self.receipts[operation_id] = receipt
        return receipt

    def lookup(self, operation_id):
        return self.receipts.get(operation_id)


def record_confirmation(job, receipt):
    if (receipt.get("operation_id") != job["operation_id"]
            or receipt.get("payload") != job["payload"]
            or receipt.get("confirmed") is not True):
        raise ValueError("Receipt does not confirm this operation")
    # One in-memory update; a real store needs an atomic durable transaction.
    job.update(state="confirmed", receipt=dict(receipt))


def resume(store, supplier, job_id, lose_reply=False):
    job = store.jobs[job_id]
    op_id = job["operation_id"]

    if job["state"] == "ready":
        job["state"] = "sent_unknown"  # simulate persisting BEFORE the call
        receipt = supplier.send(op_id, job["payload"])
        if lose_reply:
            raise ConnectionError("Simulated loss after mock supplier commits")
        record_confirmation(job, receipt)
        return "sent"

    if job["state"] == "sent_unknown":
        receipt = supplier.lookup(op_id)
        if receipt is not None:
            record_confirmation(job, receipt)
            return "reconciled_confirmed"
        return "pending_reconciliation"

    if job["state"] == "confirmed":
        record_confirmation(job, job["receipt"])
        return "already_confirmed"
    raise ValueError("Unknown job state")

Walk through the crash points. If the worker dies before resume is called at all, the job sits in ready; a new worker calls resume, writes sent_unknown, and sends — correct, because nothing happened yet. If the worker dies right after writing sent_unknown but before supplier.send returns, the new worker's resume call sees sent_unknown, looks up the operation ID in the supplier's receipts, and either finds it (reconciles to confirmed without sending again) or doesn't (reports pending_reconciliation instead of guessing). If the worker dies after confirmed is written, the job never gets touched by resume's first branch again.

The send_count records calls to this mock. The test below checks that resuming its lost-reply case performs a lookup without another send. That counter proves only what happened inside this single Python process during this test. It does not prove that a real supplier's API would treat a resent HTTP request as a duplicate, or that a real database would serialize two workers' writes the way this dictionary does. Those are separate claims that need separate evidence: an idempotency key contract with the actual receiving system, and a real transactional store.

Resuming a job by reconciling instead of resending

The graph shows the recovery path after sending may have begun. Receipt lookup can also return no result; that path stays unresolved rather than jumping to confirmed. Run a lost-reply fixture to make the distinction visible:

python
store = JobStore({"job-17": {
    "operation_id": "order-17", "state": "ready",
    "payload": {"sku": "vanilla", "quantity": 6},
}})
supplier = MockSupplier()

try:
    resume(store, supplier, "job-17", lose_reply=True)
except ConnectionError:
    pass

assert store.jobs["job-17"]["state"] == "sent_unknown"
assert supplier.send_count == 1
assert resume(store, supplier, "job-17") == "reconciled_confirmed"
assert supplier.send_count == 1
assert resume(store, supplier, "job-17") == "already_confirmed"
assert supplier.send_count == 1
print("One mock send; lost reply reconciled from its matching receipt")

This simulates a lost response with an exception; it does not kill and restart a process or test disk persistence. It verifies the intended branch under controlled local state. A real crash test must restart a worker from the actual store, with the operation ID and payload intact.

A matching receipt confirms the operation without another send; an absent receipt keeps reconciliation pending under a deadline.

What happens when two successors show up at once

The example above assumes one worker resumes one job. In practice, a crash recovery system often starts several workers that might all try to pick up the same stalled job. Two workers may perform the same read-only receipt lookup, but their local writes still need coordination. A stale reconciliation result must not overwrite a newer state, and any follow-up action must have its own operation boundary. But if two workers both read ready, both could try to send, and now you're back to duplicate orders regardless of how carefully you designed the state machine.

Use a conditional claim before acting on a job. An atomic compare-and-set against the version read can select one claimant for that version, while the loser rereads the current state. The datastore must enforce this operation; a read followed by an unconditional write recreates the race.

A claim also needs a recovery policy. If it expires while the original worker is merely paused, a successor and the old worker may both resume. Use a monotonically increasing fencing token where the receiving system can enforce it, and a stable idempotency key for repeated attempts of the same business operation. A lease by itself does not prevent a stale worker from sending after its ownership expired.

Read state and version together

Read the job's current state and version number in one durable read.

Claim the observed version conditionally

Attempt a compare-and-set write that claims the job, conditioned on the version number being unchanged. If the write fails because the version moved, another worker won the race — stop and re-read instead of proceeding.

Act only under the current claim

Only after the claim succeeds, proceed to the send-or-reconcile logic from the state machine above.

Test that claim protocol against the actual datastore: start two workers against the same job version, force the winner to crash after the mock supplier commits but before it writes confirmed, and check that the loser correctly reports already_claimed and never sends. The expected outcome is that the mock supplier's send count stays at one and the successor reconciles the existing receipt rather than treating the loser's absence of a receipt as license to retry. That check has not been run and measured as part of this article; it is a specific, buildable next step, not a reported result.

Don't let 'no receipt yet' mean 'safe to retry'

An empty receipt lookup can mean the call never happened, or it can mean the supplier's write hasn't propagated to wherever you're checking yet. Treat an unreconciled outcome as pending, never as canceled, until you have a positive signal either way.

Reading the state instead of the conversation

The reason this matters for handing work between agent instances specifically, rather than just being a general distributed-systems concern, is that an agent's "memory" is often implemented as a transcript or a summary of one. A new agent instance reading a compacted conversation history has no reliable way to tell "I said I would send the order" apart from "the order was sent." Only a structured record with an explicit state field, checked against the receiving system, can make that distinction.

This connects directly to the handoff design taught in the course material on memory and permissions for replaceable actors, which specifies that a successor needs the goal, exact current state, evidence, and known failures rather than narrative summary, and that a claimed external action must be reconciled against the external system's receipt before being trusted (course: ZEO ITAM Autumn 2026). That lesson frames the general handoff contract; this article works one narrow slice of it all the way through — the three-state send protocol and the claim step — because that is the part people most often get wrong by treating "resume" as a synonym for "replay."

A useful discipline: when you write a handoff or a checkpoint for any job that includes an external call, name the operation identifier explicitly in the record, and write the state transition for "call attempted, outcome unknown" as its own durable value, not as a comment or a log line. Two top-level labels can work only if another durable field or operation record represents the uncertain send explicitly. The state names matter less than preserving the information needed to reconcile.

Quick check — A worker's last durable write was sent_unknown for operation order-17, and the mock supplier has no receipt for order-17. What should the resumed worker do?

Check the decision

Building the check before trusting the design

Extend the local test with a sent_unknown job whose supplier has no receipt. It should remain pending_reconciliation and make zero sends. Supply a receipt for a different payload and require rejection. Supply an unknown state and require an explicit error instead of a success-shaped default. These controls catch overconfident recovery logic before you add storage.

Then repeat the crash points using a durable store: before send, after receiver commit but before local confirmation, and after confirmation. Restart from stored bytes rather than reusing an in-memory object. Race two claimants against the same version and test a worker resuming after lease expiry. Record received requests and business effects separately, because an idempotent receiver may accept several HTTP attempts while creating one order.

If two effects appear for one operation, trace both attempts through claim version, operation ID and payload to the receiver. Possible causes include an ineffective claim, a new ID allocated on retry, or an expired deduplication record at the receiver. The observed duplicate narrows the investigation; it does not identify the faulty layer by itself.

Continue with handoffs and permissions for replaceable agents

See how this state-machine discipline fits into the broader handoff contract for an agent instance that might be replaced mid-job.

Ready to put an agent to work?

Join the Prof Rod newsletter for one educational lesson a week, with worked examples attached. It is free to register for and separate from the Zero Employee community.