# When must an A2A v1.0 client treat a task as terminal despite a still-open SSE artifact stream? **Date of Record:** 2026 November 6 **Scope:** Applies to A2A v1.0 clients consuming `tasks/sendSubscribe` (SSE) and `tasks/resubscribe` streams where artifact chunks are delivered as `TaskArtifactUpdateEvent` frames and task state transitions arrive as `TaskStatusUpdateEvent` frames. Does not apply to synchronous `tasks/send` (non-streaming) calls, nor to `tasks/get` polling, where terminality is determined solely by the returned `Task.status.state`. ## Core Distinction The A2A v1.0 stream is *two interleaved event channels over one HTTP response*: artifact deltas (`TaskArtifactUpdateEvent`) and lifecycle transitions (`TaskStatusUpdateEvent`). They are not causally ordered with respect to each other. A `TaskArtifactUpdateEvent` with `lastChunk: false` is a *promise of more data*, not a *claim that the task is alive*. The task's liveness is authoritative only in `TaskStatusUpdateEvent.state`, and the stream's liveness is authoritative only in the transport (TCP close, `event: end`, or the `final` flag on a status event). The non-obvious invariant: **artifact-stream completeness and task terminality are independent axes.** A task can reach `failed` or `canceled` while an artifact stream for that task is still nominally open, and a client that keys "done" off the artifact stream will hang until its read timeout — or worse, will accept a partial artifact as complete. ## What Was Observed In a multi-agent orchestration deployment (planner agent delegating to a codegen agent over A2A v1.0), the client observed the following frame sequence on a single SSE response: ``` event: TaskArtifactUpdateEvent data: {"taskId":"t-9f2","artifact":{"artifactId":"a-1","parts":[{"text":"def f("}]},"lastChunk":false} event: TaskStatusUpdateEvent data: {"taskId":"t-9f2","status":{"state":"working"},"final":false} event: TaskArtifactUpdateEvent data: {"taskId":"t-9f2","artifact":{"artifactId":"a-1","parts":[{"text":"x):\n return x"}]},"lastChunk":false} event: TaskStatusUpdateEvent data: {"taskId":"t-9f2","status":{"state":"failed","message":{"parts":[{"text":"upstream model 529"}]}},"final":true} ``` The stream closed cleanly after the `final:true` status event. The client's artifact assembler, which had buffered `a-1` awaiting a `lastChunk:true` frame that never arrived, emitted the artifact as *incomplete but valid-looking* Python (`def f(x):\n return x` was actually complete here, but the assembler had no way to know). In a second incident, the buffered artifact was truncated mid-token (`def f(`, `x):\n return x` missing the closing paren of the signature) and was nonetheless written to a downstream build step, producing a `SyntaxError` three services away. The root cause is not a protocol violation by the server: A2A v1.0 permits a task to transition to a terminal state without every artifact having received `lastChunk:true`. The artifact is then *abandoned*, not *completed*. The `failed` status carries the reason; the artifact stream carries no such signal. ## Condition of Validity The rule "terminal status implies artifact-stream closure is safe to treat as completion" holds **only** when both of the following are true: 1. The terminal `TaskStatusUpdateEvent` has `final: true` (or the transport closed after it), **and** 2. For every artifact referenced by the task, a `TaskArtifactUpdateEvent` with `lastChunk: true` was received *before* the terminal status frame. If condition 2 fails, the artifact must be classified as `ABANDONED`, not `COMPLETE`. This is a *per-artifact* determination, not a per-task one: a task may complete one artifact fully and abandon another. The client must track `lastChunk` state per `artifactId`, not per task. The condition does **not** hold for `tasks/resubscribe` streams, where the server may replay a terminal status frame without replaying prior artifact frames; in that case the client must reconcile against `tasks/get` and treat any artifact lacking a replayed `lastChunk:true` as abandoned by default. ## Decision Boundary The client crosses from "still streaming" to "terminal, reconcile artifacts" at the first `TaskStatusUpdateEvent` whose `state ∈ {completed, failed, canceled, rejected}` — regardless of whether `final:true` is set. `final:true` is a *transport hint* (no further frames will arrive on this stream); the *state* is the lifecycle truth. A well-behaved server sets both, but a client that waits for `final:true` before acting on a `failed` state will stall on servers that omit it, and a client that acts on `final:true` alone will mis-handle a `working` status with `final:true` (legal: server is closing the stream and expects a `tasks/resubscribe`). Concretely: - `state ∈ {completed}` → all artifacts must have `lastChunk:true`; missing ones are a *server defect*, surface as a hard error. - `state ∈ {failed, canceled, rejected}` → any artifact without `lastChunk:true` is `ABANDONED`; do not pass it downstream. - `state == working` with `final:true` → stream ended, task alive; re-enter via `tasks/resubscribe`, do not emit artifacts yet. ## Operational Effect Downstream consumers must be gated on artifact *completeness*, not task *terminality*. In the deployment above, the fix was a per-artifact state machine (`OPEN → COMPLETE | ABANDONED`) that the build step consulted before reading artifact bytes. The effect is that a `failed` task now yields zero build attempts on abandoned artifacts and one build attempt on any artifact that reached `lastChunk:true` before the failure — which matters when the codegen agent emits a spec artifact fully and then fails on a test artifact. Prior to the fix, the spec was discarded along with the task; after, it is retained and the failure is attributed correctly. The secondary effect is timeout behavior: clients that previously hung on abandoned artifacts (waiting for a `lastChunk` that never comes) now terminate on the status frame and release the SSE connection immediately, reducing connection-pool pressure on the orchestrator by roughly the failure rate of the codegen agent (observed ~4% of tasks) times the mean task duration. ## Reference Implementation ```python from __future__ import annotations from dataclasses import dataclass, field from enum import Enum from typing import Iterable class ArtifactAbandonedError(RuntimeError): """Raised when an artifact is consumed without a lastChunk:true frame before the task reached a terminal state.""" class ArtifactState(Enum): OPEN = "open" COMPLETE = "complete" ABANDONED = "abandoned" TERMINAL_STATES = {"completed", "failed", "canceled", "rejected"} @dataclass class ArtifactTracker: task_id: str _state: dict[str, ArtifactState] = field(default_factory=dict) _task_state: str = "submitted" def on_artifact_update(self, artifact_id: str, last_chunk: bool) -> None: if self._task_state in TERMINAL_STATES: # Late artifact frame after terminal status: protocol violation # or resubscribe replay. Do not resurrect a terminal task. raise ArtifactAbandonedError( f"artifact {artifact_id} arrived after task {self.task_id} " f"reached terminal state {self._task_state}" ) if last_chunk: self._state[artifact_id] = ArtifactState.COMPLETE else: self._state.setdefault(artifact_id, ArtifactState.OPEN) def on_status_update(self, state: str, final: bool) -> None: self._task_state = state if state in TERMINAL_STATES: for aid, st in self._state.items(): if st is ArtifactState.OPEN: self._state[aid] = ArtifactState.ABANDONED def require_complete(self, artifact_id: str) -> None: st = self._state.get(artifact_id, ArtifactState.ABANDONED) if st is not ArtifactState.COMPLETE: raise ArtifactAbandonedError( f"artifact {artifact_id} on task {self.task_id} is {st.value}; " f"task state={self._task_state}. Refusing to emit partial data." ) def complete_artifacts(self) -> Iterable[str]: return [a for a, s in self._state.items() if s is ArtifactState.COMPLETE] ```