Files
galaxy/specs/REMOTE-1420/TECH.md
T

8.7 KiB
Raw Blame History

REMOTE-1420: Fix O(N×M) refresh storm when opening a shared session

Context

Opening a shared-session link beachballs the desktop app and crashes Warp-on-Web. Main thread stalls inside ConversationListViewModel::refresh_cached_itemsAgentConversationsModel::conversation_ids_shadowed_by_tasksBlocklistAIHistoryModel::find_conversation_id_by_server_token.

Each lookup is O(conversations + metadata) (two linear scans), the miss path emits log::info!, and INFO is captured as a synchronous Sentry breadcrumb (JSON-encoded on main).

  • N = ambient agent tasks in AgentConversationsModel.tasks.
  • M = conversations_by_id.len() + all_conversations_metadata.len() scanned per task.

Shared-session hydration + streaming fire many BlocklistAIHistoryEvents; each that reaches ConversationListViewModel triggers a full refresh → O(N × M) per event.

Relevant code

  • app/src/ai/blocklist/history_model.rs:1835find_conversation_id_by_server_token: two linear scans, log::info! on miss (line 1860).
  • app/src/ai/blocklist/history_model.rs:229all_conversations_metadata: HashMap<AIConversationId, AIConversationMetadata> (current forward index by id).
  • app/src/ai/blocklist/history_model.rs:236agent_id_to_conversation_id, the existing reverse-index pattern to mirror.
  • app/src/ai/blocklist/history_model.rs:597-645restore_conversations: emits one UpdatedConversationStatus per restored convo + one RestoredConversations.
  • app/src/ai/blocklist/history_model.rs:806-873initialize_output_for_response_stream / assign_run_id_for_conversation: where live tokens first land; agent_id_to_conversation_id is maintained here.
  • app/src/ai/blocklist/history_model.rs:1500, 1897, 1922, 1939 — other all_conversations_metadata mutation sites.
  • app/src/ai/blocklist/history_model/conversation_loader.rs:335-423, 425-573merge_cloud_conversation_metadata, initialize_historical_conversations: bulk inserts into metadata.
  • app/src/ai/agent_conversations_model.rs:1113-1134conversation_id_shadowed_by_task / conversation_ids_shadowed_by_tasks: the hot call site, one lookup per task.
  • app/src/ai/agent_conversations_model.rs:1136-1201handle_history_event: translates BlocklistAIHistoryEventAgentConversationsModelEvent; UpdatedConversationStatusConversationUpdated.
  • app/src/workspace/view/conversation_list/view_model.rs:33-46 — subscription that fans every model event into refresh_cached_items.
  • app/src/workspace/view.rs:2838-2850, app/src/ai/agent_management/view.rs:1179-1206 — other ConversationUpdated consumers (transcript side panel, management details panel) that must keep receiving the event.

Proposed changes

1. Reverse index server_token → conversation_id (primary fix)

Add a new field to BlocklistAIHistoryModel:

server_token_to_conversation_id: HashMap<ServerConversationToken, AIConversationId>,

Maintain it at every site that inserts, mutates, or removes an entry carrying a server_conversation_token. Mirror the existing agent_id_to_conversation_id pattern for symmetry and discoverability.

Insert/update sites:

  • initialize_historical_conversations (conversation_loader.rs:435) — bulk build while iterating persisted metadata.
  • merge_cloud_conversation_metadata (conversation_loader.rs:389-412) — both the matched-by-token and new-cloud-only branches.
  • mark_conversations_historical_for_terminal_view (history_model.rs:1897).
  • insert_forked_conversation_from_tasks (history_model.rs:1922).
  • initialize_output_for_response_stream (history_model.rs:814-838) — first assignment of a live token.
  • assign_run_id_for_conversation (history_model.rs:845-873) — v2 path; convenient audit point.
  • set_server_metadata_for_conversation (history_model.rs:449-479) — only re-insert if the incoming token differs from the cached one.

Removal sites:

  • remove_conversation_from_memory (history_model.rs:1487-1529).
  • reset (history_model.rs:1928-1941) — clear.

Rewrite find_conversation_id_by_server_token as a single O(1) HashMap::get on the new index.

Resolve an invariant question while implementing: all callers currently treat "conversation in memory" and "conversation in metadata" as the same thing, so a single index keyed by token is sufficient if we guarantee entries are inserted whenever a token becomes known for either a live conversation or a metadata entry. Verify this by grepping all callers (conversation_loader.rs:262, conversation_details_panel.rs, pane_group/mod.rs, workspace/view.rs, block/view_impl/output.rs, agent_view/orchestration_conversation_links.rs, conversation_list/view.rs) and the two shadow-by-task callers.

Effect: per-refresh cost drops from O(N × M) to O(N).

2. Demote miss-log to debug

Change log::info! at history_model.rs:1860 to log::debug!. INFO is forwarded to Sentry breadcrumbs and JSON-encoded synchronously on the main thread (visible in the desktop sample as SentryCrashJSONCodec frames). DEBUG is not captured.

INFO level isn't warranted here: a token miss is the expected outcome whenever a task references a conversation the local client hasn't loaded (shared-session tasks from other users, server-only tasks, pre-sync state). It's not an error, not actionable for production telemetry, and repeats identically per-task per-refresh so it provides zero new information after the first occurrence. DEBUG is the right level for local diagnostic use; if we ever need to investigate a specific lookup miss, developers can opt in.

Keep the log for diagnostics; just stop sending it to Sentry. Acts as a safety net if the index ever misses a site.

3. Stop rebuilding the list cache on ConversationUpdated

In ConversationListViewModel::new (view_model.rs:33-46), split the match arm: on ConversationUpdated, do not call refresh_cached_items. Emit ConversationListViewModelEvent directly so the view re-renders and reads fresh status at render time via get_item_by_id. Status is never cached in cached_conversation_or_task_ids, so the cache does not depend on it.

Keep AgentConversationsModelEvent::ConversationUpdated itself — the wasm transcript side panel (workspace/view.rs:2844) and the agent-management details panel (agent_management/view.rs:1195) still need it to refresh their status readouts.

This cuts a dominant source of per-event refreshes during post-restore streaming: every status flip (from AIConversation::update_status_with_error_message, many callers) no longer walks the task list.

Testing and validation

Unit tests in app/src/ai/blocklist/history_model_test.rs:

  • find_conversation_id_by_server_token returns Some(id) after each of: initialize_historical_conversations, merge_cloud_conversation_metadata (both branches), initialize_output_for_response_stream, assign_run_id_for_conversation, insert_forked_conversation_from_tasks, mark_conversations_historical_for_terminal_view.
  • Returns None after remove_conversation_from_memory and reset.
  • Returns the same id after set_server_metadata_for_conversation when token is unchanged.

Regression test for the view-model: subscribing a fake AgentConversationsModel emitter of ConversationUpdated does not change cached_conversation_or_task_ids but still emits ConversationListViewModelEvent.

Manual: open a shared-session link as a user with ≥ ~50 ambient-agent tasks. Confirm no main-thread stall and no repeating "No conversation found for server token" burst in logs. Compare desktop sample before/after to confirm find_conversation_id_by_server_token no longer appears in the hot path.

./script/presubmit (fmt + clippy + tests).

Risks and mitigations

  • Missed index maintenance site → stale lookup misses. Mitigation: unit test coverage per site above; log-level demotion keeps the miss path cheap even if we regress.
  • Token collisions between all_conversations_metadata and conversations_by_id (same token, different AIConversationId). Shouldn't happen given existing dedup-by-token logic in merge_cloud_conversation_metadata, but add a debug_assert! on insert to catch regressions.
  • ConversationUpdated no longer refreshing the cache could hide a case where a status flip should reorder/remove the item. Checked: the sort key is last_updated() (set at append time) and the filter keys don't include status. Leave a comment in refresh_cached_items documenting the invariant.

Follow-ups

  • Coalesce refresh_cached_items across a frame so bursty TasksUpdated from streaming collapse into one pass.
  • Cache conversation_ids_shadowed_by_tasks on AgentConversationsModel and maintain incrementally on task/metadata mutations, so refresh becomes O(filtered items) instead of O(N).