When A2A Tasks Sat Stuck in 'working' — Field Notes on Tracing State Transitions to Detect Stalls
Antigravity's A2A coordination fails less by crashing and more by sitting in 'working' without progress. This walks through recording state transitions as one timeline and naming a stalled task down to which agent and which edge froze, along with what running agents daily has taught me.
An overnight agent pipeline I let run had stopped by morning with "0 tasks completed." Not a single error in the logs. The orchestrator had sent three tasks and was still waiting for them to finish, indefinitely. The receiving agents showed no sign of having even started; the tasks were quietly frozen in the working state.
The maddening part was not being able to tell which agent, and where, had frozen. A2A (Agent-to-Agent) failures show up less as loud exceptions and more as a state that simply refuses to advance. Here are my field notes on measuring that silent stall as a timeline of state transitions, and on naming the stalled task down to the edge—drawn from running agents solo as an indie developer.
Why the Stall Happens Quietly
An A2A task advances through a state machine: submitted → working → input-required → completed / failed / cancelled. If it crashes, it transitions to failed, and at least the fact of failure remains. But what I see most in production is the pattern that never even reaches failed—it stays in a non-terminal state while only time passes.
The "silent stalls" I have observed fall into three paths.
Path
What is happening
Symptom from the sender
Undelivered envelope
A missing trace_id or wrong destination means the message never reaches the peer
Stays submitted; never even transitions to working
Missed capability discovery
The skill is unregistered in the peer's capabilities, so discover returns no candidate
You think you sent it, but the peer is treated as nonexistent
Skipped task_state
The peer finished processing but replied without asserting completed
Work is done, yet you keep waiting in working
What all three share is that the stall comes from a transition that fails to happen—not from an error. So no amount of staring at exception logs yields a clue. What to look at is the time axis: which state a task is sitting in, and for how long.
Record State Transitions as One Timeline
First, decide the unit of monitoring. Not individual log lines, but each task's state transitions held as one timeline. Each transition keeps the trace_id, task ID, agent, from/to states, and timestamp, in a form that lets you measure the time spent in a state (dwell time).
Recorded field
Meaning
task_id / trace_id
Which task's chain of events this is
agent
The agent that caused the transition
from_state → to_state
The transition edge. The unit for naming a stall
dwell_seconds
Seconds spent in the immediately preceding state
The key is to see a stall by edge (from → to), not by task. Even for the same working stall, whether submitted → working is not firing or working → completed is not firing points at entirely different suspects. The former is envelope or discover; the latter is the peer's skipped transition. Viewed by edge, the guess about the cause lands a step faster.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Separates the three quiet paths of an A2A stall—undelivered envelope, missed capability discovery, and a skipped task_state—by symptom
✦Records transitions as one timeline and names a stalled task down to which agent and which edge froze, in a working Python watchdog
✦Derives the stall threshold from the p95 of dwell time, and picks a DAG shape to keep tracing cheap
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Below is a minimal build that inserts transition recording into both send and receive, and has a watchdog name any task that lingers too long in a non-terminal state. Records go to a lightweight store (memory here; Redis or the like in production).
# env: A2A_STALL_FACTOR="3"import asyncioimport timeimport uuidfrom dataclasses import dataclass, fieldfrom collections import defaultdictfrom antigravity import A2AClient, TaskRequest, MessageTERMINAL = {"completed", "failed", "cancelled"}@dataclassclass Transition: task_id: str agent: str from_state: str to_state: str at: float dwell: float@dataclassclass TraceStore: events: list[Transition] = field(default_factory=list) _last: dict = field(default_factory=dict) # task_id -> (state, at) def record(self, task_id: str, agent: str, to_state: str) -> None: now = time.monotonic() prev_state, prev_at = self._last.get(task_id, ("<start>", now)) self.events.append(Transition( task_id=task_id, agent=agent, from_state=prev_state, to_state=to_state, at=now, dwell=now - prev_at)) self._last[task_id] = (to_state, now) def open_tasks(self) -> list[tuple[str, str, str, float]]: """Return tasks lingering in a non-terminal state as (task_id, agent, state, elapsed).""" now = time.monotonic() out = [] for task_id, (state, at) in self._last.items(): if state not in TERMINAL: agent = next((e.agent for e in reversed(self.events) if e.task_id == task_id), "?") out.append((task_id, agent, state, now - at)) return out def edge_p95(self) -> dict[tuple[str, str], float]: """p95 dwell time per edge; used to derive the stall threshold.""" buckets = defaultdict(list) for e in self.events: buckets[(e.from_state, e.to_state)].append(e.dwell) p95 = {} for edge, vals in buckets.items(): vals.sort() p95[edge] = vals[max(0, int(len(vals) * 0.95) - 1)] return p95
With the recorder in place, call record at the key points of sending and receiving. Even if the peer forgets to assert completed, the watchdog can still detect it as "a task lingering in working," which means the skipped transition itself becomes observable from the outside.
store = TraceStore()class MonitoredClient(A2AClient): async def send_task(self, target, task: TaskRequest): store.record(task.id, self.my_agent_id, "submitted") resp = await super().send_task(target, task) store.record(task.id, target, "working") return resp async def on_state(self, task_id: str, agent: str, state: str): store.record(task_id, agent, state)async def watchdog(stall_factor: float = 3.0, interval: float = 10.0): """Flag stalls at edge p95 x factor and warn by name.""" while True: await asyncio.sleep(interval) p95 = store.edge_p95() for task_id, agent, state, elapsed in store.open_tasks(): budgets = [v for (f, _), v in p95.items() if f == state] budget = max(budgets) * stall_factor if budgets else 60.0 if elapsed > budget: print(f"STALL task={task_id[:8]} agent={agent} " f"state={state} elapsed={elapsed:.0f}s budget={budget:.0f}s")
open_tasks emits tasks lingering in non-terminal states, and edge_p95 gives the standard duration for each edge. The watchdog reconciles the two and names any task sitting far longer than standard, with its agent and stalled state attached. Narrowing "three tasks froze" down to "summarizer has not fired working → completed within 3x its p95" changes the starting speed of the investigation completely.
Derive the Stall Threshold from Dwell Time
Applying one fixed timeout to every task is crude for agents that call an LLM internally. Summarization and image analysis differ in duration by an order of magnitude. So set the threshold from the p95 of dwell time per edge, times a constant.
Situation
Handling
elapsed < p95 x 3
Normal range. Wait
elapsed > p95 x 3 (working → completed)
Suspect a skipped transition in the peer. Verify it asserts completed
Lingering in submitted
Undelivered envelope or missed discover. Check trace_id and capabilities
Same edge stalls after resend
Halt the production rollout, route to human investigation
In my own runs I start with a factor of 3. Agent response times have a long right tail, so basing the threshold on p95 rather than the mean stays stable. Assume the distribution itself moves; do not trust the factor as fixed, and tune it while watching the false-positive rate.
Choosing a Shape That Stalls Less
Once measurement makes stalls visible, lean toward a shape that stalls less to begin with. In A2A, a cycle where agent A calls B, B calls C, and C calls A again makes the causality of a stall untraceable. I decide as follows.
Up to mid scale, use a DAG (an orchestrator calling workers in order) and prioritize traceability above all
Require the peer to always return an explicit completed / failed transition, leaving no path that replies silently
Split auth tokens per agent to bound both the blast radius of a stall and the impact of a leak
A DAG makes the center prone to becoming a bottleneck, but during a production incident it tells you where things froze in seconds. I myself pick a DAG in almost every case, prioritizing observability over scalability, and I strongly recommend that call. Hard-to-visualize choreography is safe to adopt only once you have a way to funnel transition logs into one place via an event bus.
What I Kept Tripping Over
I stepped in the same holes more than once, so I will write them down.
Make the watchdog interval too short and you misflag legitimately long tasks as stalls, and the warnings overflow. Summarization routinely takes tens of seconds, so a fixed threshold that ignores p95 exhausts the team every time it fires. Do not skip the small step of deriving the threshold from the distribution.
If you assign a trace_id on the sender side but reset it on the receiver, the edge timeline splits into two and the stall becomes untraceable. The more you hit the low-level API directly, the more care it takes to carry the envelope's trace_id unbroken through the whole chain.
And the biggest lesson: stall detection only tells you that something froze. Why it froze is settled in the final step, where a person reads the logs of the named agent. Measurement is a tool for pointing at the cause, not the cause itself. Miss this and you end up with a handsome dashboard and no fewer incidents.
The Next Step
If you have a setup where two or more agents coordinate, start by recording nothing but the state transitions. The moment a working task shows up by name in open_tasks while the error logs say nothing, the point of looking on the time axis lands.
A silent stall, left alone, only piles up the assumption that "it must be running." Turn state transitions into one timeline you can read by edge, and you can peel that assumption away one layer at a time. I am still tuning the factor myself, but I hope it helps with your own agent operations.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.