57 KiB
Client Awareness of wait_for_events Yields — Tech Spec
Context
See specs/QUALITY-780/PRODUCT.md for user-visible behavior. This spec maps the product invariants onto the existing conversation status, driver lifecycle, task sync, notifications, and orchestration pill bar code paths in the Warp client, and identifies the server-side change needed so the client can actually observe a wait_for_events yield. The server-side spec lives at warp-server/specs/QUALITY-780/TECH.md.
Today's behavior in the bug
The end-to-end path that produces the bug is:
- The model calls the server-handled
wait_for_eventstool.HandleWaitForEventsinwarp-server/logic/ai/multi_agent/runtime/ambient_agents.goreturns aServerToolCallResult::WaitForEventsResultand side-effectsMarkActiveExecutionYieldedForWaitForEventsandExtendTaskIdleTimeout. - The current model turn ends; the agent's response stream finishes successfully.
Message::ToolCallResultmessages (including the legacy server-handledWaitForEventsResult) are applied to the local conversation in theresponse_event::Type::ClientActions(...)arm ofBlocklistAIController::handle_response_stream_eventatapp/src/ai/blocklist/controller.rs:2614-2631, which callshistory_model.apply_client_actions(...). The conversation'sConversationStatus::Successtransition itself fires later when theBlocklistAIActionEventsubscriber atapp/src/ai/blocklist/controller.rs:495-518observes that no follow-up action is queued and marks the response stream completed successfully. (TheAfterStreamFinishedarm atcontroller.rs:2680+is post-stream cleanup; it does not applyClientActions.)LocalAgentTaskSyncModel.handle_history_event(app/src/ai/blocklist/local_agent_task_sync_model.rs:119-151) mapsSuccess→AgentTaskState::Succeededand firesupdate_agent_task.- The server's
ApplyClientUpdatespath callsshouldPreserveInProgressOnClientSuccessfrom theAgentTaskStateSucceededarm atwarp-server/logic/ai/ambient_agents/dispatcher.go:2013(the predicate itself lives atdispatcher.go:2110-2146). It sees thewait_for_eventsmarker and clearsin_progress_sincerather than transitioning the task toSUCCEEDED. The server task state remains preserved. - But on the client,
AgentDriver's subscription toBlocklistAIHistoryEvent::UpdatedConversationStatus(app/src/ai/agent_sdk/driver.rs:2600-2683) seesSuccessand either callsrun_exit.end_run_now(...)(noidle_on_completeconfigured) or schedulesrun_exit.end_run_after(idle_timeout, ...)(idle timeout configured). When that future resolves, the Oz CLI driver process exits viactx.terminate_app(...). AgentNotificationsModel.handle_history_event_for_mailbox(app/src/ai/agent_management/agent_management_model.rs:304-389) firesNotificationCategory::Complete("Task completed.") on the sameSuccesstransition.aggregated_orchestrator_status(app/src/ai/blocklist/orchestration_topology.rs:64-106) returnsSuccesswhen no node isInProgress/Blocked/Error/Cancelled, so the orchestration pill bar's orchestrator badge renders the green check viarender_avatar_with_status_overlay.
The combined effect is the bug report: an Oz cloud agent worker exits seconds after yielding for events and fires a misleading "Task completed" toast. The orchestration pill bar badge is also wrong in the narrower case where an orchestrator yields with no active descendants (today's one-level orchestration means active children already drive the aggregator to InProgress; the badge fix matters for the no-descendants case and is forward-compatible with any future multi-level orchestration).
Relevant files
Conversation status and persistence
app/src/ai/agent/conversation.rs:4067-4168—ConversationStatusenum,status_icon_and_color,render_icon,is_in_progress,is_blocked,is_cancelled,is_done,is_error.app/src/ai/agent/conversation.rs:777-814—status(),update_status_with_error_message.app/src/ai/agent/conversation.rs:195-323—AIConversationstruct definition with all durable fields includingparent_agent_id,agent_name,last_event_sequence,pinned.app/src/ai/agent/conversation.rs:3038-3128—write_updated_conversation_stateconstructsAgentConversationDatafor SQLite persistence.app/src/ai/agent/conversation.rs:700-720—derive_status_from_root_taskreconstructs status from last-exchange output on restore. Today, a successful exchange always derivesSuccess. Note: this function takes onlyroot_task: &Option<&Task>— it has no access toAgentConversationDataand is called from the restore path atconversation.rs:542.app/src/persistence/model/...—AgentConversationDatastruct definition (the SQLite schema for restored conversations).
Driver / process lifecycle
app/src/ai/agent_sdk/driver.rs:147-202—IdleTimeoutSender(the generation-based oneshot that drives Oz CLI exit timing).app/src/ai/agent_sdk/driver.rs:720-812—AgentDriver::run; tx/rx oneshot that signals the CLI to terminate the process. The async block that wrapsrun_internal(defined separately atdriver.rs:1594+) is spawned here.app/src/ai/agent_sdk/driver.rs:1879-1914—HarnessKind::Ozbranch awaitsstatus_rxfromexecute_run(); on resolution sleeps 1s then returns the conversation status.app/src/ai/agent_sdk/driver.rs:2429-2709—execute_run, which subscribes toBlocklistAIHistoryEvent::UpdatedConversationStatusand mapsSuccess | Blocked | Cancelledto either immediate or idle-on-complete-delayed run exit.app/src/ai/agent_sdk/driver.rs:2861-2949—subscribe_to_cli_agent_session_events; the sameSuccess | Blocked→ exit mapping for third-party harnesses.app/src/ai/agent_sdk/mod.rs:1415—ctx.terminate_app(TerminationMode::ForceTerminate, None)whendriver.runreturnsOk(()).
Task sync model
app/src/ai/blocklist/local_agent_task_sync_model.rs:119-151—handle_history_eventreacts toUpdatedConversationStatus.app/src/ai/blocklist/local_agent_task_sync_model.rs:314-355—map_conversation_statusmapsConversationStatustoAgentTaskState.
Notifications
app/src/ai/agent_management/agent_management_model.rs:209-302—handle_history_eventandhandle_history_event_for_mailbox.app/src/ai/agent_management/agent_management_model.rs:304-389— Per-status notification branches.app/src/ai/agent_management/agent_management_model.rs:471-482—ConversationStatus::should_trigger_notification.
Orchestration pill bar and topology
app/src/ai/blocklist/orchestration_topology.rs:64-106—aggregated_orchestrator_statuswith precedenceInProgress > Blocked > Error > Cancelled > Success(precedence to be updated).app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs:119-151—pill_status_sort_key,pill_secondary_sort_key,DONE_STATUS_KEY.app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs:631-705—pill_specsconstructs pill data; orchestrator gets aggregated status, children use their own status.app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs:1390-1397— Hover card uses aggregated status for orchestrators.app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs:2112-2156—render_avatar_with_status_overlay.
Server tool-call result handling
app/src/ai/blocklist/controller.rs:2614-2631— theresponse_event::Type::ClientActions(actions)arm ofBlocklistAIController::handle_response_stream_event. This is whereAddMessagesToTaskactions (which carry the tool-call-result messages, including any newWaitForEventstool-call result) are dispatched into the conversation viahistory_model.apply_client_actions(...).app/src/ai/blocklist/controller.rs:495-518—BlocklistAIActionEventsubscriber that drives the conversation'sSuccesstransition after no follow-up action is queued. Not the same code path asAfterStreamFinished.app/src/ai/blocklist/controller.rs:2680+—ResponseStreamEvent::AfterStreamFinishedhandler; post-stream cleanup. Does not applyClientActions.app/src/ai/blocklist/history_model.rs:1484—apply_client_actions(the function that addsAddMessagesToTaskactions to a conversation; the natural hook point for the newWaitForEventstool-call detection).- Search for
WaitForEventsResultin the client today: no hits. The legacy server tool-call result is opaque to clients (carried in theMessage::ToolCallResult::ServerResult { serialized_result: <opaque string> }variant perwarp-proto-apis/apis/multi_agent/v1/task.proto:939-941).
Design options
Three were considered. We are recommending Option B (first-class variant) because the existing exhaustive-matching conventions make it the safest change to land cleanly; the others are documented for context.
Option A — Marker on AIConversation, status stays Success
Add a boolean waiting_for_events: bool on AIConversation (and persist it on AgentConversationData). Conversation status still flips to Success on stream finish, but every status-consuming surface that cares (LocalAgentTaskSyncModel, AgentDriver, notifications, pill bar aggregator) reads the marker alongside the status.
- Pros: smallest blast radius; no enum-variant churn;
match conversation.status()sites that don't care about waiting keep working unchanged. - Cons: invisible to exhaustive matching, which is how the original bug propagated in the first place. Any new consumer of
ConversationStatus::Successwill silently treat a waiting conversation as done. The "is this a real success?" check has to be repeated at every site that needs it; we cannot rely on the compiler to enumerate them. - Verdict: rejected. The whole reason the bug exists is that
Successis overloaded.
Option B — First-class ConversationStatus::WaitingForEvents variant (recommended)
Add a new variant alongside InProgress, Success, Blocked, Error, Cancelled.
- Pros: exhaustive matching enumerates every site that needs to make a deliberate decision. Existing
match conversation.status()arms (icon, color, telemetry, sort key, mailbox) fail to compile until they decide what to do, which is the exact failure mode we want the compiler to catch. Models the state accurately: quiescent but not terminal, likeBlocked. - Cons: touches more files (every
match conversation.status()). - Verdict: chosen.
Option C — Reuse ConversationStatus::InProgress
Have the conversation stay InProgress while yielded.
- Pros: trivially keeps the driver alive (the existing
is_in_progress()branch already cancels idle timers) and naturally satisfies orchestration aggregation precedence. - Cons:
InProgresscarries an implicit "actively streaming" meaning throughout the codebase — block status bar shows a spinner, the Stop button is enabled, the input is disabled in some flows, "thinking" UI animates. A yielded run is none of those things. Every UI site that keys offInProgresswould either misfire or need a new way to ask "is the agent really doing anything?" - Verdict: rejected. The overload is even worse than Option A.
Proposed changes
1. ConversationStatus::WaitingForEvents variant
In app/src/ai/agent/conversation.rs:4067-4168:
pub enum ConversationStatus {
InProgress,
Success,
Error,
Cancelled,
Blocked { blocked_action: String },
// New:
WaitingForEvents,
}
Update Display, render_icon, and status_icon_and_color exhaustively. The new badge needs a color and icon distinct from every existing status. Explicit collisions to avoid:
Successusestheme.ansi_fg_green()andIcon::Check(conversation.rs:4121-4127).InProgressusestheme.ansi_fg_magenta()andIcon::ClockLoader(conversation.rs:4114-4120).Blockedusestheme.ansi_fg_yellow()andIcon::StopFilled(conversation.rs:4136-4142).
Recommended palette: theme.ansi_fg_blue() with a "listening" or "hourglass" icon. Final choice deferred to design with a TODO(design) placeholder; this spec only requires that the visual be unambiguous against the three quiescent-non-terminal-adjacent siblings above.
2. ConversationStatus::is_done() is unchanged
is_done() keeps its existing semantics — Success | Error | Cancelled — so it already returns false for WaitingForEvents. No predicate split is needed; the existing five is_done() call sites (search row, conversation-list sections, /cost, fork data source) all want "the run is finished and cannot resume", which is exactly what is_done() already conveys. should_trigger_notification adds WaitingForEvents => false.
3. Persistence and restore
The WaitingForEvents status is not durable. The agent execution that the wait keeps alive is in-process state by definition; an app shutdown ends the wait the same way it ends every other running tool call.
Concretely:
AgentConversationDatacarries nowaiting_for_eventsfield. There is nothing new to write inwrite_updated_conversation_state.derive_status_from_root_taskis the sole authority on restore status. A conversation that was yielded at shutdown restores asSuccessbecause the yielding response stream finished cleanly.- The unresolved
wait_for_eventstool-call message stays in the persisted transcript as an orphan. The next outbound request from the user re-engaging the conversation reaches the server with no result for that tool call, and the server's existing pending-tool-call supersede mechanism synthesizes the matchingCancel. From the agent's perspective the yield is just another inbound supersede. - The
LocalAgentTaskSyncModelflips back to reportingSucceededon restore. The server'sshouldPreserveInProgressOnClientSuccessgate (server TECH §1.1) handles this safely: the marker is still on the server's task row, so the dispatcher keeps the taskIN_PROGRESSfor the rollout window during which the gate exists.
Alternative considered (and rejected): persist waiting_for_events: bool on AgentConversationData and override derive_status_from_root_task on restore. Rejected because it added durable state for an in-process concept and introduced a stale-state risk (an offline client missing a resume signal could come back showing a multi-day "waiting" badge for a long-since-reaped server task). The honest model — "the wait ends when the app dies" — has a smaller surface area and degrades gracefully.
4. Wait-for-events action and executor
wait_for_events is modeled as a first-class action_model action so the watchdog, the conversation status transition, and the follow-up request all flow through the executor's lifecycle. This avoids a thicket of guards that would otherwise be needed to keep WaitingForEvents from being clobbered by code paths that treat "the response stream finished" as "the conversation succeeded".
4.1 Action variant and result
Add AIAgentActionType::WaitForEvents { tool_call_id: String, idle_timeout_seconds: i32 } in the shared ai crate and a matching AIAgentActionResultType::WaitForEvents(WaitForEventsResult) result variant. WaitForEventsResult is an enum with two cases:
Completed— watchdog timed out, or an inbound resume signal cleared the wait. Wire form is the empty protoWaitForEventsResult{}carried onRequest::Input::ToolCallResult.result.Cancelled— user cancelled the wait. Wire conversion drops it (Err(ConvertToAPITypeError::Ignore)) so no result is sent for the unresolved tool call; the server's existing supersede mechanism synthesizes the matchingCancelinstead, mirroring howRunAgents::Cancelledis handled.AIAgentActionResultType::WaitForEvents(Completed)returnstruefromis_successful()so the controller's auto-follow-up triggers a follow-up request on completion.Cancelledreturnstruefromis_cancelled()so the controller transitions the conversation toCancelledper the standard cancellation path.
4.2 Inbound conversion
app/src/ai/agent/api/convert_from.rs's Tool::WaitForEvents arm produces an AIAgentAction { action: WaitForEvents { tool_call_id, idle_timeout_seconds } }. Because this is a real action, the exchange's output.actions() contains it, which means AIConversation::mark_request_completed sees has_new_actions = true and does not transition the conversation to Success on the yield stream. No explicit Success-guard is needed in mark_request_completed.
4.3 Executor
app/src/ai/blocklist/action_model/execute/wait_for_events.rs implements WaitForEventsExecutor. Responsibilities:
try_to_execute_actionbumps a per-conversation generation counter, stores aPendingWait { tool_call_id, sender, watchdog_handle }, transitions the conversation toConversationStatus::WaitingForEventsvia a directBlocklistAIHistoryModel::update_conversation_status(WaitingForEvents)call, spawns the watchdog future and stores itsSpawnedFutureHandleon the pending entry, and returnsTryExecuteResult::ExecutedAsync. The action sits inrunning_actionsfor the entire wait. Thetool_call_idis held in the executor'spendingmap, not on the conversation — the only owner of the in-flight wait's identity is the executor.- The
start_pending_action_by_idaction-model plumbing is updated to skip the defaultupdate_conversation_in_progress_statuscall forWaitForEventsso the executor'sWaitingForEventstransition is not immediately clobbered withInProgress. cancel_execution(tool_call_id)is invoked from the executor dispatch's cancel path. It drops the pending entry, aborts the watchdogSpawnedFutureHandle, bumps the generation counter, and drops the channel sender. The caller (BlocklistAIActionExecutor::cancel_running_async_action) has already removed the action fromasync_executing_actions, so the spawn callback that wraps the channel receiver silently discards the result — noFinishedActionis emitted, no tool-call result reaches the wire.- The watchdog firing path (
fire_watchdog_if_current) is the only path that emits aWaitForEventsResult::Completed. It defensively re-checks that the conversation is still inWaitingForEventsbefore firing, so a watchdog that survives an out-of-band status transition does not inject a stale result.
4.4 Watchdog timing and the client-side safety margin
The watchdog timeout is computed by watchdog_timeout_for_stamped_seconds(idle_timeout_seconds):
- If
idle_timeout_seconds <= 0(prost's "unset" sentinel), fall back toDEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS = 30 min. - Subtract
CLIENT_WATCHDOG_SAFETY_MARGIN = 30 sto reserve a recovery window before the worker-side idle-shutdown fires (see server TECH §1.1). - Floor the result at
HARD_FLOOR = 5 sso small testing values still let the watchdog fire on a sane schedule. The margin contract is the time budget for the recovery cycle: client watchdog fires →complete_wait_action→FinishedAction→ controller auto-follow-up → outbound request withWaitForEventsResult→ serverBeginTaskProgress→ next agent turn starts producing activity, which resets the worker idle counter. The corresponding server-side margin (subtract from the stamped value inRecordWaitForEventsYield) is tracked as a follow-up.
4.5 CLI driver lifecycle
app/src/ai/agent_sdk/driver.rs's execute_run keeps its UpdatedConversationStatus subscriber's two early-return arms intact: the is_in_progress() arm still cancels the idle timer when the run resumes, and a WaitingForEvents arm returns without resolving run_exit (the driver keeps the process alive). The driver does not own a separate watchdog; the executor's watchdog and follow-up flow drive recovery regardless of whether the conversation is hosted under an AgentDriver or in the GUI's local-local pane.
subscribe_to_cli_agent_session_events is unaffected because third-party harnesses don't emit wait_for_events; exhaustive match against CLIAgentSessionStatus confirms this.
5. Task sync model
Update map_conversation_status in app/src/ai/blocklist/local_agent_task_sync_model.rs:314-355:
ConversationStatus::WaitingForEvents => (AgentTaskState::InProgress, None),
This means the client actively reports IN_PROGRESS for yielded runs rather than relying on shouldPreserveInProgressOnClientSuccess server-side. The server backstop stays in place for older clients and edge cases (see server TECH §"Server-side gates remain as a backstop").
6. Notifications
Two changes in app/src/ai/agent_management/agent_management_model.rs, both targeted at the WaitingForEvents yield case. The orchestrator-aware suppression that an earlier draft considered (consulting aggregated_orchestrator_status on the orchestrator's own Success) is out of scope per PRODUCT.md (20): if the orchestrator itself reaches a terminal status, that's its own assessment and the notification fires as today. The known orchestrator notification spam is the case where the orchestrator yielded via wait_for_events between turns, which the WaitingForEvents status (and the suppression below) covers directly.
ConversationStatus::should_trigger_notification(line 471): addWaitingForEvents => false. (Note: the function usesmatches!today, which means a new variant returnsfalseby default. Rewrite the function as an exhaustivematchso future variants force a deliberate decision.)handle_history_event_for_mailbox(line 304): add an explicitWaitingForEventsarm that mirrors theInProgressarm at line 330 — it clears any stale notification for this origin viaremove_notification_by_source.
7. Orchestration pill bar and aggregation
app/src/ai/blocklist/orchestration_topology.rs:
aggregated_orchestrator_statusprecedence:InProgress > Blocked > WaitingForEvents > Error > Cancelled > Success, with one carve-out: when the orchestrator itself yielded intoWaitingForEvents, its own waiting state outranks any descendantInProgress. This keeps the orchestrator pill honest about "THIS conversation is paused" even while child agents continue working. A descendant inBlockedstill beats the parent'sWaitingForEventsbecause Blocked needs user attention.- Implementation: scan the tree for
any_in_progress,first_blocked,any_waiting,any_error,any_cancelledas before. Whenany_in_progressis set, check whether the orchestrator's own status isWaitingForEventsand returnWaitingForEventsin that case; otherwise returnInProgress. The remaining precedence steps are unchanged. - Update the doc-comment precedence list to match.
app/src/ai/blocklist/agent_view/orchestration_pill_bar.rs:119-151:
pill_status_sort_key: giveWaitingForEventsits own slot in the "active-ish" half of the bar; do not lump it intoDONE_STATUS_KEY. Recommended order:Blocked = 0,Error = 1,InProgress = 2,WaitingForEvents = 2(same bucket asInProgress, sorts left of the done section),Cancelled | Success = DONE_STATUS_KEY (3).- Update the existing comment at lines 119-124 ("Cancelled and Success share one 'done' bucket") to also mention that
WaitingForEventsshares theInProgressbucket. Future readers should not have to re-derive this. render_avatar_with_status_overlay(lines 2112-2156) and the hover card (lines 1390-1397) pick up the new badge automatically because they consumeConversationStatus::status_icon_and_color.
8. Wiring wait_for_events and resume signals through the action model
This section covers how the client discovers a yield and how a resume reaches the executor. The server-side spec adds a first-class WaitForEvents variant to the public proto's Message::ToolCall::tool oneof and an accompanying WaitForEventsResult variant to Message::ToolCallResult::result. The client pattern-matches the public variant directly; no payload-sniffing of the opaque Message::ToolCall::Server is needed (and would not work, since that payload is opaque per task.proto:405-407).
8.1 Yield path: inbound Tool::WaitForEvents becomes an action
The yield arrives as a Tool::WaitForEvents tool-call message inside the response stream. convert_from.rs (§4.2) translates it into an AIAgentAction::WaitForEvents that lands in the exchange's output.actions(). When the response stream finishes, BlocklistAIController::handle_response_stream_event collects new actions from finished exchanges and forwards them to BlocklistAIActionModel::queue_actions, which dispatches the WaitForEvents action through the executor described in §4.3. The executor's try_to_execute_action is the single place that transitions the conversation to WaitingForEvents and arms the watchdog — there is no separate detection-point helper on BlocklistAIHistoryModel.
8.2 Resume path: silent dismissal via the standard cancellation path
Two inbound signals can close the unresolved WaitForEvents tool call and resume the agent:
- Generic
Canceltool-call result (inbound supersede). When new user input, an inbound message, or an inbound lifecycle event arrives on the waiting task, the server's pending-tool-call supersede mechanism appends a genericCanceltool-call-result referencing the unresolvedWaitForEventsid (server TECH §1.1). WaitForEventsResulttool-call result (echoed timeout). The client's own watchdog emitted this result on a follow-up request and the server echoed it back through the next stream. In both cases, the inbound message is just transcript data —apply_client_actionsappends it to the conversation transcript with no special handling. The client-side teardown of the running wait is driven by the outbound side, before the server is asked to do anything. For the orchestration-event case,BlocklistAIController::inject_pending_events_for_requestcallsBlocklistAIActionModel::cancel_wait_for_events_for_conversation(conversation_id)immediately beforesend_request_input. The cancel goes through the standardcancel_running_async_actionpath: the action is removed fromasync_executing_actions,WaitForEventsExecutor::cancel_executionaborts the watchdog handle and drops the channel sender, and the spawn callback'sasync_executing_actions.removereturnsNoneso the result is silently discarded. NoFinishedActionis emitted and noWaitForEventsResultis sent. The server'scollectCancelledResultsForIncompleteToolCallssynthesizes the matchingCancelfor the unresolved tool call so the message log stays consistent. Subsequent paths that cancel pending actions (e.g.cancel_conversation_progress,send_query) reuse the same machinery and behave identically. For the user-typed-query case the existingsend_querypath already callscancel_all_pending_actionsbefore sending; the wait is cancelled by the same silent-dismissal path described above. For the watchdog-timeout case, no outbound request precedes the firing.fire_watchdog_if_currentproduces aCompletedresult through the channel; the action_model emitsFinishedAction(Completed), the controller's auto-follow-up subscriber sends a follow-up request carrying the emptyWaitForEventsResult{}, and the server's next stream echoes the result back as transcript data.
8.3 Persistence and restart behavior
Nothing about the wait is persisted (§3). On restart, a previously-yielded conversation restores as Success per derive_status_from_root_task; the unresolved Tool::WaitForEvents tool call stays in the transcript as an orphan. When the user re-engages, the next outbound request omits a result for it and the server's existing supersede mechanism synthesizes the matching Cancel. There is no in-memory wait to clear and no transcript-scan fallback — the executor's pending map is the canonical source of truth, and after restart it is empty.
8.4 Inbound orchestration events while waiting
When an orchestration event for a waiting conversation reaches OrchestrationEventService::EventsReady, BlocklistAIController::handle_pending_events_ready drains the queued events and sends them as the next outbound request via inject_pending_events_for_request → send_request_input. The readiness check conversation_ready_for_pending_events treats WaitingForEvents the same as Success so events can be injected while the wait is in flight. The outbound request's send_request_input flips status to InProgress, which completes the wait per §8.2; the request contains the new event inputs but no WaitForEvents tool-call result, so the server synthesizes a Cancel on the next response stream as transcript data.
8.5 Why there is no detect/clear helper for the resume signal
An earlier version of this design routed the resume through BlocklistAIHistoryModel::detect_wait_for_events_transitions + clear_conversation_waiting_for_events_if_matches inside apply_client_actions. The detect/clear pair scanned inbound ToolCallResult messages for WaitForEvents / Cancel variants and flipped status to InProgress directly. Both helpers, the waiting_for_events_tool_call_id field on AIConversation, the mark_conversation_waiting_for_events setter, and the transcript-scan fallback find_unresolved_wait_for_events_tool_call_id have been removed: every reachable production resume path is preceded by an outbound send_request_input that already flips status, so the detect/clear was a no-op in every observable flow (the if !matches!(status, WaitingForEvents) { return; } early return at clear_conversation_waiting_for_events_if_matches fired before the detect/clear could do any work). Removing the machinery aligns the implementation with the natural request/response lifecycle: status transitions are driven by outbound requests and action lifecycle, not by inbound message-shape inspection.
Known limitation, intentionally undocumented as a server contract. If a future code path arranges for an inbound resume signal to arrive without any preceding outbound request that flips status (e.g. a server push synthesized without the client driving it, or a viewer flow that mirrors a sharer's status differently from how viewers currently work — see the shared-session viewer note below), the executor's UpdatedConversationStatus subscription would not fire and the wait would only complete via the watchdog timeout. The fix in that case would be to re-introduce a targeted detect/clear at the new entry point or to ensure the new entry point flips status explicitly. Shared-session viewers are not affected today: try_to_execute_action short-circuits with NotExecuted::WaitingOnSharer (action_model/execute.rs:557-563), so a viewer never has a pending wait to complete.
9. Coordinated rollout and backwards compatibility
No client-side feature flag is required. The signal that activates the client-side fix is the presence of the new public Message::ToolCall::WaitForEvents variant in a received message. Because the legacy Message::ToolCall::Server payload is opaque to clients (task.proto:405-407), there is no way for the client to detect a legacy wait_for_events call, and no sniff fallback exists.
Rollout sequencing (mirrors server TECH §"Backwards compatibility and coordinated rollout"):
warp-proto-apisrelease. The proto additions ship first as a no-op (no producer or consumer yet). Wire-compatible: older deserializers ignore the new variants.warprev bump. Cargo.toml inwarpis bumped to the new release. The client adds theWaitForEventsdetection and theWaitingForEventsflow. Without a server emitting the variant, the new code stays dormant.warp-serverrev bump + flag-on rollout. The server side ships the new emission path behind a feature flag. Flipping the flag for a tenant/workspace activates the client-side fix for that scope.- Steady state. Both repos ship the new path; the server-side flag is at 100%. The legacy server-handled
wait_for_eventspath stays compiled for one release window and is then removed (server TECH §"Cleanup").
Behavior during the rollout window
- Client old, server old. Legacy bug:
Successis reported, the CLI driver exits, the server'sshouldPreserveInProgressOnClientSuccesskeeps the taskIN_PROGRESS. Unchanged from today. - Client old, server new. Client sees the new
WaitForEventsvariant as an unknown field (proto's forward-compatibility) and ignores it. The conversation still goes toSuccesslocally; same as the legacy bug. The server-side gates protect the task. - Client new, server old. Server is still emitting via
Message::ToolCall::Server { payload: <opaque> }. The client sees only the opaque variant and treats the conversation asSuccess(same as today). The server-side gates protect the task. - Client new, server new. Full fix:
WaitForEventsvariant emitted by server, pattern-matched by client, conversation transitions toWaitingForEvents, driver stays alive, no toast, correct pill-bar badge.
Mixed-mode within a single conversation
The server-side feature flag is evaluated per wait_for_events call, so one conversation can contain both legacy and new yields. The client handles this gracefully: legacy yields produce opaque server tool-call messages that the client ignores; new yields activate the WaitingForEvents path. There is no client-side state that needs to track which mode a conversation is in.
End-to-end flow
After the changes, a wait_for_events cycle looks like:
- Model calls
wait_for_events. - Server emits
Message::ToolCall { tool: WaitForEvents }in the public proto, firesrecordWaitForEventsYieldto extendVMIdleTimeoutMinutes, and finishes the response stream without emitting a tool-call result. - Client receives the stream.
convert_from.rsturns theTool::WaitForEventsmessage into anAIAgentAction::WaitForEvents { tool_call_id, idle_timeout_seconds }in the exchange'soutput.actions()(§8.1). Becausehas_new_actions = true,mark_request_completeddoes not transition the conversation toSuccess. - When the response stream finishes,
BlocklistAIControllercollects the new actions and callsBlocklistAIActionModel::queue_actions. The wait action is dispatched toWaitForEventsExecutor::try_to_execute_action. - The executor (§4.3) bumps its per-conversation generation counter, stores a
PendingWait { tool_call_id, sender }, transitions the conversation toWaitingForEventsviaBlocklistAIHistoryModel::update_conversation_status(WaitingForEvents), spawns the watchdog withwatchdog_timeout_for_stamped_seconds, and returnsExecutedAsync. The action sits inrunning_actions. LocalAgentTaskSyncModelmapsWaitingForEvents→AgentTaskState::InProgressand firesupdate_agent_task.AgentNotificationsModeldoes not fire a toast for theWaitingForEventstransition (§6). The orchestrator's ownSuccess/Cancelled/Errornotifications continue to fire as today.- The orchestration pill bar's orchestrator badge renders the waiting state via the updated aggregator precedence (§7).
- Resume by inbound supersede. Inbound user input, an inbound message, or an inbound lifecycle event arrives. The resume is driven by an outbound request from the client (the user's message submission, an
inject_pending_events_for_requestdrain triggered byEventsReady, etc.). That code path callscancel_wait_for_events_for_conversationbeforesend_request_input; the wait is silently dismissed through the standardcancel_running_async_actionmachinery, noFinishedActionfires, and no tool-call result is sent on the wire. The server-synthesizedCancelarrives in the response stream that follows and is appended to the transcript as ordinary message data byapply_client_action(AddMessagesToTask). - Resume by watchdog timeout. If no inbound input arrives before the watchdog fires, the executor's timer callback verifies the generation counter still matches and that the conversation is still in
WaitingForEvents, then sendsCompletedon the channel. The action_model emitsFinishedAction; the auto-follow-up subscriber sends a follow-up request whose input includes the emptyWaitForEventsResultproduced by the action's result conversion. The server echoes the result through the next stream; the agent's next turn observes the empty timeout result and decides how to proceed (commonlyfinish_task, but the agent may also re-yield, ask the user, or take other action). The run is not auto-cancelled on timeout; the agent owns the decision.
Diagram
flowchart LR
Streaming([Model emits wait_for_events tool call]) -->|public Tool::WaitForEvents| Convert["convert_from.rs:<br/>build AIAgentAction::WaitForEvents"]
Convert --> Queue["queue_actions on stream finish:<br/>dispatch to WaitForEventsExecutor"]
Queue --> Exec["Executor try_to_execute_action:<br/>update_conversation_status(WaitingForEvents),<br/>spawn watchdog,<br/>action runs async"]
Exec --> Sync["LocalAgentTaskSyncModel:<br/>update_agent_task(IN_PROGRESS)"]
Exec --> Notif["NotificationsModel:<br/>no toast for WaitingForEvents,<br/>clear stale items"]
Exec --> Pill["Orchestration pill bar:<br/>waiting badge via aggregator"]
Exec -->|inbound user/event:<br/>outbound request via send_request_input| StatusFlip["send_request_input:<br/>status → InProgress"]
Exec -->|watchdog fires| Complete["Executor complete_wait_action:<br/>Completed result on channel"]
StatusFlip -->|UpdatedConversationStatus| ExecSub["Executor subscription:<br/>complete_wait_action(Completed)"]
ExecSub --> Complete
Complete --> Finished["FinishedAction event"]
Finished --> FollowUp["Controller auto-follow-up:<br/>has_active_stream ⇒ bail<br/>(else send next outbound request)"]
StatusFlip --> NextTurn([Next agent turn])
FollowUp --> NextTurn
Testing and validation
Map each PRODUCT.md invariant to a concrete test or manual verification. Numbers in parentheses reference specs/QUALITY-780/PRODUCT.md.
Unit tests
conversation_tests.rs—ConversationStatus::is_done()returns true exactly forSuccess | Error | CancelledandfalseforWaitingForEvents. Covers (3), (4), (28).conversation_tests.rs—should_trigger_notificationreturnsfalseforWaitingForEventsandInProgress, true forSuccess | Blocked | Error. Covers (16), (19).conversation_tests.rs— Restore: a conversation that was yielded viawait_for_eventsat shutdown restores asSuccess(notWaitingForEvents), the orphan tool call stays in the transcript, and no waiting state is rebuilt. Covers (10).conversation_tests.rs— Transition matrix: assert the only legal transitions intoWaitingForEventsare fromInProgress; transitions out ofWaitingForEventsare toInProgress,Cancelled,Error, orSuccess; a directWaitingForEvents→WaitingForEventsis not reachable (must re-enterInProgressfirst). Covers PRODUCT.md (9).conversation_tests.rs— Cancellation fromWaitingForEvents: invoking the existing cancel path on aWaitingForEventsconversation transitions toCancelledimmediately and emits a status update. Covers PRODUCT.md (14).local_agent_task_sync_model_tests.rs—map_conversation_status(WaitingForEvents)returns(AgentTaskState::InProgress, None). Covers (15).agent_management_model_tests.rs—handle_history_event_for_mailboxforWaitingForEventsdoes not calladd_notificationand removes any existing notification for the origin. Covers (16), (17).agent_management_model_tests.rs— No notification fires on theWaitingForEvents→InProgressresume transition. Covers PRODUCT.md (18).agent_management_model_tests.rs— Orchestrator's own terminal status fires the existing notification: an orchestrator with non-terminal descendants reachingSuccess(orCancelled/Error) still produces theComplete(or matching) toast — the mailbox does not inspect descendant state. Covers PRODUCT.md (20).orchestration_topology_tests.rs—aggregated_orchestrator_statusprecedence including the parent-waits carve-out: orchestratorWaitingForEvents+ all childrenSuccess→WaitingForEvents; orchestratorWaitingForEvents+ one childInProgress→WaitingForEvents(carve-out); orchestratorInProgress+ one childInProgress→InProgress; orchestratorWaitingForEvents+ one childBlocked→Blocked; orchestratorWaitingForEvents+ one childError→WaitingForEvents. Covers (22).orchestration_pill_bar_tests.rs—pill_status_sort_key(WaitingForEvents)returns a value strictly less thanDONE_STATUS_KEY. Covers (24).wait_for_events_tests.rs—watchdog_timeout_for_stamped_secondsmath: stamped 0 → default minus margin; stamped 60 → 30 s; stamped 10 →HARD_FLOOR; stamped negative → default minus margin. Plus named-constant checks forDEFAULT_ORCHESTRATED_IDLE_TIMEOUT_SECONDS,CLIENT_WATCHDOG_SAFETY_MARGIN, andHARD_FLOOR. Covers (11), (12).input_tests.rsoragent_message_bar_tests.rs— With the conversation inWaitingForEvents, the input is enabled and submitting a follow-up clears the waiting state and transitions toInProgress. Covers PRODUCT.md (26).history_model_tests.rs— Starting a new conversation in a terminal view that previously held aWaitingForEventsconversation does not inherit the wait state. Covers PRODUCT.md (31).
Integration tests
- Add an integration test in
crates/integration/that drives an Oz CLI agent with--idle-on-complete=5sagainst a fake server emitting the new publicWaitForEventsvariant; assert the process does not exit within 30 seconds. Covers PRODUCT.md (11). - Timeout-path integration test: drive an Oz CLI agent against a fake server, let the client watchdog fire, assert the client emits
Message::ToolCallResult { result: WaitForEvents(WaitForEventsResult{}) }against the unresolvedWaitForEventstool-call id and the run does not transition toCancelled. The fake server echoes the result back; assert the conversation transitions toInProgressand the simulated agent's next turn fires. Covers PRODUCT.md (12), (29). - Coordinated-rollout matrix: a flag-off fake server emits the legacy server tool call; the client treats the conversation as
Success(legacy bug) and the server'sshouldPreserveInProgressOnClientSuccesskeeps the taskIN_PROGRESS. A flag-on fake server emits the new variant; the client transitions toWaitingForEvents. Covers PRODUCT.md (32), (33). - Extend
agent_conversations_model_tests.rsto assert that a conversation enteringWaitingForEventsdoes not propagateSuccesssemantics to consumers that checkis_done(). Covers (28).
Manual validation
- Run a local Oz orchestrator that spawns one child agent and yields via
wait_for_events. Verify:- The orchestration pill bar's orchestrator badge shows the "waiting" icon/color (not green check). (21), (22)
- No "Task completed" toast appears. (16), (20)
- The CLI worker process stays alive until the child message arrives. (11)
- After the inbound message resumes the agent, the badge transitions back to active and the conversation eventually completes. (8), (30)
- Repeat with the orchestrator in the foreground and minimized to confirm notification behavior matches.
- Restart Warp while a conversation is
WaitingForEvents. Confirm the conversation restores asSuccess(the yield does not survive restart, per §3), the orphanwait_for_eventstool call is visible in the transcript, and re-engaging the conversation cleanly synthesizes the supersede. (10) - Submit a follow-up while in
WaitingForEvents. Confirm the input accepts the message, the conversation transitions toInProgress, and no notification fires for the transition. (26), (18)
Regression coverage
- Audit every
match conversation.status()site for an explicitWaitingForEventsarm. The exhaustive-matching rule fromWARP.mdshould already enforce this; the test suite confirms. cargo clippy --workspace --all-targets --all-features --tests -- -D warningsand./script/presubmitpass.
Orchestration
This section is the canonical cross-spec orchestration plan for QUALITY-780. The same text appears in both warp/specs/QUALITY-780/TECH.md and warp-server/specs/QUALITY-780/TECH.md so each spec is self-contained for the agent implementing it.
Decision
Implementation is fanned out across multiple AI agents working in parallel git worktrees. The work spans three repositories (warp-proto-apis, warp-server, warp), and the proto change is a hard prerequisite for everything else because both the server and client implementations consume the new generated bindings. After the proto release, the server-side and client-side core work can run in parallel; once the client core lands, the remaining client work fans out further. AI agents complete each subtask in minutes, not days — the bottleneck is wave ordering, not per-agent effort.
Worktree layout
Per the ~/src/QUALITY-780/ task-directory convention:
~/src/QUALITY-780/warp-proto-apis— proto agent.~/src/QUALITY-780/warp-server— server-impl agent.~/src/QUALITY-780/warp— client-core agent and final integrator.~/src/QUALITY-780/warp-driver,~/src/QUALITY-780/warp-sync-notif,~/src/QUALITY-780/warp-pill-bar,~/src/QUALITY-780/warp-detection— additionalwarpworktrees for the four Wave 2 client fan-out agents.
All branches use the matthew/ prefix.
Dependencies and ordering (three waves)
- Wave 0 — Proto release (single agent, sequential).
protoadds the new variants towarp-proto-apis/apis/multi_agent/v1/task.protoand publishes a release tag. All downstream waves block on this completing. - Wave 1 — Core scaffold + server (two agents in parallel, after Wave 0).
server-implandclient-corerun concurrently because they live in different repositories and share no compilation dependency. The client core is sized to be the minimum scaffold that downstream client agents need to compile against (status variant, exhaustive match arms in shared files, predicate split, persistence, restore-site). - Wave 2 — Client fan-out (four agents in parallel, after Wave 1's client-core branch is pushed).
client-driver,client-sync-notif,client-pill-bar,client-detectionbranch fromclient-core's branch and modify disjoint client subsystems. They do not touch the files client-core owns. - Wave 3 — Integration (orchestrator). Orchestrator merges all four Wave 2 branches into the client-core branch, runs
cargo fmt/cargo clippy/./script/presubmit, and opens a single draft PR forwarp.server-implindependently opens a draft PR forwarp-server. The proto release tag from Wave 0 is referenced from both implementation PR descriptions.
Launch config
Run-wide settings (execution mode, model, harness) are documented in the orchestration config attached to this plan. Defaults:
- Execution mode: local for every agent. The agents touch code paths exercised by
./script/presubmitand other local toolchains, and each works in a user-visible git worktree. - Model: inherits from the orchestrator (not pinned in the config).
- Harness: default Oz.
Each wave launches as its own run_agents batch. Do not pre-launch downstream waves — wait for each wave's lifecycle events before fanning out the next.
Child agents
- proto —
warp-proto-apisproto additions (Wave 0).- Worktree:
~/src/QUALITY-780/warp-proto-apis. Branch:matthew/QUALITY-780-proto-additions. - Owns: the proto additions in
apis/multi_agent/v1/task.protoper server TECH §0. - Output: pushes branch, opens draft PR, publishes a release tag/version. Reports the released version string + git ref to the orchestrator.
- Worktree:
- server-impl —
warp-serveremission path + flag (Wave 1).- Worktree:
~/src/QUALITY-780/warp-server. Branch:matthew/QUALITY-780-server. - Owns: server TECH §0–§1 implementation:
WaitForEventsToolCall::ProduceActions,isWaitForEventsAction, refactor ofHandleWaitForEvents→recordWaitForEventsYield, finalizer hook inRunPrimaryAgent, gating of theExecuteServerHandledToolCallarm, the newWaitForEventsClientToolEnabledfeature flag, and the unit/integration tests in server TECH §"Testing and validation". - Validation:
go fmt ./...,go vet ./...,./script/presubmitbefore opening the PR. - PR: draft, using
.github/pull_request_template.md.
- Worktree:
- client-core —
warpstatus variant + predicates + persistence (Wave 1).- Worktree:
~/src/QUALITY-780/warp. Branch:matthew/QUALITY-780-client-core. - Owns: §1 (
ConversationStatus::WaitingForEventsvariant + all match arms inconversation.rsDisplay/render_icon/status_icon_and_color); §2 (is_donestays as-is, with the new variant correctly returningfalse); §3 (persistence field onAgentConversationData+ restore-site check innew_restoredatconversation.rs:542). For files that Wave 2 agents own (driver, sync/notif, pill bar, detection), client-core leaves their match arms with conservativeWaitingForEventsplaceholders (e.g. treat likeInProgressfor the clear-stale notification path, likeBlockedfor the not-currently-streaming question) so the tree compiles and existing tests pass. Wave 2 agents replace the placeholders with their final implementations. - Validation:
cargo fmt,cargo clippy --workspace --all-targets --all-features --tests -- -D warnings,./script/presubmit. - Hand-off: pushes the branch and reports the branch name so Wave 2 agents can rebase from a known-good commit.
- Worktree:
- client-driver —
warpdriver lifecycle (Wave 2).- Worktree:
~/src/QUALITY-780/warp-driver. Branch:matthew/QUALITY-780-client-driver(offmatthew/QUALITY-780-client-core). - Owns: §4 (
app/src/ai/agent_sdk/driver.rs—IdleTimeoutSenderreuse pattern,execute_run'sUpdatedConversationStatushandler,subscribe_to_cli_agent_session_eventsno-op verification, watchdog emission ofWaitForEventsResult).
- Worktree:
- client-sync-notif —
warptask sync + notifications (Wave 2).- Worktree:
~/src/QUALITY-780/warp-sync-notif. Branch:matthew/QUALITY-780-client-sync-notif(offmatthew/QUALITY-780-client-core). - Owns: §5 (
local_agent_task_sync_model.rs—map_conversation_status) and §6 (agent_management_model.rs—should_trigger_notificationexhaustive rewrite,handle_history_event_for_mailboxWaitingForEventsarm).
- Worktree:
- client-pill-bar —
warporchestration aggregation + pill bar (Wave 2).- Worktree:
~/src/QUALITY-780/warp-pill-bar. Branch:matthew/QUALITY-780-client-pill-bar(offmatthew/QUALITY-780-client-core). - Owns: §7 (
orchestration_topology.rs—aggregated_orchestrator_statusprecedence +any_waitingaccumulator + doc-comment,orchestration_pill_bar.rs—pill_status_sort_key+ sort-bucket comment).
- Worktree:
- client-detection —
warptool-call detection + ordering rule (Wave 2).- Worktree:
~/src/QUALITY-780/warp-detection. Branch:matthew/QUALITY-780-client-detection(offmatthew/QUALITY-780-client-core). - Originally owned the inbound-resume detect/clear path in
history_model.rs. After the simplification in §8.5, no detect/clear helpers exist; the resume is driven entirely by the natural status flip insend_request_input. This agent's remaining responsibility is the client-side rollout notes in §9 and anycontroller.rsordering guards required to keep the response-streamSuccesstransition from clobbering an active wait.
- Worktree:
Merge strategy
- Each Wave 2 client agent reports its branch name and a brief summary of changed files. Each agent runs its own
cargo fmt/cargo clippy/./script/presubmitbefore reporting. - Orchestrator integrates Wave 2 into client-core in
~/src/QUALITY-780/warp:- Check out
matthew/QUALITY-780-client-core. - Merge each Wave 2 branch in sequence (driver → sync-notif → pill-bar → detection). Conflicts should be limited to client-core's placeholder arms in fan-out-owned files; each Wave 2 agent replaces only its own placeholders, so per-file conflicts are localized.
- Re-run
cargo fmt,cargo clippy --workspace --all-targets --all-features --tests -- -D warnings,./script/presubmiton the integrated branch. - Push
matthew/QUALITY-780-client-coreand open a single draft PR forwarp.
- Check out
server-implopens its own draft PR forwarp-serverdirectly from its branch.- Final state: three branches across three repos, three draft PRs (
warp-proto-apis,warp-server,warp). The two implementation PRs link to the proto release.
Diagram
flowchart LR
Plan([Plan + orchestration config approved]) --> Proto["Wave 0:<br/>proto — warp-proto-apis<br/>add variants + cut release"]
Proto --> Server["Wave 1:<br/>server-impl — warp-server<br/>emission + flag + tests"]
Proto --> ClientCore["Wave 1:<br/>client-core — warp<br/>status variant + predicates + persistence"]
ClientCore --> Driver["Wave 2:<br/>client-driver"]
ClientCore --> SyncNotif["Wave 2:<br/>client-sync-notif"]
ClientCore --> PillBar["Wave 2:<br/>client-pill-bar"]
ClientCore --> Detection["Wave 2:<br/>client-detection"]
Server --> ServerPR([warp-server draft PR])
Driver --> Integrate["Wave 3:<br/>Orchestrator integrates<br/>+ presubmit"]
SyncNotif --> Integrate
PillBar --> Integrate
Detection --> Integrate
Integrate --> ClientPR([warp draft PR])
Proto --> ProtoPR([warp-proto-apis draft PR])
Risks and mitigations
- Risk: A previously-yielded conversation restores as
Successand the user thinks it is done. Mitigation: this is by design (§3). The orphan tool call sits in the transcript so the conversation can be re-engaged at any time, at which point the server-side supersede mechanism naturally drives the resume. Cosmetically the badge isSuccessinstead ofWaitingfor the offline-and-restarted case; the user can resume manually. - Risk: The waiting watchdog races the resume signal. An inbound event arrives at almost the same time as the timeout. Mitigation: the executor's per-conversation generation counter (§4.3) makes cancellation atomic with respect to the timer fire — a still-pending watchdog whose generation no longer matches no-ops.
- Risk: Orphaned
WaitForEventstool-call message in transcript history. When the conversation transitions toCancelledvia user cancel, the unresolved tool call stays in transcript history — pending tool calls are not retroactively cancelled (per PRODUCT.md (29)). The client-side watchdog path does not orphan the call: the watchdog emits aWaitForEventsResultthat closes it before the agent decides what to do next. The only remaining orphan case is the worker-side safety-net idle-shutdown (server TECH §1.1) firing because the client is offline; that path transitions the task toCANCELLEDwithout a result message. Mitigation: this is intentional and harmless. Terminal conversations are read-only, so an orphan tool call is just historical metadata and does not affect any live behavior. - Risk: Visual badge for
WaitingForEventscollides withBlocked,InProgress, orSuccess. Mitigation: §1 enumerates the existing color/icon assignments. Until design lands a dedicated visual the placeholder reuses theInProgressicon and color so the badge never collides withSuccess/Blocked. - Risk: Client watchdog races the worker idle-shutdown. If the stamped
idle_timeout_secondsmatches the worker'sVMIdleTimeoutMinutesexactly, the worker can shut down before the client watchdog has time to fire and send the follow-up. Mitigation: the client subtractsCLIENT_WATCHDOG_SAFETY_MARGIN(and floors atHARD_FLOOR) before scheduling (§4.4). A corresponding server-side margin is tracked as a follow-up so the stamped value the client observes is already below the worker ceiling. - Risk: Coordinated rollout regression. A misordered release sequence (e.g. client builds without the new proto bindings) could break deserialization. Mitigation: ship
warp-proto-apisfirst as a no-op; bump revs inwarpandwarp-serveronly after. Server TECH §"Coordinated rollout" tracks the sequence end-to-end.
Follow-ups
- Once the server-side feature flag is at 100% and the legacy
ExecuteServerHandledToolCallarm is removed (server TECH §"Cleanup"), audit the client for any remaining references to the legacy server-handledwait_for_eventsshape and drop them. - Audit the block status bar for any remaining "spinner shows for
WaitingForEvents" cases — most likely the change in §1 makes this fall out for free, but worth confirming. - Evaluate whether the third-party harness path (
subscribe_to_cli_agent_session_events) ever needs to model a yield analogously. Today no third-party harness emitswait_for_events, but if one starts to we want a clear extension point. - Once the new client surface is stable, look at adding a richer transcript affordance ("waiting for events from agent X") that distinguishes inbound-resume (generic
Cancelarriving with new inputs) from watchdog-timeout (WaitForEventsResultarriving alone) for display purposes. Out of scope for QUALITY-780 itself.