Skip to content
7 changes: 6 additions & 1 deletion app/src/ai/agent_sdk/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,12 @@ pub(crate) const WARP_DRIVE_SYNC_TIMEOUT: Duration = Duration::from_secs(60);
/// Maximum time to wait for an automatic error resume before propagating the error.
/// If no follow-up status arrives within this window, the driver terminates with the
/// original error so the CLI does not hang indefinitely.
const AUTO_RESUME_TIMEOUT: Duration = Duration::from_secs(120);
///
/// This is re-armed per recovery attempt: a recovery that lands flips the conversation
/// back to `InProgress`, which cancels the deadline, and a subsequent failure schedules a
/// fresh one. So it bounds a single attempt, not the whole recovery chain — but a single
/// attempt's wait (including the recovery backoff) still has to fit inside it.
pub(crate) const AUTO_RESUME_TIMEOUT: Duration = Duration::from_secs(120);
/// Signals to Claude child-harness hooks that Warp already owns the background
/// message-listener lifecycle, so the plugin should reuse the shared state
/// files instead of spawning and cleaning up its own listener.
Expand Down
8 changes: 1 addition & 7 deletions app/src/ai/blocklist/block/cli_controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -572,13 +572,7 @@ impl CLISubagentController {
.collect()
};
self.controller.update(ctx, |controller, ctx| {
controller.resume_conversation(
conversation_id,
/*can_attempt_resume_on_error*/ true,
/*is_auto_resume_after_error*/ false,
resume_context,
ctx,
);
controller.resume_conversation(conversation_id, resume_context, ctx);
});
}
}
Expand Down
87 changes: 59 additions & 28 deletions app/src/ai/blocklist/controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ use warp_multi_agent_api::{Task, ToolType, message};
use warpui::r#async::{SpawnedFutureHandle, Timer};
use warpui::{AppContext, Entity, EntityId, ModelContext, ModelHandle, SingletonEntity};

use self::response_stream::{ResponseStream, ResponseStreamEvent};
use self::response_stream::{PendingResume, RecoveryBudget, ResponseStream, ResponseStreamEvent};
use super::action_model::{BlocklistAIActionEvent, BlocklistAIActionModel};
use super::context_model::{BlocklistAIContextModel, PendingAttachment, PendingFile};
use super::conversation_selection::{ConversationSelectionEvent, ConversationSelectionHandle};
Expand Down Expand Up @@ -849,7 +849,7 @@ impl BlocklistAIController {
entrypoint: entrypoint_type,
is_auto_resume_after_error: false,
}),
/*can_attempt_resume_on_error*/ true,
RecoveryBudget::fresh(),
is_queued_prompt,
ctx,
);
Expand Down Expand Up @@ -1652,7 +1652,7 @@ impl BlocklistAIController {
let result = self.send_request_input(
request_input,
None,
/*can_attempt_resume_on_error*/ true,
RecoveryBudget::fresh(),
/*is_queued_prompt*/ false,
ctx,
);
Expand Down Expand Up @@ -1922,7 +1922,7 @@ impl BlocklistAIController {
ctx,
),
None,
/*can_attempt_resume_on_error*/ true,
RecoveryBudget::fresh(),
/*is_queued_prompt*/ false,
ctx,
)
Expand Down Expand Up @@ -1981,10 +1981,33 @@ impl BlocklistAIController {
}
}

/// Resumes the conversation with a request that is not itself recovering another, so it
/// starts with a full recovery budget. Automatic resumes go through
/// [`Self::resume_conversation_with_recovery_budget`] instead, to inherit the failed
/// request's remaining budget.
pub fn resume_conversation(
&mut self,
conversation_id: AIConversationId,
can_attempt_resume_on_error: bool,
additional_context: Vec<AIAgentContext>,
ctx: &mut ModelContext<Self>,
) {
self.resume_conversation_with_recovery_budget(
conversation_id,
RecoveryBudget::fresh(),
/*is_auto_resume_after_error*/ false,
additional_context,
ctx,
);
}

/// Resumes the conversation with `recovery` as the new request's retry/resume budget.
///
/// An automatic resume passes the failed request's remaining budget so the recovery
/// chain stays bounded; see [`RecoveryBudget`].
fn resume_conversation_with_recovery_budget(
&mut self,
conversation_id: AIConversationId,
recovery: RecoveryBudget,
is_auto_resume_after_error: bool,
additional_context: Vec<AIAgentContext>,
ctx: &mut ModelContext<Self>,
Expand Down Expand Up @@ -2046,24 +2069,35 @@ impl BlocklistAIController {
ctx,
),
metadata,
can_attempt_resume_on_error,
recovery,
/*is_queued_prompt*/ false,
ctx,
);
}

/// Schedules an auto-resume-after-error for the conversation once the network is online
/// and the auto-handoff sleep modal is closed, so the resume doesn't race the user's
/// enable/dismiss decision on wake.
/// Schedules an auto-resume-after-error for the conversation, once the recovery backoff
/// carried by `resume` has elapsed, the network is online, and the auto-handoff sleep
/// modal is closed, so the resume doesn't race the user's enable/dismiss decision on
/// wake.
///
/// `resume` carries the failed request's budget with this resume already charged against
/// it, so the resumed request continues the same bounded chain instead of getting a
/// fresh budget. The backoff matters as much as the extra attempts: without it, a
/// resume fires ~1s after the reset and lands right back in the rolling deploy that
/// caused it.
fn schedule_auto_resume_after_error(
&mut self,
conversation_id: AIConversationId,
resume: PendingResume,
ctx: &mut ModelContext<Self>,
) {
let backoff = resume.backoff();
let recovery = resume.recovery();
let wait_for_online = NetworkStatus::as_ref(ctx).wait_until_online();
let wait_for_modal_closed =
OneTimeModalModel::as_ref(ctx).wait_until_auto_handoff_sleep_modal_closed();
let wait = async move {
Timer::after(backoff).await;
wait_for_online.await;
// Await the modal second: the future reads live modal state at
// poll time, so a modal surfaced on wake (after connectivity
Expand All @@ -2073,11 +2107,9 @@ impl BlocklistAIController {
let handle = ctx.spawn(wait, move |me, _, ctx| {
// Clean up the pending handle now that the resume is executing.
me.pending_auto_resume_handles.remove(&conversation_id);
me.resume_conversation(
me.resume_conversation_with_recovery_budget(
conversation_id,
// Don't allow a second resume-on-error to prevent a persistent loop.
/*can_attempt_resume_on_error*/
false,
recovery,
/*is_auto_resume_after_error*/ true,
vec![],
ctx,
Expand Down Expand Up @@ -2128,7 +2160,7 @@ impl BlocklistAIController {
},
is_auto_resume_after_error: false,
}),
/*can_attempt_resume_on_error*/ true,
RecoveryBudget::fresh(),
/*is_queued_prompt*/ false,
ctx,
)
Expand Down Expand Up @@ -2293,7 +2325,7 @@ impl BlocklistAIController {
},
is_auto_resume_after_error: false,
}),
/*can_attempt_resume_on_error*/ true,
RecoveryBudget::fresh(),
/*is_queued_prompt*/ false,
ctx,
)
Expand Down Expand Up @@ -2362,7 +2394,7 @@ impl BlocklistAIController {
&mut self,
request_input: RequestInput,
query_metadata: Option<RequestMetadata>,
can_attempt_resume_on_error: bool,
recovery: RecoveryBudget,
is_queued_prompt: bool,
ctx: &mut ModelContext<Self>,
) -> anyhow::Result<(AIConversationId, ResponseStreamId)> {
Expand Down Expand Up @@ -2413,7 +2445,11 @@ impl BlocklistAIController {
let is_passive_request = request_input
.all_inputs()
.any(|input| input.is_passive_request());
let can_attempt_resume_on_error = can_attempt_resume_on_error && !is_passive_request;
let recovery = if is_passive_request {
recovery.without_resume()
} else {
recovery
};

// Make sure there's no existing response stream for the conversation. If
// there is, something has gone wrong.
Expand Down Expand Up @@ -2496,12 +2532,7 @@ impl BlocklistAIController {
client_exchange_id: None,
model_id: Some(request_params.model.clone()),
};
ResponseStream::new(
request_params.clone(),
ai_identifiers,
can_attempt_resume_on_error,
ctx,
)
ResponseStream::new(request_params.clone(), ai_identifiers, recovery, ctx)
});
let response_stream_id = response_stream.as_ref(ctx).id().clone();
let response_stream_clone = response_stream.clone();
Expand Down Expand Up @@ -3151,11 +3182,11 @@ impl BlocklistAIController {
}

// Before cleaning up the response stream, check if we should attempt to resume.
if response_stream
.as_ref(ctx)
.should_resume_conversation_after_stream_finished()
{
self.schedule_auto_resume_after_error(conversation_id, ctx);
// The resume inherits the failed request's remaining recovery budget, so
// retries and resumes stay bounded by one shared counter.
let pending_resume = response_stream.as_ref(ctx).pending_resume();
if let Some(resume) = pending_resume {
self.schedule_auto_resume_after_error(conversation_id, resume, ctx);
}

// Clean up the response stream tracking entry now that the stream is complete.
Expand Down
Loading
Loading