Open tasks managed elsewhere as read-only history in the command center (#44969)

## Why

The command center previously refused to open tasks managed by another app server, preventing users from viewing their saved history.

## What changed

- Fall back to a frozen, read-only history snapshot when attaching fails because another server owns the task. Preserve drafts and skip buffered request replay and paused-goal prompts while viewing.
- Keep the snapshot when reopening the displayed task, and allow explicit retry to attach once the other server releases it.
- Allow pasting into the command center while a read-only conversation is displayed, while keeping the conversation composer protected.
- Box the TUI startup future to keep it out of the CLI caller's frame.

## Testing

Add coverage for read-only history, draft and selection preservation, retry before and after ownership release, and history-read failure without leaving the current conversation. Assert that opening a read-only task does not start a turn and that the TUI startup future remains below 64 KiB.

GitOrigin-RevId: f1bdb5b2d9182079de4e53346a56742a4a02a1c7
This commit is contained in:
Eric Traut
2026-09-12 04:28:14 +00:00
committed by copyberry
parent 53ff712a48
commit 7efb0262d6
6 changed files with 304 additions and 53 deletions
+39 -18
View File
@@ -1,4 +1,5 @@
//! Daemon-wide overview of recent and locally retained sessions and their subagents.
//! Tasks owned by another app server open as frozen, read-only history snapshots.
#[path = "agents_overview_composer.rs"]
mod composer;
@@ -494,7 +495,7 @@ impl App {
crate::app_server_session::ResumeModelSettings::PreserveExistingThread
}
};
let resumed = match app_server
let (resumed, read_only) = match app_server
.resume_thread(
&local_settings,
resume_config.clone(),
@@ -503,15 +504,23 @@ impl App {
)
.await
{
Ok(resumed) => resumed,
Ok(resumed) => (resumed, false),
Err(error) if crate::app_server_session::is_active_writer_error(&error) => {
match app_server
.read_thread_for_viewing(&resume_config, &local_settings, root_thread_id)
.await
{
Ok(thread) => (thread, true),
Err(error) => {
self.add_agents_overview_error(format!(
"Failed to view task open elsewhere: {error}"
));
return Ok(AppRunControl::Continue);
}
}
}
Err(error) => {
let message = if crate::app_server_session::is_active_writer_error(&error) {
tracing::warn!("failed to attach to task managed by another server");
"Task is open elsewhere. Another app server is managing this task, so it can’t be attached here.".to_string()
} else {
format!("Failed to attach to task: {error}")
};
self.add_agents_overview_error(message);
self.add_agents_overview_error(format!("Failed to attach to task: {error}"));
return Ok(AppRunControl::Continue);
}
};
@@ -577,6 +586,11 @@ impl App {
self.add_agents_overview_error(format!("Failed to attach to task: {error}"));
return Ok(AppRunControl::Continue);
}
if read_only {
self.ensure_thread_channel(root_thread_id)
.mark_external_writer();
self.chat_widget.show_external_writer_thread();
}
let mut destination_config = self.chat_widget.config_ref().clone();
if self.app_server_target.uses_remote_workspace() {
destination_config.cwd.clone_from(&self.config.cwd);
@@ -624,20 +638,25 @@ impl App {
}
if self.current_displayed_thread_id() != Some(root_thread_id)
|| self.thread_unavailable(root_thread_id)
|| (self.thread_unavailable(root_thread_id)
&& !self.chat_widget.is_external_writer_view())
{
self.select_agent_thread_and_discard_side(tui, app_server, root_thread_id)
.await?;
}
self.replay_agents_overview_requests(app_server, root_thread_id)
.await;
let read_only = self.chat_widget.is_external_writer_view();
if !read_only {
self.replay_agents_overview_requests(app_server, root_thread_id)
.await;
}
if self.current_displayed_thread_id() == Some(root_thread_id)
&& let Some(input_state) = self.agents_overview.input_states.remove(&root_thread_id)
{
let preserve_in_flight_turn = self
.active_turn_id_for_thread(root_thread_id)
.await
.is_some();
let preserve_in_flight_turn = !read_only
&& self
.active_turn_id_for_thread(root_thread_id)
.await
.is_some();
self.chat_widget.restore_thread_input_state(
Some(input_state),
ThreadInputStateRestoreMode {
@@ -648,8 +667,10 @@ impl App {
self.chat_widget.maybe_send_next_queued_input();
}
}
self.maybe_prompt_resume_paused_goal_after_resume(app_server, root_thread_id)
.await;
if !read_only {
self.maybe_prompt_resume_paused_goal_after_resume(app_server, root_thread_id)
.await;
}
Ok(AppRunControl::Continue)
}
+143 -25
View File
@@ -2644,31 +2644,82 @@ async fn command_center_handles_resume_failure_and_success() -> Result<()> {
}
#[tokio::test]
async fn command_center_attach_conflict_preserves_selection_and_draft() -> Result<()> {
async fn command_center_attach_conflict_opens_read_only_and_retries() -> Result<()> {
let mut app = Box::pin(make_test_app()).await;
trust_fixture_folders(&mut app);
std::fs::write(
app.config.codex_home.join("config.toml"),
"[tui]\nresume_cwd = \"current\"\n",
)?;
for cwd in [test_path_buf("/"), app.config.cwd.to_path_buf()] {
crate::legacy_core::config::set_project_trust_level(
app.config.codex_home.as_path(),
&cwd,
codex_protocol::config_types::TrustLevel::Trusted,
)
.map_err(std::io::Error::other)?;
}
let thread_id = ThreadId::from_string(
&app_test_support::create_fake_rollout(
app.config.codex_home.as_path(),
"2025-01-05T12-00-00",
"2025-01-05T12:00:00Z",
"Saved task",
Some(&app.config.model_provider_id),
/*git_info*/ None,
)
.expect("saved task"),
)?;
let mut owner = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
let started = Box::pin(owner.start_thread(&app.config)).await?;
let thread_id = started.session.thread_id;
// Materialize the lazy rollout so the second server can discover the locked task.
owner.thread_inject_items(thread_id, vec![serde_json::from_value(serde_json::json!({
"type": "message", "role": "assistant", "content": [{"type": "output_text", "text": "Saved task"}]
}))?]).await?;
Box::pin(owner.resume_thread(
&app.local_settings,
app.config.clone(),
thread_id,
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
))
.await?;
let thread = owner
.thread_read(thread_id, /*include_turns*/ false)
.await?;
let mut server = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
app.app_server_target = AppServerTarget::LocalDaemon {
endpoint: crate::resolve_remote_addr("ws://127.0.0.1:4500")?,
};
app.chat_widget.remote_connection =
crate::status::remote_connection::remote_connection_status_value(
&app.app_server_target,
/*server_version*/ None,
);
app.agents_overview
.threads
.insert(thread_id, Some(thread.clone()));
app.chat_widget.insert_str("Retained task draft");
app.agents_overview.input_states.insert(
thread_id,
app.chat_widget
.capture_thread_input_state()
.expect("task draft"),
);
app.agents_overview.dispatched_requests.insert(
thread_id,
vec![ServerRequest::ToolRequestUserInput {
request_id: RequestId::Integer(42),
params: ToolRequestUserInputParams {
thread_id: thread_id.to_string(),
turn_id: "turn".into(),
item_id: "question".into(),
questions: Vec::new(),
is_blocking: true,
auto_resolution_ms: None,
},
}],
);
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
app.app_event_tx = AppEventSender::new(tx);
let mut view = app.agents_overview_view(vec![thread], Some(thread_id));
view.handle_paste("Keep this draft".into());
view.handle_key_event(KeyCode::Esc.into());
app.chat_widget.show_bottom_pane_view(Box::new(view));
let before = render_bottom_popup(&app.chat_widget, /*width*/ 96);
let draft = overview_draft(&app);
let selection = app
.chat_widget
@@ -2681,34 +2732,101 @@ async fn command_center_attach_conflict_preserves_selection_and_draft() -> Resul
matches!(event, AppEvent::SelectAgentsOverviewThread { thread_id: id } if id == thread_id)
);
Box::pin(app.handle_event(&mut tui, &mut server, event)).await?;
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
assert!(app.chat_widget.is_external_writer_view());
assert_eq!(
app.thread_event_channels[&thread_id].attachment(),
ThreadEventAttachment::ExternalWriter
);
assert_eq!(app.agents_overview.dispatched_requests[&thread_id].len(), 1);
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"Retained task draft"
);
let turns = app.thread_event_channels[&thread_id]
.store
.lock()
.await
.snapshot()
.turns;
assert!(serde_json::to_string(&turns)?.contains("Saved task"));
insta::with_settings!({snapshot_path => "../snapshots"}, {
insta::assert_snapshot!("agents_overview_attach_conflict", render_bottom_popup(&app.chat_widget, /*width*/ 96));
});
app.chat_widget.handle_key_event(KeyCode::Esc.into());
assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 96), before);
app.handle_key_event(&mut tui, &mut server, KeyCode::Esc.into())
.await;
assert_eq!(overview_draft(&app), draft);
assert_eq!(
app.chat_widget
.selected_index_for_present_view(AGENTS_OVERVIEW_VIEW_ID),
selection
);
owner.shutdown().await?;
// Once the owner releases the task, the same keyboard path closes the dashboard.
app.chat_widget.handle_key_event(KeyCode::Right.into());
Box::pin(app.handle_event(&mut tui, &mut server, rx.try_recv()?)).await?;
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
app.chat_widget.handle_paste(" and this paste".into());
assert_eq!(overview_draft(&app).0, "Keep this draft and this paste");
// Opening the displayed task returns to the same frozen snapshot without retrying.
Box::pin(app.handle_event(
&mut tui,
&mut server,
AppEvent::SelectAgentsOverviewThread { thread_id },
))
.await?;
assert!(app.chat_widget.no_modal_or_popup_active());
assert!(app.chat_widget.is_external_writer_view());
app.chat_widget.handle_paste(" should be ignored".into());
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"Retained task draft"
);
assert_eq!(
app.thread_event_channels[&thread_id]
.store
.lock()
.await
.snapshot()
.turns,
turns
);
// Opening the already displayed task also returns to its conversation.
let thread = server
.thread_read(thread_id, /*include_turns*/ false)
.await?;
let view = app.agents_overview_view(vec![thread], Some(thread_id));
app.chat_widget.show_bottom_pane_view(Box::new(view));
while rx.try_recv().is_ok() {}
app.chat_widget.handle_key_event(KeyCode::Right.into());
Box::pin(app.handle_event(&mut tui, &mut server, rx.try_recv()?)).await?;
// An explicit retry remains read-only while the other server owns the task.
Box::pin(app.handle_key_event(&mut tui, &mut server, KeyCode::Char('r').into())).await;
assert!(app.chat_widget.is_external_writer_view());
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"Retained task draft"
);
assert!(
server
.thread_loaded_list(codex_app_server_protocol::ThreadLoadedListParams {
cursor: None,
limit: None,
})
.await?
.data
.is_empty()
);
// Buffered requests stay untouched while viewing; remove the synthetic request before retry.
assert_eq!(
app.agents_overview
.dispatched_requests
.remove(&thread_id)
.unwrap()
.len(),
1
);
owner.shutdown().await?;
Box::pin(app.handle_key_event(&mut tui, &mut server, KeyCode::Char('r').into())).await;
assert_eq!(app.chat_widget.thread_id(), Some(thread_id));
assert!(!app.chat_widget.is_external_writer_view());
assert_eq!(
app.thread_event_channels[&thread_id].attachment(),
ThreadEventAttachment::Live
);
assert_eq!(
app.chat_widget.composer_text_with_pending(),
"Retained task draft"
);
assert!(app.chat_widget.no_modal_or_popup_active());
server.shutdown().await?;
Ok(())
@@ -1,5 +1,6 @@
use super::*;
use crate::app_event::TranscriptExportDestination;
use crate::bottom_pane::BottomPaneView;
use app_test_support::create_fake_paginated_rollout;
use app_test_support::create_fake_parented_rollout_with_source;
use app_test_support::create_fake_rollout;
@@ -149,6 +150,7 @@ pub(super) enum HistoryCapabilities {
LegacyOnlyUnsupportedVariant,
LegacyDynamicToolsAndHistory,
ForkHydrationFails,
ReadAfterResumeFails,
ThreadListFails,
ThreadStartFails,
ConfigReadUnsupported(i64),
@@ -358,6 +360,22 @@ pub(super) async fn start_recording_app_server_with_realtime_speech(
id: request_id,
result: serde_json::json!({}),
})
} else if history_capabilities == HistoryCapabilities::ReadAfterResumeFails
&& request.method == "thread/read"
&& request_sink
.lock()
.expect("request recorder lock")
.iter()
.any(|recorded| recorded.method == "thread/resume")
{
JSONRPCMessage::Error(JSONRPCError {
id: request_id,
error: JSONRPCErrorError {
code: -32603,
data: None,
message: "saved history unavailable".to_string(),
},
})
} else if let HistoryCapabilities::ConfigReadUnsupported(code) =
history_capabilities
&& request.method == "config/read"
@@ -4460,3 +4478,86 @@ async fn external_writer_escape_preserves_snapshot_and_explicit_quits() -> Resul
}
Ok(())
}
#[tokio::test]
async fn command_center_read_only_open_requests_and_failure_preservation() -> Result<()> {
for history_capabilities in [
HistoryCapabilities::Current,
HistoryCapabilities::ReadAfterResumeFails,
] {
let (mut app, _codex_home) = Box::pin(make_history_test_app()).await?;
std::fs::write(
app.config.codex_home.join("config.toml"),
"[tui]\nresume_cwd = \"current\"\n",
)?;
for cwd in [test_path_buf("/"), app.config.cwd.to_path_buf()] {
crate::legacy_core::config::set_project_trust_level(
app.config.codex_home.as_path(),
&cwd,
codex_protocol::config_types::TrustLevel::Trusted,
)
.map_err(std::io::Error::other)?;
}
let thread_id =
create_history_rollout(&app.config, ThreadHistoryMode::Legacy, "Locked task")?;
let mut owner = Box::pin(crate::start_embedded_app_server_for_picker(&app.config)).await?;
Box::pin(owner.resume_thread(
&app.local_settings,
app.config.clone(),
thread_id,
crate::app_server_session::ResumeModelSettings::PreserveExistingThread,
))
.await?;
let (mut server, requests, proxy) = start_recording_app_server_with_history(
&app.config,
history_capabilities,
/*blocked_thread_list*/ None,
/*failed_thread_name*/ None,
crate::app_server_session::ThreadParamsMode::Embedded,
LoaderOverrides::default(),
)
.await?;
app.app_server_target = AppServerTarget::LocalDaemon {
endpoint: crate::resolve_remote_addr("ws://127.0.0.1:4500")?,
};
let current = Box::pin(server.start_thread(&app.config)).await?;
let current_id = current.session.thread_id;
app.enqueue_primary_thread_session(current.session, current.turns)
.await?;
let thread = owner
.thread_read(thread_id, /*include_turns*/ false)
.await?;
let mut view = app.agents_overview_view(vec![thread], Some(thread_id));
view.handle_paste("Keep this draft".into());
app.chat_widget.show_bottom_pane_view(Box::new(view));
let before = render_bottom_popup(&app.chat_widget, /*width*/ 96);
requests.lock().unwrap().clear();
let mut tui = crate::tui::test_support::make_test_tui()?;
Box::pin(app.select_agents_overview_thread(&mut tui, &mut server, thread_id)).await?;
assert_eq!(recorded_params(&requests, "thread/resume").len(), 1);
assert!(recorded_params(&requests, "turn/start").is_empty());
if history_capabilities == HistoryCapabilities::ReadAfterResumeFails {
let error = render_bottom_popup(&app.chat_widget, /*width*/ 96);
assert!(
error.contains("Failed to view task open elsewhere"),
"{error}"
);
assert_eq!(app.current_displayed_thread_id(), Some(current_id));
assert!(recorded_params(&requests, "thread/unsubscribe").is_empty());
app.chat_widget.handle_key_event(KeyCode::Esc.into());
assert_eq!(render_bottom_popup(&app.chat_widget, /*width*/ 96), before);
} else {
assert_eq!(app.current_displayed_thread_id(), Some(thread_id));
assert!(app.chat_widget.is_external_writer_view());
assert_eq!(
app.thread_event_channels[&thread_id].attachment(),
ThreadEventAttachment::ExternalWriter
);
}
owner.shutdown().await?;
server.shutdown().await?;
proxy.await??;
}
Ok(())
}
+1 -1
View File
@@ -516,7 +516,7 @@ impl ChatWidget {
}
pub(crate) fn handle_paste(&mut self, text: String) {
if self.external_writer_view {
if self.external_writer_view && !self.bottom_pane.has_active_view() {
return;
}
self.bottom_pane.handle_paste(text);
+17 -2
View File
@@ -1014,12 +1014,13 @@ pub async fn run_main(
explicit_remote_endpoint: Option<RemoteAppServerEndpoint>,
) -> std::io::Result<AppExitInfo> {
system_motion::initialize().await;
match startup_orchestration::run_main_inner(
// Keep the startup future out of the CLI caller's frame while the TUI is running.
match Box::pin(startup_orchestration::run_main_inner(
cli,
arg0_paths,
loader_overrides,
explicit_remote_endpoint,
)
))
.await
{
Err(err) if startup_draft::StartupCancelled::matches(&err) => Ok(AppExitInfo {
@@ -2194,6 +2195,7 @@ pub(crate) mod tests {
use super::*;
use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::ConfigOverrides;
use clap::Parser;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::RequestId;
@@ -2205,6 +2207,19 @@ pub(crate) mod tests {
use serial_test::serial;
use tempfile::TempDir;
#[test]
fn tui_startup_future_stays_bounded() {
let future = run_main(
Cli::parse_from(["codex"]),
Arg0DispatchPaths::default(),
LoaderOverrides::default(),
/*explicit_remote_endpoint*/ None,
);
let size = std::mem::size_of_val(&future);
assert!(size < 64 * 1024, "TUI startup future is {size} bytes");
}
async fn build_config(temp_dir: &TempDir) -> std::io::Result<Config> {
ConfigBuilder::default()
.codex_home(temp_dir.path().to_path_buf())
@@ -1,12 +1,8 @@
---
source: tui/src/app/agents_overview_tests.rs
assertion_line: 2231
expression: "render_bottom_popup(&app.chat_widget, 96)"
---
Unable to complete action
Task is open elsewhere. Another app server is managing this task, so it can’t be attached
here.
🔒 This conversation is open in another app R to Retry
Close it there and press R to continue here.
› 1. Return to command center
Press enter to confirm or esc to go back
r retry esc command center ctrl+c/q exit ctrl+t transcript