Files

32 KiB

Tech spec: scope SSE event subscriptions to open parent/child conversations

Context

OrchestrationEventStreamer (app/src/ai/blocklist/orchestration_event_streamer.rs) delivers v2 orchestration events (cross-agent messages and lifecycle events) into a conversation by holding a long-lived SSE connection per conversation. The streamer is SSE-only and gated solely on OrchestrationV2; the previous polling fallback path was removed before this change so all state and code paths described below are SSE-only.

A key semantic to keep in mind throughout: an agent run subscribes to its own run_id because that subscription is its inbox. Server-side, AgentEventDriverConfig::run_ids filters events whose run_id matches the set; for a given agent run, events with run_id == self_run_id are messages and lifecycle signals destined for that run. Both parent agent runs and child agent runs (including headless CLI children of any harness) must keep this subscription up while their run is active in order to receive messages.

Note. The "Today the lifecycle is wrong" subsection and the "Relevant code" listing below describe the pre-rewrite state of the streamer (then often called "the poller") that motivated this change. Specific identifiers, line numbers, and field names in those subsections are historical and no longer correspond to the current code. The "Proposed changes" section onwards reflects the implementation as it now exists.

Today the lifecycle is wrong in three ways:

  1. Wrong start trigger. SSE only opens on the ConversationStatus::Success transition (orchestration_event_streamer.rs:229-237, start_event_delivery at line 494). A conversation that is open in the UI but still in-progress will not subscribe; conversely once it has subscribed, status no longer matters.
  2. Wrong stop trigger for parents. Cleanup only runs on RemoveConversation/DeletedConversation (lines 178-193). Closing the agent view of a parent conversation does not tear down its SSE, so a parent the user has abandoned in the UI keeps a live connection.
  3. Wrong scope. on_server_token_assigned (line 240) registers the conversation's own run_id for every cloud-backed conversation that gets a server token (except shared-session viewers, line 254). Solo conversations — neither parents (have spawned children) nor children (parent_conversation_id set) — are not part of an orchestration tree and cannot meaningfully receive or send orchestration events. They should not subscribe. The shared-session-viewer exclusion is also incomplete: the GUI auto-opens a viewer pane for cloud children spawned via start_agent (Conversation::is_remote_child() is true), and those placeholders look like child conversations to the predicate — they have parent_conversation_id set — even though the actual run is on a cloud worker. Without a guard they open a redundant SSE in the parent's process for events the parent's own SSE will already deliver.

There is also a related state-leak: register_watched_run_id (line 138) only inserts into watched_run_ids; nothing removes a child's run_id when the child finishes or is deleted. A long-lived parent accumulates dead run_ids and reconnects with an ever-growing filter.

Relevant code

  • app/src/ai/blocklist/orchestration_event_streamer.rs — owner of watched_run_ids, sse_connections, polling/SSE timers, and all cleanup paths.
  • app/src/ai/blocklist/action_model/execute/start_agent.rs:112-119 — the only external caller of register_watched_run_id, registering a child's run_id under the parent's AIConversationId when the child receives a server token.
  • app/src/ai/active_agent_views_model.rs — already tracks "is this conversation expanded in any pane?" via agent_view_handles. Emits ActiveAgentViewsEvent::ConversationClosed { conversation_id } on ExitedAgentView (line 167-169) and on unregister_agent_view_controller from the pane-close path (line 200-202; see pane_group/pane/terminal_pane.rs:383-385). Helper is_conversation_open(conversation_id, ctx) (line 354) gives the cross-pane "open anywhere?" check.
  • app/src/ai/agent/conversation.rs:781-792parent_conversation_id() and is_child_agent_conversation() classify a conversation as child or non-child; combined with the poller's own watched_run_ids set these classify a conversation as parent/child/solo.
  • app/src/ai/agent_events/driver.rs (38-50, 177-199)AgentEventDriverConfig::run_ids is forwarded to ServerApi::stream_agent_events and is the server-side filter for which events come back. A run watching its own run_id is using that subscription as its inbox.
  • app/src/ai/agent_sdk/driver.rs — drives Oz conversations headlessly via the Warp CLI (locally for start_agent with execution_mode: local + Oz harness, and on cloud workers for cloud Oz runs). The same OrchestrationEventStreamer singleton is registered here (lib.rs:1564-1566 is unconditional aside from the OrchestrationV2 feature flag), so the streamer must serve a CLI/cloud process where there are no AgentViewController registrations at all.

Proposed changes

Consumer abstraction

The streamer's job is to deliver events to a consumer. Today there is one implicit consumer (the agent view), but in CLI and cloud worker processes the consumer is the agent_sdk::driver itself, which has no agent view. Generalize this: the streamer owns a set of registered consumers per conversation, where a consumer is anything that needs orchestration events delivered for that conversation.

The registry lives inside OrchestrationEventStreamer as part of the per-conversation ConversationStreamState (a single streams: HashMap<AIConversationId, ConversationStreamState> map collects every per-conversation field — watched run_ids, cursor, pending message IDs, consumers, SSE connection, restore-retry counter — under one entry). Consumers are identified by EntityId (the terminal pane id for an agent view; the driver model id for agent_sdk). Two public methods drive the registry:

  • register_consumer(conversation_id, consumer_id, ctx) — inserts and re-evaluates eligibility through reevaluate_eligibility, which calls start_sse_connection if the conversation is newly eligible.
  • unregister_consumer(conversation_id, consumer_id, ctx) — removes and re-evaluates eligibility, tearing down the SSE if the conversation is no longer eligible.

This avoids introducing a separate singleton and the event-subscribe plumbing that would come with it. The streamer is the only consumer of these signals, so co-locating the state keeps the lifecycle decisions in one place.

Two callers wire up registrations through the free helpers register_agent_event_consumer / unregister_agent_event_consumer:

  • The agent view path: ActiveAgentViewsModel reacts to EnteredAgentView / ExitedAgentView and calls the helpers on behalf of the open view.
  • The driver path: agent_sdk::driver calls the same helpers around its run lifetime.

The streamer's eligibility check observes a single signal — has_active_consumer(conversation_id) reads the conversation's stream entry — and does not care which type registered. In the GUI client this collapses to "agent view is open somewhere"; in CLI/cloud workers it collapses to "the driver is running."

A single EntityId per consumer is enough: the streamer treats every consumer as opaque, the entity id is unique per pane / driver model, and unregister keys back to that same id.

Eligibility predicate

The streamer treats parent and child agent runs differently because the trigger conditions are different, but both check the same consumer signal.

Child role — the conversation has a parent agent run from this process's perspective. Two signals satisfy this: parent_conversation_id.is_some() (the parent has a local placeholder, set by start_new_child_conversation when the GUI parent spawned the child) OR parent_agent_id.is_some() (the conversation knows its parent's server-side agent identifier, which under v2 is the parent's run_id). The two signals address two different processes:

  • The parent's GUI process gets parent_conversation_id set when it creates the child placeholder via start_new_child_conversation.
  • The driver-hosted process (CLI subprocess for execution_mode: local, cloud worker for cloud children) never sees the parent's local AIConversationId. It does fetch the parent's run_id from the server task metadata (get_ambient_agent_task returns parent_run_id), so the agent_sdk driver stamps that run_id onto the conversation's parent_agent_id field at register time. That stamp is what the streamer's child-role check picks up.

The subscription is the child's inbox. It is gated on having an active consumer in this process the same way the parent role is. In the process where the child run actually lives, the agent_sdk driver registers a consumer for the run's lifetime, so the child's inbox stays live for the whole run. In any other process (e.g. the user's GUI tracking a cloud child), the child's own SSE only opens while a consumer is registered — i.e. the child's agent view is open. Without a local consumer the events would have nowhere to land, and the user's GUI already sees child→parent traffic via the parent's SSE. This applies to every harness — Oz, ClaudeCode, Gemini, headless CLI children spawned via start_agent with execution_mode: local, and cloud children. Children today cannot themselves spawn children; if that constraint is lifted later, this role keeps applying unchanged.

Parent role — the conversation has at least one child run_id registered in watched_run_ids (from register_watched_run_id in start_agent.rs). The subscription delivers child lifecycle and child→parent messages into the parent's consumer. The parent role is gated on having an active consumer. In the GUI that means the agent view is open; in CLI/cloud it means the agent driver is running. When the last consumer for a parent disappears, child events for that parent are stored on the server and backfilled via the cursor when a consumer comes back.

Today, child agent runs cannot themselves spawn children, so no live conversation simultaneously holds both roles. The design must still admit the case so that allowing children-of-children later is a code change to the spawn path, not to the subscription model. Both roles' run_id contributions are unioned into the SSE filter whenever both apply.

The stronger present-day motivator for the consumer abstraction is a different shape: a top-level cloud Oz agent run — not itself a child — that spawns children. It has the parent role only, runs on a cloud worker with no agent view, and is driven by the agent_sdk driver. Without the consumer abstraction the parent gate would never be satisfied (no agent view ever exists in that process) and the cloud Oz agent could not receive its children's events. With the abstraction the driver registers as the consumer and the parent role is eligible.

Solo conversations — neither parent nor child — are excluded. They have no role in the orchestration tree and no inbox traffic to receive.

Formally, a conversation should be subscribed iff:

has_active_consumer()
  AND (is_child_agent_conversation() OR has_at_least_one_watched_child_run_id())

Where has_active_consumer() returns true when at least one AgentViewController or agent_sdk::driver is registered for this conversation. This predicate is the single invariant the streamer maintains. Every code path either makes a conversation eligible (and ensures a connection exists) or makes it ineligible (and tears the connection down).

Subscription drivers

Children. The trigger is server-token assignment, not status. on_server_token_assigned calls ensure_self_run_id_watched to register the self run_id and re-runs reevaluate_eligibility immediately. Children must be listening as soon as they have a run_id, regardless of status. Teardown for children happens only on RemoveConversation / DeletedConversation.

Parents. Subscribe to a single "consumer changed" signal from the registry described above:

  • On consumer-removed for a conversation: reevaluate_eligibility runs the predicate and tears the SSE down if the conversation is no longer eligible. If it is also a child, the child role keeps it alive.
  • On consumer-added for a conversation: reevaluate_eligibility opens the SSE if newly eligible. This handles both the GUI reopen case (agent view opened after being closed) and the CLI startup case (driver registered before any agent view exists).
  • register_watched_run_id (called from start_agent.rs) re-evaluates eligibility after the insert. If a consumer exists, this opens or reconnects the SSE; if none exists, it is a no-op until a consumer registers.

reevaluate_eligibility is the single dispatch point: it computes the eligibility predicate and either calls start_sse_connection, reconnects, or tears down. Status is not part of the lifecycle decision for either role.

The agent_sdk driver registers itself as a consumer at the start of its run (a natural place is AgentDriver::execute_run for Oz, or where the third-party harness path picks up the task_id for ThirdParty harnesses) and unregisters when the run terminates. Registration is by AIConversationId so it composes with the existing per-conversation streamer state.

Because both roles share a single SSE connection per conversation, the SSE's run_ids filter must be the union of {self_run_id} (child role) and the registered child run_ids (parent role), where each role's contribution is included only when that role's eligibility is met. The simplest implementation is to compute the run_id list at SSE-open time from current state.

Trigger ordering and re-evaluation

Eligibility depends on three pieces of state, each of which lands at a different time and is set by a different code path:

  • Conversation::has_parent_agent() — true when the conversation has either parent_conversation_id (set on a child created with a local placeholder) or parent_agent_id (the parent's run_id, stamped by the agent_sdk driver in driver-hosted processes). Available before any run_id is assigned for the local-placeholder case; depends on the parent's run_id for the driver case.
  • self_run_id — set on ConversationServerTokenAssigned for this conversation.
  • Watched child run_ids on a parent — added by register_watched_run_id only after the child receives its server token (start_agent.rs waits for ConversationServerTokenAssigned on the child, then registers under the parent).

A consumer that registers earlier than any of these will not yet make the conversation eligible. That is fine: re-evaluation happens at every state change, so the SSE opens at the moment eligibility flips, regardless of which event arrived first. Concretely, the streamer re-runs the predicate at exactly four sites:

  1. register_consumer and unregister_consumer — consumer set changed.
  2. register_watched_run_id — a child run_id was added to a parent's set.
  3. on_server_token_assigned — this conversation received its run_id (and may now be a child).
  4. on_conversation_removed for any other conversation — if it was a child of this one, its run_id was just pruned from this parent's watched set; the parent may have no remaining children.

A consumer never needs to re-register on its own to chase the run_id. It registers once at the start of its lifetime and unregisters once at the end; the streamer is responsible for catching up state changes in between.

A practical consequence for the GUI: opening an agent view for a brand-new conversation calls register_consumer immediately, but no SSE opens until either the conversation receives ConversationServerTokenAssigned (and is found to be a child) or register_watched_run_id runs against it (when its first child is spawned). For the CLI/cloud driver path the same applies: the driver registers immediately at run start, but the SSE waits until the run_id is assigned.

Solo exclusion at registration time

on_server_token_assigned calls ensure_self_run_id_watched, which inserts the conversation's own run_id only when the conversation already has a role: it has a parent (Conversation::has_parent_agent()) or it is already a parent (some other watched run_id is registered). Conversations that are not yet children at server-token time may later become parents via register_watched_run_id; that path runs ensure_self_run_id_watched again to add the self run_id once the parent role kicks in, so solo conversations stay out of the watched set entirely until and unless they enter the orchestration tree.

This avoids opening inbox subscriptions for conversations that have no orchestration role.

Removal of stale child run_ids in a still-eligible parent

In the existing RemoveConversation / DeletedConversation arm, also walk every other entry in watched_run_ids and remove the closed conversation's run_id from each set. Capture the run_id from BlocklistAIHistoryModel::conversation(conversation_id).and_then(|c| c.run_id()) before the conversation is removed (the event handler runs before history-model state is fully gone for RemoveConversation; for DeletedConversation we need the same guarantee — verify during implementation, fall back to passing the run_id on the event if necessary).

After the prune, re-evaluate the parent's eligibility:

  • If the parent has remaining children and its view is still open, reconnect the SSE with the smaller run_id set.
  • If the parent has no remaining children and is not itself a child, tear down its SSE.
  • If the parent has no remaining children but is itself a child, keep the SSE alive for the child-role inbox.

Reconnect behavior

reconnect_sse runs the full is_eligible predicate before re-opening: if the conversation is no longer eligible, the connection is dropped without re-opening. The proactive reconnect (AgentEventDriverConfig::proactive_reconnect_after, ~14 minutes) and error-driven reconnect both pass through this gate, so an ineligible conversation stops reconnecting automatically.

State machine

Replace "agent view open" with "has active consumer" in the parent gate; otherwise the same shape:

stateDiagram-v2
    [*] --> Solo: conversation created
    Solo --> ChildOnly: server token assigned and parent_conversation_id set
    Solo --> ParentOnly: child run_id registered while consumer active
    Solo --> ParentLatent: child run_id registered while no consumer
    ChildOnly --> ChildAndParent: registers a grandchild
    ParentOnly --> ParentLatent: last consumer unregisters
    ParentLatent --> ParentOnly: consumer registers
    ChildOnly --> Subscribed: SSE active (always-on inbox)
    ParentOnly --> Subscribed: SSE active (consumer present)
    ChildAndParent --> Subscribed: SSE active (both roles)
    Subscribed --> ChildOnly: parent role ends (no children + no consumer)
    Subscribed --> ParentOnly: child role ends (rare; only via removal)
    Subscribed --> [*]: conversation removed
    ChildOnly --> [*]: conversation removed
    ParentOnly --> [*]: conversation removed
    ParentLatent --> [*]: conversation removed
    Solo --> [*]: conversation removed

A conversation is in Subscribed iff its entry in streams has sse_connection.is_some(). Exiting Subscribed always tears down the SSE.

Testing and validation

Add unit tests in orchestration_event_streamer_tests.rs covering the new invariants:

  1. Solo conversation does not subscribe. Server token assigned for a conversation that is not a child and never spawns one; consumers come and go; status reaches Success → no entry in sse_connections at any point.
  2. Child subscribes immediately on server-token assignment regardless of consumer state. A child conversation (parent_conversation_id set) gets server token while no consumer is registered → SSE is active. Adding/removing consumers does not affect the connection.
  3. Headless CLI child of any harness subscribes. Cover Oz, ClaudeCode, and Gemini harness children spawned with execution_mode: local — each must have a live SSE once their run_id is assigned, with no agent view ever opened. The Oz CLI case must also have the agent_sdk driver registered as a consumer.
  4. Parent subscribes when first child is registered while a consumer is active. Register a consumer first, then register_watched_run_id → connection opens.
  5. Parent latent until a consumer registers. Children registered while no consumer exists → no connection. Consumer registers → connection opens with the right run_id list.
  6. Last consumer leaving tears down (parent-only conversation). A subscribed parent that is not itself a child has its only consumer unregister; sse_connections/cursor/timer state are all gone. watched_run_ids is preserved so the parent can re-subscribe when a new consumer appears.
  7. Last consumer leaving does not tear down a parent-and-child conversation. Forward-looking, since children cannot spawn children today; exercise the predicate by constructing a Conversation test fixture with both parent_conversation_id set and at least one watched child run_id. Verify it stays subscribed when its consumers leave (child role keeps it alive), and that the parent contribution is only present in the run_id filter while a consumer exists.
  8. Reconnect on consumer return. Parent-only: last consumer leaves → consumer rejoins → new SSE connection opened with the latest cursor and the current set of child run_ids.
  9. Stale child run_id is pruned. Parent with two children registered, one child conversation deleted → parent's watched_run_ids shrinks, SSE reconnects with the smaller set.
  10. Last child removed leaves a non-child parent with no role → teardown. Parent that was never a child has its only child removed → connection torn down even if a consumer is still registered.
  11. Multiple consumers do not double-tear-down. Two consumers (e.g. agent view + driver, or two agent views) for the same parent; one unregisters → SSE persists; second unregisters → SSE torn down.
  12. Cloud Oz parent (driver-only consumer) receives child events. A top-level cloud Oz conversation (not itself a child) that spawns one or more children, with only the agent_sdk driver registered as a consumer (no agent view), must have all the child run_ids in the SSE filter — verifies that the consumer abstraction admits the driver to satisfy the parent gate. This is the present-day motivator for the abstraction.

Manual validation:

Observation setup. Each test below names the process where SSE state should appear: either the user's GUI Warp app (a desktop binary) or a driver process (a local CLI subagent subprocess, or a cloud Oz worker). The relevant log lines are the same in either process; the difference is which log file you tail.

Log lines to watch for, all at info level unless noted:

  • register_consumer for {conv:?}: {consumer_id:?} (total=N) — a consumer registered.
  • unregister_consumer for {conv:?}: {consumer_id:?} (remaining=N) — a consumer unregistered.
  • Opening SSE stream for {conv:?} (gen=, run_ids=, since=) — SSE connection opened.
  • SSE driver exited for {conv:?} (gen=) — SSE driver task exited (warn level).
  • Tearing down SSE for {conv:?} (no longer eligible) — explicit teardown after eligibility flipped to false.

Where to tail:

  • GUI process: the Warp app log for the active build flavor (e.g. ~/Library/Logs/dev.warp.Warp-Stable/warp.log on macOS).
  • Local CLI driver subprocess: the subprocess's stderr or its dedicated log file. start_agent with execution_mode: local runs warp_cli as a subprocess; tail wherever that subprocess writes logs.
  • Cloud Oz worker: the worker logs surfaced by the Oz UI / cloud logging tool for the run.
  • Server-side: stream_agent_events requests in the dev warp-server access log — one request per active SSE; new requests on reconnect; disconnects when the client closes the stream.

A. Solo conversation

Start a regular cloud conversation. Don't call start_agent. Open and close its pane.

  • GUI: register_consumer and unregister_consumer for the conversation, but no Opening SSE stream line at any point.
  • Server: no stream_agent_events request.

B. Local parent + Local child (both in user's GUI; child runs as a local CLI subagent subprocess)

In the user's GUI, spawn a child via start_agent with execution_mode: local + a CLI harness (e.g. claude-code). The parent's conversation lives in the GUI; the child's run is hosted in a separate driver subprocess.

GUI process expectations:

  • With parent's pane open and a child registered: register_consumer (AgentView) for parent, then Opening SSE stream for parent with run_ids = [child_run_id, parent_self_run_id] — the child run_id for child lifecycle events and the parent's self_run_id for child→parent messages.
  • Child's Opening SSE stream does not appear in the GUI unless the user explicitly opens the child's view.
  • Closing parent's pane: unregister_consumer for parent, then SSE driver exited for parent.

Local CLI driver subprocess expectations:

  • At driver startup (after the run_id is assigned via streaming events): register_consumer (Driver) for child, then Opening SSE stream for child with run_ids containing the child's self_run_id.
  • When the driver run completes: unregister_consumer (Driver) for child, then Tearing down SSE.

Server: one stream_agent_events request per active SSE.

C. Local parent + Cloud child (parent in GUI, child in cloud worker)

In the user's GUI, spawn a child via start_agent with cloud Oz harness. Parent runs in GUI; child runs on a cloud worker.

GUI process expectations:

  • Parent: same as B — SSE only while parent's pane is open and the child run_id is registered. run_ids = [child_run_id, parent_self_run_id].
  • Child: no Opening SSE stream for the child in the GUI unless the user opens the child's view (e.g. via the cloud agent transcript). The intentional behavior is that child→parent traffic flows through the parent's SSE; the child's own SSE in the GUI would be redundant.

Cloud worker expectations:

  • register_consumer (Driver) for child on run start, then Opening SSE stream for child once run_id is assigned.
  • When the run completes: unregister_consumer + Tearing down SSE.

D. Cloud parent + Local child (both in same cloud worker process)

A top-level cloud Oz parent driver spawns a CLI subagent locally (in the same worker process). Both runs are headless on the cloud worker — driver runs do not have a GUI; any user-side viewer pane on the parent or child is a passive remote-run view and is covered by section F. Two AgentDriver instances register two Driver consumers with the worker's single streamer.

Cloud worker expectations:

  • Parent: register_consumer (Driver) for parent on run start. Once the parent registers a child run_id (from start_agent), Opening SSE stream for parent with run_ids = [child_run_id, parent_self_run_id] — the child run_id for child lifecycle and the parent's self_run_id for child→parent messages.
  • Child: register_consumer (Driver) for child, then Opening SSE stream for child with run_ids = [child_self_run_id].
  • Parent and child each unregister and tear down their own SSE when their respective drivers complete.

E. Cloud parent + Cloud child (separate cloud workers)

Like D but split across two worker processes. The parent worker holds the parent's SSE (with run_ids = [child_run_id, parent_self_run_id]); the child worker holds the child's SSE (run_ids = [child_self_run_id]). Each driver consumer is in its own process. Both runs are headless; any user-side viewer pane is covered by section F.

F. Passive view of a remote run

A conversation in the local process that is a passive view of an agent run hosted in another process. Two flavors share the same predicate path:

  • Shared-session viewer. Open a pane that views someone else's shared session. The local conversation has is_viewing_shared_session() == true.
  • Remote-child placeholder. Spawn a child via start_agent with execution_mode: cloud (e.g. cloud Oz). The user's GUI auto-opens a viewer pane for the child; the local placeholder conversation has is_remote_child() == true and parent_conversation_id set, but the actual run lives on the cloud worker.

In both cases the local process has no run to host — the inbox lives elsewhere — so opening an SSE here would re-inject events the real consumer has already processed.

  • GUI: register_consumer (AgentView) when the pane opens, but no Opening SSE stream for the viewer's conversation_id at any point. The is_remote_run_view guard in is_eligible covers both flavors and prevents subscription even when the conversation has parent_conversation_id set or appears to have a watched run_id (the auto-opened cloud-child viewer satisfies both).
  • Server: no stream_agent_events request from this client for the viewer's conversation_id. The parent's SSE in the same process is unaffected and continues to deliver child events for the cloud child via the parent's watched_run_ids.

Specific behaviors to spot-check after running B or C

  1. Stale child run_id pruned. Spawn two children, then delete the second child's conversation. Expected in the parent's process (GUI for B/C, worker for D/E): a new Opening SSE stream for the parent with run_ids shrunk by one (and a generation bump). Server: previous parent stream_agent_events request disconnects, new one opens with the smaller run_id list.
  2. Last child removed leaves a non-child parent with no role. Continuing from (1), delete the remaining child too. Expected: Tearing down SSE for the parent; no new Opening SSE stream follows.
  3. Reopen parent — SSE re-establishes with cursor. Close the parent's pane in the GUI, then reopen. Expected: register_consumer followed by Opening SSE stream with since=<cursor> matching the last persisted cursor.
  4. Solo with consumer registered. Open an agent view for a conversation that has no children registered and is not itself a child. Expected: register_consumer, but no Opening SSE stream for that conversation.

Run ./script/presubmit before opening the PR. Add a CHANGELOG-IMPROVEMENT: line for "Tighten orchestration event subscription scope: solo conversations no longer subscribe; parent subscriptions are tied to agent view openness; child subscriptions remain active for the lifetime of the run."

Risks and mitigations

  • Race between server token assignment and consumer registration. Re-deriving eligibility from current state on every consumer-add and on every register_watched_run_id call covers the ordering. Verify with unit tests.
  • Headless child without UI must still subscribe. A regression here breaks message delivery to CLI subagents. Tests (3) and the manual CLI-child step are the primary safeguards. The implementation must not gate the child path on consumer presence — only the parent path is consumer-gated.
  • Driver lifecycle in the agent_sdk. The driver must register and unregister at the right boundaries: register before any child can be spawned and unregister only after the run has terminated and any teardown work is complete. Registering too late means the driver misses early grandchild events; unregistering too early causes the same. Pin the registration to the run's outer scope in AgentDriver::execute_run.
  • Single SSE serves dual roles. A conversation that is both child and parent uses one connection with a unioned run_id set. Reconnects must include both contributions; the run_id list should be recomputed each time the connection is opened or reconnected. Add an assertion to surface a divergence in tests.
  • Event ordering. Consumer-removed and RemoveConversation may arrive in either order. Both paths run idempotent teardown — verify by making the teardown helper safe to call when the conversation is already gone.

Follow-ups

  • Expose a debug snapshot from the streamer (count of streams with an open sse_connection, count of streams with non-empty watched_run_ids, total run_ids across all sets, count of registered consumers per conversation, role classification per conversation) so we can monitor connection scope in production via a dev panel or telemetry.