mirror of
https://github.com/openai/codex.git
synced 2026-11-03 08:22:30 +00:00
## Why SQLite state was still being opened from consumer paths, including lazy `OnceCell`-backed thread-store call sites. That let one process construct multiple state DB connections for the same Codex home, which makes SQLite lock contention and `database is locked` failures much easier to hit. State DB lifetime should be chosen by main-like entrypoints and tests, then passed through explicitly. Consumers should use the supplied `Option<StateDbHandle>` or `StateDbHandle` and keep their existing filesystem fallback or error behavior when no handle is available. The startup path also needs to keep the rollout crate in charge of SQLite state initialization. Opening `codex_state::StateRuntime` directly bypasses rollout metadata backfill, so entrypoints should initialize through `codex_rollout::state_db` and receive a handle only after required rollout backfills have completed. ## What Changed - Initialize the state DB in main-like entrypoints for CLI, TUI, app-server, exec, MCP server, and the thread-manager sample. - Pass `Option<StateDbHandle>` through `ThreadManager`, `LocalThreadStore`, app-server processors, TUI app wiring, rollout listing/recording, personality migration, shell snapshot cleanup, session-name lookup, and memory/device-key consumers. - Remove the lazy local state DB wrapper from the thread store so non-test consumers use only the supplied handle or their existing fallback path. - Make `codex_rollout::state_db::init` the local state startup path: it opens/migrates SQLite, runs rollout metadata backfill when needed, waits for concurrent backfill workers up to a bounded timeout, verifies completion, and then returns the initialized handle. - Keep optional/non-owning SQLite helpers, such as remote TUI local reads, as open-only paths that do not run startup backfill. - Switch app-server startup from direct `codex_state::StateRuntime::init` to the rollout state initializer so app-server cannot skip rollout backfill. - Collapse split rollout lookup/list APIs so callers use the normal methods with an optional state handle instead of `_with_state_db` variants. - Restore `getConversationSummary(ThreadId)` to delegate through `ThreadStore::read_thread` instead of a LocalThreadStore-specific rollout path special case. - Keep DB-backed rollout path lookup keyed on the DB row and file existence, without imposing the filesystem filename convention on existing DB rows. - Verify readable DB-backed rollout paths against `session_meta.id` before returning them, so a stale SQLite row that points at another thread's JSONL falls back to filesystem search and read-repairs the DB row. - Keep `debug prompt-input` filesystem-only so a one-off debug command does not initialize or backfill SQLite state just to print prompt input. - Keep goal-session test Codex homes alive only in the goal-specific helper, rather than leaking tempdirs from the shared session test helper. - Update tests and call sites to pass explicit state handles where DB behavior is expected and explicit `None` where filesystem-only behavior is intended. ## Validation - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo check -p codex-rollout -p codex-thread-store -p codex-app-server -p codex-core -p codex-tui -p codex-exec -p codex-cli --tests` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout state_db_` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout find_thread_path` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout find_thread_path -- --nocapture` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout try_init_ -- --nocapture` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-rollout` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo clippy -p codex-rollout --lib -- -D warnings` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-thread-store read_thread_falls_back_when_sqlite_path_points_to_another_thread -- --nocapture` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-thread-store` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core shell_snapshot` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core --test all personality_migration` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core --test all rollout_list_find` - `RUST_MIN_STACK=8388608 CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core --test all rollout_list_find::find_prefers_sqlite_path_by_id -- --nocapture` - `RUST_MIN_STACK=8388608 CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core --test all rollout_list_find -- --nocapture` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-core interrupt_accounts_active_goal_before_pausing` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-app-server get_auth_status -- --test-threads=1` - `CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo test -p codex-app-server --lib` - `CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db cargo check -p codex-rollout -p codex-app-server --tests` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db just fix -p codex-rollout -p codex-thread-store -p codex-core -p codex-app-server -p codex-tui -p codex-exec -p codex-cli` - `CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db just fix -p codex-rollout -p codex-app-server` - `CARGO_TARGET_DIR=/tmp/codex-target-state-db just fix -p codex-rollout` - `CODEX_SKIP_VENDORED_BWRAP=1 CARGO_TARGET_DIR=/tmp/codex-target-state-db just fix -p codex-core` - `just argument-comment-lint -p codex-core` - `just argument-comment-lint -p codex-rollout` Focused coverage added in `codex-rollout`: - `recorder::tests::state_db_init_backfills_before_returning` verifies the rollout metadata row exists before startup init returns. - `state_db::tests::try_init_waits_for_concurrent_startup_backfill` verifies startup waits for another worker to finish backfill instead of disabling the handle for the process. - `state_db::tests::try_init_times_out_waiting_for_stuck_startup_backfill` verifies startup does not hang indefinitely on a stuck backfill lease. - `tests::find_thread_path_accepts_existing_state_db_path_without_canonical_filename` verifies DB-backed lookup accepts valid existing rollout paths even when the filename does not include the thread UUID. - `tests::find_thread_path_falls_back_when_db_path_points_to_another_thread` verifies DB-backed lookup ignores a stale row whose existing path belongs to another thread and read-repairs the row after filesystem fallback. Focused coverage updated in `codex-core`: - `rollout_list_find::find_prefers_sqlite_path_by_id` now uses a DB-preferred rollout file with matching `session_meta.id`, so it still verifies that valid SQLite paths win without depending on stale/empty rollout contents. `cargo test -p codex-app-server thread_list_respects_search_term_filter -- --test-threads=1 --nocapture` was attempted locally but timed out waiting for the app-server test harness `initialize` response before reaching the changed thread-list code path. `bazel test //codex-rs/thread-store:thread-store-unit-tests --test_output=errors` was attempted locally after the thread-store fix, but this container failed before target analysis while fetching `v8+` through BuildBuddy/direct GitHub. The equivalent local crate coverage, including `cargo test -p codex-thread-store`, passes. A plain local `cargo check -p codex-rollout -p codex-app-server --tests` also requires system `libcap.pc` for `codex-linux-sandbox`; the follow-up app-server check above used `CODEX_SKIP_VENDORED_BWRAP=1` in this container.
1482 lines
58 KiB
Rust
1482 lines
58 KiB
Rust
//! Thread routing, buffering, and app-server operation submission for the TUI app.
|
|
//!
|
|
//! This module manages active thread channels, routes server requests and notifications into those
|
|
//! channels, submits thread-scoped operations through the app server, and replays buffered events
|
|
//! when the visible thread changes.
|
|
|
|
use super::*;
|
|
use crate::session_resume::read_session_model;
|
|
|
|
impl App {
|
|
pub(super) async fn shutdown_current_thread(&mut self, app_server: &mut AppServerSession) {
|
|
if let Some(thread_id) = self.chat_widget.thread_id() {
|
|
// Clear any in-flight rollback guard when switching threads.
|
|
self.backtrack.pending_rollback = None;
|
|
if let Err(err) = app_server.thread_unsubscribe(thread_id).await {
|
|
tracing::warn!("failed to unsubscribe thread {thread_id}: {err}");
|
|
}
|
|
self.abort_thread_event_listener(thread_id);
|
|
}
|
|
}
|
|
|
|
pub(super) fn abort_thread_event_listener(&mut self, thread_id: ThreadId) {
|
|
if let Some(handle) = self.thread_event_listener_tasks.remove(&thread_id) {
|
|
handle.abort();
|
|
}
|
|
}
|
|
|
|
pub(super) fn abort_all_thread_event_listeners(&mut self) {
|
|
for handle in self
|
|
.thread_event_listener_tasks
|
|
.drain()
|
|
.map(|(_, handle)| handle)
|
|
{
|
|
handle.abort();
|
|
}
|
|
}
|
|
|
|
pub(super) fn ensure_thread_channel(&mut self, thread_id: ThreadId) -> &mut ThreadEventChannel {
|
|
self.thread_event_channels
|
|
.entry(thread_id)
|
|
.or_insert_with(|| ThreadEventChannel::new(THREAD_EVENT_CHANNEL_CAPACITY))
|
|
}
|
|
|
|
pub(super) async fn set_thread_active(&mut self, thread_id: ThreadId, active: bool) {
|
|
if let Some(channel) = self.thread_event_channels.get_mut(&thread_id) {
|
|
let mut store = channel.store.lock().await;
|
|
store.active = active;
|
|
}
|
|
}
|
|
|
|
pub(super) async fn activate_thread_channel(&mut self, thread_id: ThreadId) {
|
|
if self.active_thread_id.is_some() {
|
|
return;
|
|
}
|
|
self.set_thread_active(thread_id, /*active*/ true).await;
|
|
let receiver = if let Some(channel) = self.thread_event_channels.get_mut(&thread_id) {
|
|
channel.receiver.take()
|
|
} else {
|
|
None
|
|
};
|
|
self.active_thread_id = Some(thread_id);
|
|
self.active_thread_rx = receiver;
|
|
self.refresh_pending_thread_approvals().await;
|
|
}
|
|
|
|
pub(super) async fn store_active_thread_receiver(&mut self) {
|
|
let Some(active_id) = self.active_thread_id else {
|
|
return;
|
|
};
|
|
let input_state = self.chat_widget.capture_thread_input_state();
|
|
if let Some(channel) = self.thread_event_channels.get_mut(&active_id) {
|
|
let receiver = self.active_thread_rx.take();
|
|
let mut store = channel.store.lock().await;
|
|
store.active = false;
|
|
store.input_state = input_state;
|
|
if let Some(receiver) = receiver {
|
|
channel.receiver = Some(receiver);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn activate_thread_for_replay(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
) -> Option<(mpsc::Receiver<ThreadBufferedEvent>, ThreadEventSnapshot)> {
|
|
let channel = self.thread_event_channels.get_mut(&thread_id)?;
|
|
let receiver = channel.receiver.take()?;
|
|
let mut store = channel.store.lock().await;
|
|
store.active = true;
|
|
let snapshot = store.snapshot();
|
|
Some((receiver, snapshot))
|
|
}
|
|
|
|
pub(super) async fn clear_active_thread(&mut self) {
|
|
if let Some(active_id) = self.active_thread_id.take() {
|
|
self.set_thread_active(active_id, /*active*/ false).await;
|
|
}
|
|
self.active_thread_rx = None;
|
|
self.refresh_pending_thread_approvals().await;
|
|
}
|
|
|
|
pub(super) async fn note_thread_outbound_op(&mut self, thread_id: ThreadId, op: &AppCommand) {
|
|
let Some(channel) = self.thread_event_channels.get(&thread_id) else {
|
|
return;
|
|
};
|
|
let mut store = channel.store.lock().await;
|
|
store.note_outbound_op(op);
|
|
}
|
|
|
|
pub(super) async fn note_active_thread_outbound_op(&mut self, op: &AppCommand) {
|
|
if !ThreadEventStore::op_can_change_pending_replay_state(op) {
|
|
return;
|
|
}
|
|
let Some(thread_id) = self.active_thread_id else {
|
|
return;
|
|
};
|
|
self.note_thread_outbound_op(thread_id, op).await;
|
|
}
|
|
|
|
pub(super) async fn active_turn_id_for_thread(&self, thread_id: ThreadId) -> Option<String> {
|
|
let channel = self.thread_event_channels.get(&thread_id)?;
|
|
let store = channel.store.lock().await;
|
|
store.active_turn_id().map(ToOwned::to_owned)
|
|
}
|
|
|
|
pub(super) fn thread_label(&self, thread_id: ThreadId) -> String {
|
|
let is_primary = self.primary_thread_id == Some(thread_id);
|
|
let fallback_label = if is_primary {
|
|
"Main [default]".to_string()
|
|
} else {
|
|
let thread_id = thread_id.to_string();
|
|
let short_id: String = thread_id.chars().take(8).collect();
|
|
format!("Agent ({short_id})")
|
|
};
|
|
if let Some(entry) = self.agent_navigation.get(&thread_id) {
|
|
let label = format_agent_picker_item_name(
|
|
entry.agent_nickname.as_deref(),
|
|
entry.agent_role.as_deref(),
|
|
is_primary,
|
|
);
|
|
if label == "Agent" {
|
|
let thread_id = thread_id.to_string();
|
|
let short_id: String = thread_id.chars().take(8).collect();
|
|
format!("{label} ({short_id})")
|
|
} else {
|
|
label
|
|
}
|
|
} else {
|
|
fallback_label
|
|
}
|
|
}
|
|
|
|
/// Returns the thread whose transcript is currently on screen.
|
|
///
|
|
/// `active_thread_id` is the source of truth during steady state, but the widget can briefly
|
|
/// lag behind thread bookkeeping during transitions. The footer label and adjacent-thread
|
|
/// navigation both follow what the user is actually looking at, not whichever thread most
|
|
/// recently began switching.
|
|
pub(super) fn current_displayed_thread_id(&self) -> Option<ThreadId> {
|
|
self.active_thread_id.or(self.chat_widget.thread_id())
|
|
}
|
|
|
|
pub(super) fn ignore_same_thread_resume(
|
|
&mut self,
|
|
target_session: &crate::resume_picker::SessionTarget,
|
|
) -> bool {
|
|
if self.active_thread_id != Some(target_session.thread_id) {
|
|
return false;
|
|
};
|
|
|
|
self.chat_widget.add_info_message(
|
|
format!("Already viewing {}.", target_session.display_label()),
|
|
/*hint*/ None,
|
|
);
|
|
true
|
|
}
|
|
|
|
/// Mirrors the visible thread into the contextual footer row.
|
|
///
|
|
/// The footer sometimes shows ambient context instead of an instructional hint. In multi-agent
|
|
/// sessions, that contextual row includes the currently viewed agent label. The label is
|
|
/// intentionally hidden until there is more than one known thread so single-thread sessions do
|
|
/// not spend footer space restating that the user is already on the main conversation.
|
|
pub(super) fn sync_active_agent_label(&mut self) {
|
|
let label = self
|
|
.agent_navigation
|
|
.active_agent_label(self.current_displayed_thread_id(), self.primary_thread_id);
|
|
self.chat_widget.set_active_agent_label(label);
|
|
self.sync_side_thread_ui();
|
|
}
|
|
|
|
pub(super) async fn thread_cwd(&self, thread_id: ThreadId) -> Option<AbsolutePathBuf> {
|
|
let channel = self.thread_event_channels.get(&thread_id)?;
|
|
let store = channel.store.lock().await;
|
|
store.session.as_ref().map(|session| session.cwd.clone())
|
|
}
|
|
|
|
async fn thread_file_change_changes(
|
|
&self,
|
|
thread_id: ThreadId,
|
|
turn_id: &str,
|
|
item_id: &str,
|
|
) -> Option<Vec<codex_app_server_protocol::FileUpdateChange>> {
|
|
let channel = self.thread_event_channels.get(&thread_id)?;
|
|
let store = channel.store.lock().await;
|
|
store.file_change_changes(turn_id, item_id)
|
|
}
|
|
|
|
pub(super) async fn interactive_request_for_thread_request(
|
|
&self,
|
|
thread_id: ThreadId,
|
|
request: &ServerRequest,
|
|
) -> Option<ThreadInteractiveRequest> {
|
|
let thread_label = Some(self.thread_label(thread_id));
|
|
match request {
|
|
ServerRequest::CommandExecutionRequestApproval { params, .. } => {
|
|
let network_approval_context = params.network_approval_context.clone();
|
|
let additional_permissions = params.additional_permissions.clone();
|
|
let proposed_execpolicy_amendment = params.proposed_execpolicy_amendment.clone();
|
|
let proposed_network_policy_amendments =
|
|
params.proposed_network_policy_amendments.clone();
|
|
Some(ThreadInteractiveRequest::Approval(ApprovalRequest::Exec {
|
|
thread_id,
|
|
thread_label,
|
|
id: params
|
|
.approval_id
|
|
.clone()
|
|
.unwrap_or_else(|| params.item_id.clone()),
|
|
command: params
|
|
.command
|
|
.as_deref()
|
|
.map(split_command_string)
|
|
.unwrap_or_default(),
|
|
reason: params.reason.clone(),
|
|
available_decisions: params.available_decisions.clone().unwrap_or_else(|| {
|
|
default_exec_approval_decisions(
|
|
network_approval_context.as_ref(),
|
|
proposed_execpolicy_amendment.as_ref(),
|
|
proposed_network_policy_amendments.as_deref(),
|
|
additional_permissions.as_ref(),
|
|
)
|
|
}),
|
|
network_approval_context,
|
|
additional_permissions,
|
|
}))
|
|
}
|
|
ServerRequest::FileChangeRequestApproval { params, .. } => Some(
|
|
ThreadInteractiveRequest::Approval(ApprovalRequest::ApplyPatch {
|
|
thread_id,
|
|
thread_label,
|
|
id: params.item_id.clone(),
|
|
reason: params.reason.clone(),
|
|
cwd: self
|
|
.thread_cwd(thread_id)
|
|
.await
|
|
.unwrap_or_else(|| self.config.cwd.clone()),
|
|
changes: self
|
|
.thread_file_change_changes(thread_id, ¶ms.turn_id, ¶ms.item_id)
|
|
.await
|
|
.map(crate::app_server_approval_conversions::file_update_changes_to_display)
|
|
.unwrap_or_default(),
|
|
}),
|
|
),
|
|
ServerRequest::McpServerElicitationRequest { request_id, params } => {
|
|
if let Some(request) = McpServerElicitationFormRequest::from_app_server_request(
|
|
thread_id,
|
|
request_id.clone(),
|
|
params.clone(),
|
|
) {
|
|
Some(ThreadInteractiveRequest::McpServerElicitation(request))
|
|
} else {
|
|
Some(ThreadInteractiveRequest::Approval(
|
|
ApprovalRequest::McpElicitation {
|
|
thread_id,
|
|
thread_label,
|
|
server_name: params.server_name.clone(),
|
|
request_id: request_id.clone(),
|
|
message: match ¶ms.request {
|
|
codex_app_server_protocol::McpServerElicitationRequest::Form {
|
|
message,
|
|
..
|
|
}
|
|
| codex_app_server_protocol::McpServerElicitationRequest::Url {
|
|
message,
|
|
..
|
|
} => message.clone(),
|
|
},
|
|
},
|
|
))
|
|
}
|
|
}
|
|
ServerRequest::PermissionsRequestApproval { params, .. } => Some(
|
|
ThreadInteractiveRequest::Approval(ApprovalRequest::Permissions {
|
|
thread_id,
|
|
thread_label,
|
|
call_id: params.item_id.clone(),
|
|
reason: params.reason.clone(),
|
|
permissions: params.permissions.clone().into(),
|
|
}),
|
|
),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub(super) fn push_thread_interactive_request(&mut self, request: ThreadInteractiveRequest) {
|
|
match request {
|
|
ThreadInteractiveRequest::Approval(request) => {
|
|
self.render_inactive_patch_preview(&request);
|
|
self.chat_widget.push_approval_request(request);
|
|
}
|
|
ThreadInteractiveRequest::McpServerElicitation(request) => {
|
|
self.chat_widget
|
|
.push_mcp_server_elicitation_request(request);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn render_inactive_patch_preview(&mut self, request: &ApprovalRequest) {
|
|
let ApprovalRequest::ApplyPatch {
|
|
thread_label,
|
|
cwd,
|
|
changes,
|
|
..
|
|
} = request
|
|
else {
|
|
return;
|
|
};
|
|
if thread_label.is_none() || changes.is_empty() {
|
|
return;
|
|
}
|
|
self.chat_widget
|
|
.add_to_history(history_cell::new_patch_event(changes.clone(), cwd));
|
|
}
|
|
|
|
pub(super) async fn pending_inactive_thread_requests(&self) -> Vec<(ThreadId, ServerRequest)> {
|
|
let channels: Vec<(ThreadId, Arc<Mutex<ThreadEventStore>>)> = self
|
|
.thread_event_channels
|
|
.iter()
|
|
.map(|(thread_id, channel)| (*thread_id, Arc::clone(&channel.store)))
|
|
.collect();
|
|
|
|
let mut requests = Vec::new();
|
|
for (thread_id, store) in channels {
|
|
if Some(thread_id) == self.active_thread_id {
|
|
continue;
|
|
}
|
|
|
|
let store = store.lock().await;
|
|
requests.extend(
|
|
store
|
|
.pending_replay_requests()
|
|
.into_iter()
|
|
.map(|request| (thread_id, request)),
|
|
);
|
|
}
|
|
requests
|
|
}
|
|
|
|
pub(super) async fn surface_pending_inactive_thread_interactive_requests(&mut self) {
|
|
if self.active_side_parent_thread_id().is_some() {
|
|
return;
|
|
}
|
|
|
|
let requests = self.pending_inactive_thread_requests().await;
|
|
for (thread_id, request) in requests {
|
|
if let Some(request) = self
|
|
.interactive_request_for_thread_request(thread_id, &request)
|
|
.await
|
|
{
|
|
self.push_thread_interactive_request(request);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn submit_active_thread_op(
|
|
&mut self,
|
|
app_server: &mut AppServerSession,
|
|
op: AppCommand,
|
|
) -> Result<()> {
|
|
let Some(thread_id) = self.active_thread_id else {
|
|
self.chat_widget
|
|
.add_error_message("No active thread is available.".to_string());
|
|
return Ok(());
|
|
};
|
|
|
|
self.submit_thread_op(app_server, thread_id, op).await
|
|
}
|
|
|
|
pub(super) async fn submit_thread_op(
|
|
&mut self,
|
|
app_server: &mut AppServerSession,
|
|
thread_id: ThreadId,
|
|
op: AppCommand,
|
|
) -> Result<()> {
|
|
crate::session_log::log_outbound_op(&op);
|
|
|
|
if self.try_handle_local_history_op(thread_id, &op).await? {
|
|
return Ok(());
|
|
}
|
|
|
|
if self
|
|
.try_resolve_app_server_request(app_server, thread_id, &op)
|
|
.await?
|
|
{
|
|
return Ok(());
|
|
}
|
|
|
|
if self
|
|
.try_submit_active_thread_op_via_app_server(app_server, thread_id, &op)
|
|
.await?
|
|
{
|
|
if ThreadEventStore::op_can_change_pending_replay_state(&op) {
|
|
self.note_thread_outbound_op(thread_id, &op).await;
|
|
self.refresh_pending_thread_approvals().await;
|
|
self.refresh_side_parent_status_from_store(thread_id).await;
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
self.chat_widget
|
|
.add_error_message(format!("Not available in TUI yet for thread {thread_id}."));
|
|
Ok(())
|
|
}
|
|
|
|
/// Spawn a background task that fetches MCP server status from the app-server
|
|
/// via paginated RPCs, then delivers the result back through
|
|
/// `AppEvent::McpInventoryLoaded`.
|
|
///
|
|
/// The spawned task is fire-and-forget: no `JoinHandle` is stored, so a stale
|
|
/// result may arrive after the user has moved on. We currently accept that
|
|
/// tradeoff because the effect is limited to stale inventory output in history,
|
|
/// while request-token invalidation would add cross-cutting async state for a
|
|
/// low-severity path.
|
|
pub(super) async fn try_handle_local_history_op(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
op: &AppCommand,
|
|
) -> Result<bool> {
|
|
match op {
|
|
AppCommand::AddToHistory { text } => {
|
|
let text = text.to_string();
|
|
let config = self.chat_widget.config_ref().clone();
|
|
tokio::spawn(async move {
|
|
if let Err(err) = append_message_history_entry(&text, &thread_id, &config).await
|
|
{
|
|
tracing::warn!(
|
|
thread_id = %thread_id,
|
|
error = %err,
|
|
"failed to append to message history"
|
|
);
|
|
}
|
|
});
|
|
Ok(true)
|
|
}
|
|
AppCommand::GetHistoryEntryRequest { offset, log_id } => {
|
|
let config = self.chat_widget.config_ref().clone();
|
|
let app_event_tx = self.app_event_tx.clone();
|
|
let offset = *offset;
|
|
let log_id = *log_id;
|
|
tokio::spawn(async move {
|
|
let entry_opt = tokio::task::spawn_blocking(move || {
|
|
lookup_message_history_entry(log_id, offset, &config)
|
|
})
|
|
.await
|
|
.unwrap_or_else(|err| {
|
|
tracing::warn!(error = %err, "history lookup task failed");
|
|
None
|
|
});
|
|
|
|
app_event_tx.send(AppEvent::ThreadHistoryEntryResponse {
|
|
thread_id,
|
|
event: HistoryLookupResponse {
|
|
offset,
|
|
log_id,
|
|
entry: entry_opt.map(|entry| {
|
|
codex_protocol::message_history::HistoryEntry {
|
|
conversation_id: entry.session_id,
|
|
ts: entry.ts,
|
|
text: entry.text,
|
|
}
|
|
}),
|
|
},
|
|
});
|
|
});
|
|
Ok(true)
|
|
}
|
|
_ => Ok(false),
|
|
}
|
|
}
|
|
|
|
pub(super) async fn try_submit_active_thread_op_via_app_server(
|
|
&mut self,
|
|
app_server: &mut AppServerSession,
|
|
thread_id: ThreadId,
|
|
op: &AppCommand,
|
|
) -> Result<bool> {
|
|
match op {
|
|
AppCommand::Interrupt => {
|
|
if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await {
|
|
app_server.turn_interrupt(thread_id, turn_id).await?;
|
|
} else {
|
|
app_server.startup_interrupt(thread_id).await?;
|
|
}
|
|
Ok(true)
|
|
}
|
|
AppCommand::UserTurn {
|
|
items,
|
|
cwd,
|
|
approval_policy,
|
|
approvals_reviewer,
|
|
permission_profile,
|
|
model,
|
|
effort,
|
|
summary,
|
|
service_tier,
|
|
final_output_json_schema,
|
|
collaboration_mode,
|
|
personality,
|
|
} => {
|
|
let mut should_start_turn = true;
|
|
if let Some(turn_id) = self.active_turn_id_for_thread(thread_id).await {
|
|
let mut steer_turn_id = turn_id;
|
|
let mut retried_after_turn_mismatch = false;
|
|
loop {
|
|
match app_server
|
|
.turn_steer(thread_id, steer_turn_id.clone(), items.to_vec())
|
|
.await
|
|
{
|
|
Ok(_) => return Ok(true),
|
|
Err(error) => {
|
|
if let Some(turn_error) =
|
|
active_turn_not_steerable_turn_error(&error)
|
|
{
|
|
if !self.chat_widget.enqueue_rejected_steer() {
|
|
self.chat_widget.add_error_message(turn_error.message);
|
|
}
|
|
return Ok(true);
|
|
}
|
|
match active_turn_steer_race(&error) {
|
|
Some(ActiveTurnSteerRace::Missing) => {
|
|
if let Some(channel) =
|
|
self.thread_event_channels.get(&thread_id)
|
|
{
|
|
let mut store = channel.store.lock().await;
|
|
store.clear_active_turn_id();
|
|
}
|
|
should_start_turn = true;
|
|
break;
|
|
}
|
|
Some(ActiveTurnSteerRace::ExpectedTurnMismatch {
|
|
actual_turn_id,
|
|
}) if !retried_after_turn_mismatch
|
|
&& actual_turn_id != steer_turn_id =>
|
|
{
|
|
// Review flows can swap the active turn before the TUI
|
|
// processes the corresponding notification. Retry once with
|
|
// the server-reported turn id so non-steerable review turns
|
|
// still fall through to the existing queueing behavior.
|
|
if let Some(channel) =
|
|
self.thread_event_channels.get(&thread_id)
|
|
{
|
|
let mut store = channel.store.lock().await;
|
|
store.active_turn_id = Some(actual_turn_id.clone());
|
|
}
|
|
steer_turn_id = actual_turn_id;
|
|
retried_after_turn_mismatch = true;
|
|
}
|
|
Some(ActiveTurnSteerRace::ExpectedTurnMismatch {
|
|
actual_turn_id,
|
|
}) => {
|
|
if let Some(channel) =
|
|
self.thread_event_channels.get(&thread_id)
|
|
{
|
|
let mut store = channel.store.lock().await;
|
|
store.active_turn_id = Some(actual_turn_id);
|
|
}
|
|
return Err(error.into());
|
|
}
|
|
None => return Err(error.into()),
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if should_start_turn {
|
|
let config = self.chat_widget.config_ref();
|
|
let approvals_reviewer =
|
|
approvals_reviewer.unwrap_or(config.approvals_reviewer);
|
|
let active_permission_profile =
|
|
if config.permissions.permission_profile() == permission_profile.clone() {
|
|
config.permissions.active_permission_profile()
|
|
} else {
|
|
None
|
|
};
|
|
app_server
|
|
.turn_start(
|
|
thread_id,
|
|
items.to_vec(),
|
|
cwd.clone(),
|
|
*approval_policy,
|
|
approvals_reviewer,
|
|
permission_profile.clone(),
|
|
active_permission_profile,
|
|
model.to_string(),
|
|
*effort,
|
|
*summary,
|
|
*service_tier,
|
|
collaboration_mode.clone(),
|
|
*personality,
|
|
final_output_json_schema.clone(),
|
|
)
|
|
.await?;
|
|
}
|
|
Ok(true)
|
|
}
|
|
AppCommand::ListSkills { cwds, force_reload } => {
|
|
self.handle_skills_list_result(
|
|
app_server
|
|
.skills_list(codex_app_server_protocol::SkillsListParams {
|
|
cwds: cwds.clone(),
|
|
force_reload: *force_reload,
|
|
per_cwd_extra_user_roots: None,
|
|
})
|
|
.await,
|
|
"failed to refresh skills",
|
|
);
|
|
Ok(true)
|
|
}
|
|
AppCommand::Compact => {
|
|
app_server.thread_compact_start(thread_id).await?;
|
|
Ok(true)
|
|
}
|
|
AppCommand::SetThreadName { name } => {
|
|
app_server
|
|
.thread_set_name(thread_id, name.to_string())
|
|
.await?;
|
|
Ok(true)
|
|
}
|
|
AppCommand::ThreadRollback { num_turns } => {
|
|
let response = match app_server.thread_rollback(thread_id, *num_turns).await {
|
|
Ok(response) => response,
|
|
Err(err) => {
|
|
self.handle_backtrack_rollback_failed();
|
|
return Err(err);
|
|
}
|
|
};
|
|
self.handle_thread_rollback_response(thread_id, *num_turns, &response)
|
|
.await;
|
|
Ok(true)
|
|
}
|
|
AppCommand::Review { target } => {
|
|
app_server.review_start(thread_id, target.clone()).await?;
|
|
Ok(true)
|
|
}
|
|
AppCommand::CleanBackgroundTerminals => {
|
|
app_server
|
|
.thread_background_terminals_clean(thread_id)
|
|
.await?;
|
|
Ok(true)
|
|
}
|
|
AppCommand::RealtimeConversationStart { transport, voice } => {
|
|
app_server
|
|
.thread_realtime_start(thread_id, transport.clone(), voice.clone())
|
|
.await?;
|
|
Ok(true)
|
|
}
|
|
AppCommand::RealtimeConversationAudio(frame) => {
|
|
app_server
|
|
.thread_realtime_audio(thread_id, frame.clone())
|
|
.await?;
|
|
Ok(true)
|
|
}
|
|
AppCommand::RealtimeConversationClose => {
|
|
app_server.thread_realtime_stop(thread_id).await?;
|
|
Ok(true)
|
|
}
|
|
AppCommand::RunUserShellCommand { command } => {
|
|
app_server
|
|
.thread_shell_command(thread_id, command.to_string())
|
|
.await?;
|
|
Ok(true)
|
|
}
|
|
AppCommand::ReloadUserConfig => {
|
|
app_server.reload_user_config().await?;
|
|
self.refresh_in_memory_config_from_disk().await?;
|
|
Ok(true)
|
|
}
|
|
AppCommand::OverrideTurnContext { .. } => Ok(true),
|
|
AppCommand::ApproveGuardianDeniedAction { event } => {
|
|
app_server
|
|
.thread_approve_guardian_denied_action(thread_id, event)
|
|
.await?;
|
|
Ok(true)
|
|
}
|
|
_ => Ok(false),
|
|
}
|
|
}
|
|
|
|
pub(super) fn handle_skills_list_result(
|
|
&mut self,
|
|
result: Result<SkillsListResponse>,
|
|
failure_message: &str,
|
|
) {
|
|
match result {
|
|
Ok(response) => self.handle_skills_list_response(response),
|
|
Err(err) => {
|
|
tracing::warn!("{failure_message}: {err:#}");
|
|
self.chat_widget
|
|
.add_error_message(format!("{failure_message}: {err:#}"));
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn try_resolve_app_server_request(
|
|
&mut self,
|
|
app_server: &AppServerSession,
|
|
thread_id: ThreadId,
|
|
op: &AppCommand,
|
|
) -> Result<bool> {
|
|
let Some(resolution) = self
|
|
.pending_app_server_requests
|
|
.take_resolution(op)
|
|
.map_err(|err| color_eyre::eyre::eyre!(err))?
|
|
else {
|
|
return Ok(false);
|
|
};
|
|
|
|
match app_server
|
|
.resolve_server_request(resolution.request_id, resolution.result)
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
if ThreadEventStore::op_can_change_pending_replay_state(op) {
|
|
self.note_thread_outbound_op(thread_id, op).await;
|
|
self.refresh_pending_thread_approvals().await;
|
|
self.refresh_side_parent_status_from_store(thread_id).await;
|
|
}
|
|
Ok(true)
|
|
}
|
|
Err(err) => {
|
|
self.chat_widget.add_error_message(format!(
|
|
"Failed to resolve app-server request for thread {thread_id}: {err}"
|
|
));
|
|
Ok(false)
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn refresh_pending_thread_approvals(&mut self) {
|
|
let side_parent_thread_id = self.active_side_parent_thread_id();
|
|
let channels: Vec<(ThreadId, Arc<Mutex<ThreadEventStore>>)> = self
|
|
.thread_event_channels
|
|
.iter()
|
|
.map(|(thread_id, channel)| (*thread_id, Arc::clone(&channel.store)))
|
|
.collect();
|
|
|
|
let mut pending_thread_ids = Vec::new();
|
|
for (thread_id, store) in channels {
|
|
if Some(thread_id) == self.active_thread_id || Some(thread_id) == side_parent_thread_id
|
|
{
|
|
continue;
|
|
}
|
|
|
|
let store = store.lock().await;
|
|
if store.has_pending_thread_approvals() {
|
|
pending_thread_ids.push(thread_id);
|
|
}
|
|
}
|
|
|
|
pending_thread_ids.sort_by_key(ThreadId::to_string);
|
|
|
|
let threads = pending_thread_ids
|
|
.into_iter()
|
|
.map(|thread_id| self.thread_label(thread_id))
|
|
.collect();
|
|
|
|
self.chat_widget.set_pending_thread_approvals(threads);
|
|
}
|
|
|
|
pub(super) async fn refresh_side_parent_status_from_store(&mut self, thread_id: ThreadId) {
|
|
let Some(channel) = self.thread_event_channels.get(&thread_id) else {
|
|
return;
|
|
};
|
|
let status = {
|
|
let store = channel.store.lock().await;
|
|
store.side_parent_pending_status()
|
|
};
|
|
if let Some(status) = status {
|
|
self.set_side_parent_status(thread_id, Some(status));
|
|
} else {
|
|
self.clear_side_parent_action_status(thread_id);
|
|
}
|
|
}
|
|
|
|
pub(super) async fn enqueue_thread_notification(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
notification: ServerNotification,
|
|
) -> Result<()> {
|
|
let inferred_session = self
|
|
.infer_session_for_thread_notification(thread_id, ¬ification)
|
|
.await;
|
|
let (sender, store) = {
|
|
let channel = self.ensure_thread_channel(thread_id);
|
|
(channel.sender.clone(), Arc::clone(&channel.store))
|
|
};
|
|
|
|
let (should_send, pending_status) = {
|
|
let mut guard = store.lock().await;
|
|
if guard.session.is_none()
|
|
&& let Some(session) = inferred_session
|
|
{
|
|
guard.session = Some(session);
|
|
}
|
|
guard.push_notification(notification.clone());
|
|
(guard.active, guard.side_parent_pending_status())
|
|
};
|
|
let notification_status_change = SideParentStatusChange::for_notification(¬ification);
|
|
|
|
if should_send {
|
|
match sender.try_send(ThreadBufferedEvent::Notification(notification)) {
|
|
Ok(()) => {}
|
|
Err(TrySendError::Full(event)) => {
|
|
tokio::spawn(async move {
|
|
if let Err(err) = sender.send(event).await {
|
|
tracing::warn!("thread {thread_id} event channel closed: {err}");
|
|
}
|
|
});
|
|
}
|
|
Err(TrySendError::Closed(_)) => {
|
|
tracing::warn!("thread {thread_id} event channel closed");
|
|
}
|
|
}
|
|
}
|
|
if let Some(status) = pending_status {
|
|
self.set_side_parent_status(thread_id, Some(status));
|
|
} else if let Some(change) = notification_status_change {
|
|
self.apply_side_parent_status_change(thread_id, change);
|
|
}
|
|
self.refresh_pending_thread_approvals().await;
|
|
Ok(())
|
|
}
|
|
|
|
/// Eagerly fetches nickname and role for receiver threads referenced by a collab notification.
|
|
///
|
|
/// This runs on every buffered thread notification before it reaches rendering. For each
|
|
/// receiver thread id that the navigation cache does not yet have metadata for, it issues a
|
|
/// `thread/read` RPC and registers the result in both `AgentNavigationState` and the
|
|
/// `ChatWidget` metadata map. Threads that already have a nickname or role cached are skipped,
|
|
/// so the cost is at most one RPC per thread over the lifetime of a session.
|
|
///
|
|
/// Failures are logged and silently ignored -- the worst outcome is that a rendered item shows
|
|
/// a thread id instead of a human-readable name, which is the same behavior the TUI had before
|
|
/// this change.
|
|
pub(super) async fn hydrate_collab_agent_metadata_for_notification(
|
|
&mut self,
|
|
app_server: &mut AppServerSession,
|
|
notification: &ServerNotification,
|
|
) {
|
|
let Some(receiver_thread_ids) = collab_receiver_thread_ids(notification) else {
|
|
return;
|
|
};
|
|
|
|
for receiver_thread_id in receiver_thread_ids {
|
|
let Ok(thread_id) = ThreadId::from_string(receiver_thread_id) else {
|
|
tracing::warn!(
|
|
thread_id = receiver_thread_id,
|
|
"ignoring collab receiver with invalid thread id during metadata hydration"
|
|
);
|
|
continue;
|
|
};
|
|
|
|
if self
|
|
.agent_navigation
|
|
.get(&thread_id)
|
|
.is_some_and(|entry| entry.agent_nickname.is_some() || entry.agent_role.is_some())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
match app_server
|
|
.thread_read(thread_id, /*include_turns*/ false)
|
|
.await
|
|
{
|
|
Ok(thread) => {
|
|
self.upsert_agent_picker_thread(
|
|
thread_id,
|
|
thread.agent_nickname,
|
|
thread.agent_role,
|
|
/*is_closed*/ false,
|
|
);
|
|
}
|
|
Err(err) => {
|
|
tracing::warn!(
|
|
thread_id = %thread_id,
|
|
error = %err,
|
|
"failed to hydrate collab receiver thread metadata"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) async fn infer_session_for_thread_notification(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
notification: &ServerNotification,
|
|
) -> Option<ThreadSessionState> {
|
|
let ServerNotification::ThreadStarted(notification) = notification else {
|
|
return None;
|
|
};
|
|
let mut session = self.primary_session_configured.clone()?;
|
|
session.thread_id = thread_id;
|
|
session.thread_name = notification.thread.name.clone();
|
|
session.model_provider_id = notification.thread.model_provider.clone();
|
|
session.cwd = notification.thread.cwd.clone();
|
|
let rollout_path = notification.thread.path.clone();
|
|
if let Some(model) =
|
|
read_session_model(self.state_db.as_deref(), thread_id, rollout_path.as_deref()).await
|
|
{
|
|
session.model = model;
|
|
} else if rollout_path.is_some() {
|
|
session.model.clear();
|
|
}
|
|
session.history_log_id = 0;
|
|
session.history_entry_count = 0;
|
|
session.rollout_path = rollout_path;
|
|
self.upsert_agent_picker_thread(
|
|
thread_id,
|
|
notification.thread.agent_nickname.clone(),
|
|
notification.thread.agent_role.clone(),
|
|
/*is_closed*/ false,
|
|
);
|
|
Some(session)
|
|
}
|
|
|
|
pub(super) async fn enqueue_thread_request(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
request: ServerRequest,
|
|
) -> Result<()> {
|
|
let inactive_interactive_request = if self.active_thread_id != Some(thread_id) {
|
|
self.interactive_request_for_thread_request(thread_id, &request)
|
|
.await
|
|
} else {
|
|
None
|
|
};
|
|
let (sender, store) = {
|
|
let channel = self.ensure_thread_channel(thread_id);
|
|
(channel.sender.clone(), Arc::clone(&channel.store))
|
|
};
|
|
|
|
let (should_send, pending_status) = {
|
|
let mut guard = store.lock().await;
|
|
guard.push_request(request.clone());
|
|
(guard.active, guard.side_parent_pending_status())
|
|
};
|
|
let request_status = SideParentStatus::for_request(&request);
|
|
|
|
if should_send {
|
|
match sender.try_send(ThreadBufferedEvent::Request(request)) {
|
|
Ok(()) => {}
|
|
Err(TrySendError::Full(event)) => {
|
|
tokio::spawn(async move {
|
|
if let Err(err) = sender.send(event).await {
|
|
tracing::warn!("thread {thread_id} event channel closed: {err}");
|
|
}
|
|
});
|
|
}
|
|
Err(TrySendError::Closed(_)) => {
|
|
tracing::warn!("thread {thread_id} event channel closed");
|
|
}
|
|
}
|
|
} else if self.active_side_parent_thread_id().is_none()
|
|
&& let Some(request) = inactive_interactive_request
|
|
{
|
|
self.push_thread_interactive_request(request);
|
|
}
|
|
if let Some(status) = pending_status.or(request_status) {
|
|
self.set_side_parent_status(thread_id, Some(status));
|
|
}
|
|
self.refresh_pending_thread_approvals().await;
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) async fn enqueue_thread_history_entry_response(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
event: HistoryLookupResponse,
|
|
) -> Result<()> {
|
|
let (sender, store) = {
|
|
let channel = self.ensure_thread_channel(thread_id);
|
|
(channel.sender.clone(), Arc::clone(&channel.store))
|
|
};
|
|
|
|
let should_send = {
|
|
let mut guard = store.lock().await;
|
|
guard
|
|
.buffer
|
|
.push_back(ThreadBufferedEvent::HistoryEntryResponse(event.clone()));
|
|
if guard.buffer.len() > guard.capacity
|
|
&& let Some(removed) = guard.buffer.pop_front()
|
|
&& let ThreadBufferedEvent::Request(request) = &removed
|
|
{
|
|
guard
|
|
.pending_interactive_replay
|
|
.note_evicted_server_request(request);
|
|
}
|
|
guard.active
|
|
};
|
|
|
|
if should_send {
|
|
match sender.try_send(ThreadBufferedEvent::HistoryEntryResponse(event)) {
|
|
Ok(()) => {}
|
|
Err(TrySendError::Full(event)) => {
|
|
tokio::spawn(async move {
|
|
if let Err(err) = sender.send(event).await {
|
|
tracing::warn!("thread {thread_id} event channel closed: {err}");
|
|
}
|
|
});
|
|
}
|
|
Err(TrySendError::Closed(_)) => {
|
|
tracing::warn!("thread {thread_id} event channel closed");
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) async fn enqueue_primary_thread_session(
|
|
&mut self,
|
|
session: ThreadSessionState,
|
|
turns: Vec<Turn>,
|
|
) -> Result<()> {
|
|
let thread_id = session.thread_id;
|
|
self.primary_thread_id = Some(thread_id);
|
|
self.primary_session_configured = Some(session.clone());
|
|
self.upsert_agent_picker_thread(
|
|
thread_id, /*agent_nickname*/ None, /*agent_role*/ None,
|
|
/*is_closed*/ false,
|
|
);
|
|
let channel = self.ensure_thread_channel(thread_id);
|
|
{
|
|
let mut store = channel.store.lock().await;
|
|
store.set_session(session.clone(), turns.clone());
|
|
}
|
|
self.activate_thread_channel(thread_id).await;
|
|
self.chat_widget
|
|
.set_initial_user_message_submit_suppressed(/*suppressed*/ true);
|
|
self.chat_widget.handle_thread_session(session);
|
|
let should_buffer_initial_replay =
|
|
self.terminal_resize_reflow_enabled() && !turns.is_empty();
|
|
if should_buffer_initial_replay {
|
|
self.app_event_tx
|
|
.send(AppEvent::BeginInitialHistoryReplayBuffer);
|
|
}
|
|
self.chat_widget
|
|
.replay_thread_turns(turns, ReplayKind::ResumeInitialMessages);
|
|
if should_buffer_initial_replay {
|
|
self.app_event_tx
|
|
.send(AppEvent::EndInitialHistoryReplayBuffer);
|
|
}
|
|
let pending = std::mem::take(&mut self.pending_primary_events);
|
|
for pending_event in pending {
|
|
match pending_event {
|
|
ThreadBufferedEvent::Notification(notification) => {
|
|
self.enqueue_thread_notification(thread_id, notification)
|
|
.await?;
|
|
}
|
|
ThreadBufferedEvent::Request(request) => {
|
|
self.enqueue_thread_request(thread_id, request).await?;
|
|
}
|
|
ThreadBufferedEvent::HistoryEntryResponse(event) => {
|
|
self.enqueue_thread_history_entry_response(thread_id, event)
|
|
.await?;
|
|
}
|
|
ThreadBufferedEvent::FeedbackSubmission(event) => {
|
|
self.enqueue_thread_feedback_event(thread_id, event).await;
|
|
}
|
|
}
|
|
}
|
|
self.chat_widget
|
|
.set_initial_user_message_submit_suppressed(/*suppressed*/ false);
|
|
self.chat_widget.submit_initial_user_message_if_pending();
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) async fn enqueue_primary_thread_notification(
|
|
&mut self,
|
|
notification: ServerNotification,
|
|
) -> Result<()> {
|
|
if let Some(thread_id) = self.primary_thread_id {
|
|
return self
|
|
.enqueue_thread_notification(thread_id, notification)
|
|
.await;
|
|
}
|
|
self.pending_primary_events
|
|
.push_back(ThreadBufferedEvent::Notification(notification));
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) async fn enqueue_primary_thread_request(
|
|
&mut self,
|
|
request: ServerRequest,
|
|
) -> Result<()> {
|
|
if let Some(thread_id) = self.primary_thread_id {
|
|
return self.enqueue_thread_request(thread_id, request).await;
|
|
}
|
|
self.pending_primary_events
|
|
.push_back(ThreadBufferedEvent::Request(request));
|
|
Ok(())
|
|
}
|
|
|
|
pub(super) async fn refresh_snapshot_session_if_needed(
|
|
&mut self,
|
|
app_server: &mut AppServerSession,
|
|
thread_id: ThreadId,
|
|
is_replay_only: bool,
|
|
snapshot: &mut ThreadEventSnapshot,
|
|
) {
|
|
if !self.should_refresh_snapshot_session(thread_id, is_replay_only, snapshot) {
|
|
return;
|
|
}
|
|
|
|
match app_server
|
|
.resume_thread(self.config.clone(), thread_id)
|
|
.await
|
|
{
|
|
Ok(started) => {
|
|
self.apply_refreshed_snapshot_thread(thread_id, started, snapshot)
|
|
.await
|
|
}
|
|
Err(err) => {
|
|
tracing::warn!(
|
|
thread_id = %thread_id,
|
|
error = %err,
|
|
"failed to refresh inferred thread session before replay"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
pub(super) fn should_refresh_snapshot_session(
|
|
&self,
|
|
thread_id: ThreadId,
|
|
is_replay_only: bool,
|
|
snapshot: &ThreadEventSnapshot,
|
|
) -> bool {
|
|
!is_replay_only
|
|
&& !self.side_threads.contains_key(&thread_id)
|
|
&& snapshot.session.as_ref().is_none_or(|session| {
|
|
session.model.trim().is_empty() || session.rollout_path.is_none()
|
|
})
|
|
}
|
|
|
|
pub(super) async fn apply_refreshed_snapshot_thread(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
started: AppServerStartedThread,
|
|
snapshot: &mut ThreadEventSnapshot,
|
|
) {
|
|
let AppServerStartedThread { session, turns } = started;
|
|
if let Some(channel) = self.thread_event_channels.get(&thread_id) {
|
|
let mut store = channel.store.lock().await;
|
|
store.set_session(session.clone(), turns.clone());
|
|
store.rebase_buffer_after_session_refresh();
|
|
}
|
|
snapshot.session = Some(session);
|
|
snapshot.turns = turns;
|
|
snapshot
|
|
.events
|
|
.retain(ThreadEventStore::event_survives_session_refresh);
|
|
}
|
|
|
|
/// Opens the `/agent` picker after refreshing cached labels for known threads.
|
|
///
|
|
/// The picker state is derived from long-lived thread channels plus best-effort metadata
|
|
/// refreshes from the backend. Refresh failures are treated as "thread is only inspectable by
|
|
/// historical id now" and converted into closed picker entries instead of deleting them, so
|
|
/// the stable traversal order remains intact for review and keyboard navigation.
|
|
pub(super) async fn drain_active_thread_events(&mut self, tui: &mut tui::Tui) -> Result<()> {
|
|
let Some(mut rx) = self.active_thread_rx.take() else {
|
|
return Ok(());
|
|
};
|
|
|
|
let mut disconnected = false;
|
|
loop {
|
|
match rx.try_recv() {
|
|
Ok(event) => self.handle_thread_event_now(event),
|
|
Err(TryRecvError::Empty) => break,
|
|
Err(TryRecvError::Disconnected) => {
|
|
disconnected = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if !disconnected {
|
|
self.active_thread_rx = Some(rx);
|
|
} else {
|
|
self.clear_active_thread().await;
|
|
}
|
|
|
|
if self.backtrack_render_pending {
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Returns `(closed_thread_id, primary_thread_id)` when a non-primary active
|
|
/// thread has died and we should fail over to the primary thread.
|
|
///
|
|
/// A user-requested shutdown (`ExitMode::ShutdownFirst`) sets
|
|
/// `pending_shutdown_exit_thread_id`; matching shutdown completions are ignored
|
|
/// here so Ctrl+C-like exits don't accidentally resurrect the main thread.
|
|
///
|
|
/// Failover is only eligible when all of these are true:
|
|
/// 1. the event is `thread/closed`;
|
|
/// 2. the active thread differs from the primary thread;
|
|
/// 3. the active thread is not the pending shutdown-exit thread.
|
|
pub(super) fn active_non_primary_shutdown_target(
|
|
&self,
|
|
notification: &ServerNotification,
|
|
) -> Option<(ThreadId, ThreadId)> {
|
|
if !matches!(notification, ServerNotification::ThreadClosed(_)) {
|
|
return None;
|
|
}
|
|
let active_thread_id = self.active_thread_id?;
|
|
let primary_thread_id = self.primary_thread_id?;
|
|
if self.pending_shutdown_exit_thread_id == Some(active_thread_id) {
|
|
return None;
|
|
}
|
|
(active_thread_id != primary_thread_id).then_some((active_thread_id, primary_thread_id))
|
|
}
|
|
|
|
pub(super) fn replay_thread_snapshot(
|
|
&mut self,
|
|
snapshot: ThreadEventSnapshot,
|
|
resume_restored_queue: bool,
|
|
) {
|
|
let should_buffer_replay = self.terminal_resize_reflow_enabled()
|
|
&& (!snapshot.turns.is_empty() || !snapshot.events.is_empty());
|
|
if should_buffer_replay {
|
|
self.app_event_tx
|
|
.send(AppEvent::BeginThreadSwitchHistoryReplayBuffer);
|
|
}
|
|
let suppress_replay_notices =
|
|
replay_filter::snapshot_has_pending_interactive_request(&snapshot);
|
|
if let Some(session) = snapshot.session {
|
|
if self.side_threads.contains_key(&session.thread_id) {
|
|
self.chat_widget.handle_side_thread_session(session);
|
|
} else if suppress_replay_notices {
|
|
self.chat_widget.handle_thread_session_quiet(session);
|
|
} else {
|
|
self.chat_widget.handle_thread_session(session);
|
|
}
|
|
}
|
|
self.chat_widget
|
|
.set_queue_autosend_suppressed(/*suppressed*/ true);
|
|
self.chat_widget
|
|
.restore_thread_input_state(snapshot.input_state);
|
|
if !snapshot.turns.is_empty() {
|
|
self.chat_widget
|
|
.replay_thread_turns(snapshot.turns, ReplayKind::ThreadSnapshot);
|
|
}
|
|
for event in snapshot.events {
|
|
if suppress_replay_notices && replay_filter::event_is_notice(&event) {
|
|
continue;
|
|
}
|
|
self.handle_thread_event_replay(event);
|
|
}
|
|
if should_buffer_replay {
|
|
self.app_event_tx
|
|
.send(AppEvent::EndInitialHistoryReplayBuffer);
|
|
}
|
|
self.chat_widget
|
|
.set_queue_autosend_suppressed(/*suppressed*/ false);
|
|
self.chat_widget
|
|
.set_initial_user_message_submit_suppressed(/*suppressed*/ false);
|
|
self.chat_widget.submit_initial_user_message_if_pending();
|
|
if resume_restored_queue {
|
|
self.chat_widget.maybe_send_next_queued_input();
|
|
}
|
|
self.refresh_status_line();
|
|
}
|
|
|
|
pub(super) fn should_wait_for_initial_session(session_selection: &SessionSelection) -> bool {
|
|
matches!(
|
|
session_selection,
|
|
SessionSelection::StartFresh | SessionSelection::Exit
|
|
)
|
|
}
|
|
|
|
pub(super) fn should_prompt_for_paused_goal_after_startup_resume(
|
|
session_selection: &SessionSelection,
|
|
initial_prompt: &Option<String>,
|
|
initial_images: &[PathBuf],
|
|
) -> bool {
|
|
matches!(session_selection, SessionSelection::Resume(_))
|
|
&& initial_prompt.is_none()
|
|
&& initial_images.is_empty()
|
|
}
|
|
|
|
pub(super) fn should_handle_active_thread_events(
|
|
waiting_for_initial_session_configured: bool,
|
|
has_active_thread_receiver: bool,
|
|
) -> bool {
|
|
has_active_thread_receiver && !waiting_for_initial_session_configured
|
|
}
|
|
|
|
pub(super) fn should_stop_waiting_for_initial_session(
|
|
waiting_for_initial_session_configured: bool,
|
|
primary_thread_id: Option<ThreadId>,
|
|
) -> bool {
|
|
waiting_for_initial_session_configured && primary_thread_id.is_some()
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(super) fn handle_skills_list_response(&mut self, response: SkillsListResponse) {
|
|
let cwd = self.chat_widget.config_ref().cwd.clone();
|
|
let errors = errors_for_cwd(&cwd, &response);
|
|
emit_skill_load_warnings(&self.app_event_tx, &errors);
|
|
self.chat_widget.handle_skills_list_response(response);
|
|
}
|
|
|
|
pub(super) async fn handle_thread_rollback_response(
|
|
&mut self,
|
|
thread_id: ThreadId,
|
|
num_turns: u32,
|
|
response: &ThreadRollbackResponse,
|
|
) {
|
|
if let Some(channel) = self.thread_event_channels.get(&thread_id) {
|
|
let mut store = channel.store.lock().await;
|
|
store.apply_thread_rollback(response);
|
|
}
|
|
if self.active_thread_id == Some(thread_id)
|
|
&& let Some(mut rx) = self.active_thread_rx.take()
|
|
{
|
|
let mut disconnected = false;
|
|
loop {
|
|
match rx.try_recv() {
|
|
Ok(_) => {}
|
|
Err(TryRecvError::Empty) => break,
|
|
Err(TryRecvError::Disconnected) => {
|
|
disconnected = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if !disconnected {
|
|
self.active_thread_rx = Some(rx);
|
|
} else {
|
|
self.clear_active_thread().await;
|
|
}
|
|
}
|
|
self.handle_backtrack_rollback_succeeded(num_turns);
|
|
}
|
|
|
|
pub(super) fn handle_thread_event_now(&mut self, event: ThreadBufferedEvent) {
|
|
let needs_refresh = matches!(
|
|
&event,
|
|
ThreadBufferedEvent::Notification(ServerNotification::TurnStarted(_))
|
|
| ThreadBufferedEvent::Notification(ServerNotification::ThreadTokenUsageUpdated(_))
|
|
);
|
|
match event {
|
|
ThreadBufferedEvent::Notification(notification) => {
|
|
self.chat_widget
|
|
.handle_server_notification(notification, /*replay_kind*/ None);
|
|
}
|
|
ThreadBufferedEvent::Request(request) => {
|
|
if self
|
|
.pending_app_server_requests
|
|
.contains_server_request(&request)
|
|
{
|
|
self.chat_widget
|
|
.handle_server_request(request, /*replay_kind*/ None);
|
|
}
|
|
}
|
|
ThreadBufferedEvent::HistoryEntryResponse(event) => {
|
|
self.chat_widget.handle_history_entry_response(event);
|
|
}
|
|
ThreadBufferedEvent::FeedbackSubmission(event) => {
|
|
self.handle_feedback_thread_event(event);
|
|
}
|
|
}
|
|
if needs_refresh {
|
|
self.refresh_status_line();
|
|
}
|
|
}
|
|
|
|
pub(super) fn handle_thread_event_replay(&mut self, event: ThreadBufferedEvent) {
|
|
match event {
|
|
ThreadBufferedEvent::Notification(notification) => self
|
|
.chat_widget
|
|
.handle_server_notification(notification, Some(ReplayKind::ThreadSnapshot)),
|
|
ThreadBufferedEvent::Request(request) => self
|
|
.chat_widget
|
|
.handle_server_request(request, Some(ReplayKind::ThreadSnapshot)),
|
|
ThreadBufferedEvent::HistoryEntryResponse(event) => {
|
|
self.chat_widget.handle_history_entry_response(event)
|
|
}
|
|
ThreadBufferedEvent::FeedbackSubmission(event) => {
|
|
self.handle_feedback_thread_event(event);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Handles an event emitted by the currently active thread.
|
|
///
|
|
/// This function enforces shutdown intent routing: unexpected non-primary
|
|
/// thread shutdowns fail over to the primary thread, while user-requested
|
|
/// app exits consume only the tracked shutdown completion and then proceed.
|
|
pub(super) async fn handle_active_thread_event(
|
|
&mut self,
|
|
tui: &mut tui::Tui,
|
|
app_server: &mut AppServerSession,
|
|
event: ThreadBufferedEvent,
|
|
) -> Result<()> {
|
|
// Capture this before any potential thread switch: we only want to clear
|
|
// the exit marker when the currently active thread acknowledges shutdown.
|
|
let pending_shutdown_exit_completed = matches!(
|
|
&event,
|
|
ThreadBufferedEvent::Notification(ServerNotification::ThreadClosed(_))
|
|
) && self.pending_shutdown_exit_thread_id
|
|
== self.active_thread_id;
|
|
|
|
// Processing order matters:
|
|
//
|
|
// 1. handle unexpected non-primary shutdown failover first;
|
|
// 2. clear pending exit marker for matching shutdown;
|
|
// 3. forward the event through normal handling.
|
|
//
|
|
// This preserves the mental model that user-requested exits do not trigger
|
|
// failover, while true sub-agent deaths still do.
|
|
if let ThreadBufferedEvent::Notification(notification) = &event
|
|
&& let Some((closed_thread_id, primary_thread_id)) =
|
|
self.active_non_primary_shutdown_target(notification)
|
|
{
|
|
self.mark_agent_picker_thread_closed(closed_thread_id);
|
|
if self.side_threads.contains_key(&closed_thread_id) {
|
|
self.discard_closed_side_thread(closed_thread_id).await;
|
|
self.select_agent_thread(tui, app_server, primary_thread_id)
|
|
.await?;
|
|
} else {
|
|
self.select_agent_thread_and_discard_side(tui, app_server, primary_thread_id)
|
|
.await?;
|
|
}
|
|
if self.active_thread_id == Some(primary_thread_id) {
|
|
self.chat_widget.add_info_message(
|
|
format!(
|
|
"Agent thread {closed_thread_id} closed. Switched back to main thread."
|
|
),
|
|
/*hint*/ None,
|
|
);
|
|
} else {
|
|
self.clear_active_thread().await;
|
|
self.chat_widget.add_error_message(format!(
|
|
"Agent thread {closed_thread_id} closed. Failed to switch back to main thread {primary_thread_id}.",
|
|
));
|
|
}
|
|
return Ok(());
|
|
}
|
|
|
|
if pending_shutdown_exit_completed {
|
|
// Clear only after seeing the shutdown completion for the tracked
|
|
// thread, so unrelated shutdowns cannot consume this marker.
|
|
self.pending_shutdown_exit_thread_id = None;
|
|
}
|
|
if let ThreadBufferedEvent::Notification(notification) = &event {
|
|
self.hydrate_collab_agent_metadata_for_notification(app_server, notification)
|
|
.await;
|
|
}
|
|
|
|
self.handle_thread_event_now(event);
|
|
if self.backtrack_render_pending {
|
|
tui.frame_requester().schedule_frame();
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|