Skip to content

Phase 3a: A2A message/stream + tasks/resubscribe via SSE - #50

Merged
rohitg00 merged 1 commit into
mainfrom
phase3a/a2a-stream
Apr 26, 2026
Merged

Phase 3a: A2A message/stream + tasks/resubscribe via SSE#50
rohitg00 merged 1 commit into
mainfrom
phase3a/a2a-stream

Conversation

@rohitg00

@rohitg00 rohitg00 commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Tracks #45 — Phase 3a of MCP+A2A overhaul.

⚠️ Do not merge yet. Two P1 blockers from final review (see "Open issues"). PR up for review iteration.

Summary

A2A v0.3 streaming over SSE using iii-sdk `ChannelWriter`:

  • `message/stream` (alias `SendStreamingMessage`) — opens an SSE response, emits `TaskStatusUpdateEvent` (`Submitted` → `Working` → terminal) + `TaskArtifactUpdateEvent` per artifact, closes channel on terminal.
  • `tasks/resubscribe` (alias `SubscribeToTask`) — load task, terminal-or-subscribe; in-progress tasks attach to the live stream via shared `StreamRegistry`.
  • Cross-method propagation: sync `message/send` and `tasks/cancel` broadcast state changes through the registry so concurrent stream subscribers see live updates.
  • New types: `TaskStatusUpdateEvent` and `TaskArtifactUpdateEvent` (camelCase + `#[serde(rename = "final")]` per spec).
  • `capabilities.streaming: true` advertised in the agent card.
  • Slow-socket fanout: `tokio::JoinSet` for parallel writes, dead writers pruned on the next bus lock.

Reference SDK pattern: `iii/sdk/packages/rust/iii/tests/api_triggers.rs:498`. Note: SDK exposes `ChannelWriter::new(addr, &ref)` (lazy connect), not `connect()` — README documents this.

Test plan

  • `cargo check -p iii-a2a` clean
  • `cargo test -p iii-a2a` green (11 pass, 3 ignored e2e)
  • `cargo clippy -p iii-a2a --all-targets` clean
  • Manual SSE smoke (curl `message/stream` against running engine)

Open issues (must fix before merge)

  • P1 Subscriber index races: broadcast snapshot captures positional indices in the `subscribers` Vec, but `Vec::retain` on dead-writer pruning shifts indices. Two concurrent broadcasts with overlapping dead lists can prune the wrong subscribers. Fix: assign each `Subscriber` a stable `u64` id and prune by id.
  • P1 `tasks/resubscribe` replay writes a hardcoded `id: 1` directly to the new writer without bumping its `next_id`. The next `broadcast` then assigns `id: 1` again — duplicate id violates SSE `Last-Event-ID` monotonicity. Fix: bump `next_id` to 2 after replay, or consume one id from the Subscriber under the bus lock.

Sequencing

Needs rebase onto phase1 once it lands — uses `ExposureConfig` / `is_always_hidden_pub` / `is_exposed_pub` / `resolve_function_pub` from `crate::handler`, all of which Phase 1 removes.

Summary by CodeRabbit

Release Notes v0.3.4

  • New Features

    • Added real-time streaming support for task progress and status updates via Server-Sent Events (SSE), enabling live monitoring of task execution.
    • Added resubscription capability to join ongoing task streams and receive historical state plus live updates.
  • Documentation

    • Updated documentation with streaming endpoint specifications, including event structure, status progression, and cross-method broadcast behavior.
  • Chores

    • Bumped package version to 0.3.4.

@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The changes add comprehensive Server-Sent Events (SSE) streaming support to the A2A JSON-RPC endpoint. A new streaming module introduces StreamRegistry for managing per-task event subscriptions, the handler is updated to dispatch and broadcast stream events, and new payload types are defined. Version is incremented to 0.3.4.

Changes

Cohort / File(s) Summary
Streaming Core Infrastructure
a2a/src/streaming.rs
New 769-line module implementing SSE-based streaming with StreamRegistry for per-task event management, TaskBus subscriber tracking, build_sse_frame serialization, and async handlers handle_stream and handle_resubscribe with task lifecycle integration, race-guard replay, and terminal state signaling.
Handler Integration
a2a/src/handler.rs
Adds streaming method dispatch (dispatch_stream, dispatch_resubscribe), integrates StreamRegistry into request handling, updates task lifecycle to broadcast status/artifact updates and close streams on terminal transitions; exports internal helpers (is_always_hidden, is_exposed, is_function_exposed, store_task, load_task, msg_id, text_part, iso_now, resolve_function) and updates handle_a2a_request/handle_send/handle_cancel signatures to accept registry dependency.
Type Definitions
a2a/src/types.rs
Adds serde-compatible structs for A2A v0.3 streaming: TaskStatusUpdateEvent, TaskArtifactUpdateEvent, and ResubscribeParams with proper field name mapping (final_eventfinal).
Module & Documentation
a2a/src/lib.rs, a2a/README.md
Exports new streaming module; documentation updates to include message/stream and tasks/resubscribe endpoints with full SSE behavior, event framing, progression order, replay semantics, and cross-method broadcast integration.
Version & Tests
a2a/Cargo.toml, a2a/tests/streaming.rs
Increments version to 0.3.4; adds 330-line test module with SSE frame validation, serde wire compatibility checks for streaming events, async StreamRegistry unit tests (subscription tracking, task isolation, event id reservation, terminal watch), and e2e stubs for live-engine patterns.

Sequence Diagram

sequenceDiagram
    participant Client as Client (SSE)
    participant Handler as Handler<br/>(handle_stream)
    participant Registry as StreamRegistry
    participant Task as Task<br/>(persistence)
    participant Function as Function<br/>(trigger)
    
    Client->>Handler: POST /a2a<br/>(message/stream)
    Handler->>Task: Load or create task<br/>(Working state)
    Handler->>Registry: subscribe(task_id,<br/>writer)
    Registry-->>Handler: subscriber_id
    Handler->>Client: [SSE] Submitted frame
    Handler->>Client: [SSE] Working frame
    Handler->>Handler: Validate function
    Handler->>Function: Spawn trigger
    
    par Function Execution
        Function->>Function: Execute
    and Task Broadcast
        Function-->>Registry: broadcast(artifact)
        Registry->>Client: [SSE] artifact-update
    end
    
    Function-->>Handler: Complete/Error
    Handler->>Registry: broadcast(Completed<br/>or Failed, final=true)
    Registry->>Client: [SSE] Final status frame
    Handler->>Registry: close_task(task_id)
    Handler->>Client: Close stream
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

🐰 A registry sprouts, subscriptions bloom,
SSE frames dance through the streaming room,
Tasks replay their tales with final grace,
Artifacts broadcast at breakneck pace!
The little burrow of A2A grows bright,
With v0.3.4's streaming might!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: implementation of A2A streaming for message/stream and tasks/resubscribe endpoints via Server-Sent Events (SSE).
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch phase3a/a2a-stream

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (6)
a2a/src/streaming.rs (4)

209-230: SSE preamble is missing X-Accel-Buffering: no and Connection: keep-alive.

Many reverse proxies (nginx, several CDNs) buffer text/event-stream responses by default unless X-Accel-Buffering: no is set, which delays — sometimes indefinitely — frames reaching the client until the response body is large enough to flush. Likewise some HTTP/1.1 stacks benefit from an explicit Connection: keep-alive. Adding both to the set_headers map is cheap insurance for production deployments behind any L7 proxy.

🛡️ Suggested headers
             &serde_json::to_string(&json!({
                 "type": "set_headers", "headers": {
                     "content-type": "text/event-stream",
                     "cache-control": "no-cache",
+                    "connection": "keep-alive",
+                    "x-accel-buffering": "no",
                 }
             }))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@a2a/src/streaming.rs` around lines 209 - 230, The SSE preamble in
send_sse_preamble currently sets content-type and cache-control but omits
buffering/keep-alive signals; update the set_headers payload sent via
ChannelWriter in send_sse_preamble to include "X-Accel-Buffering": "no" and
"Connection": "keep-alive" so reverse proxies and HTTP/1.1 stacks do not buffer
the event-stream; locate the set_headers JSON sent in send_sse_preamble and add
those two header entries to the headers map before calling writer.send_message.

282-322: handle_stream ignores params.message.task_id for idempotent re-stream after terminal.

If a client resends a streaming message/send with an existing terminal task_id (the documented idempotency contract for sync message/send), this path correctly emits a single terminal status-update and closes. But unlike the sync path it doesn't return the stored task back to the caller in a JSON-RPC envelope — that's expected on a streaming socket, just confirming that's intentional. Worth a one-line comment near the early return so the parallel with handle_send's terminal-idempotency branch is obvious.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@a2a/src/streaming.rs` around lines 282 - 322, handle_stream currently detects
an existing terminal task via load_task and emits a terminal "status-update"
then closes the writer, but unlike the sync handle_send path it does not return
the stored task in a JSON-RPC envelope; add a one-line comment immediately
before the early return in the existing-terminal branch (the block that writes
the frame, closes the writer and returns) noting that this is intentional
idempotent re-stream behavior — we emit a single terminal status-update and
close the SSE stream instead of returning the full task object (to mirror
handle_send’s idempotency contract for sync calls).

96-152: Broadcast pruning skips writers whose tasks panic, and a slow socket still blocks close_task.

Two minor points on the JoinSet fan-out:

  1. The Err(join_err) arm at 137-143 logs but doesn't capture the subscriber id — if the spawned task panics, that subscriber stays on the bus until the next failed write. Given each broadcast spawns one task per subscriber, a writer whose write() future panics consistently will be retried on every broadcast. Consider tagging each spawned task with its sub_id (e.g., join_set.spawn(async move { (sub_id, writer.write(&frame).await) })) so the JoinError arm can also push to dead.

  2. broadcast awaits every spawned write before returning, so one stuck writer (TCP push-back, dead peer keeping the half-open socket) holds up the call. Since close_task runs strictly after the final broadcast in handle_stream/handle_send, a single slow client can delay terminal notification to all other subscribers waiting on terminal_watch. A bounded write timeout per task (e.g., wrap writer.write in tokio::time::timeout) would put a ceiling on the worst-case fan-out latency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@a2a/src/streaming.rs` around lines 96 - 152, The broadcast loop in broadcast
spawns a task per subscriber but neither associates the spawned task with its
subscriber id nor bounds each write, so panics or stuck writes leave dead
writers on the bus and a single slow client can block the whole fan-out; modify
the spawn to return the subscriber id with the write result (e.g., spawn an
async move that yields (sub_id, writer.write(&frame).await)) so the
Err(join_err) arm can mark that sub_id as dead, and wrap the writer.write future
in a tokio::time::timeout with a reasonable duration so timed-out writes return
an error which you convert into marking the sub_id dead; ensure you still
collect dead ids into the dead HashSet and call g.subscribers.retain(|s|
!dead.contains(&s.id)) on the bus after join_set completes.

660-727: Streaming module's #[cfg(test)] block duplicates the integration tests in a2a/tests/streaming.rs.

task_status_update_serializes_camel_case_with_final, task_artifact_update_serializes_camel_case, resubscribe_params_round_trip, and sse_frame_format here cover the same wire-shape assertions as task_status_update_uses_final_not_final_event / task_artifact_update_serializes_camel_case / resubscribe_params_round_trip / sse_frame_matches_a2a_layout in a2a/tests/streaming.rs. Pick one location (the integration test file is fine since it's already exercising the public surface) and drop the other to avoid drift.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@a2a/src/streaming.rs` around lines 660 - 727, The tests inside the
#[cfg(test)] mod in streaming.rs duplicate integration tests in
a2a/tests/streaming.rs; remove the duplicated unit tests here to avoid drift.
Specifically, delete or comment out the test functions sse_frame_format,
task_status_update_serializes_camel_case_with_final,
task_artifact_update_serializes_camel_case, and resubscribe_params_round_trip
(the entire #[cfg(test)] mod) so the project relies on the existing integration
tests (e.g., task_status_update_uses_final_not_final_event,
task_artifact_update_serializes_camel_case, resubscribe_params_round_trip,
sse_frame_matches_a2a_layout) instead. Ensure no other non-test code in that
module is removed.
a2a/src/handler.rs (2)

421-444: Duplicated payload builders across handler.rs and streaming.rs.

status_update_payload / artifact_update_payload here mirror task_status_update_payload / task_artifact_update_payload in a2a/src/streaming.rs (lines 232–257). Both serialize the same TaskStatusUpdateEvent / TaskArtifactUpdateEvent shapes with the same fallback-to-Value::Null. Consolidate into a single pair of pub(crate) helpers in streaming.rs (or types.rs) and reuse from both call sites so the wire shape can't drift between sync and streaming flows.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@a2a/src/handler.rs` around lines 421 - 444, status_update_payload and
artifact_update_payload duplicate
task_status_update_payload/task_artifact_update_payload in streaming.rs; move
the shared serializers into a single pub(crate) helper pair (e.g.,
task_status_update_payload and task_artifact_update_payload) placed in
streaming.rs or a common types.rs, keep the exact serialized shapes (fields like
append: None and last_chunk: Some(true) for artifacts and final_event for
status) and the same unwrap_or(Value::Null) fallback, make them pub(crate) and
replace calls in handler.rs to call the consolidated helpers so both sync and
streaming code reuse the same functions.

596-603: Mid-flight cancel path drops the trigger result silently — confirm that's intentional.

When tasks/cancel lands during the iii.trigger(...).await, this branch returns the canceled task and discards result. That matches the documented "result is discarded and the task keeps its Canceled state" semantic in the README, so behaviorally fine. Worth a single-line tracing log though, since otherwise a function that produced a real artifact gets dropped with no breadcrumb in the worker logs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@a2a/src/handler.rs` around lines 596 - 603, The cancel branch in the
Ok(result) path currently returns the canceled task and silently drops result;
add a one-line tracing log before the return so there's a breadcrumb when a
trigger-produced artifact is discarded. In the Ok(result) handling (around the
load_task(...) / matches!(t.status.state, TaskState::Canceled) block), emit a
concise trace/info via the tracing/logger used in the file (e.g., mentioning
task_id and that the trigger result was discarded) immediately before the
A2AResponse::success(...) return so callers can see that the trigger result was
intentionally dropped.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@a2a/src/handler.rs`:
- Around line 187-190: The code calls iii_inner.address() which doesn't exist in
iii-sdk 0.11.3; change the call site to accept and use the engine WebSocket URL
passed into the handler (e.g., the engine_ws_base parameter) instead of trying
to call III::address(). Specifically, update the place creating the
ChannelWriter (the Arc::new(iii_sdk::ChannelWriter::new(...))) to pass the
engine_ws_base (or equivalent function parameter) as the first argument rather
than iii_inner.address(), and ensure the handler signature/callers provide that
engine_ws_base value like image-resize's handler does.

In `@a2a/src/streaming.rs`:
- Around line 367-384: store_task wrote the task as Working before emitting a
synthesized Submitted frame, so persisted state never shows Submitted; either
persist Submitted first, broadcast it, then transition the stored record to
Working and persist again (use store_task with the cloned submitted Task, call
registry.broadcast with task_status_update_payload(&submitted, false), then
update the original task to Working and call store_task + registry.broadcast for
Working), or if the wire-only emission is intentional add a one-line comment
near store_task/Submitted explaining the deliberate mismatch; refer to
store_task, Submitted, Working, task.clone, task_status_update_payload,
registry.broadcast and task_id to locate and implement the change.
- Around line 600-634: Race occurs when task becomes terminal between load_task
and registry.subscribe causing a new empty TaskBus to be created and the handler
to wait forever; after calling registry.subscribe (and after
reserve_event_id/replay), immediately re-load the task state via load_task (or
the same storage fetch used earlier) and if the reloaded task is terminal, build
and write the final frame with build_sse_frame(task_status_update_payload(...,
true)) and close the writer instead of entering the terminal_watch loop; ensure
you release/unsubscribe the subscriber_id if your registry has an
unsubscribe/remove method and avoid waiting on terminal_watch for a
newly-created bus when the persistent task is already terminal.
- Around line 202-207: The function writer_ref_from_input currently uses
iii_sdk::extract_channel_refs(...) and .find(...) which silently returns the
first ChannelDirection::Write it sees; change this to explicitly verify the SDK
contract by collecting all writable channels from extract_channel_refs(input)
(filtering by r.direction == ChannelDirection::Write) and then: if more than one
writable channel is found, emit a debug/error via your logger or panic/assert in
non-production builds to surface the contract violation; if exactly one, return
that StreamChannelRef; if zero, return None. Update writer_ref_from_input and
reference iii_sdk::extract_channel_refs and the
StreamChannelRef/ChannelDirection symbols in the fix and add a short comment
documenting the expected single-writable-channel contract or that the code
defends against violations.

---

Nitpick comments:
In `@a2a/src/handler.rs`:
- Around line 421-444: status_update_payload and artifact_update_payload
duplicate task_status_update_payload/task_artifact_update_payload in
streaming.rs; move the shared serializers into a single pub(crate) helper pair
(e.g., task_status_update_payload and task_artifact_update_payload) placed in
streaming.rs or a common types.rs, keep the exact serialized shapes (fields like
append: None and last_chunk: Some(true) for artifacts and final_event for
status) and the same unwrap_or(Value::Null) fallback, make them pub(crate) and
replace calls in handler.rs to call the consolidated helpers so both sync and
streaming code reuse the same functions.
- Around line 596-603: The cancel branch in the Ok(result) path currently
returns the canceled task and silently drops result; add a one-line tracing log
before the return so there's a breadcrumb when a trigger-produced artifact is
discarded. In the Ok(result) handling (around the load_task(...) /
matches!(t.status.state, TaskState::Canceled) block), emit a concise trace/info
via the tracing/logger used in the file (e.g., mentioning task_id and that the
trigger result was discarded) immediately before the A2AResponse::success(...)
return so callers can see that the trigger result was intentionally dropped.

In `@a2a/src/streaming.rs`:
- Around line 209-230: The SSE preamble in send_sse_preamble currently sets
content-type and cache-control but omits buffering/keep-alive signals; update
the set_headers payload sent via ChannelWriter in send_sse_preamble to include
"X-Accel-Buffering": "no" and "Connection": "keep-alive" so reverse proxies and
HTTP/1.1 stacks do not buffer the event-stream; locate the set_headers JSON sent
in send_sse_preamble and add those two header entries to the headers map before
calling writer.send_message.
- Around line 282-322: handle_stream currently detects an existing terminal task
via load_task and emits a terminal "status-update" then closes the writer, but
unlike the sync handle_send path it does not return the stored task in a
JSON-RPC envelope; add a one-line comment immediately before the early return in
the existing-terminal branch (the block that writes the frame, closes the writer
and returns) noting that this is intentional idempotent re-stream behavior — we
emit a single terminal status-update and close the SSE stream instead of
returning the full task object (to mirror handle_send’s idempotency contract for
sync calls).
- Around line 96-152: The broadcast loop in broadcast spawns a task per
subscriber but neither associates the spawned task with its subscriber id nor
bounds each write, so panics or stuck writes leave dead writers on the bus and a
single slow client can block the whole fan-out; modify the spawn to return the
subscriber id with the write result (e.g., spawn an async move that yields
(sub_id, writer.write(&frame).await)) so the Err(join_err) arm can mark that
sub_id as dead, and wrap the writer.write future in a tokio::time::timeout with
a reasonable duration so timed-out writes return an error which you convert into
marking the sub_id dead; ensure you still collect dead ids into the dead HashSet
and call g.subscribers.retain(|s| !dead.contains(&s.id)) on the bus after
join_set completes.
- Around line 660-727: The tests inside the #[cfg(test)] mod in streaming.rs
duplicate integration tests in a2a/tests/streaming.rs; remove the duplicated
unit tests here to avoid drift. Specifically, delete or comment out the test
functions sse_frame_format, task_status_update_serializes_camel_case_with_final,
task_artifact_update_serializes_camel_case, and resubscribe_params_round_trip
(the entire #[cfg(test)] mod) so the project relies on the existing integration
tests (e.g., task_status_update_uses_final_not_final_event,
task_artifact_update_serializes_camel_case, resubscribe_params_round_trip,
sse_frame_matches_a2a_layout) instead. Ensure no other non-test code in that
module is removed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b3271454-a18f-41d7-887b-68545d69d701

📥 Commits

Reviewing files that changed from the base of the PR and between 004ff2f and 295d461.

⛔ Files ignored due to path filters (1)
  • a2a/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (8)
  • a2a/Cargo.toml
  • a2a/README.md
  • a2a/src/handler.rs
  • a2a/src/lib.rs
  • a2a/src/main.rs
  • a2a/src/streaming.rs
  • a2a/src/types.rs
  • a2a/tests/streaming.rs

Comment thread a2a/src/handler.rs Outdated
Comment on lines +187 to +190
let writer = Arc::new(iii_sdk::ChannelWriter::new(
iii_inner.address(),
&writer_ref,
));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

iii-sdk Rust crate 0.11.3 III struct address method

💡 Result:

The iii-sdk Rust crate version 0.11.3 exists (released 2026-04-22 per crates.io version history). It provides the core III struct (re-exported from the iii module), which is the main SDK client instance obtained via register_worker(address: &str, options: InitOptions) -> Result. The address parameter to register_worker is a WebSocket URL string (e.g., "ws://127.0.0.1:49134") for connecting to the III Engine. No evidence of an address method on the III struct itself. The III struct exposes methods like register_function, register_trigger, and trigger for registering/invoking functions and managing triggers. Related types include WorkerInfo and WorkerMetadata (re-exported from iii module), which may contain worker address/metadata, but no specific address method details found. Official documentation: https://docs.rs/iii-sdk/latest/iii_sdk/ (shows re-exports including III). Source repository: https://github.com/iii-hq/iii/tree/main/sdk (Rust SDK in packages/rust or similar). For full API, check docs.rs for exact version or GitHub source.

Citations:


🏁 Script executed:

#!/bin/bash
# Check how other workers in this repo construct ChannelWriter to see the canonical engine_ws_base source.
rg -nP --type=rust -C3 'ChannelWriter::new\s*\('
# And how III exposes its address.
rg -nP --type=rust -C2 '\.address\s*\(\)'

Repository: iii-hq/workers

Length of output: 1724


🏁 Script executed:

# Find where iii_inner is created in a2a
rg -nP --type=rust 'iii_inner' a2a/src/handler.rs | head -20

Repository: iii-hq/workers

Length of output: 481


🏁 Script executed:

# Search for III struct definition or address method usage in the codebase
rg -nP --type=rust 'impl.*III|fn address\s*\(' | head -30

Repository: iii-hq/workers

Length of output: 3723


🏁 Script executed:

# Check the full function signature of the streaming method to understand iii_inner's type
sed -n '170,200p' a2a/src/handler.rs

Repository: iii-hq/workers

Length of output: 1569


🏁 Script executed:

# Search for iii_card and iii_rpc definitions
rg -nP --type=rust 'iii_card|iii_rpc' a2a/src/handler.rs | head -40

Repository: iii-hq/workers

Length of output: 228


🏁 Script executed:

# Look at the register_worker call and surrounding context
sed -n '1,130p' a2a/src/handler.rs

Repository: iii-hq/workers

Length of output: 4263


🏁 Script executed:

# Search for any trait implementations on III
rg -nP --type=rust 'impl.*III|fn address' a2a/src/

Repository: iii-hq/workers

Length of output: 40


🏁 Script executed:

# Also check the Cargo.toml to see the exact iii-sdk version and check if there are any patches
cat a2a/Cargo.toml

Repository: iii-hq/workers

Length of output: 935


🏁 Script executed:

# Search for address method definition anywhere in the codebase
rg -nP --type=rust 'fn address\s*\(' --type-list

Repository: iii-hq/workers

Length of output: 448


🏁 Script executed:

# Search the entire codebase for address() to see all usages
rg -nP --type=rust 'address\s*\('

Repository: iii-hq/workers

Length of output: 126


🏁 Script executed:

# Check if there's a wrapper type around III or a trait
rg -nP --type=rust 'struct.*III|trait' a2a/src/ | grep -i addr

Repository: iii-hq/workers

Length of output: 40


🏁 Script executed:

# Let's try a web search for iii-sdk documentation on III::address

Repository: iii-hq/workers

Length of output: 40


III::address() method does not exist in iii-sdk 0.11.3; code at line 187-190 will not compile.

The III struct in iii-sdk 0.11.3 has no .address() method. Web search and official documentation (docs.rs, crates.io) show only methods like register_function, register_trigger, and trigger; no address getter is exposed. This is the only place in the codebase calling .address().

Compare with image-resize/src/handler.rs:36, which correctly receives engine_ws_base as a function parameter and passes it directly to ChannelWriter::new(). Pass the engine WebSocket URL the same way instead of attempting to retrieve it via a non-existent method.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@a2a/src/handler.rs` around lines 187 - 190, The code calls
iii_inner.address() which doesn't exist in iii-sdk 0.11.3; change the call site
to accept and use the engine WebSocket URL passed into the handler (e.g., the
engine_ws_base parameter) instead of trying to call III::address().
Specifically, update the place creating the ChannelWriter (the
Arc::new(iii_sdk::ChannelWriter::new(...))) to pass the engine_ws_base (or
equivalent function parameter) as the first argument rather than
iii_inner.address(), and ensure the handler signature/callers provide that
engine_ws_base value like image-resize's handler does.

Comment thread a2a/src/streaming.rs Outdated
Comment on lines +202 to +207
pub fn writer_ref_from_input(input: &Value) -> Option<StreamChannelRef> {
iii_sdk::extract_channel_refs(input)
.into_iter()
.find(|(_, r)| matches!(r.direction, ChannelDirection::Write))
.map(|(_, r)| r)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find canonical usage of extract_channel_refs in this repo and any docs.
rg -nP --type=rust -C5 'extract_channel_refs'
fd -e md | xargs rg -n -C2 'extract_channel_refs|writer.?ref|StreamChannelRef' 2>/dev/null

Repository: iii-hq/workers

Length of output: 3270


🏁 Script executed:

# Check for HTTP trigger specifications or tests
fd -e rs -e md | xargs rg -l 'http.*trigger|trigger.*http|HTTP' 2>/dev/null | head -20

Repository: iii-hq/workers

Length of output: 508


🏁 Script executed:

# Look for tests related to extract_channel_refs or writer_ref_from_input
rg -n 'test.*extract_channel_refs|test.*writer_ref|#\[test\]' a2a/src/streaming.rs -B2 -A10

Repository: iii-hq/workers

Length of output: 1673


🏁 Script executed:

# Search for iii-sdk version specification and any contracts/docs
fd -e toml | xargs rg -n 'iii.sdk|iii_sdk' 2>/dev/null

Repository: iii-hq/workers

Length of output: 716


🏁 Script executed:

# Check for any documentation about channel constraints
fd -e md | xargs rg -n 'one.*writable|single.*writable|writable.*channel.*one' 2>/dev/null

Repository: iii-hq/workers

Length of output: 42


🏁 Script executed:

# Find all tests in a2a/src/streaming.rs
rg -n '#\[test\]' a2a/src/streaming.rs -A20

Repository: iii-hq/workers

Length of output: 2449


🏁 Script executed:

# Check if there are HTTP trigger tests or docs in a2a
fd -e rs -e md a2a/ | xargs rg -n 'writer_ref_from_input|HTTP.*trigger|http_trigger' 2>/dev/null

Repository: iii-hq/workers

Length of output: 3796


🏁 Script executed:

# Look for the full function context and any assertions about channel count
rg -n 'writer_ref|extract_channel_refs' a2a/src/ -B3 -A3

Repository: iii-hq/workers

Length of output: 1947


🏁 Script executed:

# Search for any validation or assertions about multiple writable channels
rg -n 'writable|Write|multiple.*channel|one.*channel' a2a/src/ --type=rust | head -30

Repository: iii-hq/workers

Length of output: 1169


🏁 Script executed:

# Check if there's any documentation about HTTP trigger format/structure
fd . a2a/ -type f \( -name '*.md' -o -name '*.rs' \) | xargs rg -n 'trigger.*input|input.*envelope|channel.*ref' 2>/dev/null | head -20

Repository: iii-hq/workers

Length of output: 1721


🏁 Script executed:

# Check if there are integration tests or examples showing HTTP trigger payloads
fd . a2a/ -type f -name '*.rs' | xargs rg -n 'json!' a2a/src/handler.rs | head -10

Repository: iii-hq/workers

Length of output: 855


🏁 Script executed:

# Look for any documentation about the iii-sdk extract_channel_refs behavior
rg -n 'extract_channel_refs' --type=rust -B2 -A8 image-resize/src/handler.rs

Repository: iii-hq/workers

Length of output: 1334


Add defensive code or verify SDK contract for single writable channel.

The code assumes HTTP triggers surface exactly one writable channel per request (documented in the comment), but this assumption is neither verified against the iii-sdk 0.11.3 contract nor defended in code. Using .find() to pick the first writable channel will silently select the wrong one if the assumption is violated.

Either:

  • Confirm in SDK 0.11.3 documentation that HTTP triggers guarantee exactly one writable channel per request, and document that guarantee in code, or
  • Add a debug-log or assert when multiple writable channels are found to catch contract violations early.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@a2a/src/streaming.rs` around lines 202 - 207, The function
writer_ref_from_input currently uses iii_sdk::extract_channel_refs(...) and
.find(...) which silently returns the first ChannelDirection::Write it sees;
change this to explicitly verify the SDK contract by collecting all writable
channels from extract_channel_refs(input) (filtering by r.direction ==
ChannelDirection::Write) and then: if more than one writable channel is found,
emit a debug/error via your logger or panic/assert in non-production builds to
surface the contract violation; if exactly one, return that StreamChannelRef; if
zero, return None. Update writer_ref_from_input and reference
iii_sdk::extract_channel_refs and the StreamChannelRef/ChannelDirection symbols
in the fix and add a short comment documenting the expected
single-writable-channel contract or that the code defends against violations.

Comment thread a2a/src/streaming.rs Outdated
Comment thread a2a/src/streaming.rs
Adds A2A v0.3 streaming for the a2a worker. Both SSE endpoints emit
TaskStatusUpdateEvent and TaskArtifactUpdateEvent through a shared
process-local StreamRegistry, so a sync `message/send` or `tasks/cancel`
broadcasts to concurrent stream subscribers without polling.

- New `streaming` module with StreamRegistry (stable u64 subscriber ids,
  per-subscriber event counters, JoinSet-based fan-out, prune-by-id).
- `message/stream` walks Submitted (wire-only) -> Working -> artifact ->
  Completed/Failed.
- `tasks/resubscribe` replays the current state then forwards broadcasts
  until terminal. Race guard re-loads the task post-subscribe and
  synthesizes a final frame if the producer transitioned in between.
- handler::register now plumbs an Arc<StreamRegistry>; the JSON-RPC
  dispatch closure snapshots the writable channel ref before stripping
  the body so streaming methods can hand it to ChannelWriter::new.
- AgentCapabilities.streaming flipped to true.
- Cargo.toml bumped to 0.3.4.

Tests: 13 unit tests (frame layout, A2A v0.3 wire fields, registry
mechanics) and 3 #[ignore]'d e2e tests documenting the live-engine
contract.
@rohitg00
rohitg00 force-pushed the phase3a/a2a-stream branch from 54222f5 to f5d7762 Compare April 26, 2026 15:43

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
a2a/src/handler.rs (2)

165-166: Nit: the intermediate registry binding is unused.

registry on line 165 is only used to produce registry_rpc; nothing else in register() references it. Collapsing to a single binding is a one-line cleanup.

-    let registry = Arc::new(StreamRegistry::new());
-    let registry_rpc = registry.clone();
+    let registry_rpc = Arc::new(StreamRegistry::new());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@a2a/src/handler.rs` around lines 165 - 166, The local variable `registry` is
unused and only cloned immediately to produce `registry_rpc` inside the
`register()` function; remove the intermediate binding and create `registry_rpc`
directly as Arc::new(StreamRegistry::new()). Update the code to eliminate `let
registry = ...` and replace `let registry_rpc = registry.clone();` with a single
`let registry_rpc = Arc::new(StreamRegistry::new());` so only the used symbol
(`registry_rpc`) remains.

209-245: Optional: extract the "no writable channel" error envelope.

The same 8-line A2AResponse::error envelope is repeated for message/stream and tasks/resubscribe. A small helper (or a single check before the match) would tighten this and make it harder for the two arms to drift apart in error code/message.

♻️ Sketch
+                let no_writer_response = |id: Option<Value>| {
+                    json!({
+                        "status_code": 200,
+                        "headers": { "content-type": "application/json" },
+                        "body": A2AResponse::error(
+                            id,
+                            -32004,
+                            "Streaming not supported on this transport (no writable channel)"
+                        )
+                    })
+                };
+
                 match request.method.as_str() {
                     "message/stream" | "SendStreamingMessage" => {
                         let Some(writer_ref) = writer_ref else {
-                            return Ok(json!({
-                                "status_code": 200,
-                                "headers": { "content-type": "application/json" },
-                                "body": A2AResponse::error(
-                                    request.id,
-                                    -32004,
-                                    "Streaming not supported on this transport (no writable channel)"
-                                )
-                            }));
+                            return Ok(no_writer_response(request.id));
                         };
                         dispatch_stream(&iii_inner, request.params, writer_ref, registry, cfg)
                             .await;
                         return Ok(Value::Null);
                     }
                     "tasks/resubscribe" | "SubscribeToTask" => {
                         let Some(writer_ref) = writer_ref else {
-                            return Ok(json!({ ... duplicate ... }));
+                            return Ok(no_writer_response(request.id));
                         };
                         ...
                     }
                     _ => {}
                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@a2a/src/handler.rs` around lines 209 - 245, Extract the repeated "no writable
channel" error envelope into a small helper or pre-check to avoid duplication:
add a function or closure (e.g., ensure_writable_channel) that takes request.id
and returns the standardized A2AResponse::error Value used when writer_ref is
None, then call that helper from both the "message/stream" /
"SendStreamingMessage" and "tasks/resubscribe" / "SubscribeToTask" arms (before
calling dispatch_stream or dispatch_resubscribe) or perform the check once
before the match and reuse the same error Value; reference writer_ref,
A2AResponse::error, dispatch_stream, dispatch_resubscribe, and request.id when
implementing the change.
a2a/src/streaming.rs (1)

757-769: Dead touch_task helper — drop or wire it up.

touch_task is #[allow(dead_code)] and never called; store_task in handler.rs already does the equivalent state write. The "kept for symmetry with future store-side uses" comment will rot — easier to delete and re-add when the future use materialises.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@a2a/src/streaming.rs` around lines 757 - 769, The helper touch_task (async fn
touch_task(iii: &III, task: &Task)) is dead code and should be removed to avoid
rot since store_task in handler.rs already performs the same state write; delete
the touch_task function, its #[allow(dead_code)] and the accompanying comment.
Alternatively, if you intend to keep the behavior, wire it up by invoking
touch_task from the existing store_task or the place where tasks are persisted
(passing the III and Task), instead of leaving it unused.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@a2a/src/streaming.rs`:
- Around line 495-548: This branch can overwrite a concurrently-set Canceled
state (TOCTOU); before calling store_task(&iii_run, &completed) and broadcasting
the Completed status, reload the task (using load_task(&iii_run,
&task_id_run).await) and if its status.state is now TaskState::Canceled (or any
terminal non-Completed state) skip the store_task and final
registry_run.broadcast of EVENT_STATUS; alternatively, if you intend to accept
this race, add a one-line comment next to the existing load_task check
referencing the accepted design tradeoff and why no CAS is available.
- Around line 477-593: The spawned tokio::spawn future running iii.trigger can
panic or be dropped and currently never guarantees registry.close_task is
called, leaving terminal_watch waiting and the SSE open; fix by retaining the
JoinHandle returned by tokio::spawn (instead of dropping it) and concurrently
select!ing the JoinHandle against the registry.terminal_watch() watcher in the
parent task: if the JoinHandle returns Err(JoinError) or finishes unexpectedly,
call registry.close_task(&task_id).await and break the wait loop so
writer.close() runs; ensure the same cleanup is invoked when the cancel-branch
returns early (or alternatively add a small drop-guard tied to the spawned task
that invokes registry_run.close_task(&task_id_run) if the spawned future is
dropped) so registry.close_task is always executed even on panics in
iii.trigger, serde_json::to_string_pretty, or load_task.

---

Nitpick comments:
In `@a2a/src/handler.rs`:
- Around line 165-166: The local variable `registry` is unused and only cloned
immediately to produce `registry_rpc` inside the `register()` function; remove
the intermediate binding and create `registry_rpc` directly as
Arc::new(StreamRegistry::new()). Update the code to eliminate `let registry =
...` and replace `let registry_rpc = registry.clone();` with a single `let
registry_rpc = Arc::new(StreamRegistry::new());` so only the used symbol
(`registry_rpc`) remains.
- Around line 209-245: Extract the repeated "no writable channel" error envelope
into a small helper or pre-check to avoid duplication: add a function or closure
(e.g., ensure_writable_channel) that takes request.id and returns the
standardized A2AResponse::error Value used when writer_ref is None, then call
that helper from both the "message/stream" / "SendStreamingMessage" and
"tasks/resubscribe" / "SubscribeToTask" arms (before calling dispatch_stream or
dispatch_resubscribe) or perform the check once before the match and reuse the
same error Value; reference writer_ref, A2AResponse::error, dispatch_stream,
dispatch_resubscribe, and request.id when implementing the change.

In `@a2a/src/streaming.rs`:
- Around line 757-769: The helper touch_task (async fn touch_task(iii: &III,
task: &Task)) is dead code and should be removed to avoid rot since store_task
in handler.rs already performs the same state write; delete the touch_task
function, its #[allow(dead_code)] and the accompanying comment. Alternatively,
if you intend to keep the behavior, wire it up by invoking touch_task from the
existing store_task or the place where tasks are persisted (passing the III and
Task), instead of leaving it unused.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8176bba1-9b35-467b-8595-3b142072ea8e

📥 Commits

Reviewing files that changed from the base of the PR and between 295d461 and f5d7762.

⛔ Files ignored due to path filters (1)
  • a2a/Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • a2a/Cargo.toml
  • a2a/README.md
  • a2a/src/handler.rs
  • a2a/src/lib.rs
  • a2a/src/streaming.rs
  • a2a/src/types.rs
  • a2a/tests/streaming.rs
✅ Files skipped from review due to trivial changes (1)
  • a2a/Cargo.toml
🚧 Files skipped from review as they are similar to previous changes (3)
  • a2a/src/lib.rs
  • a2a/README.md
  • a2a/tests/streaming.rs

Comment thread a2a/src/streaming.rs
Comment on lines +477 to +593
// Spawn the actual function call. Runs concurrently with any further
// broadcasts (e.g. from a sibling `tasks/cancel`).
let iii_run = iii.clone();
let registry_run = registry.clone();
let task_id_run = task_id.clone();
let context_id_run = task.context_id.clone();
let metadata_run = task.metadata.clone();
let history_run = task.history.clone();
tokio::spawn(async move {
let result = iii_run
.trigger(TriggerRequest {
function_id,
payload,
action: None,
timeout_ms: Some(30000),
})
.await;

// Cancel-while-running: if the task is now Canceled, don't
// overwrite that with Completed. The cancel path has already
// broadcast its own final frame.
let fresh = load_task(&iii_run, &task_id_run).await;
if let Some(ref t) = fresh
&& matches!(t.status.state, TaskState::Canceled)
{
return;
}

match result {
Ok(value) => {
let result_text =
serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
let artifact = Artifact {
artifact_id: uuid::Uuid::new_v4().to_string(),
parts: vec![Part {
text: Some(result_text),
data: None,
url: None,
raw: None,
media_type: Some("application/json".to_string()),
}],
name: Some(fn_name),
metadata: None,
};

let artifact_payload = artifact_event_payload(
&task_id_run,
context_id_run.as_deref(),
artifact.clone(),
);
registry_run
.broadcast(&task_id_run, EVENT_ARTIFACT, &artifact_payload)
.await;

let completed = Task {
id: task_id_run.clone(),
context_id: context_id_run.clone(),
status: TaskStatus {
state: TaskState::Completed,
message: None,
timestamp: Some(iso_now()),
},
artifacts: Some(vec![artifact]),
history: history_run.clone(),
metadata: metadata_run.clone(),
};
store_task(&iii_run, &completed).await;
let final_payload = status_event_payload(&completed, TaskState::Completed, true);
registry_run
.broadcast(&task_id_run, EVENT_STATUS, &final_payload)
.await;
}
Err(err) => {
let failed = Task {
id: task_id_run.clone(),
context_id: context_id_run.clone(),
status: TaskStatus {
state: TaskState::Failed,
message: Some(Message {
message_id: msg_id(),
role: MessageRole::Agent,
parts: vec![text_part(format!("Error: {}", err))],
task_id: None,
context_id: None,
metadata: None,
}),
timestamp: Some(iso_now()),
},
artifacts: None,
history: history_run.clone(),
metadata: metadata_run.clone(),
};
store_task(&iii_run, &failed).await;
let final_payload = status_event_payload(&failed, TaskState::Failed, true);
registry_run
.broadcast(&task_id_run, EVENT_STATUS, &final_payload)
.await;
}
}

registry_run.close_task(&task_id_run).await;
});

// Wait for the producer to signal terminal, then close our writer so
// the SSE response completes cleanly.
if let Some(mut rx) = registry.terminal_watch(&task_id).await {
loop {
if *rx.borrow() {
break;
}
if rx.changed().await.is_err() {
break;
}
}
}
let _ = writer.close().await;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Spawned trigger task can hang the SSE response on panic.

The trigger driver is tokio::spawn'd (line 485) and the JoinHandle is dropped, so a panic anywhere inside the future — including serde_json::to_string_pretty, load_task deserialization, or any future panic in iii.trigger — never reaches close_task at line 577. The bus is left in buses with terminal_tx alive, and the parent's loop at 582-591 (rx.changed().await) blocks indefinitely because the watch sender is never sent and never dropped. The SSE socket then stays open with no further frames.

Two low-cost mitigations:

  • Hold the JoinHandle and select! it against terminal_watch; on Err(JoinError) (panic), force registry.close_task(...) and break.
  • Or use a small drop-guard that calls close_task on the registry when the task future is dropped (covers panic and explicit early returns like the cancel branch at 502, which today also skips close_task).

The cancel-branch early return at line 502 has the same shape: it relies on the cancel path having already called close_task, but if cancel raced in a way that skipped it (e.g., cancel saw the task still Working and didn't close because of any future code change), the same hang reappears. A drop-guard would make this self-healing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@a2a/src/streaming.rs` around lines 477 - 593, The spawned tokio::spawn future
running iii.trigger can panic or be dropped and currently never guarantees
registry.close_task is called, leaving terminal_watch waiting and the SSE open;
fix by retaining the JoinHandle returned by tokio::spawn (instead of dropping
it) and concurrently select!ing the JoinHandle against the
registry.terminal_watch() watcher in the parent task: if the JoinHandle returns
Err(JoinError) or finishes unexpectedly, call
registry.close_task(&task_id).await and break the wait loop so writer.close()
runs; ensure the same cleanup is invoked when the cancel-branch returns early
(or alternatively add a small drop-guard tied to the spawned task that invokes
registry_run.close_task(&task_id_run) if the spawned future is dropped) so
registry.close_task is always executed even on panics in iii.trigger,
serde_json::to_string_pretty, or load_task.

Comment thread a2a/src/streaming.rs
Comment on lines +495 to +548
// Cancel-while-running: if the task is now Canceled, don't
// overwrite that with Completed. The cancel path has already
// broadcast its own final frame.
let fresh = load_task(&iii_run, &task_id_run).await;
if let Some(ref t) = fresh
&& matches!(t.status.state, TaskState::Canceled)
{
return;
}

match result {
Ok(value) => {
let result_text =
serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string());
let artifact = Artifact {
artifact_id: uuid::Uuid::new_v4().to_string(),
parts: vec![Part {
text: Some(result_text),
data: None,
url: None,
raw: None,
media_type: Some("application/json".to_string()),
}],
name: Some(fn_name),
metadata: None,
};

let artifact_payload = artifact_event_payload(
&task_id_run,
context_id_run.as_deref(),
artifact.clone(),
);
registry_run
.broadcast(&task_id_run, EVENT_ARTIFACT, &artifact_payload)
.await;

let completed = Task {
id: task_id_run.clone(),
context_id: context_id_run.clone(),
status: TaskStatus {
state: TaskState::Completed,
message: None,
timestamp: Some(iso_now()),
},
artifacts: Some(vec![artifact]),
history: history_run.clone(),
metadata: metadata_run.clone(),
};
store_task(&iii_run, &completed).await;
let final_payload = status_event_payload(&completed, TaskState::Completed, true);
registry_run
.broadcast(&task_id_run, EVENT_STATUS, &final_payload)
.await;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Cancel-during-running TOCTOU can clobber a Canceled state with Completed.

Sequence:

  1. Trigger completes successfully (line 493).
  2. load_task at 498 returns Working (cancel hasn't fired yet).
  3. Concurrent tasks/cancel runs: stores Canceled, broadcasts the final frame, calls close_task.
  4. This branch proceeds to store_task(completed) at 543, silently overwriting the persisted Canceled state, and broadcasts a Completed frame to a now-empty bus.

End user sees a canceled final frame on the original stream (correct), but tasks/get later returns completed (incorrect) and any race-losing resubscriber loads completed from storage.

Per prior repo learnings, the iii engine's state API has no atomic CAS, so this exact TOCTOU pattern has been accepted elsewhere (sandbox capacity check). If you want to keep that posture here, a one-line code comment near line 498 explicitly noting the accepted race would prevent it being read as a bug later. Otherwise, the cleanest fix is to make the post-trigger update conditional: re-check state immediately before store_task and skip the write if it's now terminal.

Based on learnings: "the iii engine has no atomic reserve/CAS API for global enforcement across worker instances ... this is documented in the code by comment and is a known, accepted design tradeoff."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@a2a/src/streaming.rs` around lines 495 - 548, This branch can overwrite a
concurrently-set Canceled state (TOCTOU); before calling store_task(&iii_run,
&completed) and broadcasting the Completed status, reload the task (using
load_task(&iii_run, &task_id_run).await) and if its status.state is now
TaskState::Canceled (or any terminal non-Completed state) skip the store_task
and final registry_run.broadcast of EVENT_STATUS; alternatively, if you intend
to accept this race, add a one-line comment next to the existing load_task check
referencing the accepted design tradeoff and why no CAS is available.

@rohitg00
rohitg00 merged commit 6ba9413 into main Apr 26, 2026
7 checks passed
@rohitg00
rohitg00 deleted the phase3a/a2a-stream branch April 26, 2026 18:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant