Allow restarting managed background servers from the TUI (#53173)

## Why

Startup configuration warnings can reflect settings from when the background server started, even after those settings have been fixed. A restart lets users refresh the server without leaving the TUI.

## What changed

- Add a confirmed restart action to `/daemon`, warning that active or queued work in other chats may be interrupted. Reconnect automatically and let an in-progress restart finish if the TUI exits.
- Verify that Codex manages a local server before enabling restart, including servers discovered with auto-start disabled and those started by `codex agents`.
- Show a restart hint on the first startup configuration warning for managed servers, preserving the original diagnostic for copying and dismissal.
- Repaint the full surface of centered dialogs to restore background colors over cached blank cells.

## Testing

Add CLI integration coverage for agents startup and restarting a discovered daemon with auto-start disabled. Extend TUI tests for confirmation, cancellation, unmanaged servers, warning hints and dismissal, and dialog background rendering.

GitOrigin-RevId: 8e4319fab84f13bf6e7335deb93364fccaff14a7
This commit is contained in:
Eric Traut
2026-10-11 17:43:08 +00:00
committed by copyberry
parent 72f999edc7
commit 27065a91cc
35 changed files with 515 additions and 42 deletions
+2 -1
View File
@@ -2444,11 +2444,12 @@ async fn run_interactive_tui(
cloud_config::load_config(&interactive.config_overrides, LoaderOverrides::default())
.await
.map_err(std::io::Error::other)?;
codex_app_server_daemon::run(AppServerLifecycleCommand::Start)
let output = codex_app_server_daemon::run(AppServerLifecycleCommand::Start)
.await
.map_err(|err| std::io::Error::other(format!(
"{err:#}\nThe agents overview requires a shared server. Use codex --no-daemon to work without it."
)))?;
interactive.managed_daemon = output.backend.is_some();
}
let remote_endpoint = match resolve_remote_endpoint(remote, remote_auth_token_env.clone()) {
+63 -2
View File
@@ -14,6 +14,18 @@ async fn auto_daemon_start_attaches_to_shared_server() -> Result<()> {
daemon_startup("start").await
}
#[tokio::test]
#[cfg(unix)]
async fn agents_can_offer_restart_for_managed_daemon() -> Result<()> {
daemon_startup("agents").await
}
#[tokio::test]
#[cfg(unix)]
async fn discovered_managed_daemon_can_restart_with_auto_start_disabled() -> Result<()> {
daemon_startup("discovered").await
}
#[tokio::test]
#[cfg(unix)]
async fn incompatible_daemon_can_cancel() -> Result<()> {
@@ -159,8 +171,14 @@ async fn daemon_startup(command: &str) -> Result<()> {
suppress_unstable_features_warning = true\nanalytics.enabled = false\n\
windows.sandbox = \"unelevated\"\n\
tui.disable_paste_burst = true\n\
{}\n\
notice.model_migrations.\"gpt-5.6-terra\" = \"gpt-6-sol\"\n\
[projects.{}]\ntrust_level = \"trusted\"\n",
if command == "discovered" {
"tui.status_line=[\"model\",\"thread-id\"]"
} else {
""
},
serde_json::to_string(&workspace_path)?,
),
)?;
@@ -196,7 +214,11 @@ async fn daemon_startup(command: &str) -> Result<()> {
env.insert("TERM".into(), "xterm-256color".into());
let mut args = vec!["--no-alt-screen".to_string()];
let mut steps: VecDeque<(&str, &[u8])> = VecDeque::new();
if matches!(command, "start" | "bedrock-running" | "restrictive-job") || mismatch {
if matches!(
command,
"start" | "agents" | "discovered" | "bedrock-running" | "restrictive-job"
) || mismatch
{
// A selected package with a stopped daemon avoids installing a release.
let managed = home
.path()
@@ -223,7 +245,9 @@ async fn daemon_startup(command: &str) -> Result<()> {
}
let pid_file = home.path().join("app-server-daemon/daemon.pid");
let result = async {
let mut existing_daemon = if command == "bedrock-running" || (mismatch && !disabling) {
let mut existing_daemon = if matches!(command, "bedrock-running" | "discovered")
|| (mismatch && !disabling)
{
let started = Command::new(&codex)
.env_clear()
.envs(&env)
@@ -243,6 +267,19 @@ async fn daemon_startup(command: &str) -> Result<()> {
// The draft header is visible before the session's command composer is ready.
steps.push_back(("GPT-5.6-Terra", b"/status\r"));
"Server:Localbackgroundserver"
} else if command == "agents" {
args.push("agents".into());
steps.push_back(("Notasksyet", b"\x1b"));
steps.push_back(("GPT-5.6-Terra", b"/daemon\r"));
steps.push_back(("Restartbackgroundserver", b"\x1b[F\r"));
"Restartbackgroundserver?"
} else if command == "discovered" {
args.extend(["--disable".into(), "daemon_auto_start".into()]);
steps.push_back(("GPT-5.6-Terra", b"/daemon\r"));
steps.push_back(("Restartbackgroundserver", b"2"));
steps.push_back(("Restartbackgroundserver?", b"2\r"));
steps.push_back(("GPT-5.6-Terra", b"/status\r"));
"Server:Localbackgroundserver"
} else if elevated_launch || command == "restrictive-job" && elevated {
if let Some(action) = command.strip_prefix("elevated-") {
args.push(action.into());
@@ -344,6 +381,27 @@ async fn daemon_startup(command: &str) -> Result<()> {
if let Some((ready, input)) = steps.front()
&& text.contains(ready)
{
if command == "discovered" {
if *ready == "Restartbackgroundserver?" {
existing_daemon = Some(fs::read(&pid_file)?);
} else if *ready == "GPT-5.6-Terra" {
// The header renders before thread/start completes. Wait for the
// live status line to confirm there is a session to reconnect.
if !screen.screen().contents().split_whitespace().any(|part| {
codex_protocol::ThreadId::from_string(part).is_ok()
}) {
continue;
}
// The old header can linger while the daemon stops. Do not send
// /status until the new process is up and reconnect has finished.
if steps.len() == 1
&& (!fs::read(&pid_file).is_ok_and(|pid| Some(pid) != existing_daemon)
|| text.contains("Reconnecting"))
{
continue;
}
}
}
if disabling && *ready == "Backgroundserverhasincompatiblefeaturesettings" {
existing_daemon = Some(fs::read(&pid_file)?);
let settings: serde_json::Value = serde_json::from_slice(&fs::read(home.path().join("app-server-daemon/settings.json"))?)?;
@@ -379,6 +437,9 @@ async fn daemon_startup(command: &str) -> Result<()> {
} else if command == "start" {
ensure!(text.contains("Server:Localbackgroundserver"));
ensure!(home.path().join("app-server-daemon/daemon.pid").exists());
} else if command == "discovered" {
let previous_pid = existing_daemon.as_ref().context("missing original daemon PID")?;
ensure!(fs::read(&pid_file)? != *previous_pid, "daemon PID did not change");
} else if let Some(existing_daemon) = &existing_daemon {
ensure!(fs::read(&pid_file)? == *existing_daemon);
} else if elevated_launch {