Fix CLI subagent task routing, Ctrl+C cancellation, and session restore reliability

- Construct ServerTask directly for Bedrock CLI subagents so messages route
  correctly without needing a server CreateTask upgrade
- Handle CliAgentUserQuery input type in both request translators, including
  running command context and terminal output
- Add force_cancel_all_streaming_exchanges fallback for when Ctrl+C finds no
  in-flight streams (stuck subagent / unexpected stream end)
- Cancel active conversation on Ctrl+C in agent view compose state
- Skip agent view entry when agent is tagged-in for a running command
- Set root_task_id on Bedrock requests for proper optimistic task upgrade
- Persist app state on will_terminate to avoid losing sessions
- Trust persisted CWD without is_dir() recheck (fixes network mount restore)
- Log warning instead of silently dropping tabs with unreadable root nodes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ryan Ward
2026-06-25 08:43:10 -05:00
co-authored by Claude Opus 4.6
parent 148c97eab1
commit 57c843d1de
11 changed files with 338 additions and 19 deletions
+55 -1
View File
@@ -2090,6 +2090,59 @@ impl AIConversation {
Ok(())
}
/// Force-cancels all streaming exchanges in this conversation.
///
/// This is used as a fallback when Ctrl+C is pressed but there are no in-flight
/// response streams to cancel (e.g. the stream ended without proper cleanup, or a
/// subagent got stuck). It walks all exchanges and transitions any that are still
/// in `Streaming` state to `Cancelled`, emitting the appropriate UI update events.
pub fn force_cancel_all_streaming_exchanges(
&mut self,
terminal_view_id: EntityId,
reason: CancellationReason,
ctx: &mut ModelContext<BlocklistAIHistoryModel>,
) {
if self.transaction.is_some() {
self.commit_transaction();
}
let streaming_exchange_ids: Vec<_> = self
.task_store
.all_exchanges()
.filter(|exchange| matches!(exchange.output_status, AIAgentOutputStatus::Streaming { .. }))
.map(|exchange| exchange.id)
.collect();
for exchange_id in streaming_exchange_ids {
if let Ok(exchange) = self.get_exchange_to_update(exchange_id) {
let output = match &exchange.output_status {
AIAgentOutputStatus::Streaming { output } => {
output.as_ref().map(Shared::get_owned)
}
_ => continue,
};
exchange.output_status = AIAgentOutputStatus::Finished {
finished_output: FinishedAIAgentOutput::Cancelled { output, reason },
};
exchange.finish_time = Some(Local::now());
let is_hidden = self.is_exchange_hidden(exchange_id);
ctx.emit(BlocklistAIHistoryEvent::UpdatedStreamingExchange {
exchange_id,
terminal_view_id,
conversation_id: self.id,
is_hidden,
});
}
}
self.write_updated_conversation_state(ctx);
if !reason.is_follow_up_for_same_conversation() {
self.update_status(ConversationStatus::Cancelled, terminal_view_id, ctx);
}
}
pub fn mark_request_cancelled_due_to_revert(
&mut self,
terminal_view_id: EntityId,
@@ -3002,7 +3055,8 @@ impl AIConversation {
);
}
let new_task = Task::new_optimistic_cli_agent_subtask(block_id.clone());
let parent_task_id = Some(self.task_store.root_task_id().to_string());
let new_task = Task::new_optimistic_cli_agent_subtask(block_id.clone(), parent_task_id);
let new_task_id = new_task.id().clone();
self.optimistic_cli_subagent_subtask_id = Some(new_task_id.clone());
self.task_store.insert(new_task);
+36 -5
View File
@@ -138,6 +138,7 @@ mod optimistic {
#[derive(Debug, Clone)]
pub(super) enum Task {
Root,
#[allow(dead_code)] // Used in the server-mode path; Bedrock direct creates Server tasks directly
CLIAgent(CLIAgentSubtask),
}
@@ -184,12 +185,42 @@ impl Task {
}
}
pub(super) fn new_optimistic_cli_agent_subtask(block_id: BlockId) -> Self {
pub(super) fn new_optimistic_cli_agent_subtask(block_id: BlockId, parent_task_id: Option<String>) -> Self {
let task_id = Uuid::new_v4().to_string();
Self {
id: TaskId::new(Uuid::new_v4().to_string()),
data: TaskImpl::Optimistic(optimistic::Task::CLIAgent(optimistic::CLIAgentSubtask {
block_id,
})),
id: TaskId::new(task_id.clone()),
// Use a Server task with a source and CLI subagent_params so that:
// 1. add_messages can immediately append response messages without
// needing a CreateTask upgrade from the server (Bedrock direct path
// has no server to emit CreateTask).
// 2. cli_subagent_block_id() returns the correct block ID so the
// CLISubagentController can set up the monitoring view.
// 3. is_cli_subagent() returns true so the task is filtered from the
// main blocklist (responses only show in the CLI subagent panel).
data: TaskImpl::Server(ServerTask {
source: api::Task {
id: task_id.clone(),
description: String::new(),
dependencies: parent_task_id.map(|parent_id| api::task::Dependencies {
parent_task_id: parent_id,
}),
messages: vec![],
summary: String::new(),
server_data: String::new(),
},
subagent_params: Some(SubagentParams {
tool_call_id: String::new(),
call: api::message::tool_call::Subagent {
task_id: task_id,
payload: String::new(),
metadata: Some(Metadata::Cli(
api::message::tool_call::subagent::CliSubagent {
command_id: block_id.as_str().to_owned(),
},
)),
},
}),
}),
exchanges: vec![],
}
}
+2 -2
View File
@@ -44,7 +44,7 @@ fn create_test_task_with_exchanges(exchange_count: usize) -> Task {
fn create_test_subtask_with_exchanges(exchange_count: usize) -> Task {
use crate::terminal::model::block::BlockId;
let mut task = Task::new_optimistic_cli_agent_subtask(BlockId::new());
let mut task = Task::new_optimistic_cli_agent_subtask(BlockId::new(), None);
for _ in 0..exchange_count {
task.append_exchange(create_test_exchange());
}
@@ -486,7 +486,7 @@ fn test_linearization_nested_subtasks() {
// Create child subtask with a call to grandchild
use crate::terminal::model::block::BlockId;
let mut child_subtask = Task::new_optimistic_cli_agent_subtask(BlockId::new());
let mut child_subtask = Task::new_optimistic_cli_agent_subtask(BlockId::new(), None);
let child_id = child_subtask.id().clone();
let child_exchange1 = create_test_exchange();