Why architecture matters here
Consider the naive alternative: server-generated task IDs. The client sends a task; the connection times out; the client retries; the server — having no way to link the two requests — spins up a second task. Now an expense-report agent files the report twice, a procurement agent orders twice, a code-migration agent applies the patch twice. With human-triggered RPC this is an occasional bug; with autonomous agents that retry aggressively and chain into other agents, duplicate amplification is the default behavior. One duplicated task at the top of a three-level delegation tree becomes eight duplicated side effects at the leaves.
Client-generated IDs invert the problem. The ID is minted once, before any network activity, and becomes the task's identity everywhere: in retries, in tasks/get polling, in webhook payloads, in delegation records, in traces. Dedup becomes a lookup instead of a heuristic. But the lookup must be correct under concurrency (two retries racing to register the same ID must resolve to one task), under partial failure (the agent crashed after registering but before executing — is a resend a duplicate or a resume?), and under time (how long must the agent remember finished IDs before a replay becomes indistinguishable from a new task?). These are the questions the architecture answers.
Task-ID reuse adds a second dimension: A2A deliberately reuses the same ID across a task's multi-turn lifecycle. A task that pauses in input-required is continued by sending a new message with the same task ID — reuse as continuation, not duplication. The receiving agent must therefore distinguish three cases sharing one ID: a retry of a message it already processed (replay stored state), a legitimate continuation (advance the state machine), and an illegal reuse (a message for a task already completed or cancelled — reject with a terminal-state error). Getting that three-way branch right is the difference between a robust agent and one that double-executes under load.
The architecture: every piece explained
Top row: the request path. The client agent generates a collision-resistant task ID (UUIDv4 or ULID) and persists its intent before sending — if the client crashes post-send, its own recovery can re-ask about the same ID instead of minting a fresh one. The retry layer resends on timeout with the same ID and body, with exponential backoff and jitter. At the remote agent, the dedup check is the first thing that runs: a known ID with an identical message short-circuits to replaying the task's current state (the same response the original send would have produced); a known ID with a different message routes to lifecycle evaluation; an unknown ID proceeds to registration.
Second row: the state machine core. The task registry is the durable map from task ID to task record — state, message history, artifacts, and a request digest for equality checks. Registration must be atomic (insert-if-absent in the store; two racing duplicates must produce one row), which makes the registry's backing store's semantics — a relational unique constraint, a Redis SETNX, a DynamoDB conditional put — the real dedup mechanism. The lifecycle guard encodes legal transitions: submitted → working → input-required → working → completed/failed/canceled. A continuation message is legal only in input-required; a duplicate of the original send is legal in any state (replay); anything else is rejected with a structured error naming the current state. Fencing handles the stale-writer problem: each accepted continuation increments a fencing token, and internal workers include their token on writes, so a worker that stalled through a cancel-and-retry cannot commit results into a task that moved on without it.
Bottom rows: the edges of the contract. Side-effect keys propagate idempotency downstream: the agent derives deterministic keys (task ID + step index) for every external call its reasoning makes — the payment API's idempotency key, the email service's dedup token — because deduplicating the task while its tools re-execute is a hollow victory. Push-notification dedup covers the reverse direction: webhooks are delivered at-least-once, so events carry the task ID plus a monotonic event sequence, and consumers keep a processed-set. Retention and TTL bounds memory: finished task records are remembered for a dedup window (typically 24-72h) after which a replayed ID would be treated as new — so the TTL must exceed the longest plausible client retry horizon, and expired-task queries return a distinct 'unknown/expired' error rather than silently re-executing.
End-to-end flow
Walk one delegation through the stack. An orchestrator agent needs a contract-review task done; it mints task ID t-9f2c, writes an outbox record, and calls tasks/send on the reviewer agent. The request crosses a flaky link: the reviewer receives it, registers t-9f2c atomically (insert-if-absent succeeds), transitions to working, and begins LLM processing — but the response is lost. Four seconds later the orchestrator's retry lands on a different replica of the reviewer.
The replica's dedup check finds t-9f2c in the shared registry with an identical request digest: this is a retry, not a continuation. It replays current state — working, no artifacts yet — and the orchestrator settles into polling tasks/get with the same ID. No second task exists anywhere. Mid-review, the reviewer needs clarification and parks the task in input-required; the webhook event (task ID + sequence 3) reaches the orchestrator twice — its processed-set drops the second copy. The orchestrator answers by reusing t-9f2c with the clarifying message: the lifecycle guard sees input-required + new message = legal continuation, increments the fencing token to 2, and resumes work.
Now the interesting failure: the first working-phase worker — presumed dead after a pod eviction — comes back and tries to write its stale half-result. Its write carries fencing token 1 against a registry now at 2: rejected. The token-2 worker completes the review; among its steps was a call to a document-signing API, made with idempotency key t-9f2c:step-4 — when that call had itself been retried internally, the signing service deduplicated it. The task completes; artifacts are stored under t-9f2c.
Three days later, a misconfigured batch job replays ancient outbox records, resending t-9f2c. The record has passed its 72h retention: the reviewer returns 'task expired/unknown' — an explicit error the batch job surfaces to a human — instead of re-running a contract review that already produced a signed document. Every hop in this story is boring, which is the point: idempotency architecture converts distributed chaos into replayed state and structured refusals.