49 KiB
Session Sharing for Orchestrated Agent Sessions
Context
See specs/QUALITY-726/PRODUCT.md for user-visible behavior. This spec maps the product invariants onto the existing orchestration pill bar and shared-session viewer infrastructure, and fixes the gaps observed across the eight share-parent / share-child × local/remote permutations.
The orchestration pill bar in shared-session viewers was originally built for remote-remote (cloud-spawned orchestrator + cloud children) in specs/orch-pill-bar-web/TECH.md. That design already covers apply_children_fetch, per-child hidden viewer panes, REST polling, and pill click navigation. This spec extends those mechanisms to the other topologies (local-local, local-remote, remote-local, remote-remote child link) and fixes agent-name resolution in both the pill bar and conversation bodies.
Where the pill bar currently renders
- Render gate (shared by native and viewer pill bars):
app/src/terminal/view/pane_impl.rs:503-552(maybe_add_parent_navigation_card), keyed onFeatureFlag::OrchestrationPillBar||FeatureFlag::OrchestrationViewerPillBarplusAgentViewfullscreen. - Pill data:
app/src/ai/blocklist/agent_view/orchestration_pill_bar.rsreadsBlocklistAIHistoryModel::descendant_conversation_ids_in_spawn_orderviapill_specs(orchestration_pill_bar.rs:555-580).pill_specsreturnsNonewhen the orchestrator has no descendants; the pill bar collapses toEmptyin that case. - Shared-session viewer’s discovery side:
OrchestrationViewerModelREST-polls children (app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs:1-416). Its construction site isapp/src/terminal/shared_session/viewer/terminal_manager.rs:778-816, currently gated onSessionSourceType::AmbientAgent { .. }.
Where children are made shareable
- Remote children are always shared by the server: spawning a remote child mints an
ai_tasksrow and aSessionSourceType::AmbientAgent { task_id }shared session. - Local children created from a local orchestrator inherit sharing via
inherit_share_for_local_childinapp/src/pane_group/pane/terminal_pane.rs:228-248, but only when the host terminal’sSessionSourceTypeisAmbientAgent. Manual local shares (created via the share modal atapp/src/terminal/view/shared_session/view_impl.rs:1864-1890) do not get anAmbientAgentsource, so they fail this gate and children stay unshared. - After QUALITY-726, the host’s orchestrator
task_idrides on a siblingsource_task_idfield, not on theSessionSourceType::Uservariant itself — see Sidecar source_task_id design below.
Where agent IDs are resolved in conversation bodies
conversation_id_for_agent_idinapp/src/ai/blocklist/agent_view/orchestration_conversation_links.rs:33-45first checksBlocklistAIHistoryModel::conversation_id_for_agent_id(which uses theagent_id_to_conversation_idindex keyed byAIConversation::orchestration_agent_id; seehistory_model.rs:961-988,history_model.rs:1029-1033,agent_id_keyathistory_model.rs:2228-2232), and falls back tofind_conversation_id_by_server_token.AIConversation::orchestration_agent_idis populated when the local conversation is given a server conversation token or a run id (history_model.rs:961-988,history_model.rs:994-1027).BlocklistAIHistoryModel::start_new_child_conversation(history_model.rs:398-433) setsagent_name,parent_agent_id, and the parent/child relationship.
Where the task ↔ session link is established server-side
- Today, when a local-to-driver child of a remote orchestrator starts sharing, no client tells the server which
session_idis bound to that child’sai_tasksrow. The remote driver mints the localai_tasksrow at spawn time and the local terminal manager mints the shared session, but the two aren’t linked on the server side. This is the root cause for the remote-local-share-parent (cases 5/6) hang in the pill bar. - For live session sharing, the viewer joins child sessions by the child task’s
session_id(surfaced on the client asAmbientAgentTask::session_id, populated fromRunItem.session_idon the server). Other linkages (e.g.ai_tasks.agent_conversation_id) are restore-oriented and orthogonal to live shared-session join; we do not need them for QUALITY-726.
Observed gaps
- Local-local: share parent. Native viewer shows the parent transcript but no pill bar; child names resolve in native, show as
Unknownon web. Cause:OrchestrationViewerModelis not initialized because the shared session source is notAmbientAgent; web viewer has no local history index, so agent-id lookups miss; children are not registered into the viewer history. - Local-local: share child. Child shows without pill bar; child name references show
Orchestrator(native) orOrchestrator/Unknown(web). Cause: child link is a leaf view (expected), but in-transcript references resolve throughparent_agent_idfallbacks and the missing agent index, so other agents render with the parent’s name placeholder. - Local-remote: share parent. Native viewer shows the parent transcript but no pill bar; web shows
Unknownchild names. Cause: sameAmbientAgent-gate problem as (1); the remote child is shareable, but the parent viewer doesn’t know to discover it. - Local-remote: share child. Child shows without pill bar; same name-resolution issues. Cause: same root cause as (2) plus remote child links don’t pre-load the agent index for sibling references.
- Remote-local: share parent. Pill bar shows, but clicking child stays on loading. Cause:
OrchestrationViewerModelregisters the child butAmbientAgentTask.session_idis never populated for local-to-driver children (driver creates the session locally and never reports it via REST). - Remote-local: share child. Never gets past loading. Cause: same as (5); no
session_idto join. - Remote-remote: share parent. Pill bar works, child loads. This is the happy path designed for in
specs/orch-pill-bar-web/TECH.md. - Remote-remote: share child. Child loads without pill bar (expected), but name references are not resolved. Cause: child link is leaf (expected); transcript-side agent name index is missing as in (2)/(4).
Relevant files
- Pill bar UI:
app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs;pill_specsat lines 555-580. - Render gate:
app/src/terminal/view/pane_impl.rs:503-552. - Viewer pill bar discovery model construction:
app/src/terminal/shared_session/viewer/terminal_manager.rs:778-816. The viewer model itself:app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs. - Per-child hidden viewer panes:
app/src/pane_group/mod.rs:3270-3548(create_hidden_child_agent_pane,ensure_shared_session_viewer_child_pane). - Local sharing cascade:
app/src/pane_group/pane/terminal_pane.rs:183-248(host_terminal_shared_session_source_type,inherit_share_for_local_child). - Share initiation:
app/src/terminal/view/shared_session/view_impl.rs:521-584(attempt_to_share_session(source_type: SessionSourceType, ...)). Call sites that currently passSessionSourceType::default():app/src/terminal/view.rs:20928(StartRemoteControl),app/src/pane_group/mod.rs:2627(ShareSessionModalEvent::StartSharing),app/src/terminal/view/use_agent_footer/mod.rs:259(UseAgentToolbarEvent::StartRemoteControl), and the test sites inview_tests.rs:1032/1107/1178. Cloud-agent path that already passesAmbientAgent { task_id }:app/src/ai/agent_sdk/driver/terminal.rs:133-141. - Conversation-body agent name resolution:
app/src/ai/blocklist/agent_view/orchestration_conversation_links.rs:33-129,app/src/ai/blocklist/history_model.rs:961-1033,app/src/ai/blocklist/history_model.rs:398-450. - Existing local-event-to-server sync home:
app/src/ai/blocklist/task_status_sync_model.rs(subscribes to local events and firesupdate_agent_taskwith the standard viewer/remote-child guards). Trait signature inapp/src/server/server_api/ai.rs:920-927; the actualfire_updatesite istask_status_sync_model.rs:188-200. - Source type plumbing:
session_sharing_protocol::sharer::SessionSourceType(external crate; today:User(unit) andAmbientAgent { task_id }).
Proposed changes
The implementation has four threads. They can be implemented in parallel and merged together because they touch largely disjoint files; see Parallelization.
Thread A — Cascade sharing from manually shared local orchestrators
Goal: invariants 8, 9, 32 (local-local share parent), 33 (local-remote share parent).
Sidecar source_task_id design
The existing session_sharing_protocol::sharer::SessionSourceType has two variants on main:
User(unit, default) — the session was started by a user directly.AmbientAgent { task_id: Option<String> }— the session was started in the course of spinning up an ambient agent. A manually shared local conversation is conceptually still aUser-initiated share — the same person clicked “share session” as for any other manual share. What’s new is that modern local conversations are always associated with a server-sideai_tasksrow bytask_id, regardless of whether the conversation happens to act as an orchestrator. An earlier iteration of QUALITY-726 turnedUserinto a struct variantUser { task_id: Option<String> }to carry that id. That broke wire compatibility with pre-QUALITY-726 viewers that only understood the bare"User"JSON form. Thread A keepsUserstrictly unit and instead carries the orchestratortask_idon a sidecar field of the payloads that already cross the wire:
// session-sharing-protocol/src/sharer.rs
pub struct InitPayload {
// ...existing fields...
#[serde(default)]
pub source_type: SessionSourceType, // strict `User` | `AmbientAgent { task_id }`
/// Orchestrator `task_id` for this share. Set whenever the conversation
/// has an `ai_tasks` row, regardless of whether `source_type` is `User`
/// or `AmbientAgent`. Old clients omit this; new code reads it directly.
#[serde(default)]
pub source_task_id: Option<String>,
// ...
}
// session-sharing-protocol/src/viewer.rs::DownstreamMessage::JoinedSuccessfully
#[serde(default)]
pub source_task_id: Option<String>,
The SessionManifest in session-sharing-server adds the same source_task_id field so the server can plumb the value from InitPayload to JoinedSuccessfully.
This preserves the existing semantic split:
Userstill means “a user initiated this share.” The orchestratortask_idlives insource_task_id.AmbientAgent { task_id }still means “cloud-executed agent session.” The variant continues to carry the canonical ambienttask_id. New sharer code MAY mirror that value intosource_task_idso downstream readers can use a single field; legacy AmbientAgent producers without the mirror keep working because viewers fall back to the variant. Concrete client places that key offAmbientAgentfor cloud-execution semantics (not orchestration semantics) must continue to match onlyAmbientAgent:app/src/tab.rs:833—Indicator::AmbientAgentpaints the tab badge.app/src/terminal/view/shared_session/view_impl.rs:720—ai_context_menu.set_is_in_ambient_agent(true).app/src/terminal/view/shared_session/view_impl.rs:766-771— auto-opens the conversation details panel forCloudModeviewers.app/src/terminal/view/shared_session/view_impl.rs:817-833— viewer-driven-sizing skip and shareable-object retention on session end.app/src/terminal/shared_session/viewer/terminal_manager.rs:786— marks theTerminalViewas an ambient-agent session view (read in many places).is_ambient_agent_session()/is_shared_ambient_agent_session()andpassive_suggestions/maa.rscloud-specific paths. No behavior change is required for any of those sites: a manualUsershare carryingsource_task_id: Some(_)continues to fall through everymatches!(source_type, SessionSourceType::AmbientAgent { .. })check, so no cloud UI activates for a local orchestrator share. Orchestration-discovery sites read the sidecar instead of the variant. There is no singleSessionSourceType::orchestrator_task_id()helper anymore; callers readsource_task_idfrom the payload (or the model field that mirrors it) and fall back toAmbientAgent.task_idonly when interoperating with legacy producers. The viewer-side construction site atterminal_manager.rs:778-816needs an explicit restructure, not just a one-line gate swap:- Lift
task_idparsing out of the existingmatch &source_typeblock. Read the newsource_task_idfield onNetworkEvent::JoinedSuccessfully, falling back toAmbientAgent.task_idwhen the sidecar isNone. The current code (terminal_manager.rs:778-783) only parses whenSessionSourceType::AmbientAgent { task_id }matches. - The cloud-only side effects —
mark_terminal_view_as_ambient_agent_session_view(terminal_manager.rs:788-790),ActiveAgentViewsModel::register_ambient_session(794-796), and theif matches!(&source_type, SessionSourceType::AmbientAgent { .. })outer guard at786— must stayAmbientAgent-only. - The
OrchestrationViewerModel::newconstruction (terminal_manager.rs:798-816) moves outside the ambient-only guard and runs wheneverenable_orchestration_polling && FeatureFlag::OrchestrationViewerPillBar.is_enabled() && slot.is_none() && resolved_task_id.is_some(). Pass the resolvedtask_id(sidecar-first) intoOrchestrationViewerModel::new. pane_group/pane/terminal_pane.rs:183-248host_terminal_shared_session_source_typereturns both the active source type AND the host’ssource_task_id;inherit_share_for_local_childcascades when the host has any resolved orchestratortask_id(sidecar orAmbientAgent.task_id).- Cascade rule: when the host carries an orchestrator
task_id, cascade to a local child as the same variant kind.Userhost withsource_task_id: Some(parent_task_id)→ child getsUserwithsource_task_id: Some(child_task_id).AmbientAgent { Some(parent_task_id) }host (cloud orchestrator) → child getsAmbientAgent { Some(child_task_id) }(unchanged from today). A host whose orchestratortask_idis stillNone(pre-StreamInit) does not cascade; onceStreamInitupgrades the host’s storedsource_task_idtoSome(_), subsequent local children cascade.
Wire compatibility
Leaving SessionSourceType::User strictly unit means existing readers — including pre-QUALITY-726 viewers — keep parsing the source type without code changes. The sidecar source_task_id is additive, gated on #[serde(default)], and ignored by older deserializers that don’t know about it.
- Sharer → server. The sharer threads
source_task_idintoInitPayload. Older sharers omit the field entirely; the server’s#[serde(default)]falls back toNone, and the server still readssource_type.AmbientAgent.task_idfor legacy AmbientAgent producers when the sidecar is absent. - Server → viewer. The server emits
source_task_idonJoinedSuccessfullyfrom the manifest. Older viewers ignore the unknown field; new viewers consume it as the canonical orchestratortask_idfor the share. From<&SessionSourceType> for LegacySessionSourceTypeatsharer.rs:210-217only needs to recognize the strict unitUser(matchesmain); no struct-variant wildcard required.session-sharing-servervendors its own copy of the protocol crate underprotocol/rather than depending on the published git crate. The sidecar field must land in both repos in lockstep; otherwise the server’s untagged deserializer would drop the field silently and downstream viewers would never see it. This dual-repo update tax is a known maintenance hazard; see Follow-ups.
Always stamp the conversation’s task_id at share time
- Modern local conversations are already associated with a server-side
ai_tasksrow:AIConversation::task_idis populated from the first response’sStreamInit.run_id(app/src/ai/agent/conversation.rs:1695-1699). Thread A reuses this existing id rather than minting a new task at share-time. - The share initiation API is
TerminalView::attempt_to_share_session(source_type: SessionSourceType, source_task_id: Option<String>, ...)atapp/src/terminal/view/shared_session/view_impl.rs:521-584. Update each existing caller that currently passesSessionSourceType::default()to instead passSessionSourceType::Userplus the conversation’s task id insource_task_id. The concrete call sites are enumerated above in Relevant files. start_sharing_sessioninapp/src/terminal/local_tty/terminal_manager.rs:1306-1480is the underlying implementation; it already stores thesource_typeon theTerminalModelviaset_shared_session_source_type(line 1330-1332). Add a siblingshared_session_source_task_id: Option<String>field onTerminalModelwith a matching setter, set duringstart_sharing_session.- Pre-first-response edge case: if the user shares a brand-new local conversation before any response has arrived, the conversation has no
task_idyet. Options:- Carry the share intent and stamp the task id on
StreamInit; the share starts withsource_task_id = Noneand upgrades tosource_task_id = Some(...)oncetask_idis known. The upgrade path mutates the model’s stored sidecar and re-emits it to existing viewers via the active sharerNetwork. - Or block share acceptance until the conversation has a
task_id(simpler, less ideal UX). - Recommend the first: it preserves “share immediately” UX and reuses the existing event that already mutates the conversation.
- Carry the share intent and stamp the task id on
- Conversations that genuinely have no
task_id(legacy / unanchored) continue to share withsource_task_id: Noneand stay invisible to orchestration discovery.
Cascade to local children
- Single cascade rule:
inherit_share_for_local_childcascades when the host carries an orchestratortask_id(resolved fromsource_task_id, withAmbientAgent.task_idas a fallback for cloud orchestrators that already populate it). A host that is sharing pre-StreamInit(no resolved task_id) does not cascade, since the host’s viewer cannot enumerate children via REST without the host task id; cascaded children would just hang as loading placeholders. OnceStreamInitupgrades the host’s storedsource_task_idtoSome(_)(per the pre-first-response handling above), subsequent local children cascade.
Catch-up cascade for pre-existing children
- The cascade in
inherit_share_for_local_childonly fires at child-pane creation time. If the user shares an orchestrator that already has spawned children, those pre-existing child panes were created withIsSharedSessionCreator::Noand stay unshared even after the parent share goes active. The parent viewer's pill bar lists the children (warp-server has their task rows) but the materialization gate inOrchestrationViewerModelnever trips becausesession_idis never populated on the child task rows. - Fix:
PaneGroupsubscribes toBlocklistAIHistoryEvent::LocalSharedSessionEstablished(Thread D's event). When the parent's local share goes active,transitively_share_existing_local_childreniterates direct child agent panes in this group, computes the cascaded source type viainherit_share_for_local_child, and dispatchesattempt_to_share_sessionon each child that isn't already in a sharer/viewer state. Cascaded children are recorded intransitively_shared_child_panesso the host's stop-share cascade also stops them. - Multi-level: grandchildren are picked up transitively. Each newly-shared child's own
LocalSharedSessionEstablishedevent re-enters the subscriber, which then cascades to its direct children. Direct-only iteration per event keeps each cascade decision local to one host pane. - The cascade carries the child’s own
task_idalongside the host’s variant kind.Userhost withsource_task_id: Some(parent_task_id)→ child getsUser+source_task_id: Some(child_task_id).AmbientAgent { Some(parent_task_id) }host (cloud orchestrator) → child getsAmbientAgent { Some(child_task_id) }(unchanged from today). The child’stask_idis provided by its launch path (launch_local_no_harness_child/launch_local_harness_child,terminal_pane.rs:1694-1989). - Calling auto-cascaded child shares
Useris semantic shorthand: the cascade root was user-initiated, descendants inherit that family for cloud-UI-avoidance purposes, not for strict provenance accuracy. Downstream code that distinguishes user-initiated vs cascaded shares (none today) can use a separate signal if it needs to. - The local child’s
IsSharedSessionCreator::Yes { source_type }flows throughinsert_terminal_pane_hidden_for_child_agent(app/src/pane_group/mod.rs:4405-4433) into the local terminal manager. - This means an originally single-agent share that later spawns child agents will surface those children in a pill bar without needing any new “upgrade share” step: as long as the host has its
task_id, the cascade fires, the viewer’s discovery model picks the child up on the next poll, and the pill bar appears. - Stop-share cascade. When the host’s manual share stops (
stop_sharing_sessioninlocal_tty/terminal_manager.rsand the existingStopSharingCurrentSessionaction path), any local children whose share was created via this cascade must also stop sharing. Implementation: track each cascaded child’sPaneIdin atransitively_shared_child_panes: HashSet<PaneId>onPaneGroup, populated at cascade time ininherit_share_for_local_child; on stop, iterate and callstop_sharing_sessionon each. Cloud-spawned children that share independently (AmbientAgenthost path) are not affected by this stop because they were never in the cascade set. This satisfiesPRODUCT.mdinvariant 12. - Server-side authorization is unchanged: viewers of the parent already have view access to descendant tasks (see
specs/orch-pill-bar-web/TECH.md:127-135). REST child discovery (GET /agent/runs?ancestor_run_id=) already locates local children via the task-id hierarchy populated bylaunch_local_no_harness_child/launch_local_harness_child; no extra server-side linkage is required for discovery.
Non-orchestrator shares
- A non-orchestrator user share now carries
source_type = Userplussource_task_id: Some(...)whenever the conversation has atask_id. The viewer-sideOrchestrationViewerModelwill issue one REST descendant fetch and get an empty list; with no children to register inBlocklistAIHistoryModel, the pill bar gate in Thread C collapses toEmpty. See Thread C for the polling-cost handling. - A non-orchestrator share with no
task_id(rare — pre-first-response or legacy conversation) stayssource_task_id: Noneand triggers no orchestration discovery at all. - Pill bar render gate at
pane_impl.rs:517-521already triggers fromFeatureFlag::OrchestrationViewerPillBar, so no additional rendering change is required once children are registered inBlocklistAIHistoryModelbyOrchestrationViewerModel.
Thread B — Resolve sibling agent names in conversation bodies
Goal: invariants 26 (parent + child) and 40. Two independent gaps; do them as separate edits rather than one combined helper:
B1. Populate agent_id_to_conversation_id for viewer-created children
- Currently
OrchestrationViewerModel::apply_children_fetch(orchestration_viewer_model.rs:235-358) callsstart_new_child_conversationand thenconversation.set_task_id(task_id)viahistory.conversation_mut(&id).set_task_id(conversation.rs:790-792) updates the field on the conversation only — it does not updateBlocklistAIHistoryModel::agent_id_to_conversation_id. The index is normally populated throughassign_run_id_for_conversation(history_model.rs:994-1027), which is the functionagent_id_keykeys off (history_model.rs:2228-2232). - Fix: in
apply_children_fetch, afterstart_new_child_conversation, callBlocklistAIHistoryModel::assign_run_id_for_conversation(conversation_id, run_id, Some(task_id), terminal_view_id, ctx)usingAmbientAgentTask.run_idinstead of (or in addition to)set_task_id. This populates the index on first poll, so transcript references to that child resolve to itsagent_name.
B2. Backfill parent_agent_id on viewer-created children
- Today
apply_children_fetchcallsstart_new_child_conversation, which internally reads the orchestrator’sorchestration_agent_idand setsparent_agent_idon the child (history_model.rs:406-414). When the orchestrator’s id isNoneat child-creation time, the child’sparent_agent_idstays unset and the existingparent_conversation_idfallback (orchestration_conversation_links.rs:120-129) cannot resolve back to the parent. - Fix: when the orchestrator conversation receives its own
ConversationServerTokenAssignedevent, iterate previously-tracked viewer-created children whoseparent_agent_idis unset and callconversation.set_parent_agent_id(orchestrator.orchestration_agent_id())directly. No change to theagent_id_to_conversation_idindex is needed for this leg — that index is keyed by the conversation’s own id, not its parent’s.
B3. Sibling references in conversation bodies (downstream of B1 + B2)
- With B1 + B2 done, received-message-from-agent / send-message-to-agent / lifecycle blocks resolve correctly through the existing renderer (which calls
conversation_id_for_agent_id,orchestration_conversation_links.rs:33-45). No new renderer code is required. - Confirm no other call site silently substitutes “Orchestrator” for an unresolved id. The current renderer treats missing entries as “unresolved” at
orchestration_conversation_links.rs:120-129; audit other resolution paths (lifecycle status block, hover card details, breadcrumb) to make sure they do the same.
B4. Child-link sibling preload (cases 2/4/8)
- Deferred to a follow-up. The original design created live sibling conversations on the child-link viewer’s terminal, which polluted
live_conversation_ids_for_terminal_viewand emittedStartedNewConversationevents for placeholders. A future change should add a name-only resolution path that doesn’t go throughstart_new_conversation.
Thread C — Render the pill bar in parent-scoped viewers for all topologies
Goal: invariants 24–27, 31, 38, 51, 52 from PRODUCT.md.
- The viewer-side gate restructure described in Thread A is what makes Thread C work end-to-end. Specifically: lift
task_idparsing out of the AmbientAgent-onlymatchatterminal_manager.rs:778-783, keep the ambient-only side effects guarded atterminal_manager.rs:786-797, and moveOrchestrationViewerModel::new(798-816) so it runs wheneversource_type.orchestrator_task_id().is_some()plus the feature-flag and slot guards. - The pill bar continues to hide when there are no children. With Thread A’s always-stamp policy, the model spins up for every shared session that has a
task_id, but its REST descendant fetch returns no rows for non-orchestrators,descendant_conversation_ids_in_spawn_order(orchestrator_id)stays empty, andOrchestrationPillBar::pill_specsreturnsNone(seeorchestration_pill_bar.rs:555-580), so the pill bar collapses toEmpty. The only cost is one initial REST fetch per viewer of a non-orchestrator share. - Polling cost handling.
OrchestrationViewerModel’s polling state machine (orchestration_viewer_model.rs:116-186) today distinguishes only “active” vs “idle” cadence and usespolling_handle.is_none()inmaybe_kick_polling(lines 168-172) to mean “a kick fetch is already in flight — skip to prevent pile-up”. To add a genuine “stopped because empty” state, introduce an explicit flag on the model:- Add
idle_due_to_no_children: bool(false by default). - In
apply_children_fetch, when the resultingchildrenmap is empty, setidle_due_to_no_children = true, abort the polling handle, and do not schedule another timer. - In
maybe_kick_polling, treatidle_due_to_no_childrenas a resume signal: if it is true and the new exchange is on the orchestrator, clear the flag and callfetch_children. The existingpolling_handle.is_none()guard alone is not enough — it would conflate the new stopped state with the existing “in-flight” state. TheAppendedExchangesubscription itself stays in place. - In any subsequent
apply_children_fetchthat does discover children, clearidle_due_to_no_childrenbefore scheduling the next poll.
- Add
- For native parent-scoped viewers (the user who is doing the sharing): the host’s own
TerminalViewalready renders the pill bar viaFeatureFlag::OrchestrationPillBarandBlocklistAIHistoryModel::descendant_conversation_ids_in_spawn_order, which already knows about the user’s local children. No new model needed on the sharer side. - For native parent-scoped viewers on a second client (the user joining the share from another device, native build): the path is the same shared-session-viewer
TerminalManageras the web case, just compiled native. Once Thread A’s gate restructure runs,OrchestrationViewerModelconstructs and discovery proceeds normally. - For the web/WASM viewer: same path. Confirm
OrchestrationViewerModel’s REST client (ServerApiProvider) is WASM-safe (wasm_view.rs:180-196). The pill bar itself has WASM-incompatible pane-management helpers; those are already guarded behind#[cfg(not(target_family = "wasm"))]inorchestration_pill_bar.rs:1670-1750.
Thread D — Surface local-to-driver children inside a remote orchestrator’s pill bar
Goal: invariants 35 (remote-local share parent) and 49.
- Today
OrchestrationViewerModel::apply_children_fetch(orchestration_viewer_model.rs:235-358) waits forAmbientAgentTask.session_idbefore emittingEnsureSharedSessionViewerChildPane. For a remote-local child, the local-to-driver child does not get its session id reported via the RESTagent/runsendpoint, so the materialization never fires and the user sees a perpetual loading pane. - Root cause: the remote driver mints a local
ai_tasksrow for the local-to-driver child (via the samelaunch_local_no_harness_childpath as local-local) and shares that session, but it does not yet register the shared session id on the server sideai_tasksrow that the viewer can poll. - Implementation options:
- Driver-side server update: when the local-to-driver child starts sharing (via the cascade from Thread A on the driver), report the new
session_idon the child’sai_tasksrow. This is the sameupdate_agent_taskpath used by the SDK driver. Adds one fire-and-forget RPC per local child shared session. - Viewer-side fallback discovery: when the viewer notices a non-terminal child whose
session_idstaysNonefor longer than X seconds, attempt to discover it via a different endpoint. This is a workaround and still depends on the driver having reported the session id.
- Recommend option 1.
- Driver-side server update: when the local-to-driver child starts sharing (via the cascade from Thread A on the driver), report the new
- Once
session_idflows through, the existingEnsureSharedSessionViewerChildPanepath inpane_group/mod.rs:3440-3548joins the child session and the pill UX matches remote-remote. - Trigger. Subscribe to a new lightweight event emitted from
local_tty/terminal_manager.rsat the point where the sharer’sNetworkreports a successful share creation — specifically alongsidemanager.started_share(...)in the existingSharedSessionCreatedSuccessfullyhandling. Add a newBlocklistAIHistoryEvent::LocalSharedSessionEstablished { conversation_id, session_id }and emit it from that site. The new subscriber lives in a dedicated sibling model atapp/src/ai/blocklist/local_shared_session_link_model.rs, separate fromTaskStatusSyncModel, so the two concerns (task-status sync vs session-link sync) stay decoupled. - RPC. The trait signature is
update_agent_task(task_id: AmbientAgentTaskId, task_state: Option<AgentTaskState>, session_id: Option<SessionId>, conversation_id: Option<String>, status_message: Option<TaskStatusUpdate>) -> ...(app/src/server/server_api/ai.rs:920-927). The call is:
ai_client
.update_agent_task(
task_id,
/* task_state */ None,
/* session_id */ Some(session_id),
/* conversation_id */ None,
/* status_message */ None,
)
.await
Wrap it in the same fire-and-forget pattern as TaskStatusSyncModel::fire_update (task_status_sync_model.rs:188-200).
- Guards. Apply the same viewer/remote-child/missing-id checks already used in
TaskStatusSyncModel:- Skip when the conversation is a viewer (
conversation.is_viewing_shared_session()). - Skip when the child is itself a remote child placeholder (
conversation.is_remote_child()). - Skip when
task_idorsession_idisNone. - Dedupe per
(task_id, session_id)in an in-memoryHashSet; reconnect/restart paths should not re-fire. The server treats repeated updates as idempotent; dedupe is a network-traffic optimization.
- Skip when the conversation is a viewer (
Server-side gates required by Thread D
Two warp-server predicates that previously assumed cloud-only execution must be relaxed before Thread D's update_agent_task(session_id) will land for local children:
updateSharedSessionLinkQuerystate gate (model/ai_run_executions.go). The query previously requiredstate = 'RUNNING', but local executions transition directly fromCLAIMEDtoENDEDwithout ever passing throughRUNNING(only the cloud worker'smarkExecutionRunningpath advances state). Predicate must acceptstate IN ('CLAIMED', 'RUNNING')so the update lands for local children.ENDEDstays excluded to block stale-session writes against terminal rows.convertTasksToItemsREST stale-link guard (router/handlers/public_api/agent_webhooks.go). The/agent/runs?ancestor_run_id=handler previously strippedsession_idfor any non-active run without GCS transcript data. Local runs have no GCS transcript and reach terminal state quickly; the guard must exempt LOCAL-execution rows so childsession_idcontinues to surface to the viewer after the child finishes. The stale-link concern motivating the original guard is cloud-sandbox-specific.
Cross-cutting: keep the existing pill bar visual + interactions unchanged
PRODUCT.mdinvariant 22 explicitly defers visual/interaction design to the existing pill bar. No changes toorchestration_pill_bar.rsare required for QUALITY-726 beyond making sure the gating expressions in Threads A/C produce a non-empty pill set in each topology.- Continue to gate the entire feature behind
FeatureFlag::OrchestrationViewerPillBarso partial rollouts don’t break manual shares for users who don’t have the flag.
Out of scope
- Multi-level orchestration (children with children) —
PRODUCT.mdnon-goal 1. - Bulk controls (cancel/restart/message) from the parent pill bar — non-goal 3.
- Visual redesign of the pill bar — non-goal 4.
- Combined export/replay artifacts — non-goal 5.
- Web viewer pane management actions remain native-only and are not added here.
Testing and validation
Map each affected PRODUCT.md invariant to a concrete test or manual verification. Numbers in parentheses reference specs/QUALITY-726/PRODUCT.md.
Unit tests
- Thread A:
terminal_pane_tests.rs(or sibling) —inherit_share_for_local_childreturnsYes { User, source_task_id: Some(child_task_id) }when the host is sharing asUserwithsource_task_id: Some(_), returnsYes { AmbientAgent { task_id: Some(child_task_id) }, source_task_id: Some(child_task_id) }when the host is sharing asAmbientAgent { task_id: Some(_) }, and returnsNowhen the host has no resolved orchestratortask_id. Covers (32), (33). - Thread A: share-modal handler integration — verify that any call to
attempt_to_share_sessionon a conversation with atask_idpassesSessionSourceType::Userplussource_task_id: Some(...), regardless of whether the conversation currently has child agents. Add a pre-first-response variant that asserts the sidecar is upgraded onStreamInitoncetask_idbecomes available. Add a regression test that a conversation with notask_idstays atsource_task_id: None. Covers (8), (9). - Thread A: no-cloud-UI regression — a
Userviewer withsource_task_id: Some(...)must not get theIndicator::AmbientAgenttab badge, must not auto-open the conversation details panel, and must not flipset_is_in_ambient_agent(true)on the AI context menu. Cover with focused unit tests ontab.rs:833,view_impl.rs:720, andview_impl.rs:766-771. - Thread A: stop-share cascade — starting a manual share on a host with a
task_id, spawning a local child (which cascades), then stopping the host’s share, should also stop the cascaded child’s share. A cloud-spawnedAmbientAgent-cascaded child remains unaffected. CoversPRODUCT.md:12and Risks L7. - Thread A: wire-compat tests in the
session-sharing-protocolcrate — (1) deserializing the legacy"User"payload still producesSessionSourceType::Userand anInitPayloadround-trip leavessource_task_idpopulated when present; (2)serde_json::to_string(&SessionSourceType::User)continues to produce the bare"User"form so pre-QUALITY-726 readers can parse it; (3) anInitPayloadwithoutsource_task_iddeserializes withsource_task_id: None(backward compat for older sharers); (4)From<&SessionSourceType> for LegacySessionSourceTyperound-trips bothUserandAmbientAgentto their legacy unit counterparts. - Thread C:
orchestration_viewer_model_tests.rs— a viewer of aUsershare withsource_task_id: Some(...)and no descendants setsidle_due_to_no_children = true, aborts the polling handle after the first empty fetch, and resumes polling on the nextAppendedExchangeevent on the orchestrator. A subsequent fetch that discovers children clears the flag and returns to active cadence. Covers the polling-cost mitigation. - Thread B (B1):
history_model_tests.rs— afterapply_children_fetchcallsassign_run_id_for_conversation(child_id, run_id, ...),conversation_id_for_agent_id(run_id)returnsSome(child_id). Covers (26). - Thread B (B2):
history_model_tests.rs— a child whose orchestrator’s server token arrived after the child was created has itsparent_agent_idbackfilled whenConversationServerTokenAssignedfires for the orchestrator;parent_conversation_id(child, ctx)resolves to the orchestrator afterwards. Covers (26). - Thread D: a
LocalSharedSessionEstablished(or equivalent) event withtask_id+session_idtriggers exactly oneupdate_agent_task(task_id, None, Some(session_id), None, None)RPC; verify the dedupe set blocks a second identical event. Add the standard viewer-guard / remote-child-guard / missing-id negative cases. Covers (35), (49).
Integration tests
- Local-local share-parent: extend
view_impl_tests.rs(mirrors existingJoinedSharedSessiontests) to assert the pill bar renders with all known children once the orchestrator is shared and at least one child exists. Covers (32). - Local-remote share-parent: combine a local orchestrator with a remote child; assert the parent viewer’s pill bar lists the remote child and selecting it materializes the existing child pane. Covers (33).
- Remote-local share-parent: stub
update_agent_task(session_id)to be present, assert pill click materializes the child viewer pane. Covers (35). - Child-link views (2, 4, 8): assert child transcript references resolve to siblings’ display names via
conversation_id_for_agent_id, not“Orchestrator”or“Unknown”. Covers (14), (26), (40).
Manual validation
For each of the eight topology × scope combinations in the user’s exploration, validate:
- Parent link → parent transcript renders, pill bar visible when ≥1 direct child, child references show correct agent names.
- Child link → child transcript only, pill bar hidden, sibling references show correct agent names.
- Web/WASM viewer parity for both link types (no native-only affordances expected on web).
- Behavior on parent share stop, child finishes, child errored, reconnect — pill bar persists with last known state. Track results in a check matrix in the PR description.
Regression coverage
- Add
task_status_sync_model_tests.rs-shaped coverage for the newsession_idlink (Thread D): positive case (local child gets asession_id, RPC fires once), viewer guard (is_viewing_shared_session = true→ no RPC), remote-child guard (is_remote_child = true→ no RPC), missing-id guards, and dedupe on repeated(task_id, session_id). orchestration_pill_bar_tests.rsshould keep its existing rendering coverage; no behavior changes required there.
Parallelization
The four threads touch largely independent code areas. They run as parallel local sub-agents with separate worktrees once Thread A0 lands, then merge into a single PR (or 1-PR-per-thread). The strict sequencing is: A0 (protocol crate) → A/B/C/D in parallel.
All worktrees live under the shared task directory ~/src/orch-shared-sessions/, alongside the existing warp and warp-server worktrees on this branch. Thread A0 adds a sibling session-sharing-protocol worktree. Each warp-side thread gets its own warp worktree so the four threads can run in parallel without conflicting on the same working copy.
- Thread A0 — protocol crate changes + warp rev bump (Thread A prerequisite).
- Files owned: external
session-sharing-protocolcrate (sharer.rs,viewer.rsif needed for legacy payload audits) and the warp-sideCargo.tomlgit rev pin atCargo.toml:249(applied via thewarp-thread-aworktree as part of A landing). - Worktree:
~/src/orch-shared-sessions/session-sharing-protocol(new sibling of the existingwarpandwarp-serverworktrees). - Branch:
matthew/QUALITY-726-protocol. - Depends on: none.
- Must merge to the protocol crate (and have its commit hash available) before A or C can compile on the warp side.
- Files owned: external
- Thread A — local share cascade + reuse of orchestrator
task_id(warp client).- Files owned:
app/src/pane_group/pane/terminal_pane.rs,app/src/pane_group/mod.rs(cascade tracking + stop-share iteration),app/src/terminal/view/shared_session/view_impl.rs(attempt_to_share_sessioncallers),app/src/terminal/local_tty/terminal_manager.rs(start_sharing_session+ source type upgrade onStreamInit), call sites that currently passSessionSourceType::default()(see Relevant files), and theCargo.toml:249git rev bump that picks up Thread A0’s commit, new tests. - Worktree:
~/src/orch-shared-sessions/warp-thread-a. - Branch:
matthew/QUALITY-726-thread-a. - Depends on: Thread A0.
- Shared gate logic with Thread C: the viewer-side gate restructure at
terminal_manager.rs:778-816is owned by Thread A (it lives in a Thread-A-owned file); Thread C consumes the helper / restructured gate and adds the polling-cost mitigation.
- Files owned:
- Thread B — agent-id index population (B1) + parent_agent_id backfill (B2). B4 (child-link sibling preload) is deferred; see §B4.
- Files owned:
app/src/ai/blocklist/history_model.rs,app/src/ai/blocklist/agent_view/orchestration_conversation_links.rs(audit only),app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs, tests. - Worktree:
~/src/orch-shared-sessions/warp-thread-b. - Branch:
matthew/QUALITY-726-thread-b. - Depends on: none beyond A0 (the source type change does not affect Thread B’s code paths).
- Files owned:
- Thread C — polling-cost mitigation + render-gate verification.
- Files owned:
app/src/terminal/shared_session/viewer/orchestration_viewer_model.rs(idle-due-to-empty flag, polling state machine),app/src/terminal/view/pane_impl.rs(gate audit only — no expected changes), integration tests. - Worktree:
~/src/orch-shared-sessions/warp-thread-c. - Branch:
matthew/QUALITY-726-thread-c. - Depends on: Thread A (consumes the viewer-side gate restructure landed in Thread A).
- Coordination with Thread B: both threads touch
orchestration_viewer_model.rs. C’s state-machine changes are in the polling/timer section (116-186); B’s changes are inapply_children_fetch(235-358) and the join handshake. Merge order does not matter, but rebase the second-to-merge thread on top of the first.
- Files owned:
- Thread D — driver-side
session_idlink.- Files owned:
app/src/terminal/local_tty/terminal_manager.rs(SharedSessionCreatedSuccessfullyemission point + new event),app/src/ai/blocklist/local_shared_session_link_model.rs(new dedicated subscriber model), tests. - Worktree:
~/src/orch-shared-sessions/warp-thread-d. - Branch:
matthew/QUALITY-726-thread-d. - Depends on: none beyond A0 (the new
update_agent_task(session_id)call uses an existing trait signature parameter). Execution mode: all five threads run locally. The repo builds and integration tests run on the developer’s machine; no remote-only resources are involved.
- Files owned:
graph TD
A0[Thread A0: session-sharing-protocol crate + Cargo.toml rev bump]
A[Thread A: share cascade + reuse orchestrator task_id]
B[Thread B: agent-id index + parent_agent_id backfill]
C[Thread C: polling-cost mitigation + render-gate verification]
D[Thread D: driver session_id link]
Merge[Combined PR / merge point]
A0 --> A
A0 --> C
A --> C
A0 --> B
A0 --> D
A --> Merge
B --> Merge
C --> Merge
D --> Merge
If launched as sub-agents, each one should:
- Stay in its assigned worktree under
~/src/orch-shared-sessions/. - Run
./script/presubmit(warp) or the crate-equivalent (cargo test,cargo clippy --workspace --all-targets --all-features --tests -- -D warnings,cargo fmt --checkinsession-sharing-protocol) before reporting back. - Report the branch name, changed files, and a list of failing or skipped tests.
- Not merge into
matthew/orch-shared-sessionsdirectly; the orchestrator (the user, or a separate merge step) integrates the threads into one branch.
Risks and mitigations
- Risk: Cascading sharing from a manually-shared host to every local child it spawns could surprise users who expect a single-pane share. Mitigation: the cascade only fires for local children of a host that is currently sharing AND has a
task_id; the user can see the cascaded children in the orchestration pill bar; stop-share on the host cascades to a stop-share on the children (see Thread A “Stop-share cascade”); document the behavior in the share modal copy. - Risk: A user shares before any response has come back, so the conversation has no
task_idyet. Mitigation: stamp the share withsource_task_id: Noneinitially and upgrade tosource_task_id: Some(...)on the firstStreamInitevent. The cascade does not fire until the upgrade happens, so children spawned in the pre-stamp window are not auto-shared; surface this in the share modal copy if it becomes a UX problem in practice. - Risk: Every non-orchestrator user share now triggers one initial REST descendant fetch in the viewer, since
source_task_id: Some(...)is now the new default whenever the conversation has anai_tasksrow. Mitigation: the explicitidle_due_to_no_childrenflag inOrchestrationViewerModel(see Thread C) ensures the model stops polling after an empty descendant fetch and only resumes on a realAppendedExchange. The single initial fetch per viewer is acceptable. - Risk: Driver-side
session_idlink introduces a new fire-and-forget RPC. Mitigation: dedupe on(task_id, session_id)and apply the standard viewer/remote-child/missing-id guards (see Thread D) so it only fires when the local client actually owns the shared session. - Risk: Adding
source_task_idtoInitPayloadandJoinedSuccessfullyadds new fields to the wire. Mitigation: both are#[serde(default)], so old producers/consumers ignore them silently.SessionSourceType::Userstays unit-shaped, so legacy viewers continue to parse the source type without changes. - Risk: Forgetting to migrate an orchestration-discovery site from
matches!(source_type, SessionSourceType::AmbientAgent { .. })to the new sidecar-read path would leave the local-local pill bar broken even after the rollout. Mitigation: readsource_task_idvia a single helper on the payload/model, audit and migrate every existing match site, and add a clippy-friendly comment at theAmbientAgent-only sites (tab indicator, AI context menu, details-panel auto-open, viewer-driven sizing skip) explaining why they intentionally stay variant-matched. - Risk:
TelemetryEvent::JoinedSharedSession { session_id, source_type }(view_impl.rs:773-779) still emits the variant kind, but the orchestratortask_idis no longer carried inside the variant. Mitigation: includesource_task_idas a sibling field on the telemetry event so analytics dashboards can filter on the task id without parsing the variant payload.
Follow-ups
- Once Threads A and D are in place, evaluate whether the
OrchestrationViewerModelpolling cadence is still appropriate for live local-local shares (every 5s may be excessive when all updates are also flowing through the shared-session WebSocket). A follow-up could switch to a WebSocket-driven update for local-local at the cost of larger protocol changes. - Consider unifying the dispatcher state-machine on the server side so local and cloud executions share a single life-cycle for session-link writes. The CLAIMED-vs-RUNNING split is currently implicit in worker behavior; an explicit
state IN ('CLAIMED', 'RUNNING')predicate captures the intent but doesn't address why local executions never advance past CLAIMED in the first place. - Once the upstream
session-sharing-protocolcommit is published and consumed by both warp and session-sharing-server, drop the vendored copy in session-sharing-server entirely and depend on the git crate. Removes the two-place-update tax for future wire changes.