A2A Task Lifecycle Without Duplicate Work
A replay-safe A2A task state machine for retries, reconnection, streamed artifacts, cancellation races, and terminal receipts without duplicate work.
A2A task lifecycle design fails when a reconnect is mistaken for permission to execute work again. A durable state machine must cover submitted, working, both interruption states, and all four terminal outcomes while disorder converges on one inspectable result.
This guide implements an application transition policy for every concrete A2A state, separates task and artifact cursors, and proves retries are harmless. It labels protocol guarantees, authorization duties, and local policy separately so interoperability does not surrender execution truth to the network.
Model the A2A task lifecycle as durable truth
An A2A task lifecycle should answer one operational question: which accepted status owns the task now? The current specification names eight concrete states: submitted, working, input-required, auth-required, completed, failed, canceled, and rejected. Completed, failed, canceled, and rejected are terminal; input-required and auth-required interrupt progress. Every event carries task ID, event ID, producer, source sequence, payload digest, and observed time so retries are decided from durable identity rather than connection luck.
I keep transport identity and task identity in separate columns during review. A socket can reconnect three times while one task remains active, and a client can retry one submission without authorizing three executions. The durable key therefore belongs to the accepted task record. Its status can be submitted, working, input-required, auth-required, completed, failed, canceled, or rejected; connection identifiers remain diagnostic metadata.
Cover every concrete Agent2Agent task state.
The current A2A specification defines the state vocabulary and terminal classification, but it does not publish a normative exhaustive transition table. Figure 1 is a representative interruption-to-terminal subgraph: it keeps all eight concrete states visible while tracing working through input-required or auth-required toward terminal outcomes. The runnable reducer directly below it—not the diagram—is the exhaustive declared application allowlist, including direct submitted and working transitions that the representative trace does not draw.
The reducer implements the exhaustive declared application allowlist because the A2A specification defines state meaning and terminality without prescribing one transition graph. The nearby SVG is only a representative interruption-to-terminal subgraph. Input-required and auth-required can resume after their requirements are satisfied, rejected can record initial or later refusal, and completed, failed, canceled, and rejected never reopen.
Runnable artifact: The reducer covers all eight concrete A2A states, enforces the exhaustive declared application allowlist, requires an authorization receipt to resume, and freezes every terminal outcome.
Save this proof as a2a-task-reducer.test.mjs and run node a2a-task-reducer.test.mjs. Expected final line: PASS: replay-safe A2A lifecycle.
import assert from "node:assert/strict";
const concreteStates=["submitted","working","input-required","auth-required","completed","failed","canceled","rejected"];
const terminal=new Set(["completed","failed","canceled","rejected"]);
// Application allowlist: A2A defines the states and terminality, not a normative transition table.
const allowed={
submitted:["working","input-required","auth-required","completed","failed","canceled","rejected"],
working:["input-required","auth-required","completed","failed","canceled","rejected"],
"input-required":["working","auth-required","completed","failed","canceled","rejected"],
"auth-required":["working","input-required","completed","failed","canceled","rejected"]
};
const reduce=(state,event)=>{
if(state.seen.has(event.id))return state;
const seen=new Set(state.seen).add(event.id);
const reject=reason=>({...state,seen,rejected:[...state.rejected,{id:event.id,reason}]});
if(!concreteStates.includes(event.status)||terminal.has(state.status)||!allowed[state.status]?.includes(event.status))return reject("illegal-transition");
if(state.status==="auth-required"&&!event.authorizationReceipt)return reject("authorization-receipt-required");
return {...state,seen,status:event.status,sequence:state.sequence+1,accepted:[...state.accepted,event.id]};
};
const start=()=>({status:"submitted",sequence:0,seen:new Set(),accepted:[],rejected:[]});
let resumed=start();
for(const event of [{id:"w",status:"working"},{id:"a",status:"auth-required"},{id:"a",status:"auth-required"},{id:"resume",status:"working",authorizationReceipt:"authz:7"},{id:"i",status:"input-required"},{id:"back",status:"working"},{id:"done",status:"completed"},{id:"late",status:"working"}])resumed=reduce(resumed,event);
let declined=reduce(start(),{id:"no",status:"rejected"});
assert.deepEqual(new Set(concreteStates),new Set(["submitted","working","input-required","auth-required","completed","failed","canceled","rejected"]));
assert.equal(resumed.status,"completed"); assert.equal(resumed.sequence,6); assert.equal(resumed.rejected.at(-1).id,"late");
assert.equal(declined.status,"rejected"); assert.equal(reduce(declined,{id:"revive",status:"working"}).status,"rejected");
const terminalReceipts=Object.fromEntries([...terminal].map(status=>{const settled=reduce(start(),{id:"terminal:"+status,status});assert.equal(settled.status,status);assert.equal(reduce(settled,{id:"revive:"+status,status:"working"}).status,status);return [status,settled.accepted[0]];}));
let unauthorized=reduce(reduce(start(),{id:"auth",status:"auth-required"}),{id:"resume",status:"working"});
assert.equal(unauthorized.status,"auth-required"); assert.equal(unauthorized.rejected[0].reason,"authorization-receipt-required");
console.log(JSON.stringify({states:concreteStates,accepted:resumed.accepted,rejected:resumed.rejected,terminal:resumed.status,terminalReceipts}));
console.log("PASS: replay-safe A2A lifecycle");
Stream artifacts with independent coordinates
A2A streaming can interleave status updates with several artifacts, so one sequence cannot describe everything cleanly. The artifact coordinate combines artifact ID and part number, while the task ledger orders accepted status. A checksum identifies an exact replay, a final marker closes one artifact, and a manifest closes the complete result; consumers can render partial work without mistaking it for a terminal task.
Artifact streaming needs its own replay cursor because a task status and an artifact chunk advance at different rates. I store each part with artifact ID, monotonically increasing part number, media type, checksum, and final flag; a duplicate checksum is harmless, but the same coordinate with different bytes becomes a protocol incident. That rule lets reconnecting clients request only the absent suffix.
Resolve retry, reconnect, authorization, and cancellation.
Retrying submission reuses the idempotency key and returns the existing task, whereas reconnecting asks for status and artifact suffixes after known cursors. Auth-required only reports that progress is blocked; it is never itself proof of authorization, so the application validates a fresh authorization receipt before resuming. A cancel request still competes with completion at the authority, not in the browser. The matrix below and event delivery semantics make those outcomes explicit.
Cancellation is a request until the executor acknowledges a terminal canceled state. If completion wins the race, the client receives completed plus the cancellation request in the ledger, not a fictional canceled result; if cancellation wins, all later output is quarantined. This ordering makes compensation and billing decisions depend on observed terminal state rather than whichever network response arrived first.
| Event | Durable lookup | Accepted result | Safety boundary |
|---|---|---|---|
| Auth required | Task + event ID | Interrupted task | Status is not authorization |
| Authorized resume | Authorization receipt | Working | No credentials in ledger |
| Rejected | Task version | Terminal receipt | Cannot reopen |
| Artifact resume | Artifact + part | Missing suffix | Part checksum |
Separate A2A guarantees from application policy
The A2A specification defines task concepts, auth-required responsibilities, and terminal behavior; the official JavaScript SDK demonstrates concrete types, and the official samples show integration shapes. This implementation owns transition allowlisting, authorization validation, retention, leases, billing, and compensation. Credentials never enter the task ledger, and a status value never substitutes for an authorization decision.
The official JavaScript SDK and samples are useful interoperability fixtures, not substitutes for the application policy around retention or side effects. I serialize the same reducer trace through JSON, reconnect from every event boundary, and compare final state and artifact hashes. An SDK upgrade is acceptable only when the trace stays equivalent or a deliberate migration explains the difference.
Reconstruct a complete task from one worked ledger. Consider task T-42 submitted with request digest R7. The authority accepts working E1, auth-required E2, authorized working E3 with authorization receipt A4, input-required E4, resumed working E5, and completed E6 with manifest M9. It also receives E2 again after reconnect and a late failed E7. The A2A task lifecycle records the duplicate as a lookup hit, stores only an authorization receipt reference rather than credentials, rejects E7 after completion, and exposes artifact parts independently until M9 closes the result set.
A second task T-43 moves directly from submitted to rejected when the agent declines it; that receipt is terminal and cannot be revived. A client starting from nothing can replay both accepted histories, while a reconnecting client presents E4 and draft/0 as cursors. The representative figure makes every concrete state visible through selected edges; the reducer fixture, separately, executes the exhaustive declared application transition policy rather than claiming a normative protocol graph.
Make concurrent task status updates deterministic
The authority assigns an accepted sequence only after validating prior state and event identity. Concurrent producers can race, but exactly one compare-and-set observes the current version; the loser reloads and is either a duplicate, a legal next event, or stale. This same receipt boundary extends idempotency lifecycle contracts from an HTTP request into a long-lived interoperable task.
A concurrent agent may emit progress while another worker notices expiration, so ordering cannot depend on wall-clock timestamps alone. The authority assigns a sequence when it accepts an event and records the producer timestamp separately; exact duplicates reuse a prior receipt, while stale sequence numbers lose deterministically. This is a small consensus boundary inside one task, not a claim of global distributed ordering.
Set retention and compensation outside the protocol. The A2A task lifecycle does not decide how long a settled task remains queryable, whether an external charge is compensatable, or when a worker lease expires. Those application rules reference the terminal receipt and artifact manifest but live in versioned policies with their own owners. A payment-producing task may retain evidence for years, while a temporary research task may expire quickly after its deliverables are verified and exported.
Before release, I ask whether every consequential effect can be reconciled from the task ID and whether a retry after local data loss can rediscover the prior authority. I also simulate conflicting bytes at one artifact coordinate, an expired input request, and an executor crash after effect but before completion. A2A task lifecycle reliability is not the absence of errors; it is the ability to classify each error without creating a second world-state change.
- 1Work
Accept submitted → working
- 2Authorize
Resume auth-required with receipt
- 3Answer
Resume input-required
- 4Settle
Complete once; reject late event
Operate from receipts instead of connection logs
A useful dashboard shows age in state, current owner lease, last accepted sequence, rejected event count, artifact completion, and terminal receipt. Connection churn is supporting evidence, not the unit of work. Pairing the ledger with durable AI agent execution and a resumable SSE client gives operators a path from protocol event to user-visible recovery.
Operational alerts should describe the broken invariant: conflicting artifact coordinates, post-terminal events, tasks without an owner lease, or repeated illegal edges. A graph of generic request errors cannot show whether users are waiting on work that will never advance. The runbook starts from the task ID, follows its accepted sequence, and verifies the terminal receipt and every artifact digest.
The acceptance review also replays this dashboard from a compact ledger export after deleting every connection log. A second operator must still identify the owner, accepted transition, rejected event, missing artifact part, and terminal digest. That exercise proves the operational view depends on durable task evidence rather than incidental transport history.
Release one replay-proof task trace
The runnable reducer executes working, auth-required, authorized resume, input-required, completion, duplicate delivery, rejected submission, and a post-terminal event. The release drill adds reordering, concurrent parts, cancellation, and restart. This A2A task lifecycle passes when those declared application transitions converge on the same state and artifact set. The trace demonstrates protocol state coverage without pretending the sample allowlist exhausts every optional policy nuance.
Agent2Agent tasks become dependable only when agent interoperability includes shared rules for identity, replay, and terminal ownership. This ledger makes those rules observable instead of treating a successful connection as proof of compatible behavior.
My release drill kills the stream after two parts, retries submission, requests authorization, attempts one unauthorized resume, supplies a valid authorization receipt, requests input, then settles. A second fixture rejects work and a late event tries to revive it. The expected outcome is one accepted history per task, no credentials in the ledger, no post-terminal mutation, and a complete audit chain.
A2A task lifecycle engineering is successful when disorder becomes boring: retries converge, artifacts resume, cancellation races settle once, and terminal truth cannot be revived. Keep the A2A task lifecycle reducer and release trace versioned beside every interoperability upgrade.