Files
galaxy/specs/QUALITY-768/TECH.md
T

27 KiB
Raw Blame History

QUALITY-768: Restore orchestration session state

Context

Restarting Warp left orchestration sessions in four distinct broken states. Local-local orchestration came back without the pill bar, and send_message_to_agent / messages_received rows rendered the child as "Unknown agent". Local-remote orchestration restored the placeholder child pane as the empty "New agent conversation" shell instead of the cloud transcript that was actually streaming server-side. A meaningful share (~50% in some traces) of local-no-harness Oz child conversations were silently dropped at read time entirely. And a cloud-parent orchestration session (a shared-session viewer pane locally) survived the first restart correctly but lost its cloud-mode shape on the next snapshot — the second restart re-materialised the parent as a plain local terminal with the cloud session's env-setup blocks replayed and no orchestration UI. Underneath all four symptoms, the on-disk eviction policy was splitting orchestration trees across restarts, so even a healthy read path would re-encounter partial state on the next boot.

The subsystem-level explanation of restoration ↔ orchestration lives in the architecture report at /Users/matthew/src/orch-restore/restoration-and-orchestration.md. The PR body summarises the user-visible behaviour. This spec is the implementation-level account of the four code-level decisions that ship in this PR; an earlier read-time optimistic-stub filter was superseded by upstream PR #11814 and dropped during the rebase — see change 3 below.

Relevant code

  • crates/persistence/src/schema.rs:11agent_conversations table.
  • crates/persistence/src/schema.rs:20agent_tasks table.
  • crates/persistence/src/model.rs (935-1006)AgentConversation::is_restorable (the multi-root acceptance rules introduced by upstream PR #11814 cover the optimistic-stub case).
  • app/src/persistence/agent.rs (38-233) — disk-side prune entry point and select_conversations_to_evict.
  • app/src/ai/restored_conversations.rs:17RestoredAgentConversations, the consume-once startup store.
  • app/src/ai/blocklist/history_model.rs:55-68MAX_HISTORICAL_CONVERSATIONS.
  • app/src/ai/blocklist/history_model/conversation_loader.rs:64convert_persisted_conversation_to_ai_conversation_with_metadata, the single conversion entry point used by both startup consumers.
  • app/src/ai/blocklist/history_model/conversation_loader.rs:472initialize_historical_conversations.
  • app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs:624OrchestrationPillBar::pill_specs, reads conversations_by_id.
  • app/src/ai/blocklist/block/view_impl/orchestration.rs:87participant_for_agent_id, reads conversations_by_id.
  • app/src/pane_group/mod.rs:927child_agent_panes index (HashMap<AIConversationId, PaneId>).
  • app/src/pane_group/mod.rs (1078-1151)PendingRemoteChildHydration struct, RemoteChildHydrationAction enum, decide_remote_child_hydration_action.
  • app/src/pane_group/mod.rs:3899hydrate_task_backed_hidden_child_pane.
  • app/src/pane_group/mod.rs:4007attempt_remote_child_hydration.
  • app/src/pane_group/mod.rs:4114hydrate_remote_child_transcript_in_place.
  • app/src/pane_group/mod.rs:4228attach_ambient_session_and_maybe_tombstone.
  • app/src/ai/blocklist/history_model.rs:2353merge_cloud_tasks_into_existing_conversation.
  • app/src/pane_group/mod.rscreate_shared_session_viewer (the is_cloud_mode parameter routes restored parent panes through the cloud-mode constructor).
  • app/src/pane_group/pane/terminal_pane.rsTerminalPane::snapshot, whose shared-session-viewer branch chooses between LeafContents::AmbientAgent { task_id } and a fallback empty LeafContents::Terminal based on view.ambient_agent_view_model().
  • app/src/terminal/shared_session/viewer/terminal_manager.rsviewer::TerminalManager::new (non-deferred constructor; is_cloud_mode is plumbed through new_internal).
  • app/src/terminal/view.rsTerminalView::new, where ambient_agent_view_model is only constructed when is_cloud_mode == true.

Proposed changes

The implementation now breaks into four self-contained changes that share the read/write path described in the architecture report. A fifth change in the original scope (a read-time optimistic-stub filter in crates/persistence/src/model.rs) has been superseded by upstream PR #11814 "Fix optimistic-root persistence breaking conversation restore (QUALITY-774)" and is no longer carried by this PR; the rationale lives below in change 3.

1. Eager orchestration-child hydration in initialize_historical_conversations

initialize_historical_conversations (app/src/ai/blocklist/history_model/conversation_loader.rs:472) previously only indexed orchestration children in children_by_parent and agent_id_to_conversation_id; insertion into conversations_by_id was deferred until the parent's hidden child pane materialised. After the change, the loader detects orchestration children via resolved_parent_conversation_id_from_persisted_data and eagerly inserts the fully-deserialised AIConversation into conversations_by_id (conversation_loader.rs:558-573), reusing the existing convert_persisted_conversation_to_ai_conversation_with_metadata conversion. No RestoredConversations event is emitted, no live_conversation_ids_for_terminal_view entry is registered, and no AI blocks are constructed. The later restore_conversations call from lazy pane materialisation overwrites the eager entry idempotently.

Tradeoff. The plan considered emitting a RestoredConversations event from the eager path so downstream consumers could subscribe symmetrically. Rejected: it would force every RestoredConversations subscriber to handle the "no terminal view" case, and the surfaces that actually need the child (pill bar, name resolver) already read from conversations_by_id directly. The eager insert is also gated to orchestration children only; non-orchestration historical conversations stay on the lazy path.

2. Direct AmbientAgentTask inspection for remote-child transcript hydration

The plan originally routed restored remote-child hydration through AgentConversationsModel::resolve_open_action. Once conversations_by_id carries the placeholder eagerly (change 1), the resolver returns RestoreOrNavigateToConversation, a variant that collapses "navigate to the local conversation" and "hydrate the cloud transcript onto the local placeholder" into a single outcome. Widening the resolver would force every navigation site to handle a new variant; the remote-child path is the only site that wants the cloud-transcript outcome.

Instead the hidden-pane hydration inspects the AmbientAgentTask directly. The dispatch is extracted into a pure function:

enum RemoteChildHydrationAction {
    LiveAttach,
    LoadTranscript { server_token: ServerConversationToken, task_is_terminal: bool },
    Fallback { task_is_terminal: bool },
}

fn decide_remote_child_hydration_action(task: &AmbientAgentTask) -> RemoteChildHydrationAction { ... }

RemoteChildHydrationAction and decide_remote_child_hydration_action live in app/src/pane_group/mod.rs. The function filters empty/whitespace conversation_id tokens via task.conversation_id().map(str::trim).filter(|t| !t.is_empty()) before treating the value as a usable LoadTranscript target, and computes task_is_terminal = matches!(live_session_state, Inactive) once so both LoadTranscript and Fallback carry the same gate.

hydrate_task_backed_hidden_child_pane creates the hidden pane up front, registers it under the placeholder's local id in child_agent_panes, and either calls attempt_remote_child_hydration synchronously when task data is cached, or installs an entry in the named pending_remote_child_hydrations: HashMap<AmbientAgentTaskId, PendingRemoteChildHydration> map and retries when AgentConversationsModelEvent::TasksUpdated fires. An inner idempotency guard returns whenever a live tracked pane already exists for the placeholder, so a second mid-hydration call from restore_missing_child_agent_panes_for_parent — even before the initial transcript merge has populated any exchanges — cannot insert a duplicate hidden pane and orphan the first one. The LoadTranscript branch routes through hydrate_remote_child_transcript_in_place, which fetches the cloud transcript via BlocklistAIHistoryModel::load_conversation_by_server_token and merges it onto the placeholder via BlocklistAIHistoryModel::merge_cloud_tasks_into_existing_conversation (app/src/ai/blocklist/history_model.rs). The merge preserves the placeholder's local AIConversationId, parent linkage, agent name, run id, and is_remote_child flag, and returns anyhow::Error if the placeholder has been evicted from conversations_by_id. The caller already handles that error path by falling back to the live-attach + tombstone branch.

The post-match step is centralised in attach_ambient_session_and_maybe_tombstone, which calls apply_existing_ambient_task_to_pane and then inserts the conversation-ended tombstone iff task_is_terminal == true. All four arms (LoadTranscript Ok merge, LoadTranscript Err merge, LoadTranscript non-Oz / fetch-failure fallback, and Fallback) route through this helper so the gate stays uniform — an ActiveUnattachable task whose transcript fetch errors out, returns a non-Oz payload, or never had a server token in the first place no longer gets a misleading "conversation ended" tombstone.

The async continuation guard inside ctx.spawn requires both child_agent_panes[child_id] == pane_id and the pane's terminal view's active_conversation_id == Some(child_id) before mutating UI state, so a racing nav or competing hydration cannot clobber a stale target.

Tradeoff. The plan considered widening resolve_open_action to expose a HydrateRemoteChildPlaceholder variant. Rejected for the reason above. The plan also considered keeping the dispatch inline inside attempt_remote_child_hydration; extracted into a free function so the decision (Attachable, ActiveUnattachable + token, Inactive + token, Inactive + no token, ActiveUnattachable + no token, plus the empty-token-filter case) is unit-testable without standing up a PaneGroup.

3. Optimistic-stub handling (superseded by upstream PR #11814)

Local-no-harness Oz children sometimes persisted two root-shaped tasks: a 38-byte zero-payload optimistic stub created at child-spawn time, and a real upgraded root carrying the actual messages. AgentConversation::is_restorable saw two root-shaped tasks and rejected the row, silently dropping the conversation at the entry to initialize_historical_conversations.

Upstream PR #11814 ("Fix optimistic-root persistence breaking conversation restore", QUALITY-774) addresses the same symptom on two coordinated layers: Task::source_for_persistence returns None for Optimistic(Root) so no new stub rows are written, and AIConversation::new_restored deduplicates multi-root payloads by preferring the parentless task with non-empty messages. AgentConversation::is_restorable was relaxed to accept the multi-root [stub + real] shape so legacy DB rows still load through the restore path. The local-DB conversion entry point convert_persisted_conversation_to_ai_conversation_with_metadata now calls AIConversation::new_restored_synthesizing_on_empty, which synthesizes a fresh optimistic root when the persisted task list is empty.

This PR's earlier read-time filter (task_is_root_shaped, optimistic_stub_task_id, tasks_for_restore, into_tasks_for_restore) and the seven unit tests pinning it have been removed during the rebase onto upstream. Upstream's restore-side dedupe (with a 50-iteration HashMap-order regression test in app/src/ai/agent/conversation_tests.rs) plus its relaxed is_restorable cover the same ground at the same layer with less surface area, so a parallel filter is redundant.

4. Tree-aware persisted-conversation prune

select_conversations_to_evict (app/src/persistence/agent.rs:143) replaces the previous per-row FIFO LRU prune in upsert_agent_conversation (agent.rs:60-118). Each persisted row is grouped into its orchestration tree by walking parent_conversation_id to a root (parse failures are treated as their own root; orphan references where the declared parent is missing from the row set are likewise treated as roots). Trees are sorted freshest-first by max(member.last_modified_at) with ties broken by root_id ascending. The greedy keep loop always retains the freshest tree intact — even if it alone exceeds MAX_PERSISTED_CONVERSATION_COUNT — and then keeps each subsequent tree atomically while the cumulative kept count is within the cap. Hard-stop semantics: once any tree exceeds the budget, every older tree is also evicted.

The retention cap moved from 100 to 200 (MAX_PERSISTED_CONVERSATION_COUNT, app/src/persistence/agent.rs:49). The mirrored read-side cap MAX_HISTORICAL_CONVERSATIONS (app/src/ai/blocklist/history_model.rs:68) was bumped to the same value with an inline comment noting that the read-side cap is currently moot because the disk-side prune keeps the persisted set inside the same window.

The iteration uses an iter.next() pattern to unconditionally keep the freshest tree, then a for loop over the remainder, avoiding the first: bool flag that would otherwise sit inside the loop body.

Tradeoffs.

  • Freshest-tree exception. The plan considered a strict cap that evicts even from the freshest tree. Rejected: a strict cap could split an active orchestration session in half on disk, regressing into the same "broken half-tree" failure mode that motivated this change. The unbounded freshest-tree case is documented as a known limitation below.
  • Sharing the constant. Two const usize values in two files is a soft drift hazard, but crates/persistence is upstream of warp in the workspace graph and cannot import from it. The reverse import would pull persistence-only code into the read-side path. Documented in MAX_HISTORICAL_CONVERSATIONS's comment that the read cap is moot only as long as it stays ≥ the disk cap.
  • Parse failure handling. Rows whose conversation_data fails JSON parsing are treated as their own root rather than being silently linked into another tree. The disk row is untouched and the eviction algorithm just refuses to chain a malformed row into a tree.

5. Cloud-mode shared-session viewer snapshot/restore loop

Changes 14 fix restoration for orchestration children. A separate latent bug affected the parent of an orchestration tree when the parent itself was a cloud agent: its local pane is a shared-session viewer attached to the cloud session, and the restoration → snapshot → restoration loop lost the cloud-mode shape on the second cycle.

restore_pane_leaf's LeafContents::AmbientAgent → AmbientRestoreKind::SharedSession arm called create_shared_session_viewer(session_id, …), which used shared_session::viewer::TerminalManager::new(…). The non-deferred constructor hardcoded is_cloud_mode = false when delegating to new_internal, so the resulting TerminalView had no ambient_agent_view_model (TerminalView::new only constructs the model when is_cloud_mode == true). The pill bar and transcript still rendered on restart 1 because the JoinedSuccessfully viewer-network handler spins up an OrchestrationViewerModel regardless of is_cloud_mode. But the snapshot path in app/src/pane_group/pane/terminal_pane.rs checks view.ambient_agent_view_model() to decide between LeafContents::AmbientAgent { task_id } and the fallback empty LeafContents::Terminal. With ambient_agent_view_model = None, every shutdown after the first restoration emitted the empty Terminal shape and the task_id was lost. On restart 2 the pane re-materialised as a fresh local terminal pane sharing the original UUID, replayed the cloud session's env-setup blocks from the persisted block list, and surfaced a local shell prompt with no orchestration UI or pill bar.

The fix threads an explicit is_cloud_mode: bool parameter through create_shared_session_viewerviewer::TerminalManager::newnew_internal. The two ambient-agent restoration call sites (restore_pane_leaf's SharedSession arm and process_pending_ambient_restorations's OpenOrAttachAmbientAgentConversation arm) now pass is_cloud_mode: true, so the restored view has an ambient_agent_view_model, the existing JoinedSuccessfully handler's enter_viewing_existing_session(task_id) path fires, and the next snapshot correctly emits LeafContents::AmbientAgent with the task_id. The two other callers stay false: ensure_shared_session_viewer_child_pane (per-child viewer; hidden child panes are excluded from the snapshot tree by design) and new_for_shared_session_viewer (navigation-to-shared-session entry point — has the same latent issue but is outside this PR's scope; see Follow-ups).

Tradeoff. The plan considered a snapshot-only fallback that would read the task_id from the live OrchestrationViewerModel when ambient_agent_view_model is absent. Rejected: it would leave the runtime shape mismatch in place. A freshly created cloud-mode pane has an ambient_agent_view_model; a restored one would still not, and any future feature reading from ambient_agent_view_model on a viewer pane would silently misbehave only after a restart. Routing the restoration through the cloud-mode constructor makes the restored pane's shape match the freshly created shape.

End-to-end flow

Tracing one boot through the four changes (with upstream PR #11814's stub handling sitting upstream of them) makes the causal chain visible. The diagram below covers changes 14; change 5 is orthogonal (it concerns the parent pane's snapshot shape rather than the child-restore data flow).

flowchart TD
    A[SQLite agent_conversations + agent_tasks] -->|read_agent_conversations| B[Vec<AgentConversation>]
    B -->|new_restored_synthesizing_on_empty<br/>+ multi-root dedupe<br/>(upstream PR #11814)| C[restored AIConversation]
    C --> D[RestoredAgentConversations]
    C --> E[initialize_historical_conversations]
    E -->|orchestration children| F[(conversations_by_id)]
    F --> G[OrchestrationPillBar::pill_specs]
    F --> H[participant_for_agent_id]
    F --> I[hydrate_task_backed_hidden_child_pane]
    I -->|decide_remote_child_hydration_action| J{Action}
    J --> K[LiveAttach: apply_existing_ambient_task_to_pane]
    J --> L[LoadTranscript: merge_cloud_tasks_into_existing_conversation]
    J --> M[Fallback: attach in place]
    L --> N[attach_ambient_session_and_maybe_tombstone]
    K --> N
    M --> N
    A -->|upsert_agent_conversation| O[select_conversations_to_evict]
    O -.tree-aware.- A

Upstream PR #11814 sits upstream of every other change: it determines which rows survive restore and therefore which rows enter RestoredAgentConversations and initialize_historical_conversations. The eager-child hydration is what surfaces children into conversations_by_id early enough for the pill bar / name resolver to see them and what creates the resolver-collision case that motivates direct AmbientAgentTask inspection in the hidden-pane hydration path. The tree-aware prune feeds back into the next boot: if the prune splits trees, the read path's invariants are violated regardless of how well the in-memory side is wired up.

Testing and validation

Unit coverage lives alongside each module:

  • Tree-aware eviction (app/src/persistence/agent.rs (348-566), eight cases): prune_is_no_op_when_under_limit, keeps_fresh_tree_atomically_and_evicts_older_singletons, child_kept_drags_parent_along, parent_kept_drags_child_along, orphan_with_missing_parent_is_its_own_tree, single_tree_larger_than_limit_is_kept_in_full, parse_failure_row_is_treated_as_root_and_can_be_referenced_by_others, eviction_is_deterministic.
  • Stub / multi-root restore coverage now lives in upstream PR #11814's tests: is_restorable_* in crates/persistence/src/model.rs (five cases covering single-root, multi-root with one real root, multi-root with multiple real roots, multi-root with no real root, empty/single-task) and the dedupe loop in test_new_restored_prefers_parentless_task_with_messages_over_empty_stub (app/src/ai/agent/conversation_tests.rs).
  • Eager orchestration-child hydration (app/src/ai/blocklist/history_model_tests.rs): test_initialize_historical_conversations_eagerly_hydrates_orchestration_children (line 332), plus test_initialize_historical_conversations_resolves_parent_agent_id_children_via_seeded_run_ids (line 258) covers the parent-resolution side path. The eager-hydration test asserts the child is in conversations_by_id, that the parent is NOT loaded eagerly, that child run-ids resolve via conversation_id_for_agent_id, and that child metadata is excluded from navigation.
  • Remote-child hydration dispatch (app/src/pane_group/mod_tests.rs): decide_remote_child_hydration_action covered with six cases — LiveAttach for Attachable; LoadTranscript for Inactive + token (task_is_terminal: true); LoadTranscript for ActiveUnattachable + token (task_is_terminal: false); Fallback for Inactive + no token (task_is_terminal: true); Fallback for ActiveUnattachable + no token (task_is_terminal: false, asserting the in-progress run is not visually marked ended); and decide_remote_child_hydration_empty_token_falls_back for the empty/whitespace filter added during review.
  • LoadTranscript → merge integration coverage (app/src/ai/blocklist/history_model_tests.rs): merge_cloud_tasks_into_existing_conversation_preserves_placeholder_identity. Builds a placeholder remote-child conversation with parent_conversation_id + agent_name + run_id + is_remote_child, drives merge_cloud_tasks_into_existing_conversation with a cloud transcript carrying a non-empty title and one user-query exchange, and asserts the merged conversation retains the placeholder's local id and orchestration linkage while surfacing the cloud transcript content. A second assertion exercises the precondition guard by calling merge against an unknown placeholder and asserting Err.
  • Cloud-mode viewer snapshot contract (app/src/pane_group/mod_tests.rs): create_shared_session_viewer_with_cloud_mode_populates_ambient_agent_view_model asserts the restored view's ambient_agent_view_model().is_some() so the snapshot path's LeafContents::AmbientAgent branch is reachable on the next shutdown. A companion test, create_shared_session_viewer_without_cloud_mode_does_not_populate_ambient_agent_view_model, pins the non-cloud-mode behaviour so a future flip of the default would be loud.

Manual validation matrix (covers each change end-to-end on a restart):

  1. Local-local restart: pill bar renders with the correct agent names (no "Unknown agent" fallback); the multi-root stub shape is silently healed by upstream restore dedupe; conversation list UI is intact.
  2. Tree-aware prune: orchestration trees stay together on disk across the cap.
  3. Local-remote restart: the cloud transcript merges onto the local placeholder; the "New agent conversation" placeholder bug is gone; live runs continue to attach.
  4. Cloud-parent restart-restart: a remote orchestration parent restores correctly across two consecutive Warp restarts; no fallback to a stray local terminal with replayed env-setup blocks.
  5. Baseline single-conversation restore: no regressions.

Validation commands: cargo fmt -p warp -p persistence, cargo clippy -p warp --all-targets --features local_fs -- -D warnings, cargo clippy -p persistence --tests --all-features -- -D warnings, and cargo nextest run over pane_group::, persistence::agent::tests, ai::blocklist::history_model::tests, and persistence::model::tests.

Deferred coverage:

  • A property test that asserts select_conversations_to_evict never produces an evict-list that splits an orchestration tree (currently inferred from the case tests).
  • An end-to-end PaneGroup restart test that exercises hydrate_remote_child_transcript_in_place against a mock cloud fetch (the existing test covers the smaller seam directly).

Risks and mitigations

  • Unbounded freshest tree. select_conversations_to_evict always retains the freshest tree intact, so a single very large orchestration session can push the on-disk row count above 200. Mitigation: the next session's prune evicts everything older, so the steady-state row count stays within the cap unless every session spawns a hundred-plus children. If this becomes a real problem, the freshest-tree exception can be replaced with an "evict the oldest tree members first" within-tree policy, but that re-introduces the half-tree failure mode and is deliberately deferred.
  • Soft drift between MAX_PERSISTED_CONVERSATION_COUNT and MAX_HISTORICAL_CONVERSATIONS. The two constants live in different files and crates. They happen to agree today, and the read-side cap's comment documents the invariant: the read cap is moot only as long as the disk cap is it. Mitigation: the comment, the symmetry of the test coverage, and a future single-const refactor (called out under Follow-ups).
  • Eager-hydration sort-window asymmetry. initialize_historical_conversations walks rows sorted freshest-first by last_modified_at, capped at MAX_HISTORICAL_CONVERSATIONS. In principle a fresh child could land inside the window without its (stale) parent. Mitigation: tree-aware disk eviction keeps parents fresh enough that this case hasn't been observed; the parent-resolution path tolerates parents that aren't in conversations_by_id by falling back to children_by_parent.
  • Shared AgentConversationsModel subscription for two pending maps. ensure_pending_ambient_restoration_subscription (app/src/pane_group/mod.rs:3725) drives both pending_ambient_agent_conversation_restorations and pending_remote_child_hydrations. Mitigation: maps are kept distinct so the visible-tree replace_pane flow does not accidentally swap a hidden child pane; the cost is small and bounded by the number of restored ambient panes.

Follow-ups

  • Share the retention constant between crates/persistence and warp once the read cap might plausibly be raised independently (for example, if a future history view wants to surface more than the disk cap can hold). Today the read cap is moot, so a separate constant is acceptable; tomorrow it may need to be a single source of truth.
  • Add a property test that asserts select_conversations_to_evict never returns an evict-list that splits an orchestration tree across the kept/evicted boundary. The existing case tests cover the substantive shapes; a property test would shore them up under generated inputs.
  • Tighten decide_remote_child_hydration_action against AmbientAgentLiveSessionState variants added in future schema bumps. The function currently matches Attachable and Inactive explicitly; new variants fall into the LoadTranscript / Fallback decision based on whether the task has a server token. A cfg-gated exhaustiveness assertion would catch a new variant at compile time.
  • new_for_shared_session_viewer has the same latent snapshot/restore loop as the ambient-agent restoration paths fixed by §5. It's a separate entry point used when the user opens a shared session from the conversation list, not the orchestration restore flow, so it's deferred. The shape of the fix is identical: pass is_cloud_mode: true from that call site once we confirm the cloud-mode behaviour is desired for non-restore opens.