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:
- Wrong start trigger. SSE only opens on the
ConversationStatus::Successtransition (orchestration_event_streamer.rs:229-237,start_event_deliveryat 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. - 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. - 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_idset) — 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 viastart_agent(Conversation::is_remote_child()is true), and those placeholders look like child conversations to the predicate — they haveparent_conversation_idset — 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 ofwatched_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 ofregister_watched_run_id, registering a child's run_id under the parent'sAIConversationIdwhen the child receives a server token.app/src/ai/active_agent_views_model.rs— already tracks "is this conversation expanded in any pane?" viaagent_view_handles. EmitsActiveAgentViewsEvent::ConversationClosed { conversation_id }onExitedAgentView(line 167-169) and onunregister_agent_view_controllerfrom the pane-close path (line 200-202; seepane_group/pane/terminal_pane.rs:383-385). Helperis_conversation_open(conversation_id, ctx)(line 354) gives the cross-pane "open anywhere?" check.app/src/ai/agent/conversation.rs:781-792—parent_conversation_id()andis_child_agent_conversation()classify a conversation as child or non-child; combined with the poller's ownwatched_run_idsset these classify a conversation as parent/child/solo.app/src/ai/agent_events/driver.rs (38-50, 177-199)—AgentEventDriverConfig::run_idsis forwarded toServerApi::stream_agent_eventsand 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 forstart_agentwithexecution_mode: local+ Oz harness, and on cloud workers for cloud Oz runs). The sameOrchestrationEventStreamersingleton is registered here (lib.rs:1564-1566is unconditional aside from theOrchestrationV2feature flag), so the streamer must serve a CLI/cloud process where there are noAgentViewControllerregistrations 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 throughreevaluate_eligibility, which callsstart_sse_connectionif 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:
ActiveAgentViewsModelreacts toEnteredAgentView/ExitedAgentViewand calls the helpers on behalf of the open view. - The driver path:
agent_sdk::drivercalls 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_idset when it creates the child placeholder viastart_new_child_conversation. - The driver-hosted process (CLI subprocess for
execution_mode: local, cloud worker for cloud children) never sees the parent's localAIConversationId. It does fetch the parent'srun_idfrom the server task metadata (get_ambient_agent_taskreturnsparent_run_id), so the agent_sdk driver stamps that run_id onto the conversation'sparent_agent_idfield 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_eligibilityruns 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_eligibilityopens 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 fromstart_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 eitherparent_conversation_id(set on a child created with a local placeholder) orparent_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 onConversationServerTokenAssignedfor this conversation.- Watched child run_ids on a parent — added by
register_watched_run_idonly after the child receives its server token (start_agent.rswaits forConversationServerTokenAssignedon 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:
register_consumerandunregister_consumer— consumer set changed.register_watched_run_id— a child run_id was added to a parent's set.on_server_token_assigned— this conversation received its run_id (and may now be a child).on_conversation_removedfor 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:
- 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_connectionsat any point. - 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.
- 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. - Parent subscribes when first child is registered while a consumer is active. Register a consumer first, then
register_watched_run_id→ connection opens. - Parent latent until a consumer registers. Children registered while no consumer exists → no connection. Consumer registers → connection opens with the right run_id list.
- 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_idsis preserved so the parent can re-subscribe when a new consumer appears. - 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
Conversationtest fixture with bothparent_conversation_idset 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. - 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.
- Stale child run_id is pruned. Parent with two children registered, one child conversation deleted → parent's
watched_run_idsshrinks, SSE reconnects with the smaller set. - 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.
- 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.
- 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.logon macOS). - Local CLI driver subprocess: the subprocess's stderr or its dedicated log file.
start_agentwithexecution_mode: localrunswarp_clias 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_eventsrequests in the devwarp-serveraccess 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_consumerandunregister_consumerfor the conversation, but noOpening SSE streamline at any point. - Server: no
stream_agent_eventsrequest.
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, thenOpening SSE streamfor parent withrun_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 streamdoes not appear in the GUI unless the user explicitly opens the child's view. - Closing parent's pane:
unregister_consumerfor parent, thenSSE driver exitedfor parent.
Local CLI driver subprocess expectations:
- At driver startup (after the run_id is assigned via streaming events):
register_consumer(Driver) for child, thenOpening SSE streamfor child withrun_idscontaining the child'sself_run_id. - When the driver run completes:
unregister_consumer(Driver) for child, thenTearing 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 streamfor 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, thenOpening SSE streamfor child oncerun_idis 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 (fromstart_agent),Opening SSE streamfor parent withrun_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, thenOpening SSE streamfor child withrun_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_agentwithexecution_mode: cloud(e.g. cloud Oz). The user's GUI auto-opens a viewer pane for the child; the local placeholder conversation hasis_remote_child() == trueandparent_conversation_idset, 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 noOpening SSE streamfor the viewer's conversation_id at any point. Theis_remote_run_viewguard inis_eligiblecovers both flavors and prevents subscription even when the conversation hasparent_conversation_idset or appears to have a watched run_id (the auto-opened cloud-child viewer satisfies both). - Server: no
stream_agent_eventsrequest 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'swatched_run_ids.
Specific behaviors to spot-check after running B or C
- 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 streamfor the parent withrun_idsshrunk by one (and a generation bump). Server: previous parentstream_agent_eventsrequest disconnects, new one opens with the smaller run_id list. - Last child removed leaves a non-child parent with no role. Continuing from (1), delete the remaining child too. Expected:
Tearing down SSEfor the parent; no newOpening SSE streamfollows. - Reopen parent — SSE re-establishes with cursor. Close the parent's pane in the GUI, then reopen. Expected:
register_consumerfollowed byOpening SSE streamwithsince=<cursor>matching the last persisted cursor. - 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 noOpening SSE streamfor 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_idcall 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
RemoveConversationmay 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-emptywatched_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.