Restore the daemon auto-start feature flag (#59447)

## What changed

Honor `features.daemon_auto_start` as a stable, default-enabled setting instead of ignoring it with a deprecation warning. Apply it to interactive daemon auto-start and remote-control daemon eligibility, and accept it in managed feature requirements.

Preserve explicit `false` values when saving the setting, even when the server reports a disabled default. Record the setting and disabled auto-start outcomes in launch telemetry.

## Testing

Update coverage for restarting a discovered daemon with auto-start disabled and for persisting an explicit opt-out when the server default is disabled. Adjust embedded-backend tests to disable auto-start through configuration.

GitOrigin-RevId: cbf079e3e27b4a7e6e802bf8f19dae653dd7472c
This commit is contained in:
Eric Traut
2026-11-04 16:46:21 +00:00
committed by copyberry
parent 49f2fc2c1e
commit 65c83ea5a2
20 changed files with 99 additions and 111 deletions
+3 -1
View File
@@ -16,6 +16,7 @@ use codex_app_server_protocol::RemoteControlPairingStartResponse;
use codex_arg0::Arg0DispatchPaths;
use codex_config::LoaderOverrides;
use codex_core::config::ConfigBuilder;
use codex_features::Feature;
use codex_protocol::protocol::SessionSource;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cli::CliConfigOverrides;
@@ -160,7 +161,8 @@ async fn daemon_eligible(root_config_overrides: &CliConfigOverrides) -> anyhow::
let Ok(config) = ConfigBuilder::default().build().await else {
return Ok(false);
};
Ok(!codex_tui::uses_wsl_drvfs(&config.codex_home))
Ok(config.features.enabled(Feature::DaemonAutoStart)
&& !codex_tui::uses_wsl_drvfs(&config.codex_home))
}
fn print_remote_control_progress(json: bool, message: &str) -> anyhow::Result<()> {
+4 -5
View File
@@ -40,7 +40,7 @@ async fn agents_checks_trust_when_starting_after_dismissing_overview() -> Result
#[tokio::test]
#[cfg(unix)]
async fn discovered_managed_daemon_can_restart() -> Result<()> {
async fn discovered_managed_daemon_can_restart_with_auto_start_disabled() -> Result<()> {
daemon_startup("discovered").await
}
@@ -194,10 +194,8 @@ async fn daemon_startup(command: &str) -> Result<()> {
{}\n\
notice.model_migrations.\"gpt-5.6-terra\" = \"gpt-6-sol\"\n\
[projects.{}]\ntrust_level = \"{}\"\n",
if command == "start" {
"features.daemon_auto_start=false"
} else if command == "discovered" {
"tui.status_line=[\"model\",\"thread-id\",\"thread-name\"]"
if command == "discovered" {
"tui.status_line=[\"model\",\"thread-id\",\"thread-name\"]\nfeatures.daemon_auto_start=false"
} else if bedrock_onboarding {
"features.gcp_setup_wizard=false"
} else {
@@ -332,6 +330,7 @@ async fn daemon_startup(command: &str) -> Result<()> {
}
"entercontinue·escback"
} else if command == "discovered" {
args.extend(["--disable".into(), "daemon_auto_start".into()]);
// Empty drafts need not survive a daemon restart. Renaming materializes the
// thread; wait for the confirmed footer name, not the echoed command.
steps.push_back(("GPT-5.6-Terra", b"/rename daemon-restart-test\r"));
+10 -3
View File
@@ -192,6 +192,7 @@ cli_auth_credentials_store = "file"
chatgpt_base_url = "{}/source/backend-api"
analytics.enabled = false
check_for_update_on_startup = false
features.daemon_auto_start = {}
features.mcp_oauth_refresh_coordination = true
model_provider = "local"
model = "test-model"
@@ -210,6 +211,7 @@ trust_level = "trusted"
trust_level = "trusted"
"#,
server.uri(),
backend == "daemon",
server.uri(),
serde_json::to_string(&source)?,
serde_json::to_string(&launcher)?
@@ -388,9 +390,6 @@ trust_level = "trusted"
.mount(&server)
.await;
let mut args = worktree_args.clone();
if backend == "embedded" && !analytics {
args.push("--no-daemon".into());
}
if backend == "daemon" && previous.is_empty() {
args.extend(["-c".into(), "features.auth_elicitation=true".into()]);
}
@@ -699,6 +698,14 @@ trust_level = "trusted"
("terminal_rows", "40-79"),
("multiplexer", "none"),
("daemon_selection_reason", "incompatible_option"),
(
"daemon_auto_start",
if backend == "daemon" {
"enabled"
} else {
"disabled"
},
),
("auto_update", "enabled"),
("auto_update_setting", "default"),
("update_interval_setting", "default"),
-1
View File
@@ -12446,7 +12446,6 @@ shell_tool = false
#[test_case::test_case(Feature::Personality; "personality")]
#[test_case::test_case(Feature::GuardianThreadContext; "guardian thread context")]
#[test_case::test_case(Feature::DaemonAutoStart; "daemon auto start")]
#[test_case::test_case(Feature::Worktrees; "worktrees")]
#[test_case::test_case(Feature::RealtimeConversation; "realtime conversation")]
#[test_case::test_case(Feature::ShellZshFork; "shell zsh fork")]
+1 -4
View File
@@ -244,10 +244,7 @@ fn parse_feature_requirements(
if key == Feature::Personality.key() {
continue;
}
if matches!(
key.as_str(),
"daemon_auto_start" | "worktrees" | "realtime_conversation"
) {
if key == Feature::Worktrees.key() || key == Feature::RealtimeConversation.key() {
push_feature_requirement_warning(
&mut startup_warnings,
format!(
+2 -13
View File
@@ -122,7 +122,7 @@ pub enum Feature {
/// Store CLI auth in the encrypted local secrets backend when keyring storage is selected.
SecretAuthStorage,
/// Removed compatibility flag. Eligible interactive launches always start or reuse the daemon.
/// Automatically start the shared local daemon for eligible interactive launches.
DaemonAutoStart,
/// Allow the TUI to discover and select among multiple conversation backends.
TuiMultiBackend,
@@ -738,13 +738,6 @@ impl Features {
);
continue;
}
"daemon_auto_start" => {
self.record_legacy_usage_force(
"features.daemon_auto_start",
Feature::DaemonAutoStart,
);
continue;
}
"transcript_v2" => {
self.record_legacy_usage_force("features.transcript_v2", Feature::TranscriptV2);
continue;
@@ -868,10 +861,6 @@ fn legacy_usage_notice(alias: &str, feature: Feature) -> (String, Option<String>
"`[features.guardianv2].thread_context` is deprecated and ignored.".to_string(),
Some("Thread-owned Guardian context is always enabled. Remove `thread_context` from [features.guardianv2] in config.toml, including profile overrides.".to_string()),
),
Feature::DaemonAutoStart => (
"`[features].daemon_auto_start` is deprecated and ignored.".to_string(),
Some("Remove `daemon_auto_start` from [features] in config.toml or profile configuration files. Use `--no-daemon` to run without the shared background server.".to_string()),
),
Feature::TranscriptV2 => (
"`[features].transcript_v2` is deprecated and ignored.".to_string(),
Some("Use `[tui].fullscreen_transcript` in config.toml instead.".to_string()),
@@ -1136,7 +1125,7 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::DaemonAutoStart,
key: "daemon_auto_start",
stage: Stage::Removed,
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
@@ -282,10 +282,7 @@ impl BottomPaneView for ExperimentalFeaturesView {
let mut count = 0;
for feature in features {
if feature.stage != ExperimentalFeatureStage::Beta
|| matches!(
feature.name.as_str(),
"daemon_auto_start" | "worktrees" | "realtime_conversation"
)
|| matches!(feature.name.as_str(), "worktrees" | "realtime_conversation")
{
continue;
}
@@ -104,7 +104,6 @@ fn experimental_features_discovery_preserves_server_order_and_accepts_new_keys()
.send(Ok(vec![
server_feature("network_proxy"),
server_feature("future_feature"),
server_feature("daemon_auto_start"),
server_feature("worktrees"),
server_feature("realtime_conversation"),
stable,
+2 -1
View File
@@ -109,7 +109,8 @@ pub(crate) async fn write(
let key = format!("features.{}", serde_json::json!(name));
Ok(crate::config_update::replace_config_value(
key,
if *enabled || feature.default_enabled {
// Keep the daemon opt-out explicit even before rollout defaults enable it.
if *enabled || feature.default_enabled || name == "daemon_auto_start" {
serde_json::json!(enabled)
} else {
serde_json::Value::Null
@@ -181,6 +181,7 @@ async fn experimental_feature_writes_use_server_defaults_and_refresh_configured_
"initialize" => json!({"userAgent": "experimental-test"}),
"experimentalFeature/list" => {
json!({"data": [
{"name": "daemon_auto_start", "stage": "beta", "displayName": null, "description": null, "announcement": null, "enabled": !wrote || scenario == "overridden", "defaultEnabled": false},
{"name": "default_off", "stage": "beta", "displayName": null, "description": null, "announcement": null, "enabled": !wrote || scenario == "overridden", "defaultEnabled": false},
{"name": "default_on", "stage": "beta", "displayName": null, "description": null, "announcement": null, "enabled": !wrote, "defaultEnabled": true},
{"name": "new_server_feature", "stage": "beta", "displayName": null, "description": null, "announcement": null, "enabled": wrote, "defaultEnabled": false}
@@ -191,6 +192,7 @@ async fn experimental_feature_writes_use_server_defaults_and_refresh_configured_
request.params,
Some(json!({
"edits": [
{"keyPath": "features.\"daemon_auto_start\"", "value": false, "mergeStrategy": "replace"},
{"keyPath": "features.\"default_off\"", "value": null, "mergeStrategy": "replace"},
{"keyPath": "features.\"default_on\"", "value": false, "mergeStrategy": "replace"},
{"keyPath": "features.\"new_server_feature\"", "value": true, "mergeStrategy": "replace"}
@@ -227,6 +229,7 @@ async fn experimental_feature_writes_use_server_defaults_and_refresh_configured_
handle,
thread_id,
vec![
("daemon_auto_start".to_string(), false),
("default_off".to_string(), false),
("default_on".to_string(), false),
("new_server_feature".to_string(), true),
@@ -1,8 +0,0 @@
---
source: tui/src/history_cell/tests.rs
expression: "render_lines(&cell.transcript_lines(80)).join(\"\\n\")"
---
⚠ `[features].daemon_auto_start` is deprecated and ignored.
Remove `daemon_auto_start` from [features] in config.toml or profile
configuration files. Use `--no-daemon` to run without the shared background
server.
+9 -20
View File
@@ -2973,26 +2973,15 @@ fn deprecation_notice_renders_summary_with_details() {
}
#[test]
fn legacy_feature_migration_notices() {
for (key, snapshot) in [
("shell_zsh_fork", "legacy_zsh_migration_notice_snapshot"),
("daemon_auto_start", "daemon_auto_start_migration_notice"),
] {
let mut features = codex_features::Features::with_defaults();
features.apply_map(&std::collections::BTreeMap::from([(
key.to_string(),
false,
)]));
let usage = features
.legacy_feature_usages()
.next()
.expect("migration notice");
let cell = new_deprecation_notice(usage.summary.clone(), usage.details.clone());
insta::assert_snapshot!(
snapshot,
render_lines(&cell.transcript_lines(/*width*/ 80)).join("\n")
);
}
fn legacy_zsh_migration_notice_snapshot() {
let mut features = codex_features::Features::default();
features.record_legacy_usage_force("shell_zsh_fork", codex_features::Feature::ShellZshFork);
let usage = features
.legacy_feature_usages()
.next()
.expect("migration notice");
let cell = new_deprecation_notice(usage.summary.clone(), usage.details.clone());
insta::assert_snapshot!(render_lines(&cell.transcript_lines(/*width*/ 80)).join("\n"));
}
#[test]
+16 -2
View File
@@ -568,6 +568,7 @@ pub(super) async fn run_main_inner(
None
};
let auto_start_daemon = cfg!(any(unix, windows))
&& (cli.agents_overview || config.features.enabled(Feature::DaemonAutoStart))
&& implicit_connection
&& !cli.no_daemon
&& !app_server_target.uses_remote_workspace();
@@ -732,7 +733,7 @@ pub(super) async fn run_main_inner(
(_, Some(_)) => "incompatible_option",
_ if auto_start_daemon => "auto_start",
(AppServerTarget::LocalDaemon { .. }, _) => "existing_daemon",
(AppServerTarget::Embedded, None) => "unclassified",
(AppServerTarget::Embedded, None) => "auto_start_disabled",
};
let daemon_settings = if metrics.is_some() {
codex_app_server_daemon::telemetry::settings_tags(&config.codex_home)
@@ -743,7 +744,17 @@ pub(super) async fn run_main_inner(
};
let mut launch_tags = daemon_settings.to_vec();
launch_tags.extend(terminal_size_tags);
launch_tags.push(("daemon_selection_reason", selection_reason));
launch_tags.extend([
("daemon_selection_reason", selection_reason),
(
"daemon_auto_start",
if config.features.enabled(Feature::DaemonAutoStart) {
"enabled"
} else {
"disabled"
},
),
]);
let selected_local_daemon = matches!(app_server_target, AppServerTarget::LocalDaemon { .. });
let launch_telemetry = move |app_server_mode| {
let Some(metrics) = metrics else {
@@ -803,6 +814,9 @@ pub(super) async fn run_main_inner(
Some(daemon_startup::WSL_DRVFS_EXCLUSION) => "wsl_drvfs",
Some("this Windows launcher") => "windows_detach_restricted",
Some("daemon feature settings") => "daemon_compatibility",
None if selection_reason == "auto_start_disabled" => {
"auto_start_disabled_no_daemon"
}
None if selected_local_daemon => "daemon_connection_failed",
None if selection_reason == "agents" => "agents_no_daemon_discovered",
Some(_) | None => "unclassified",
@@ -21,43 +21,37 @@ async fn incompatible_daemon_can_run_embedded_for_explicit_features() -> Result<
let socket = codex_app_server_client::app_server_control_socket_path(home.path())?;
std::fs::create_dir_all(socket.parent().unwrap())?;
let listener = UnixListener::bind(socket.as_path())?;
let server_home = home.path().to_path_buf();
let server = tokio::spawn(async move {
// Startup probes the running daemon, then checks its feature settings.
for _ in 0..2 {
let mut socket = loop {
let (stream, _) = listener.accept().await?;
if let Ok(socket) = tokio_tungstenite::accept_async(stream).await {
break socket;
}
let mut socket = loop {
let (stream, _) = listener.accept().await?;
if let Ok(socket) = tokio_tungstenite::accept_async(stream).await {
break socket;
}
};
while let Some(Ok(Message::Text(text))) = socket.next().await {
let JSONRPCMessage::Request(request) = serde_json::from_str(&text)? else {
continue;
};
while let Some(Ok(Message::Text(text))) = socket.next().await {
let JSONRPCMessage::Request(request) = serde_json::from_str(&text)? else {
continue;
};
let response = match request.method.as_str() {
"initialize" => {
json!({"id": request.id, "result": {"userAgent": "daemon-test/0.0.0", "codexHome": server_home, "platformFamily": std::env::consts::FAMILY, "platformOs": std::env::consts::OS}})
}
"config/read" => {
json!({"id": request.id, "result": {"config": {}, "layers": []}})
}
"configRequirements/read" => {
json!({"id": request.id, "result": {"requirements": null}})
}
"experimentalFeature/list" => {
json!({"id": request.id, "result": {"data": [{
let response = match request.method.as_str() {
"initialize" => {
json!({"id": request.id, "result": {"userAgent": "daemon-test/0.0.0"}})
}
"config/read" => {
json!({"id": request.id, "result": {"config": {}, "layers": []}})
}
"configRequirements/read" => {
json!({"id": request.id, "result": {"requirements": null}})
}
"experimentalFeature/list" => json!({"id": request.id, "result": {"data": [{
"name": "api_key_model_discovery", "stage": "stable",
"displayName": null, "description": null, "announcement": null,
"enabled": scenario == "explicit disable", "defaultEnabled": true,
}], "nextCursor": null}})
}
_ => panic!("unexpected request: {}", request.method),
};
socket
.send(Message::Text(response.to_string().into()))
.await?;
}
}], "nextCursor": null}}),
_ => panic!("unexpected request: {}", request.method),
};
socket
.send(Message::Text(response.to_string().into()))
.await?;
}
Ok::<_, anyhow::Error>(())
});
+1 -3
View File
@@ -39,7 +39,6 @@ async fn connected_trust_cancellation_and_acceptance_control_task_creation() ->
let cwd = repo_root.clone();
let methods = Arc::new(Mutex::new(Vec::new()));
let requests = Arc::clone(&methods);
let server_home = codex_home.path().to_path_buf();
let server = tokio::spawn(async move {
let mut clients = tokio::task::JoinSet::new();
let trust_reads = Arc::new(AtomicUsize::new(0));
@@ -49,7 +48,6 @@ async fn connected_trust_cancellation_and_acceptance_control_task_creation() ->
.await
.expect("accept fake app-server client");
let cwd = cwd.clone();
let server_home = server_home.clone();
let methods = Arc::clone(&requests);
let trust_reads = Arc::clone(&trust_reads);
clients.spawn(async move {
@@ -76,7 +74,7 @@ async fn connected_trust_cancellation_and_acceptance_control_task_creation() ->
};
methods.lock().unwrap().push(request.method.clone());
let result = match request.method.as_str() {
"initialize" => json!({"userAgent": "trust-pty/0.0.0", "codexHome": server_home, "platformFamily": std::env::consts::FAMILY, "platformOs": std::env::consts::OS}),
"initialize" => json!({"userAgent": "trust-pty"}),
"experimentalFeature/list" => json!({"data": (["api_key_model_discovery", "code_mode_host", "auth_elicitation"].map(|name| json!({
"name": name, "stage": "stable", "displayName": null,
"description": null, "announcement": null,
+16 -4
View File
@@ -35,8 +35,7 @@ fn focus_gained_with_unanswered_palette_queries_preserves_immediate_input() -> R
let codex_home = tempfile::tempdir()?;
write_test_config(codex_home.path(), &repo_root)?;
let mut terminal =
PtyCodex::start(&repo_root, codex_home, &["--no-daemon", "--no-alt-screen"])?;
let mut terminal = PtyCodex::start(&repo_root, codex_home, &["--no-alt-screen"])?;
terminal.wait_for_startup()?;
let startup_output_len = terminal.output.len();
@@ -166,7 +165,7 @@ fn default_owned_screen_entry_paints_before_sync_ends_and_exit_clears_inline_dra
let repo_root = codex_utils_cargo_bin::repo_root()?;
let codex_home = tempfile::tempdir()?;
write_test_config(codex_home.path(), &repo_root)?;
let mut terminal = PtyCodex::start(&repo_root, codex_home, &["--no-daemon"])?;
let mut terminal = PtyCodex::start(&repo_root, codex_home, &[])?;
terminal.wait_for_startup()?;
let deadline = Instant::now() + STARTUP_TIMEOUT;
while !terminal.parser.screen().alternate_screen() && Instant::now() < deadline {
@@ -281,7 +280,7 @@ fn fullscreen_transcript_can_opt_out_to_terminal_scrollback() -> Result<()> {
let mut terminal = PtyCodex::start(
&repo_root,
codex_home,
&["--no-daemon", "-c", "tui.fullscreen_transcript=false"],
&["-c", "tui.fullscreen_transcript=false"],
)?;
terminal.wait_for_startup()?;
terminal.wait_for_screen("GPT-5.6-Terra")?;
@@ -571,6 +570,7 @@ pub(super) fn write_test_config(codex_home: &Path, repo_root: &Path) -> Result<(
let config = format!(
"model = \"gpt-5.6-terra\"\nmodel_provider = \"openai\"\n\
suppress_unstable_features_warning = true\nanalytics.enabled = false\n\
features.daemon_auto_start = false\n\
notice.model_migrations.\"gpt-5.6-terra\" = \"gpt-6-sol\"\n\n\
[projects.\"{repo_root}\"]\ntrust_level = \"trusted\"\n"
);
@@ -589,6 +589,12 @@ fn no_daemon_skips_startup_and_discovery() -> Result<()> {
let workspace = tempfile::tempdir()?;
let home = tempfile::tempdir()?;
write_test_config(home.path(), workspace.path())?;
let config = home.path().join("config.toml");
let contents = std::fs::read_to_string(&config)?;
std::fs::write(
config,
contents.replace("features.daemon_auto_start = false\n", ""),
)?;
let socket_path = codex_app_server_client::app_server_control_socket_path(home.path())?;
std::fs::create_dir_all(socket_path.as_path().parent().unwrap())?;
let listener = if running {
@@ -631,6 +637,12 @@ fn auto_daemon_start_failure_exits_with_manual_fallback_hint() -> Result<()> {
let workspace = tempfile::tempdir()?;
let home = tempfile::tempdir()?;
write_test_config(home.path(), workspace.path())?;
let config = home.path().join("config.toml");
let contents = std::fs::read_to_string(&config)?;
std::fs::write(
config,
contents.replace("features.daemon_auto_start = false\n", ""),
)?;
// An incomplete selected package must fail without installing a replacement.
std::fs::create_dir_all(home.path().join("packages/app-server-daemon/current"))?;
let mut terminal = PtyCodex::start(
+3 -6
View File
@@ -41,7 +41,6 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
let (submitted_tx, submitted_rx) = tokio::sync::oneshot::channel();
let server_cwd = workspace_path.clone();
let id = "00000000-0000-0000-0000-000000000001";
let server_home = codex_home.path().to_path_buf();
let server = tokio::spawn(async move {
let mut methods = Vec::new();
let mut restore_rx = Some(restore_rx);
@@ -53,8 +52,8 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
"status": {"type": "active", "activeFlags": []}, "cwd": server_cwd,
"cliVersion": "0.0.0", "source": "cli", "turns": [{"id": "running", "items": [], "status": "inProgress", "error": null}]
});
// Startup probes the running daemon and checks compatibility before opening the session.
for connection in -2..7 {
// The first connection checks daemon compatibility before TUI startup.
for connection in -1..7 {
let mut socket = loop {
let (stream, _) = listener.accept().await?;
// Startup probes the default daemon socket before opening its WebSocket.
@@ -106,9 +105,7 @@ async fn automatic_reconnect_restores_draft_and_routes_new_notifications() -> Re
continue;
}
let result = match request.method.as_str() {
"initialize" => {
json!({"userAgent": "reconnect-pty/0.0.0", "codexHome": server_home, "platformFamily": std::env::consts::FAMILY, "platformOs": std::env::consts::OS})
}
"initialize" => json!({"userAgent": "reconnect-pty"}),
// An older daemon can omit the client's default-disabled features.
"experimentalFeature/list" => {
json!({"data": (["api_key_model_discovery", "code_mode_host", "auth_elicitation"].map(|name| json!({
+1 -1
View File
@@ -15,7 +15,7 @@ fn startup_records_screen_reader_detection() -> Result<()> {
let path = home.path().join("config.toml");
let contents = std::fs::read_to_string(&path)?;
std::fs::write(&path, format!("{contents}\n[tui]\nanimations = true\n"))?;
let mut terminal = PtyCodex::start(&repo_root, home, &["--no-daemon"])?;
let mut terminal = PtyCodex::start(&repo_root, home, &[])?;
terminal.wait_for_startup()?;
let deadline = Instant::now() + Duration::from_secs(/*secs*/ 30);
loop {
@@ -17,7 +17,7 @@ fn tui_mode_picker_applies_only_after_restart() -> Result<()> {
),
)?;
for (launch, current) in [false, true, false].into_iter().enumerate() {
let mut terminal = PtyCodex::start(&root, home, &["--no-daemon"])?;
let mut terminal = PtyCodex::start(&root, home, &[])?;
terminal.wait_for_startup()?;
// The header precedes session setup; wait for the thread ID before sending commands.
let deadline = Instant::now() + STARTUP_TIMEOUT;
@@ -99,7 +99,6 @@ async fn picker_side_worktree_fork_and_cd_run_on_the_production_stack() -> Resul
&root,
codex_home,
&[
"--no-daemon",
"--no-alt-screen",
"-c",
"features.worktrees=true",