From f1bc19fd7f4846e09c15eb85b0ca98898d376383 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Wed, 21 Oct 2026 10:52:38 -0300 Subject: [PATCH 01/14] wip(2899): preserve retained P04 source runtime draft --- src-tauri/src/commands/session.rs | 408 ++++++++++++++++++++++++-- src-tauri/src/config/agent_command.rs | 3 + src-tauri/src/config/settings.rs | 50 ++++ src-tauri/src/lib.rs | 55 ++++ src-tauri/src/session/selection.rs | 2 + 5 files changed, 493 insertions(+), 25 deletions(-) diff --git a/src-tauri/src/commands/session.rs b/src-tauri/src/commands/session.rs index 2941428a2..dbfb95325 100644 --- a/src-tauri/src/commands/session.rs +++ b/src-tauri/src/commands/session.rs @@ -2244,7 +2244,14 @@ async fn create_session_inner_impl( let agent_kind = CodingAgentKind::detect(&shell, &shell_args); let is_agent_owned_launch = agent_id.is_some() || agent_kind.is_some() || is_root_agent; - let coding_agent_binding = match coding_agent_binding.clone() { + let source_seed = resolved_spawn.as_ref().and_then(|spawn| spawn.source_seed.as_ref()); + let coding_agent_binding = if let Some(seed) = source_seed { + let mut binding = coding_agent_binding.clone().unwrap_or_else(|| crate::session::manager::CodingAgentBinding { + id: Uuid::new_v4().to_string(), seed: None, origin: None, + }); + binding.seed = Some(serde_json::to_value(seed).map_err(|error| error.to_string())?); + Some(binding) + } else { match coding_agent_binding.clone() { Some(binding) => Some(binding), None => match agent_id.as_ref() { Some(original_agent_id) => { @@ -2280,6 +2287,7 @@ async fn create_session_inner_impl( None => None, }, }; + }; let resource_monitor = app.state::>().inner().clone(); let mut resource_permit = if is_agent_owned_launch { let cfg = app @@ -3517,6 +3525,211 @@ fn resolve_launch_auto_self_clear( crate::config::settings::resolve_auto_self_clear(settings, &agent_name, class_default_on) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SourceRestoreMode { + Wake, + Defer, +} + +pub(crate) enum SourceRestoreDecision { + Legacy, + SourceWake { + scoped_settings: AppSettings, + entry_hint: String, + effective_profile: String, + updated_seed: crate::config::sessions_persistence::CodingAgentBindingSeed, + binding: crate::session::manager::CodingAgentBinding, + }, + SourceDeferred { + updated_seed: crate::config::sessions_persistence::CodingAgentBindingSeed, + binding: crate::session::manager::CodingAgentBinding, + }, + Refuse { diagnostic: String }, +} + +/// No IO: the caller captures the authorized file and actual-target policy. +/// `target_profile` is that read-only policy observation, never a global cell. +#[allow(clippy::too_many_arguments)] +pub(crate) fn prepare_source_restore_decision( + seed: Option<&crate::config::sessions_persistence::CodingAgentBindingSeed>, + binding: Result, String>, + _legacy_agent: Option<&str>, + target_profile: Option<&str>, + cwd: &str, + mode: SourceRestoreMode, + host: &AppSettings, + snapshot: Result, +) -> SourceRestoreDecision { + use crate::config::settings::{self, SourceRevision}; + let prepared: Result = (|| { + let binding = binding?; + let Some(seed) = seed else { return Ok(SourceRestoreDecision::Legacy) }; + let Some(reference) = seed.configuration_ref.as_ref() else { + return if seed.identity_revision.starts_with("s1:") { + Err("invalidSourceSeed".into()) + } else { + Ok(SourceRestoreDecision::Legacy) + }; + }; + let pair = crate::config::coding_agent_profiles::ReplicaSelectionPair { + coding_agent_id: seed.original_agent_id.clone(), + requested_profile: reference.requested_profile.clone().unwrap_or_else(|| "A".into()), + }; + crate::config::coding_agent_profiles::validate_configuration_ref_pair(reference, &pair)?; + let suffix = format!(":{}", reference.calculated_id); + let digest = seed.identity_revision.strip_prefix("s1:") + .and_then(|value| value.strip_suffix(&suffix)).ok_or("invalidSourceSeed")?; + if digest.len() != 64 || !digest.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) { + return Err("invalidSourceSeed".into()); + } + let snapshot = snapshot?; + let scoped_settings = settings::resolve_runtime_source_row(host, &snapshot, reference)?; + let entry_hint = seed.original_agent_id.clone(); + // No launch_path: the pure seam cannot reread aliases or disk defaults. + let name = crate::config::coding_agent_profiles::agent_name_from_dir(std::path::Path::new(cwd)); + let resolution = crate::config::coding_agent_profiles::resolve_profile( + &scoped_settings, + crate::config::coding_agent_profiles::ProfileResolutionRequest { + coding_agent_id: &entry_hint, + launch_path: None, + agent_matrix_name: name.as_deref(), + requested_profile: reference.requested_profile.as_deref().or(target_profile), + requested_profile_authoritative: true, + }, + ); + let agent = &scoped_settings.agents[0]; + let mut reference = reference.clone(); + reference.profile_calculated_id = Some(settings::pair_identity(&agent.command, &agent.envs, &resolution.cell)); + let SourceRevision::Bytes { sha256 } = snapshot.revision else { return Err("sourceUnavailable".into()) }; + let updated_seed = crate::config::sessions_persistence::CodingAgentBindingSeed { + identity_revision: format!("s1:{sha256}:{}", reference.calculated_id), + original_agent_id: entry_hint.clone(), + configuration_ref: Some(reference), + }; + let mut binding = binding.ok_or("sourceBindingMissing")?; + binding.seed = Some(serde_json::to_value(&updated_seed).map_err(|e| e.to_string())?); + Ok(match mode { + SourceRestoreMode::Wake => SourceRestoreDecision::SourceWake { + scoped_settings, entry_hint, effective_profile: resolution.effective_profile, + updated_seed, binding, + }, + SourceRestoreMode::Defer => SourceRestoreDecision::SourceDeferred { updated_seed, binding }, + }) + })(); + prepared.unwrap_or_else(|diagnostic| SourceRestoreDecision::Refuse { diagnostic }) +} + +/// Read-only final resolution; authoritative letter plus full pair witness make +/// a second alias/default decision impossible before host preparation. +pub(crate) fn resolve_source_wake_spawn( + scoped: &AppSettings, + hint: &str, + effective: &str, + cwd: &str, + seed: &crate::config::sessions_persistence::CodingAgentBindingSeed, +) -> Result { + let mut spawn = crate::config::agent_command::resolve_agent_spawn_command( + scoped, hint, Some(std::path::Path::new(cwd)), Some(effective), true, + )?; + let agent = scoped.agents.iter().find(|agent| agent.id == hint).ok_or("sourceRowMissing")?; + let reference = seed.configuration_ref.as_ref().ok_or("sourceReferenceMissing")?; + let pair = crate::config::settings::pair_identity(&agent.command, &agent.envs, &spawn.profile_resolution.cell); + if spawn.profile_resolution.effective_profile != effective + || reference.profile_calculated_id.as_deref() != Some(pair.as_str()) + { + return Err("sourcePairChangedBeforeSpawn".into()); + } + spawn.source_seed = Some(seed.clone()); + Ok(spawn) +} + +fn runtime_replica_expectation( + cwd: &str, + required: bool, +) -> Result, String> { + let path = std::path::Path::new(cwd); + let replica = path.file_name().and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("__agent_")); + if !replica { + return if required { Err("storedSelectionRequiresExistingReplica".into()) } else { Ok(None) }; + } + crate::config::coding_agent_profiles::read_replica_selection_state(path).expectation().map(Some) +} + +/// Capture target policy before the pure seam. Only coherent source-null +/// `source-default` aliases are suppressed; historical/manual aliases stay pins. +pub(crate) fn source_target_profile( + scoped: &AppSettings, + reference: &crate::config::settings::ConfigurationRef, + cwd: &str, +) -> Result, String> { + if let Some(letter) = &reference.requested_profile { return Ok(Some(letter.clone())); } + let path = std::path::Path::new(cwd); + let expected = runtime_replica_expectation(cwd, false)?; + if let Some(expected) = expected { + if expected.profile_override_source.as_deref() != Some("source-default") { + if let Some(pair) = expected.pair { return Ok(Some(pair.requested_profile)); } + } else { + // Reuse the marker-aware strict reader and retain its disagreement diagnostic. + let read = crate::config::coding_agent_profiles::read_source_null_profile_result(path)?; + if let Some(warning) = read.warning { log::warn!("[source-selection] {warning}"); } + if let Some(letter) = read.profile { return Ok(Some(letter)); } + } + let validated = crate::config::coding_agent_profiles::validate_profile_selection_agent_path(scoped, path)?; + if let Some(config) = crate::config::agent_config::read_agent_local_config_json(&validated.origin_matrix_dir)? { + if let Some(letter) = config.pointer("/tooling/defaultProfile").and_then(serde_json::Value::as_str) + .and_then(crate::config::settings::normalize_profile_letter) { + return Ok(Some(letter)); + } + } + } + let name = crate::config::coding_agent_profiles::agent_name_from_dir(path); + Ok(name.and_then(|name| scoped.coding_agent_profiles.default_profile_by_agent.get(&name).cloned())) +} + +#[allow(clippy::too_many_arguments)] +fn resolve_runtime_source_spawn( + settings: &AppSettings, + settings_path: &std::path::Path, + cwd: &str, + reference: &crate::config::settings::ConfigurationRef, + ticket: Option<&crate::config::settings::SourceSelectionTicket>, + binding: Option, +) -> Result<(AgentSpawnCommand, crate::session::manager::CodingAgentBinding), String> { + let snapshot = crate::config::settings::capture_runtime_source(settings, settings_path, &reference.source)?; + if let Some(ticket) = ticket { + if ticket.configuration_ref != *reference + || ticket.expected_source_revision != snapshot.revision + || ticket.catalog_context_revision != snapshot.context_revision { + return Err("staleSourceSelectionTicket".into()); + } + } + let scoped = crate::config::settings::resolve_runtime_source_row(settings, &snapshot, reference)?; + let target_profile = source_target_profile(&scoped, reference, cwd)?; + let crate::config::settings::SourceRevision::Bytes { sha256 } = &snapshot.revision else { return Err("sourceUnavailable".into()) }; + let seed = crate::config::sessions_persistence::CodingAgentBindingSeed { + identity_revision: format!("s1:{sha256}:{}", reference.calculated_id), + original_agent_id: reference.legacy_entry_hint.clone().ok_or("missingSourceHint")?, + configuration_ref: Some(reference.clone()), + }; + let binding = binding.unwrap_or_else(|| crate::session::manager::CodingAgentBinding { + id: Uuid::new_v4().to_string(), seed: None, origin: None, + }); + match prepare_source_restore_decision(Some(&seed), Ok(Some(binding)), None, target_profile.as_deref(), cwd, + SourceRestoreMode::Wake, settings, Ok(snapshot)) { + SourceRestoreDecision::SourceWake { scoped_settings, entry_hint, effective_profile, updated_seed, binding } => { + if ticket.is_some() && reference.requested_profile.is_some() + && reference.profile_calculated_id != updated_seed.configuration_ref.as_ref().and_then(|r| r.profile_calculated_id.clone()) { + return Err("staleSourceSelectionPair".into()); + } + let spawn = resolve_source_wake_spawn(&scoped_settings, &entry_hint, &effective_profile, cwd, &updated_seed)?; + Ok((spawn, binding)) + } + SourceRestoreDecision::Refuse { diagnostic } => Err(diagnostic), + _ => Err("sourceSelectionRequired".into()), + } +} + pub(crate) fn resolve_configured_agent_spawn_for_cwd( settings: &AppSettings, agent_id: &str, @@ -3666,6 +3879,32 @@ pub async fn create_session( // or any caller that has no tile to measure, simply omits it and gets AC's 120x30. cols: Option, rows: Option, +) -> Result { + create_session_with_source(app, session_mgr, pty_mgr, _tg_mgr, settings, shell, + shell_args, cwd, session_name, agent_id, requested_profile, None, git_repos, + skip_auto_resume, cols, rows).await +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn create_session_with_source( + app: AppHandle, + session_mgr: State<'_, Arc>>, + pty_mgr: State<'_, Arc>>, + _tg_mgr: State<'_, TelegramBridgeState>, + settings: State<'_, SettingsState>, + shell: Option, + shell_args: Option>, + cwd: Option, + session_name: Option, + agent_id: Option, + requested_profile: Option, + source_ticket: Option, + git_repos: Option>, + skip_auto_resume: Option, + // #973 - the terminal size the view has already fitted to. Optional: an older frontend, + // or any caller that has no tile to measure, simply omits it and gets AC's 120x30. + cols: Option, + rows: Option, ) -> Result { let cfg = settings .current_read() @@ -3674,7 +3913,20 @@ pub async fn create_session( let cwd = resolve_create_session_cwd(cwd); - let resolved_spawn = if let Some(aid) = agent_id.as_deref() { + let replica = runtime_replica_expectation(&cwd, false)?; + let reference = source_ticket.as_ref().map(|ticket| ticket.configuration_ref.clone()) + .or_else(|| replica.as_ref().and_then(|saved| saved.configuration_ref.clone())); + let resolved_spawn = if let Some(reference) = reference.as_ref() { + if source_ticket.is_none() + && (agent_id.as_ref().is_some_and(|id| Some(id) != reference.legacy_entry_hint.as_ref()) + || requested_profile.as_ref().is_some_and(|letter| Some(letter) != reference.requested_profile.as_ref())) { + return Err("sourceSelectionProvenanceMismatch".into()); + } + let (spawn, _) = resolve_runtime_source_spawn(&cfg, settings.settings_path(), &cwd, + reference, source_ticket.as_ref(), None)?; + crate::config::agent_command::prepare_agent_spawn_command(&spawn)?; + Some(spawn) + } else if let Some(aid) = agent_id.as_deref() { build_configured_agent_spawn_for_cwd(&cfg, aid, &cwd, requested_profile.as_deref())? } else { None @@ -4692,6 +4944,8 @@ pub(crate) fn resolve_restart_selected_agent_id( #[derive(Debug, Clone)] pub(crate) struct RestartJobRequest { + pub source_ticket: Option, + pub use_stored_selection: bool, pub session_id: Uuid, pub agent_id: Option, pub requested_profile: Option, @@ -4797,10 +5051,34 @@ pub(crate) async fn restart_session_inner_with_intent( intent: TrustedRestartIntent, communication_override: Option, enforcement: crate::config::sessions_persistence::CreationGateEnforcement, +) -> Result { + restart_session_inner_with_source(app, _session_mgr, _pty_mgr, _settings, uuid, + agent_id, requested_profile, None, false, skip_auto_resume, activate_after, + intent, communication_override, enforcement).await +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn restart_session_inner_with_source( + app: &AppHandle, + _session_mgr: &Arc>, + _pty_mgr: &Arc>, + _settings: &SettingsState, + uuid: Uuid, + agent_id: Option, + requested_profile: Option, + source_ticket: Option, + use_stored_selection: bool, + skip_auto_resume: Option, + activate_after: bool, + intent: TrustedRestartIntent, + communication_override: Option, + enforcement: crate::config::sessions_persistence::CreationGateEnforcement, ) -> Result { let restarted = app .state::() .restart_lifecycle(RestartJobRequest { + source_ticket, + use_stored_selection, session_id: uuid, agent_id, requested_profile, @@ -5027,6 +5305,8 @@ pub(crate) async fn execute_restart_transaction( let pty_mgr = app.state::>>().inner().clone(); let settings = app.state::().inner().clone(); let RestartJobRequest { + source_ticket, + use_stored_selection, session_id: uuid, agent_id, requested_profile, @@ -5036,6 +5316,9 @@ pub(crate) async fn execute_restart_transaction( communication_override, enforcement, } = request; + if use_stored_selection && (agent_id.is_some() || requested_profile.is_some() || source_ticket.is_some()) { + return Err("storedSelectionMixedArguments".into()); + } // 1. Read config from existing session BEFORE destroying it let ( shell, @@ -5075,29 +5358,49 @@ pub(crate) async fn execute_restart_transaction( ) }; - let cwd = if is_root_agent { - crate::config::root_agent::ensure_root_agent_dir()? - } else { - cwd - }; + if use_stored_selection && is_root_agent { return Err("rootStoredSelectionUnsupported".into()); } + let binding_manager = session_mgr.read().await.clone(); + let original_binding = binding_manager.coding_agent_binding(uuid).await; + let old_seed = original_binding.as_ref().and_then(|binding| binding.seed.as_ref()) + .map(|seed| serde_json::from_value::(seed.clone())) + .transpose().map_err(|error| error.to_string())?; + if old_seed.as_ref().is_some_and(|seed| seed.identity_revision.starts_with("s1:") && seed.configuration_ref.is_none()) { + return Err("invalidSourceSeed".into()); + } + // root_agent_dir is a read-only locator. Provision only after resolution. + let cwd = if is_root_agent { crate::config::root_agent::root_agent_dir()? } else { cwd }; let cwd = crate::path_utils::normalize_windows_verbatim_path(&cwd); - crate::config::archive_gate::probe_spawn_refusal(app, &cwd).await?; - // §1295 5.1b / B2: the creation gate runs AFTER `probe_spawn_refusal` but - // WELL BEFORE `teardown_old_for_restart` (below). A rejected restart of a - // live outside-roots session therefore DECLINES: the old session stays - // running (live PTY untouched), `Err(sessionCreateBlocked: ...)` propagates, - // and no `finalize_failed_restart` runs. Root-agent restarts already - // normalized `cwd` to `ensure_root_agent_dir()` above, which the gate - // exempts by rule 1. - crate::config::sessions_persistence::enforce_creation_gate(app, &cwd, enforcement).await?; + // 2. Strip auto-injected args before restart so the new session starts from the saved recipe. let clean_args = crate::config::sessions_persistence::strip_auto_injected_args(&shell, &shell_args); - let requested_agent_id = agent_id; + let replica = runtime_replica_expectation(&cwd, use_stored_selection)?; + let mut selected_legacy_agent = agent_id; + let mut selected_legacy_profile = requested_profile; + let reference = if let Some(ticket) = source_ticket.as_ref() { + Some(ticket.configuration_ref.clone()) + } else if use_stored_selection { + let saved = replica.as_ref().ok_or("storedSelectionUnavailable")?; + let pair = saved.pair.as_ref().ok_or("storedSelectionUnavailable")?; + selected_legacy_agent = Some(pair.coding_agent_id.clone()); + selected_legacy_profile = Some(pair.requested_profile.clone()); + saved.configuration_ref.clone() + } else { + old_seed.as_ref().and_then(|seed| seed.configuration_ref.clone()) + .or_else(|| replica.as_ref().and_then(|saved| saved.configuration_ref.clone())) + }; + if let Some(reference) = reference.as_ref() { + if source_ticket.is_none() && !use_stored_selection + && (selected_legacy_agent.as_ref().is_some_and(|id| Some(id) != reference.legacy_entry_hint.as_ref()) + || selected_legacy_profile.as_ref().is_some_and(|letter| Some(letter) != reference.requested_profile.as_ref())) { + return Err("sourceSelectionProvenanceMismatch".into()); + } + } + let requested_agent_id = selected_legacy_agent; let selected_requested_profile = - effective_restart_requested_profile(requested_profile, stored_requested_profile); + effective_restart_requested_profile(selected_legacy_profile, stored_requested_profile); // #537 read-side / #2434: resolve the launch agent through the one matched // selection drift also reads, and resolve its spawn under a single settings // read guard. No await is held across the guard; it is dropped at the end of @@ -5108,6 +5411,12 @@ pub(crate) async fn execute_restart_transaction( .current_read() .await .map_err(|error| error.to_string())?; + if let Some(reference) = reference.as_ref() { + let (spawn, _) = resolve_runtime_source_spawn(&cfg, settings.settings_path(), &cwd, + reference, source_ticket.as_ref(), original_binding.clone())?; + let host = Some(ResolvedAgentHostShell { program: cfg.default_shell.clone(), args: cfg.default_shell_args.clone() }); + (Some(spawn.trusted_agent_id.clone()), Some(spawn), host) + } else { let selection = matched_selection( &cfg, &cwd, @@ -5162,7 +5471,18 @@ pub(crate) async fn execute_restart_transaction( None }; (selected_agent_id, resolved_spawn, resolved_agent_host_shell) + } }; + crate::config::archive_gate::probe_spawn_refusal(app, &cwd).await?; + // §1295 5.1b / B2: the creation gate runs AFTER `probe_spawn_refusal` but + // WELL BEFORE `teardown_old_for_restart` (below). A rejected restart of a + // live outside-roots session therefore DECLINES: the old session stays + // running (live PTY untouched), `Err(sessionCreateBlocked: ...)` propagates, + // and no `finalize_failed_restart` runs. Root-agent restarts already + // normalized `cwd` to `ensure_root_agent_dir()` above, which the gate + // exempts by rule 1. + crate::config::sessions_persistence::enforce_creation_gate(app, &cwd, enforcement).await?; + if is_root_agent { crate::config::root_agent::ensure_root_agent_dir()?; } // #2434 - prepare (isolated CODEX_HOME, OpenCode config dir) outside the // settings guard and BEFORE any teardown: a failure aborts the restart and // leaves the old session running. @@ -5219,8 +5539,7 @@ pub(crate) async fn execute_restart_transaction( restart_reader_seam::hit_after_old_release(&uuid.to_string()).await; // 4. Spawn the replacement as a pending, unannounced row. - let binding_manager = session_mgr.read().await.clone(); - let coding_agent_binding = binding_manager.coding_agent_binding(uuid).await; + let coding_agent_binding = original_binding; let completion = create_session_inner_impl( app, &session_mgr, @@ -5822,6 +6141,7 @@ pub async fn get_active_session( #[derive(Debug, Clone)] pub(crate) struct RootJobRequest { + pub source_ticket: Option, pub requested_agent_id: Option, pub requested_profile: Option, pub skip_auto_resume_for_new_session: bool, @@ -5868,8 +6188,30 @@ pub(crate) async fn create_root_agent_inner_with_intent( intent: TrustedCreateIntent, select_after: bool, ) -> Result { + create_root_agent_inner_with_source(app, _session_mgr, _pty_mgr, _tg_mgr, _settings, + requested_agent_id, requested_profile, None, false, skip_auto_resume_for_new_session, + intent, select_after).await +} + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn create_root_agent_inner_with_source( + app: &AppHandle, + _session_mgr: &Arc>, + _pty_mgr: &Arc>, + _tg_mgr: &TelegramBridgeState, + _settings: &SettingsState, + requested_agent_id: Option, + requested_profile: Option, + source_ticket: Option, + use_stored_selection: bool, + skip_auto_resume_for_new_session: bool, + intent: TrustedCreateIntent, + select_after: bool, +) -> Result { + if use_stored_selection { return Err("rootStoredSelectionUnsupported".into()); } app.state::() .root_lifecycle(RootJobRequest { + source_ticket, requested_agent_id, requested_profile, skip_auto_resume_for_new_session, @@ -5890,7 +6232,15 @@ pub(crate) async fn execute_root_transaction( .clone(); let pty_mgr = app.state::>>().inner().clone(); let settings = app.state::().inner().clone(); - let root_agent_path = crate::config::root_agent::ensure_root_agent_dir()?; + let root_agent_path = crate::config::root_agent::root_agent_dir()?; + // Validate ticket and resolve read-only before provisioning, publication or teardown. + let source_spawn = if let Some(ticket) = request.source_ticket.as_ref() { + let cfg = settings.current_read().await.map_err(|error| error.to_string())?; + let (spawn, _) = resolve_runtime_source_spawn(&cfg, settings.settings_path(), &root_agent_path, + &ticket.configuration_ref, Some(ticket), None)?; + let host = ResolvedAgentHostShell { program: cfg.default_shell.clone(), args: cfg.default_shell_args.clone() }; + Some((spawn, host)) + } else { None }; let existing = session_mgr .read() .await @@ -5914,7 +6264,7 @@ pub(crate) async fn execute_root_transaction( .unwrap_or_else(|error| error.into_inner()) .has_session(session_id); match classify_existing_root(&existing.status, has_pty) { - ExistingRootAction::ReuseLive => { + ExistingRootAction::ReuseLive if request.source_ticket.is_none() => { if request.select_after { let decision = transaction .live_decision(session_id) @@ -5941,7 +6291,7 @@ pub(crate) async fn execute_root_transaction( .map(|session| SessionInfo::from(&session)) .ok_or_else(|| "Root session disappeared during reuse".to_string()); } - ExistingRootAction::WakeDormant | ExistingRootAction::DiscardMissingPty => { + ExistingRootAction::ReuseLive | ExistingRootAction::WakeDormant | ExistingRootAction::DiscardMissingPty => { let wake_dormant = matches!( classify_existing_root(&existing.status, has_pty), ExistingRootAction::WakeDormant @@ -5953,6 +6303,8 @@ pub(crate) async fn execute_root_transaction( return execute_restart_transaction( transaction, RestartJobRequest { + source_ticket: request.source_ticket, + use_stored_selection: false, session_id, agent_id: request.requested_agent_id, requested_profile: request.requested_profile, @@ -5973,7 +6325,9 @@ pub(crate) async fn execute_root_transaction( } let last_coding_agent = crate::config::root_agent::read_last_coding_agent(&root_agent_path); - let (shell, shell_args, agent_id, agent_label) = { + let (shell, shell_args, agent_id, agent_label) = if let Some((spawn, _)) = source_spawn.as_ref() { + (spawn.shell.clone(), spawn.shell_args.clone(), Some(spawn.trusted_agent_id.clone()), Some(spawn.trusted_agent_label.clone())) + } else { let settings = settings .current_read() .await @@ -5984,7 +6338,9 @@ pub(crate) async fn execute_root_transaction( last_coding_agent.as_deref(), )? }; - let (resolved_spawn, resolved_agent_host_shell) = if let Some(agent_id) = agent_id.as_deref() { + let (resolved_spawn, resolved_agent_host_shell) = if let Some((spawn, host)) = source_spawn { + (Some(spawn), Some(host)) + } else if let Some(agent_id) = agent_id.as_deref() { // #1271 - build the spawn and copy the configured default host shell // from the SAME single guard, before any await, so the pair can never // mix across a configuration change (Phase 1 items 1-2; mirrors the @@ -6016,6 +6372,8 @@ pub(crate) async fn execute_root_transaction( } else { (shell, shell_args, agent_label) }; + crate::config::root_agent::ensure_root_agent_dir()?; + if let Some(spawn) = resolved_spawn.as_ref() { crate::config::agent_command::prepare_agent_spawn_command(spawn)?; } let info = create_session_inner_impl( app, &session_mgr, diff --git a/src-tauri/src/config/agent_command.rs b/src-tauri/src/config/agent_command.rs index 0ca9792bf..d5031e6ac 100644 --- a/src-tauri/src/config/agent_command.rs +++ b/src-tauri/src/config/agent_command.rs @@ -63,6 +63,7 @@ pub struct NormalizedAgentCommand { #[derive(Debug, Clone)] pub struct AgentSpawnCommand { + pub(crate) source_seed: Option, pub shell: String, pub shell_args: Vec, pub agent_env: BTreeMap, @@ -1386,6 +1387,7 @@ pub(crate) fn resolve_agent_spawn_command( }; Ok(AgentSpawnCommand { + source_seed: None, shell, shell_args, agent_env, @@ -1604,6 +1606,7 @@ mod tests { env_remove_keys: Vec, ) -> AgentSpawnCommand { AgentSpawnCommand { + source_seed: None, shell: "codex".to_string(), shell_args: Vec::new(), agent_env: BTreeMap::new(), diff --git a/src-tauri/src/config/settings.rs b/src-tauri/src/config/settings.rs index d1bd7423a..0b8afda39 100644 --- a/src-tauri/src/config/settings.rs +++ b/src-tauri/src/config/settings.rs @@ -41964,6 +41964,56 @@ pub(crate) fn agent_file_projection( }) } +/// Read only an authorized current locator. A persisted reference is never a +/// path grant, and revisions are deliberately not frozen across restoration. +pub(crate) fn capture_runtime_source( + settings: &AppSettings, + settings_path: &Path, + source: &SourceRef, +) -> Result { + let context = SourceReadContext::from_settings(settings, settings_path) + .map_err(|_| "sourceContextUnavailable")?; + read_agent_file_snapshot(&context, source) +} + +/// Pure physical-row matching. Keep host/resource policy but replace every +/// agent/profile projection; a global same-ID or same-BASE row is never a donor. +pub(crate) fn resolve_runtime_source_row( + host: &AppSettings, + snapshot: &SelectedAgentFileSnapshot, + reference: &ConfigurationRef, +) -> Result { + if reference.version != 1 || snapshot.source != reference.source { + return Err("invalidSourceReference".into()); + } + let hint = reference.legacy_entry_hint.as_deref().ok_or("missingSourceHint")?; + let mut rows = snapshot.rows.iter().filter(|row| row.entry_hint == hint); + let row = rows.next().ok_or("sourceRowMissing")?; + if rows.next().is_some() || !row.launchable { + return Err("sourceRowMissingOrAmbiguous".into()); + } + let projection = agent_file_projection(snapshot)?; + let mut agents = projection.agents.into_iter().filter(|agent| agent.id == hint); + let agent = agents.next().ok_or("sourceRowIncomplete")?; + if agents.next().is_some() + || configuration_identity(&agent.command, &agent.envs) != reference.calculated_id + { + return Err("sourceBaseChanged".into()); + } + let mut scoped = host.clone(); + scoped.agents = vec![agent]; + scoped.agents_layer = AppSettings::default().agents_layer; + scoped.coding_agent_profiles = projection.coding_agent_profiles; + scoped.coding_agent_profiles.profiles_by_agent.retain(|id, _| id == hint); + scoped.coding_agent_profiles.profile_labels_by_agent.retain(|id, _| id == hint); + scoped.coding_agent_profiles.identity_by_agent.retain(|id, _| id == hint); + scoped.coding_agent_profiles.codex_home_key_by_agent.retain(|id, _| id == hint); + // Matrix-name policy is not an agent definition or a profile-cell donor. + scoped.coding_agent_profiles.default_profile_by_agent = + host.coding_agent_profiles.default_profile_by_agent.clone(); + Ok(scoped) +} + pub(crate) fn read_agent_file_snapshot( context: &SourceReadContext, source: &SourceRef, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d5a839cd7..72be96e2f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2337,6 +2337,60 @@ fn load_startup_sessions( } #[allow(clippy::too_many_arguments)] +/// Production restore seam shared by root, ordinary and deferred branches. +fn prepare_persisted_restore( + persisted: &config::sessions_persistence::PersistedSession, + mode: commands::session::SourceRestoreMode, + settings: &config::settings::AppSettings, + snapshot: Result, + target_profile: Option<&str>, +) -> commands::session::SourceRestoreDecision { + commands::session::prepare_source_restore_decision( + persisted.coding_agent_binding_seed.as_ref(), persisted.binding_metadata(), + persisted.agent_id.as_deref(), target_profile, &persisted.working_directory, + mode, settings, snapshot, + ) +} + +fn resolve_current_persisted_restore( + persisted: &mut config::sessions_persistence::PersistedSession, + mode: commands::session::SourceRestoreMode, + settings: &config::settings::AppSettings, + settings_path: &std::path::Path, +) -> Result, String> { + use commands::session::SourceRestoreDecision; + let reference = persisted.coding_agent_binding_seed.as_ref() + .and_then(|seed| seed.configuration_ref.as_ref()); + let snapshot = reference.map(|reference| config::settings::capture_runtime_source( + settings, settings_path, &reference.source, + )).unwrap_or_else(|| Err("legacyHasNoSource".into())); + let target_profile = match (reference, snapshot.as_ref()) { + (Some(reference), Ok(snapshot)) => { + let scoped = config::settings::resolve_runtime_source_row(settings, snapshot, reference)?; + commands::session::source_target_profile(&scoped, reference, &persisted.working_directory)? + } + _ => None, + }; + let (updated_seed, binding, spawn) = match prepare_persisted_restore( + persisted, mode, settings, snapshot, target_profile.as_deref(), + ) { + SourceRestoreDecision::Legacy => return Ok(None), + SourceRestoreDecision::Refuse { diagnostic } => return Err(diagnostic), + SourceRestoreDecision::SourceWake { scoped_settings, entry_hint, effective_profile, updated_seed, binding } => { + let spawn = commands::session::resolve_source_wake_spawn( + &scoped_settings, &entry_hint, &effective_profile, &persisted.working_directory, &updated_seed, + )?; + (updated_seed, binding, Some(spawn)) + } + SourceRestoreDecision::SourceDeferred { updated_seed, binding } => (updated_seed, binding, None), + }; + persisted.coding_agent_binding_seed = Some(updated_seed); + persisted.coding_agent_binding_id = Some(binding.id); + persisted.coding_agent_binding_origin = binding.origin; + Ok(spawn) +} + + fn start_native_restore_startup( app: tauri::AppHandle, restore_barrier: crate::session::selection::RestoreBarrierGuard, @@ -2449,6 +2503,7 @@ fn start_native_restore_startup( match commands::session::execute_root_transaction( &restore_transaction_for_task, commands::session::RootJobRequest { + source_ticket: None, requested_agent_id: None, requested_profile: None, skip_auto_resume_for_new_session: true, diff --git a/src-tauri/src/session/selection.rs b/src-tauri/src/session/selection.rs index af8c224ab..70a59d5dd 100644 --- a/src-tauri/src/session/selection.rs +++ b/src-tauri/src/session/selection.rs @@ -7248,6 +7248,8 @@ fn commit_selection_transition_with_carrier_permit() { session_id, agent_id: None, requested_profile: None, + source_ticket: None, + use_stored_selection: false, skip_auto_resume: None, activate_after: false, intent: TrustedRestartIntent::Background, From 46c7b0646b46a0de284ee8badb4c707039f1ffd6 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Wed, 21 Oct 2026 11:28:30 -0300 Subject: [PATCH 02/14] feat(2899): preserve selected-source runtime provenance and restart seeds --- src-tauri/src/commands/session.rs | 636 +++++++++++++++++++++++++- src-tauri/src/config/agent_command.rs | 2 +- src-tauri/src/config/settings.rs | 9 + src-tauri/src/lib.rs | 197 ++++++-- src-tauri/src/session/manager.rs | 341 +++++++++++++- 5 files changed, 1128 insertions(+), 57 deletions(-) diff --git a/src-tauri/src/commands/session.rs b/src-tauri/src/commands/session.rs index dbfb95325..3c770839f 100644 --- a/src-tauri/src/commands/session.rs +++ b/src-tauri/src/commands/session.rs @@ -451,7 +451,14 @@ impl CreateSelectionIntent { } } +struct RestartBindingStageInput { + retiring_runtime: Uuid, + expected: crate::session::manager::CodingAgentBinding, + desired_seed: serde_json::Value, +} + struct DeferredCreateOutput { + restart_binding: Option, info: SessionInfo, binding: PendingCreateBinding, warnings: Vec, @@ -1737,6 +1744,7 @@ pub async fn create_session_inner( None, crate::config::sessions_persistence::default_creation_gate_enforcement(), None, + None, ) .await? .into_finalized() @@ -1787,6 +1795,7 @@ pub(crate) async fn create_session_inner_with_pty_target_ownership( None, crate::config::sessions_persistence::default_creation_gate_enforcement(), coding_agent_binding, + None, ) .await? .into_finalized() @@ -2087,7 +2097,12 @@ async fn create_session_inner_impl( preheld_target: Option<&crate::api::message_store::PtyInputTargetOwnership<'_>>, enforcement: crate::config::sessions_persistence::CreationGateEnforcement, coding_agent_binding: Option, + restart_stage: Option, ) -> Result { + if restart_stage.is_some() + && (!defer_inline_finalization || !matches!(inline_cause, Some(SelectionCause::Restart(_)))) { + return Err("restart binding staging requires deferred restart".into()); + } // #1327 - startup coding-agent updates gate: no session may open (GUI, // restore, restart, root agent, web, phone) before the startup update run // finishes or times out. Absent in tests (mock app, nothing managed) and in @@ -2245,11 +2260,18 @@ async fn create_session_inner_impl( let agent_kind = CodingAgentKind::detect(&shell, &shell_args); let is_agent_owned_launch = agent_id.is_some() || agent_kind.is_some() || is_root_agent; let source_seed = resolved_spawn.as_ref().and_then(|spawn| spawn.source_seed.as_ref()); - let coding_agent_binding = if let Some(seed) = source_seed { + if let Some(seed) = source_seed { + validate_source_spawn_seed(resolved_spawn.as_ref().expect("source seed has spawn"), seed)?; + } + let coding_agent_binding = if restart_stage.is_some() { + coding_agent_binding.clone() + } else if let Some(seed) = source_seed { let mut binding = coding_agent_binding.clone().unwrap_or_else(|| crate::session::manager::CodingAgentBinding { id: Uuid::new_v4().to_string(), seed: None, origin: None, }); - binding.seed = Some(serde_json::to_value(seed).map_err(|error| error.to_string())?); + binding.seed = Some(serde_json::to_value(validate_source_spawn_seed( + resolved_spawn.as_ref().expect("source seed has spawn"), seed, + )?).map_err(|error| error.to_string())?); Some(binding) } else { match coding_agent_binding.clone() { Some(binding) => Some(binding), @@ -2286,8 +2308,7 @@ async fn create_session_inner_impl( } None => None, }, - }; - }; + } }; let resource_monitor = app.state::>().inner().clone(); let mut resource_permit = if is_agent_owned_launch { let cfg = app @@ -2426,16 +2447,24 @@ async fn create_session_inner_impl( return Err(e); } }; - if let Err(error) = mgr - .install_pending_coding_agent_binding(pending_binding, coding_agent_binding) - .await - { - if let Some(transaction) = inline_transaction { - transaction.rollback_inline_create(pending_binding).await; + let admission = if let Some(stage) = restart_stage { + mgr.stage_pending_restart_binding( + pending_binding, stage.retiring_runtime, stage.expected, stage.desired_seed, + ).await.map(Some) + } else { + mgr.install_pending_coding_agent_binding(pending_binding, coding_agent_binding) + .await.map(|()| None) + }; + let restart_binding = match admission { + Ok(transition) => transition, + Err(error) => { + if let Some(transaction) = inline_transaction { + transaction.rollback_inline_create(pending_binding).await; + } + release_resource_launch_permit(&resource_monitor, &mut resource_permit); + return Err(error); } - release_resource_launch_permit(&resource_monitor, &mut resource_permit); - return Err(error); - } + }; if inline_transaction.is_some() { *inline_pending_for_body .lock() @@ -3347,7 +3376,13 @@ async fn create_session_inner_impl( // Without this fallback, callers that pass agent_id but no label (session-requests, // web remote) would write app: "Unknown" into the per-instance config.json. // #2433 - computed under the settings read lock, written after it drops. - let write = { + let write = if let Some(spawn) = resolved_spawn.as_ref().filter(|spawn| spawn.source_seed.is_some()) { + Some(LastCodingAgentWrite { + agent_id: spawn.trusted_agent_id.clone(), + label: spawn.trusted_agent_label.clone(), + descriptor: None, + }) + } else { let settings = app.state::(); let write = match settings.current_read().await { Ok(cfg) => Some(prepare_last_coding_agent_write( @@ -3405,6 +3440,7 @@ async fn create_session_inner_impl( .unwrap_or_else(|error| error.into_inner()) .take(); return Ok(CreateCompletion::Deferred(DeferredCreateOutput { + restart_binding, info, binding: pending_binding, warnings: finalization_warnings, @@ -3607,6 +3643,7 @@ pub(crate) fn prepare_source_restore_decision( configuration_ref: Some(reference), }; let mut binding = binding.ok_or("sourceBindingMissing")?; + Uuid::parse_str(&binding.id).map_err(|_| "invalidSourceBinding")?; binding.seed = Some(serde_json::to_value(&updated_seed).map_err(|e| e.to_string())?); Ok(match mode { SourceRestoreMode::Wake => SourceRestoreDecision::SourceWake { @@ -3634,15 +3671,46 @@ pub(crate) fn resolve_source_wake_spawn( let agent = scoped.agents.iter().find(|agent| agent.id == hint).ok_or("sourceRowMissing")?; let reference = seed.configuration_ref.as_ref().ok_or("sourceReferenceMissing")?; let pair = crate::config::settings::pair_identity(&agent.command, &agent.envs, &spawn.profile_resolution.cell); - if spawn.profile_resolution.effective_profile != effective + if seed.original_agent_id != hint + || reference.legacy_entry_hint.as_deref() != Some(hint) + || reference.calculated_id != crate::config::settings::configuration_identity(&agent.command, &agent.envs) + || spawn.profile_resolution.effective_profile != effective || reference.profile_calculated_id.as_deref() != Some(pair.as_str()) { return Err("sourcePairChangedBeforeSpawn".into()); } - spawn.source_seed = Some(seed.clone()); + spawn.profile_resolution.cell.id_hash = Some(pair); + let value = serde_json::to_value(seed).map_err(|error| error.to_string())?; + validate_source_spawn_seed(&spawn, &value)?; + spawn.source_seed = Some(value); Ok(spawn) } +/// Typed revalidation of the opaque carrier before host effects and admission. +fn validate_source_spawn_seed( + spawn: &AgentSpawnCommand, + value: &serde_json::Value, +) -> Result { + let seed: crate::config::sessions_persistence::CodingAgentBindingSeed = + serde_json::from_value(value.clone()).map_err(|_| "invalidSourceSeed")?; + let reference = seed.configuration_ref.as_ref().ok_or("invalidSourceSeed")?; + let suffix = format!(":{}", reference.calculated_id); + let digest = seed.identity_revision.strip_prefix("s1:") + .and_then(|value| value.strip_suffix(&suffix)).ok_or("invalidSourceSeed")?; + if digest.len() != 64 || !digest.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + || seed.original_agent_id != spawn.trusted_agent_id + || reference.legacy_entry_hint.as_deref() != Some(seed.original_agent_id.as_str()) + || reference.profile_calculated_id != spawn.profile_resolution.cell.id_hash { + return Err("invalidSourceSeed".into()); + } + let pair = crate::config::coding_agent_profiles::ReplicaSelectionPair { + coding_agent_id: seed.original_agent_id.clone(), + requested_profile: spawn.profile_resolution.effective_profile.clone(), + }; + crate::config::coding_agent_profiles::validate_configuration_ref_pair(reference, &pair)?; + Ok(seed) +} + fn runtime_replica_expectation( cwd: &str, required: bool, @@ -3784,6 +3852,41 @@ pub(crate) fn build_configured_agent_spawn_for_cwd( .map(Some) } +fn source_profile_outdated( + settings: &AppSettings, + owner: &SettingsState, + info: &SessionInfo, + binding: Option, +) -> Option { + let seed = match binding.as_ref().and_then(|binding| binding.seed.as_ref()) { + Some(value) => match serde_json::from_value::(value.clone()) { + Ok(seed) => Some(seed), + Err(_) => return Some(false), + }, + None => None, + }; + if seed.as_ref().is_some_and(|seed| seed.identity_revision.starts_with("s1:") && seed.configuration_ref.is_none()) { + return Some(false); + } + let replica = match runtime_replica_expectation(&info.working_directory, false) { + Ok(replica) => replica, + Err(_) => return Some(false), + }; + let reference = seed.as_ref().and_then(|seed| seed.configuration_ref.as_ref()) + .or_else(|| replica.as_ref().and_then(|saved| saved.configuration_ref.as_ref()))?; + let resolved = owner.runtime_source_settings_path().and_then(|path| { + resolve_runtime_source_spawn(settings, &path, &info.working_directory, reference, None, binding.clone()) + }); + Some(match resolved { + Ok((spawn, _)) => info.profile_content_hash.clone().or_else(|| { + crate::config::coding_agent_profiles::read_replica_profile_content_hash( + std::path::Path::new(&info.working_directory), + ) + }).is_some_and(|loaded| loaded != spawn.profile_content_hash), + Err(_) => false, + }) +} + /// #592 - true when a session's loaded profile cell no longer matches what a /// Reload would load right now. Mirrors the restart-time resolution so the /// "configured" side equals `restart_session`'s effective cell. Hashes RAW @@ -3922,7 +4025,7 @@ pub(crate) async fn create_session_with_source( || requested_profile.as_ref().is_some_and(|letter| Some(letter) != reference.requested_profile.as_ref())) { return Err("sourceSelectionProvenanceMismatch".into()); } - let (spawn, _) = resolve_runtime_source_spawn(&cfg, settings.settings_path(), &cwd, + let (spawn, _) = resolve_runtime_source_spawn(&cfg, &settings.runtime_source_settings_path()?, &cwd, reference, source_ticket.as_ref(), None)?; crate::config::agent_command::prepare_agent_spawn_command(&spawn)?; Some(spawn) @@ -5376,6 +5479,12 @@ pub(crate) async fn execute_restart_transaction( let clean_args = crate::config::sessions_persistence::strip_auto_injected_args(&shell, &shell_args); + if use_stored_selection { + let cfg = settings.current_read().await.map_err(|error| error.to_string())?; + crate::config::coding_agent_profiles::validate_profile_selection_agent_path( + &cfg, std::path::Path::new(&cwd), + )?; + } let replica = runtime_replica_expectation(&cwd, use_stored_selection)?; let mut selected_legacy_agent = agent_id; let mut selected_legacy_profile = requested_profile; @@ -5412,7 +5521,7 @@ pub(crate) async fn execute_restart_transaction( .await .map_err(|error| error.to_string())?; if let Some(reference) = reference.as_ref() { - let (spawn, _) = resolve_runtime_source_spawn(&cfg, settings.settings_path(), &cwd, + let (spawn, _) = resolve_runtime_source_spawn(&cfg, &settings.runtime_source_settings_path()?, &cwd, reference, source_ticket.as_ref(), original_binding.clone())?; let host = Some(ResolvedAgentHostShell { program: cfg.default_shell.clone(), args: cfg.default_shell_args.clone() }); (Some(spawn.trusted_agent_id.clone()), Some(spawn), host) @@ -5473,6 +5582,22 @@ pub(crate) async fn execute_restart_transaction( (selected_agent_id, resolved_spawn, resolved_agent_host_shell) } }; + let restart_stage = if let Some(expected) = original_binding.as_ref() { + let desired_seed = if let Some(value) = resolved_spawn.as_ref().and_then(|spawn| spawn.source_seed.as_ref()) { + validate_source_spawn_seed(resolved_spawn.as_ref().expect("source spawn"), value)?; + Some(value.clone()) + } else if old_seed.as_ref().is_some_and(|seed| seed.configuration_ref.is_some()) { + let cfg = settings.current_read().await.map_err(|error| error.to_string())?; + let original_agent_id = selected_agent_id.as_ref().ok_or("legacyReplacementRequiresConfiguredAgent")?; + Some(serde_json::json!({ + "identityRevision": crate::config::settings::bootstrap_binding_revision(&cfg, original_agent_id)?, + "originalAgentId": original_agent_id, + })) + } else { None }; + desired_seed.map(|desired_seed| RestartBindingStageInput { + retiring_runtime: uuid, expected: expected.clone(), desired_seed, + }) + } else { None }; crate::config::archive_gate::probe_spawn_refusal(app, &cwd).await?; // §1295 5.1b / B2: the creation gate runs AFTER `probe_spawn_refusal` but // WELL BEFORE `teardown_old_for_restart` (below). A rejected restart of a @@ -5565,6 +5690,7 @@ pub(crate) async fn execute_restart_transaction( None, enforcement, coding_agent_binding, + restart_stage, ) .await; let deferred = match completion { @@ -5618,6 +5744,15 @@ pub(crate) async fn execute_restart_transaction( let mut mutations = LifecycleMutations::default(); mutations.finalize_live(deferred.binding, live); mutations.remove(uuid); + if let Some(transition) = deferred.restart_binding { + if let Err(error) = mutations.set_restart_binding(transition) { + transaction.rollback_inline_create(deferred.binding).await; + if old_lost { + finalize_failed_restart(transaction, uuid, old_selected, intent).await?; + } + return Err(error); + } + } let decision = if old_selected || activate_after { CommitDecision::Live(live) } else { @@ -5781,7 +5916,9 @@ pub async fn list_sessions( session_mgr: State<'_, Arc>>, settings: State<'_, SettingsState>, ) -> Result, String> { - let mut infos = { session_mgr.read().await.list_sessions().await }; + let manager = session_mgr.read().await.clone(); + let snapshot = manager.aggregate_snapshot().await; + let mut infos: Vec = snapshot.sessions.iter().map(SessionInfo::from).collect(); // #592 - recompute drift per session against current settings (settings-aware, // unlike the `From<&Session>` path which always emits `profile_outdated=false`). let cfg = settings @@ -5789,7 +5926,16 @@ pub async fn list_sessions( .await .map_err(|error| error.to_string())?; for info in infos.iter_mut() { - info.profile_outdated = compute_profile_outdated(&cfg, info); + let binding = Uuid::parse_str(&info.id).ok() + .and_then(|id| snapshot.coding_agent_bindings.get(&id)) + .map(|id| crate::session::manager::CodingAgentBinding { + id: id.clone(), seed: snapshot.binding_seeds.get(id).cloned(), + origin: snapshot.binding_origins.get(id).cloned(), + }); + info.profile_outdated = match source_profile_outdated(&cfg, &settings, info, binding) { + Some(outdated) => outdated, + None => compute_profile_outdated(&cfg, info), + }; } Ok(infos) } @@ -6236,7 +6382,7 @@ pub(crate) async fn execute_root_transaction( // Validate ticket and resolve read-only before provisioning, publication or teardown. let source_spawn = if let Some(ticket) = request.source_ticket.as_ref() { let cfg = settings.current_read().await.map_err(|error| error.to_string())?; - let (spawn, _) = resolve_runtime_source_spawn(&cfg, settings.settings_path(), &root_agent_path, + let (spawn, _) = resolve_runtime_source_spawn(&cfg, &settings.runtime_source_settings_path()?, &root_agent_path, &ticket.configuration_ref, Some(ticket), None)?; let host = ResolvedAgentHostShell { program: cfg.default_shell.clone(), args: cfg.default_shell_args.clone() }; Some((spawn, host)) @@ -6399,6 +6545,7 @@ pub(crate) async fn execute_root_transaction( None, crate::config::sessions_persistence::default_creation_gate_enforcement(), None, + None, ) .await? .into_finalized()?; @@ -6766,6 +6913,57 @@ async fn inject_restart_resume_prompt(app: &AppHandle, target: &RestartResumeTar } } +#[cfg(test)] +pub(crate) fn e2432_physical_restore_fixture( + root: &std::path::Path, + arguments: &str, +) -> (AppSettings, std::path::PathBuf, crate::config::sessions_persistence::PersistedSession) { + use crate::config::settings::{self, SourceKind, SourceRevision}; + std::fs::create_dir_all(root).unwrap(); + let path = root.join(crate::config::instance_artifacts::SETTINGS_FILE_NAME); + std::fs::write(&path, serde_json::to_vec(&AppSettings::default()).unwrap()).unwrap(); + let raw = serde_json::json!({ + "agents":[{"id":"same", "label":"Physical", "color":"blue", "baseCommand":"tool", "backend":{"image":arguments}}], + "codingAgentProfiles":{"profilesByAgent":{"same":{ + "A":{"command":format!("tool {arguments}"), "env":{"SOURCE":arguments}}, + "B":{"command":"tool matrix-B"}, "C":{"command":"tool matrix-C"}, + "D":{"command":"tool manual-D"} + }}} + }); + std::fs::write(root.join(crate::config::instance_artifacts::AGENTS_INSTANCE_FILE_NAME), serde_json::to_vec(&raw).unwrap()).unwrap(); + let host = AppSettings::default(); + let source = settings::list_agent_files(&host, &path).unwrap().sources.into_iter() + .find(|option| option.source.kind == SourceKind::RegisteredInstance).unwrap().source; + let snapshot = settings::capture_runtime_source(&host, &path, &source).unwrap(); + let base = settings::configuration_identity("tool", &[]); + let reference = settings::ConfigurationRef { + version: 1, source, calculated_id: base.clone(), requested_profile: None, + profile_calculated_id: Some(base.clone()), legacy_entry_hint: Some("same".into()), + }; + let scoped = settings::resolve_runtime_source_row(&host, &snapshot, &reference).unwrap(); + let resolution = crate::config::coding_agent_profiles::resolve_profile(&scoped, + crate::config::coding_agent_profiles::ProfileResolutionRequest { + coding_agent_id:"same", launch_path:None, agent_matrix_name:None, + requested_profile:Some("A"), requested_profile_authoritative:true, + }); + let mut reference = reference; + reference.profile_calculated_id = Some(settings::pair_identity("tool", &[], &resolution.cell)); + let SourceRevision::Bytes { sha256 } = snapshot.revision else { panic!("physical bytes") }; + let seed = crate::config::sessions_persistence::CodingAgentBindingSeed { + identity_revision:format!("s1:{sha256}:{base}"), original_agent_id:"same".into(), + configuration_ref:Some(reference), + }; + let persisted = crate::config::sessions_persistence::PersistedSession { + name:"physical".into(), working_directory:root.to_string_lossy().into_owned(), + shell:"saved-shell-decoy".into(), agent_id:Some("same".into()), + coding_agent_binding_id:Some(Uuid::new_v4().to_string()), + coding_agent_binding_seed:Some(seed), + coding_agent_binding_origin:Some(serde_json::json!({"sessionsPath":"recipe", "rawFileSHA256":"original", "originalOrdinal":3})), + ..Default::default() + }; + (host, path, persisted) +} + #[cfg(test)] mod tests { use super::{ @@ -6866,6 +7064,400 @@ mod tests { crate::config::settings::load_settings_from_path(&path) } + #[test] + fn e2432_restart_source_decoy() { + let temp = tempfile::tempdir().unwrap(); + let (host, path, persisted) = super::e2432_physical_restore_fixture(&temp.path().join("F"), "F"); + super::e2432_physical_restore_fixture(&temp.path().join("G"), "G"); + let seed = persisted.coding_agent_binding_seed.as_ref().unwrap(); + let reference = seed.configuration_ref.as_ref().unwrap(); + let snapshot = crate::config::settings::capture_runtime_source(&host, &path, &reference.source).unwrap(); + let decision = super::prepare_source_restore_decision(Some(seed), persisted.binding_metadata(), + Some("same"), None, &persisted.working_directory, super::SourceRestoreMode::Wake, + &host, Ok(snapshot)); + let super::SourceRestoreDecision::SourceWake { scoped_settings, entry_hint, effective_profile, updated_seed, binding } = decision + else { panic!("physical Wake must resolve") }; + let spawn = super::resolve_source_wake_spawn(&scoped_settings, &entry_hint, &effective_profile, + &persisted.working_directory, &updated_seed).unwrap(); + assert_eq!(spawn.profile_resolution.cell.env.get("SOURCE").map(String::as_str), Some("F")); + assert_eq!(spawn.backend.image.as_deref(), Some("F")); + assert_eq!(binding.id, persisted.coding_agent_binding_id.clone().unwrap()); + assert_eq!(binding.origin, persisted.coding_agent_binding_origin); + let mut malformed = spawn.source_seed.clone().unwrap(); + malformed["originalAgentId"] = serde_json::json!("foreign"); + assert!(super::validate_source_spawn_seed(&spawn, &malformed).is_err()); + malformed = spawn.source_seed.clone().unwrap(); + malformed["configurationRef"]["requestedProfile"] = serde_json::json!("Z"); + assert!(super::validate_source_spawn_seed(&spawn, &malformed).is_err()); + assert!(matches!(super::prepare_source_restore_decision(Some(seed), persisted.binding_metadata(), Some("same"), + None, &persisted.working_directory, super::SourceRestoreMode::Wake, &host, + Err("source unavailable".into())), super::SourceRestoreDecision::Refuse { .. })); + } + + #[test] + fn e2432_source_runtime_production_wiring_guards() { + let source = include_str!("session.rs"); + let create = &source[source.find("async fn create_session_inner_impl<").unwrap().. + source.find("pub(crate) enum SourceRestoreMode").unwrap()]; + assert!(create.find("validate_source_spawn_seed(").unwrap() + < create.find("let resource_monitor =").unwrap()); + assert!(create.find("if let Some(stage) = restart_stage").unwrap() + < create.find(".install_pending_coding_agent_binding(").unwrap()); + assert!(create.contains("restart_binding,")); + let restart = &source[source.find("pub(crate) async fn execute_restart_transaction<").unwrap().. + source.find("pub async fn rename_session(").unwrap()]; + assert!(restart.find("resolve_runtime_source_spawn(").unwrap() + < restart.find("prepare_agent_spawn_command(").unwrap()); + assert!(restart.find("let restart_stage =").unwrap() + < restart.find("teardown_old_for_restart(").unwrap()); + assert!(restart.contains("mutations.set_restart_binding(transition)")); + let root = &source[source.find("pub(crate) async fn execute_root_transaction<").unwrap().. + source.find("pub(crate) fn e2432_physical_restore_fixture(").unwrap()]; + assert!(root.find("resolve_runtime_source_spawn(").unwrap() + < root.find("ensure_root_agent_dir()?").unwrap()); + let root_dispatch = &source[source.find("pub(crate) async fn create_root_agent_inner_with_source(").unwrap().. + source.find("pub(crate) async fn execute_root_transaction<").unwrap()]; + assert!(root_dispatch.find("rootStoredSelectionUnsupported").unwrap() + < root_dispatch.find(".root_lifecycle(").unwrap()); + } + + #[test] + fn e2432_source_null_contextless_pair_is_observational_and_effective_pair_is_final() { + let temp = tempfile::tempdir().unwrap(); + let (mut host, path, persisted) = super::e2432_physical_restore_fixture(&temp.path().join("F"), "F-A"); + let cwd = temp.path().join("_agent_matrix"); + std::fs::create_dir(&cwd).unwrap(); + host.coding_agent_profiles.default_profile_by_agent.insert("matrix".into(), "B".into()); + let reference = persisted.coding_agent_binding_seed.as_ref().unwrap().configuration_ref.as_ref().unwrap(); + let snapshot = crate::config::settings::capture_runtime_source(&host, &path, &reference.source).unwrap(); + let ticket = crate::config::settings::SourceSelectionTicket { + configuration_ref:reference.clone(), expected_source_revision:snapshot.revision.clone(), + catalog_context_revision:snapshot.context_revision.clone(), + }; + let (spawn, binding) = super::resolve_runtime_source_spawn(&host, &path, &cwd.to_string_lossy(), + reference, Some(&ticket), None).unwrap(); + assert_eq!(spawn.profile_resolution.effective_profile, "B"); + let final_seed: crate::config::sessions_persistence::CodingAgentBindingSeed = serde_json::from_value(binding.seed.unwrap()).unwrap(); + let final_ref = final_seed.configuration_ref.unwrap(); + assert_eq!(final_ref.requested_profile, None); + assert_ne!(final_ref.profile_calculated_id, reference.profile_calculated_id); + assert_eq!(spawn.source_seed.as_ref().unwrap()["configurationRef"]["profileCalculatedId"], + serde_json::json!(final_ref.profile_calculated_id)); + let mut explicit = ticket.clone(); + explicit.configuration_ref.requested_profile = Some("A".into()); + explicit.configuration_ref.profile_calculated_id = Some(format!("cid1:{}", "0".repeat(64))); + assert!(super::resolve_runtime_source_spawn(&host, &path, &cwd.to_string_lossy(), + &explicit.configuration_ref, Some(&explicit), None).is_err()); + super::e2432_physical_restore_fixture(&temp.path().join("F"), "F-changed"); + assert!(super::resolve_runtime_source_spawn(&host, &path, &cwd.to_string_lossy(), + reference, Some(&ticket), None).is_err()); + } + + #[test] + fn e2432_source_null_marker_refreshes_derived_aliases_but_preserves_manual_pin() { + let (project, cwd, _) = strict_target_fixture(); + let (mut host, path, persisted) = super::e2432_physical_restore_fixture(&project.path().join("F"), "F-A"); + host.project_paths = vec![project.path().join("project").to_string_lossy().into_owned()]; + host.coding_agent_profiles.default_profile_by_agent.insert("dev-one".into(), "C".into()); + let reference = persisted.coding_agent_binding_seed.as_ref().unwrap().configuration_ref.as_ref().unwrap(); + let config_path = std::path::Path::new(&cwd).join("config.json"); + let original: serde_json::Value = serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap(); + let origin_path = project.path().join("project/.ac/_agent_dev-one/config.json"); + for (marker, pin, origin, expected) in [ + ("source-default", "B", None, "C"), + ("source-default", "B", Some("B"), "B"), + ("source-default", "B", Some("C"), "C"), + ("manual", "D", Some("C"), "D"), + ] { + let origin = origin.map(|letter| serde_json::json!({"tooling":{"defaultProfile":letter}})) + .unwrap_or_else(|| serde_json::json!({})); + std::fs::write(&origin_path, serde_json::to_vec(&origin).unwrap()).unwrap(); + let mut raw = original.clone(); + raw["tooling"] = serde_json::json!({"currentCodingAgent":"same", "profile":pin, + "instanceProfileOverride":pin, "instanceProfileOverrideSource":marker, + "selectionLocked":true, "configurationRef":reference}); + std::fs::write(&config_path, serde_json::to_vec(&raw).unwrap()).unwrap(); + let (spawn, _) = super::resolve_runtime_source_spawn(&host, &path, &cwd, reference, None, + persisted.binding_metadata().unwrap()).unwrap(); + assert_eq!(spawn.profile_resolution.effective_profile, expected); + } + } + + #[test] + fn e2432_catalog_key_absent_globally_uses_only_physical_row() { + let temp = tempfile::tempdir().unwrap(); + let (host, path, _) = super::e2432_physical_restore_fixture(temp.path(), "global-decoy"); + let catalog_path = crate::config::coding_agents_catalog::independent_catalog_path( + temp.path(), crate::config::settings::SourceKind::CatalogPersonal, + ).unwrap(); + std::fs::create_dir_all(catalog_path.parent().unwrap()).unwrap(); + let base = crate::config::settings::configuration_identity("tool", &[]); + std::fs::write(&catalog_path, serde_json::to_vec(&serde_json::json!({"schemaVersion":2, + "agents":[{"key":"catalog-only", "baseCommand":"tool", "label":"Only", "color":"blue"}], + "profileBindingVersion":1, "profilesByBaseHash":{(base.clone()):{ + "A":{"additionalArguments":"tool only-here", "env":{"SOURCE":"catalog"}} + }}})).unwrap()).unwrap(); + let source = crate::config::settings::list_agent_files(&host, &path).unwrap().sources.into_iter() + .find(|option| option.source.kind == crate::config::settings::SourceKind::CatalogPersonal).unwrap().source; + let snapshot = crate::config::settings::capture_runtime_source(&host, &path, &source).unwrap(); + let reference = crate::config::settings::ConfigurationRef { + version:1, source, calculated_id:base.clone(), requested_profile:None, + profile_calculated_id:Some(base), legacy_entry_hint:Some("catalog-only".into()), + }; + assert!(!host.agents.iter().any(|agent| agent.id == "catalog-only")); + let (spawn, binding) = super::resolve_runtime_source_spawn(&host, &path, &temp.path().to_string_lossy(), + &reference, None, None).unwrap(); + assert_eq!(spawn.trusted_agent_id, "catalog-only"); + assert_eq!(spawn.profile_resolution.cell.env.get("SOURCE").map(String::as_str), Some("catalog")); + assert!(binding.seed.unwrap()["identityRevision"].as_str().unwrap().starts_with("s1:")); + assert_eq!(snapshot.rows[0].entry_hint, "catalog-only"); + } + + #[tokio::test] + async fn e2432_source_restart_runs_real_deferred_create_and_atomic_commit() { + let temp = tempfile::tempdir().unwrap(); + let (host, path, persisted) = super::e2432_physical_restore_fixture(&temp.path().join("F"), "F-old"); + let session_mgr = Arc::new(tokio::sync::RwLock::new(SessionManager::new())); + let backend = Arc::new(ScriptedSpawnBackend::default()); + let pty_mgr = Arc::new(Mutex::new(crate::pty::manager::PtyManager::new_for_test(backend.clone()))); + let app = session_test_app(host.clone(), session_mgr.clone(), pty_mgr.clone()); + let owner = crate::config::settings::SettingsOwner::open_active(&path, Duration::from_secs(1)).unwrap(); + app.unmanage::(); + assert!(app.manage(owner)); + let transaction = crate::session::selection::SelectionTransaction::for_test(app.handle().clone()); + let reference = persisted.coding_agent_binding_seed.as_ref().unwrap().configuration_ref.as_ref().unwrap(); + let (spawn, binding) = super::resolve_runtime_source_spawn(&host, &path, &persisted.working_directory, + reference, None, persisted.binding_metadata().unwrap()).unwrap(); + let info = super::create_session_inner_for_restore(&transaction, &session_mgr, &pty_mgr, + spawn.shell.clone(), spawn.shell_args.clone(), persisted.working_directory.clone(), Some("F".into()), + Some("same".into()), Some(spawn.trusted_agent_label.clone()), true, Vec::new(), true, + Some(spawn), None, None, Some(false), None, Some(binding)).await.unwrap(); + drop(transaction); + let manager = session_mgr.read().await.clone(); + let mut before = manager.coding_agent_binding(Uuid::parse_str(&info.id).unwrap()).await.unwrap(); + let root_transaction = crate::session::selection::SelectionTransaction::for_test(app.handle().clone()); + let mut stale = crate::config::settings::SourceSelectionTicket { + configuration_ref:reference.clone(), expected_source_revision:crate::config::settings::SourceRevision::Absent {}, + catalog_context_revision:String::new(), + }; + let spawn_count = backend.spawn_count.load(Ordering::SeqCst); + assert!(super::execute_root_transaction(&root_transaction, super::RootJobRequest { + source_ticket:Some(stale.clone()), requested_agent_id:None, requested_profile:None, + skip_auto_resume_for_new_session:true, + intent:crate::session::selection::TrustedCreateIntent::User, select_after:false, + }).await.is_err()); + assert_eq!(backend.spawn_count.load(Ordering::SeqCst), spawn_count); + drop(root_transaction); + stale.catalog_context_revision = "still-stale".into(); + super::e2432_physical_restore_fixture(&temp.path().join("F"), "F-current"); + let settings = app.state::(); + assert!(super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, settings.inner(), + Uuid::parse_str(&info.id).unwrap(), None, None, Some(stale), false, Some(true), true, + crate::session::selection::TrustedRestartIntent::User, None, + crate::config::sessions_persistence::default_creation_gate_enforcement()).await.is_err()); + assert!(manager.get_session(Uuid::parse_str(&info.id).unwrap()).await.is_some()); + assert_eq!(super::source_profile_outdated(&host, settings.inner(), &info, Some(before.clone())), Some(true)); + let dormant_id = Uuid::parse_str(&info.id).unwrap(); + backend.kill(dormant_id).unwrap(); + app.state::() + .container_lifecycle_sender().route_lost(dormant_id, 23).await.unwrap(); + backend.fail_spawn(backend.spawn_count.load(Ordering::SeqCst) + 1); + assert!(super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, settings.inner(), + dormant_id, None, None, None, false, Some(true), true, + crate::session::selection::TrustedRestartIntent::User, None, + crate::config::sessions_persistence::default_creation_gate_enforcement()).await.is_err()); + assert_eq!(manager.coding_agent_binding(dormant_id).await.unwrap().seed, before.seed); + assert!(manager.aggregate_snapshot().await.pending_ids.is_empty()); + assert!(manager.get_session(dormant_id).await.is_some()); + backend.fail_spawn(0); + let (started_sender, started) = tokio::sync::oneshot::channel(); + let (release, wait) = tokio::sync::oneshot::channel(); + backend.gate_spawn(backend.spawn_count.load(Ordering::SeqCst) + 1, started_sender, wait); + let attempt = super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, settings.inner(), + dormant_id, None, None, None, false, Some(true), true, + crate::session::selection::TrustedRestartIntent::User, None, + crate::config::sessions_persistence::default_creation_gate_enforcement()); + let (refused, ()) = tokio::join!(attempt, async { + started.await.unwrap(); + let pending = manager.aggregate_snapshot().await; + assert_eq!(pending.binding_seeds.get(&before.id), before.seed.as_ref()); + assert_eq!(pending.binding_origins.get(&before.id), before.origin.as_ref()); + assert_eq!(pending.pending_ids.len(), 1); + manager.refresh_failed_binding_recipes(vec![(crate::session::manager::CodingAgentBinding { + id:before.id.clone(), seed:None, origin:Some(serde_json::json!("O2")), + }, serde_json::json!({}))]).await; + release.send(()).unwrap(); + }); + assert!(refused.is_err()); + assert!(manager.aggregate_snapshot().await.pending_ids.is_empty()); + assert!(manager.get_session(dormant_id).await.is_some()); + assert_eq!(manager.coding_agent_binding(dormant_id).await.unwrap().origin, Some(serde_json::json!("O2"))); + before = manager.coding_agent_binding(dormant_id).await.unwrap(); + let restarted = super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, + settings.inner(), Uuid::parse_str(&info.id).unwrap(), None, None, None, false, Some(true), true, + crate::session::selection::TrustedRestartIntent::User, None, + crate::config::sessions_persistence::default_creation_gate_enforcement()).await.unwrap(); + let after = manager.coding_agent_binding(Uuid::parse_str(&restarted.id).unwrap()).await.unwrap(); + assert_eq!(after.id, before.id); + assert_eq!(after.origin, before.origin); + assert_ne!(after.seed, before.seed); + assert!(manager.get_session(Uuid::parse_str(&info.id).unwrap()).await.is_none()); + let saved = crate::config::sessions_persistence::snapshot_sessions(&manager).await; + let saved: Vec = + serde_json::from_value(serde_json::to_value(saved).unwrap()).unwrap(); + assert_eq!(saved[0].coding_agent_binding_id.as_deref(), Some(after.id.as_str())); + assert_eq!(saved[0].coding_agent_binding_seed.as_ref().unwrap().configuration_ref.as_ref().unwrap().source, + reference.source); + let g_path = crate::config::coding_agents_catalog::independent_catalog_path( + path.parent().unwrap(), crate::config::settings::SourceKind::CatalogPersonal, + ).unwrap(); + std::fs::create_dir_all(g_path.parent().unwrap()).unwrap(); + let base = reference.calculated_id.clone(); + let g_raw = serde_json::json!({"schemaVersion":2, + "agents":[{"key":"same", "label":"G", "color":"green", "baseCommand":"tool"}], + "profileBindingVersion":1, "profilesByBaseHash":{ + (base.clone()):{"A":{"additionalArguments":"tool G-current", "env":{"SOURCE":"G-current"}}} + }}); + std::fs::write(&g_path, serde_json::to_vec(&g_raw).unwrap()).unwrap(); + let g_source = crate::config::settings::list_agent_files(&host, &path).unwrap().sources.into_iter() + .find(|option| option.source.kind == crate::config::settings::SourceKind::CatalogPersonal).unwrap().source; + let g_snapshot = crate::config::settings::capture_runtime_source(&host, &path, &g_source).unwrap(); + let mut g_ref = reference.clone(); + g_ref.source = g_source; + let g_scoped = crate::config::settings::resolve_runtime_source_row(&host, &g_snapshot, &g_ref).unwrap(); + let g_cell = crate::config::coding_agent_profiles::resolve_profile(&g_scoped, + crate::config::coding_agent_profiles::ProfileResolutionRequest { + coding_agent_id:"same", launch_path:None, agent_matrix_name:None, + requested_profile:Some("A"), requested_profile_authoritative:true, + }); + g_ref.profile_calculated_id = Some(crate::config::settings::pair_identity("tool", &[], &g_cell.cell)); + let g_ticket = crate::config::settings::SourceSelectionTicket { + configuration_ref:g_ref.clone(), expected_source_revision:g_snapshot.revision, + catalog_context_revision:g_snapshot.context_revision, + }; + let replacement = super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, + settings.inner(), Uuid::parse_str(&restarted.id).unwrap(), None, None, Some(g_ticket), false, + Some(true), true, crate::session::selection::TrustedRestartIntent::User, None, + crate::config::sessions_persistence::default_creation_gate_enforcement()).await.unwrap(); + let old_id = Uuid::parse_str(&replacement.id).unwrap(); + let replaced = manager.coding_agent_binding(old_id).await.unwrap(); + assert_eq!(replaced.id, before.id); + assert_eq!(replaced.origin, before.origin); + let replaced_seed: crate::config::sessions_persistence::CodingAgentBindingSeed = + serde_json::from_value(replaced.seed.clone().unwrap()).unwrap(); + assert_eq!(replaced_seed.configuration_ref.unwrap(), g_ref); + let before_refusal = manager.coding_agent_binding(old_id).await.unwrap(); + std::fs::remove_file(&g_path).unwrap(); + assert!(super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, settings.inner(), + old_id, None, None, None, false, Some(true), true, + crate::session::selection::TrustedRestartIntent::User, None, + crate::config::sessions_persistence::default_creation_gate_enforcement()).await.is_err()); + assert!(manager.get_session(old_id).await.is_some()); + assert_eq!(manager.coding_agent_binding(old_id).await.unwrap().seed, before_refusal.seed); + std::fs::write(&g_path, serde_json::to_vec(&g_raw).unwrap()).unwrap(); + backend.fail_spawn(backend.spawn_count.load(Ordering::SeqCst) + 1); + assert!(super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, settings.inner(), + old_id, None, None, None, false, Some(true), true, + crate::session::selection::TrustedRestartIntent::User, None, + crate::config::sessions_persistence::default_creation_gate_enforcement()).await.is_err()); + let lost = manager.aggregate_snapshot().await; + assert!(lost.pending_ids.is_empty()); + assert!(!lost.sessions.iter().any(|row| row.id == old_id)); + assert!(!lost.binding_seeds.contains_key(&before.id)); + assert!(!lost.binding_origins.contains_key(&before.id)); + close_test_coordinator(&app).await; + } + + #[tokio::test] + async fn e2432_explicit_stored_selection_overrides_seed_only_in_requested_mode() { + let (project, cwd, _) = strict_target_fixture(); + let (_, path, mut persisted) = super::e2432_physical_restore_fixture(&project.path().join("H"), "H-A"); + let mut host = crate::config::settings::load_settings_from_path(&path); + host.project_paths = vec![project.path().join("project").to_string_lossy().into_owned()]; + std::fs::write(&path, serde_json::to_vec(&host).unwrap()).unwrap(); + persisted.working_directory = cwd.clone(); + persisted.coding_agent_binding_seed.as_mut().unwrap().configuration_ref.as_mut().unwrap().requested_profile = Some("A".into()); + let config_path = std::path::Path::new(&cwd).join("config.json"); + let mut config: serde_json::Value = serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap(); + let g_path = crate::config::coding_agents_catalog::independent_catalog_path( + &project.path().join("project/.ac"), crate::config::settings::SourceKind::CatalogPersonal, + ).unwrap(); + std::fs::create_dir_all(g_path.parent().unwrap()).unwrap(); + let base = crate::config::settings::configuration_identity("tool", &[]); + std::fs::write(&g_path, serde_json::to_vec(&serde_json::json!({"schemaVersion":2, + "agents":[{"key":"same", "label":"F", "color":"green", "baseCommand":"tool"}], + "profileBindingVersion":1, "profilesByBaseHash":{(base):{ + "A":{"additionalArguments":"tool F-A"}, "D":{"additionalArguments":"tool F-D"} + }}})).unwrap()).unwrap(); + let f_source = crate::config::settings::list_agent_files(&host, &path).unwrap().sources.into_iter() + .find(|option| option.source.kind == crate::config::settings::SourceKind::CatalogPersonal + && option.source.scope == crate::config::settings::SourceScope::Project).unwrap().source; + let snapshot = crate::config::settings::capture_runtime_source(&host, &path, &f_source).unwrap(); + let mut f_ref = persisted.coding_agent_binding_seed.as_ref().unwrap().configuration_ref.clone().unwrap(); + f_ref.source = f_source; + f_ref.requested_profile = Some("D".into()); + let scoped = crate::config::settings::resolve_runtime_source_row(&host, &snapshot, &f_ref).unwrap(); + let f_cell = crate::config::coding_agent_profiles::resolve_profile(&scoped, + crate::config::coding_agent_profiles::ProfileResolutionRequest { + coding_agent_id:"same", launch_path:None, agent_matrix_name:None, + requested_profile:Some("D"), requested_profile_authoritative:true, + }); + f_ref.profile_calculated_id = Some(crate::config::settings::pair_identity("tool", &[], &f_cell.cell)); + config["tooling"] = serde_json::json!({"currentCodingAgent":"same", "profile":"D", + "instanceProfileOverride":"D", "instanceProfileOverrideSource":"manual", + "selectionLocked":true, "configurationRef":f_ref}); + std::fs::write(&config_path, serde_json::to_vec(&config).unwrap()).unwrap(); + let session_mgr = Arc::new(tokio::sync::RwLock::new(SessionManager::new())); + let pty_mgr = Arc::new(Mutex::new(crate::pty::manager::PtyManager::new_for_test(Arc::new(ScriptedSpawnBackend::default())))); + let app = session_test_app(host.clone(), session_mgr.clone(), pty_mgr.clone()); + let owner = crate::config::settings::SettingsOwner::open_active(&path, Duration::from_secs(1)).unwrap(); + app.unmanage::(); + assert!(app.manage(owner)); + let transaction = crate::session::selection::SelectionTransaction::for_test(app.handle().clone()); + let seed = persisted.coding_agent_binding_seed.as_ref().unwrap(); + let (spawn, binding) = super::resolve_runtime_source_spawn(&host, &path, &cwd, + seed.configuration_ref.as_ref().unwrap(), None, persisted.binding_metadata().unwrap()).unwrap(); + let info = super::create_session_inner_for_restore(&transaction, &session_mgr, &pty_mgr, + spawn.shell.clone(), spawn.shell_args.clone(), cwd.clone(), Some("H".into()), Some("same".into()), + None, true, Vec::new(), true, Some(spawn), None, None, None, None, Some(binding)).await.unwrap(); + drop(transaction); + let settings = app.state::(); + let manager = session_mgr.read().await.clone(); + let original = manager.coding_agent_binding(Uuid::parse_str(&info.id).unwrap()).await.unwrap(); + assert!(super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, settings.inner(), + Uuid::parse_str(&info.id).unwrap(), Some("same".into()), None, None, true, Some(true), true, + crate::session::selection::TrustedRestartIntent::User, None, + crate::config::sessions_persistence::default_creation_gate_enforcement()).await.is_err()); + assert!(manager.get_session(Uuid::parse_str(&info.id).unwrap()).await.is_some()); + let ordinary = super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, settings.inner(), + Uuid::parse_str(&info.id).unwrap(), None, None, None, false, Some(true), true, + crate::session::selection::TrustedRestartIntent::User, None, + crate::config::sessions_persistence::default_creation_gate_enforcement()).await.unwrap(); + let ordinary_binding = manager.coding_agent_binding(Uuid::parse_str(&ordinary.id).unwrap()).await.unwrap(); + assert_eq!(ordinary_binding.seed.as_ref().unwrap()["configurationRef"]["requestedProfile"], "A"); + let stored = super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, settings.inner(), + Uuid::parse_str(&ordinary.id).unwrap(), None, None, None, true, Some(true), true, + crate::session::selection::TrustedRestartIntent::User, None, + crate::config::sessions_persistence::default_creation_gate_enforcement()).await.unwrap(); + let stored_id = Uuid::parse_str(&stored.id).unwrap(); + let stored_binding = manager.coding_agent_binding(stored_id).await.unwrap(); + let stored_seed: crate::config::sessions_persistence::CodingAgentBindingSeed = serde_json::from_value(stored_binding.seed.unwrap()).unwrap(); + assert_eq!(stored_seed.configuration_ref, Some(f_ref)); + assert_eq!(stored_binding.id, original.id); + assert_eq!(stored_binding.origin, original.origin); + config["tooling"].as_object_mut().unwrap().remove("configurationRef"); + std::fs::write(&config_path, serde_json::to_vec(&config).unwrap()).unwrap(); + let legacy = super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, settings.inner(), + stored_id, None, None, None, true, Some(true), true, + crate::session::selection::TrustedRestartIntent::User, None, + crate::config::sessions_persistence::default_creation_gate_enforcement()).await.unwrap(); + let legacy_binding = manager.coding_agent_binding(Uuid::parse_str(&legacy.id).unwrap()).await.unwrap(); + assert_eq!(legacy_binding.id, original.id); + assert_eq!(legacy_binding.origin, original.origin); + assert!(legacy_binding.seed.unwrap().get("configurationRef").is_none()); + close_test_coordinator(&app).await; + } + #[tokio::test] async fn derived_agent_id_binding_creation_restart_and_plain_shell() { let cwd = tempfile::tempdir().unwrap(); @@ -13678,6 +14270,7 @@ exec claude \"$@\" None, CreationGateEnforcement::Enforce, None, + None, ) .await; let err = match err { @@ -13744,6 +14337,7 @@ exec claude \"$@\" None, CreationGateEnforcement::Enforce, None, + None, ) .await; assert!( diff --git a/src-tauri/src/config/agent_command.rs b/src-tauri/src/config/agent_command.rs index 37a2cabfc..8a6a15732 100644 --- a/src-tauri/src/config/agent_command.rs +++ b/src-tauri/src/config/agent_command.rs @@ -63,7 +63,7 @@ pub struct NormalizedAgentCommand { #[derive(Debug, Clone)] pub struct AgentSpawnCommand { - pub(crate) source_seed: Option, + pub(crate) source_seed: Option, pub shell: String, pub shell_args: Vec, pub agent_env: BTreeMap, diff --git a/src-tauri/src/config/settings.rs b/src-tauri/src/config/settings.rs index 0b8afda39..f0d06795c 100644 --- a/src-tauri/src/config/settings.rs +++ b/src-tauri/src/config/settings.rs @@ -18527,6 +18527,15 @@ fn compose_captured_sources( } impl SettingsOwner { + /// Runtime provenance uses only the active owner's retained settings locator. + pub(crate) fn runtime_source_settings_path(&self) -> Result { + self.revalidate().map_err(|error| error.to_string())?; + let lease = self.lease.lock().unwrap_or_else(|error| error.into_inner()); + let lease = lease.as_ref().ok_or("sourceContextUnavailable")?; + lease.verify().map_err(|_| "ownerIntegrityLost")?; + Ok(lease.settings_path.clone()) + } + /// StagedCompatibility owner around already-loaded settings: the P40 /// constructor path. Ready, with managed operations disabled. fn staged(settings: AppSettings) -> Self { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 72be96e2f..80241c745 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2356,14 +2356,14 @@ fn resolve_current_persisted_restore( persisted: &mut config::sessions_persistence::PersistedSession, mode: commands::session::SourceRestoreMode, settings: &config::settings::AppSettings, - settings_path: &std::path::Path, + settings_path: Result, ) -> Result, String> { use commands::session::SourceRestoreDecision; let reference = persisted.coding_agent_binding_seed.as_ref() .and_then(|seed| seed.configuration_ref.as_ref()); - let snapshot = reference.map(|reference| config::settings::capture_runtime_source( - settings, settings_path, &reference.source, - )).unwrap_or_else(|| Err("legacyHasNoSource".into())); + let snapshot = reference.map(|reference| settings_path.and_then(|path| config::settings::capture_runtime_source( + settings, &path, &reference.source, + ))).unwrap_or_else(|| Err("legacyHasNoSource".into())); let target_profile = match (reference, snapshot.as_ref()) { (Some(reference), Ok(snapshot)) => { let scoped = config::settings::resolve_runtime_source_row(settings, snapshot, reference)?; @@ -2437,23 +2437,21 @@ fn start_native_restore_startup( return; } }; - for ps in &mut persisted { + let mut failed_recoverable: Vec = Vec::new(); + persisted.retain_mut(|ps| { if let Err(error) = ps.capture_binding_seed(&settings_snapshot) { - log::error!( - "[restore] binding seed unavailable for '{}': {}", - ps.name, - error - ); - } - } + log::error!("[restore] binding seed unavailable for '{}': {}", ps.name, error); + failed_recoverable.push(ps.clone()); + false + } else { true } + }); let mut active_id = None; - let mut failed_recoverable: Vec = Vec::new(); // #248 Grinch Z5 — count outcomes for the end-of-restore summary line. let mut n_woken: usize = 0; let mut n_deferred: usize = 0; - let root_agent_path = match crate::config::root_agent::ensure_root_agent_dir() { + let root_agent_path = match crate::config::root_agent::root_agent_dir() { Ok(path) => Some(path), Err(e) => { log::error!( @@ -2463,7 +2461,7 @@ fn start_native_restore_startup( None } }; - let root_ps = persisted + let mut root_ps = persisted .iter() .find(|ps| { ps.is_root_agent @@ -2471,14 +2469,28 @@ fn start_native_restore_startup( }) .cloned(); - if let Some(root_path) = root_agent_path.clone() { + let root_capture_failed = failed_recoverable.iter().any(|ps| ps.is_root_agent + || crate::config::root_agent::is_root_agent_path(&ps.working_directory)); + let original_root_ps = root_ps.clone(); + let root_source = if let Some(ps) = root_ps.as_mut() { + let mode = if should_wake_root_agent_on_restore(ps.status.as_ref()) { + commands::session::SourceRestoreMode::Wake + } else { commands::session::SourceRestoreMode::Defer }; + resolve_current_persisted_restore(ps, mode, &settings_snapshot, settings_state_clone.runtime_source_settings_path()) + } else { Ok(None) }; + let root_source_failed = root_source.is_err(); + if let Err(error) = root_source.as_ref() { + log::error!("[restore] source Root refused: {error}"); + } + let root_source_spawn = root_source.ok().flatten(); + if let Some(root_path) = root_agent_path.clone().filter(|_| !root_source_failed && !root_capture_failed) { if shutdown_for_body.token().is_cancelled() { // 14.4 - exit during restore: never spawn a root PTY // once shutdown is underway (it would be orphaned until // the next boot's container-orphan cleanup). Keep the // row for next boot via the failed_recoverable merge. if let Some(ps) = root_ps.as_ref() { - failed_recoverable.push(ps.clone()); + failed_recoverable.push(original_root_ps.as_ref().unwrap_or(ps).clone()); } } else { match root_ps.as_ref() { @@ -2526,7 +2538,7 @@ fn start_native_restore_startup( } Some(ps) if ps.binding_metadata().is_err() => { log::error!("[restore] ambiguous Root binding; preserving recipe"); - failed_recoverable.push(ps.clone()); + failed_recoverable.push(original_root_ps.as_ref().unwrap_or(ps).clone()); } Some(ps) if should_wake_root_agent_on_restore(ps.status.as_ref()) => { let existing_root = { @@ -2589,7 +2601,17 @@ fn start_native_restore_startup( } if should_create { let mut rebuild_failed = false; - let resolved_spawn = if ps.agent_id.is_some() + let resolved_spawn = if let Some(spawn) = root_source_spawn.clone() { + match config::agent_command::prepare_agent_spawn_command(&spawn) { + Ok(()) => Some(spawn), + Err(error) => { + log::error!("[restore] source Root preparation refused: {error}"); + failed_recoverable.push(original_root_ps.as_ref().unwrap_or(ps).clone()); + rebuild_failed = true; + None + } + } + } else if ps.agent_id.is_some() && settings_snapshot.agents_layer.unreadable_path().is_some() { // #2716 (B3, 4.5b option S): skip, and push nothing to @@ -2615,7 +2637,7 @@ fn start_native_restore_startup( ps.name, e ); - failed_recoverable.push(ps.clone()); + failed_recoverable.push(original_root_ps.as_ref().unwrap_or(ps).clone()); rebuild_failed = true; None } @@ -2636,6 +2658,13 @@ fn start_native_restore_startup( } else { None }; + if !rebuild_failed { + if let Err(error) = crate::config::root_agent::ensure_root_agent_dir() { + log::error!("[restore] Root provisioning refused: {error}"); + failed_recoverable.push(original_root_ps.as_ref().unwrap_or(ps).clone()); + rebuild_failed = true; + } + } if !rebuild_failed { let (shell, shell_args, agent_label) = if let Some(spawn) = resolved_spawn.as_ref() { @@ -2734,7 +2763,7 @@ fn start_native_restore_startup( ps.name, e ); - failed_recoverable.push(ps.clone()); + failed_recoverable.push(original_root_ps.as_ref().unwrap_or(ps).clone()); } } } @@ -2793,7 +2822,7 @@ fn start_native_restore_startup( ps.name, e ); - failed_recoverable.push(ps.clone()); + failed_recoverable.push(original_root_ps.as_ref().unwrap_or(ps).clone()); } } } @@ -2801,7 +2830,7 @@ fn start_native_restore_startup( } } } else if let Some(ps) = root_ps.as_ref() { - failed_recoverable.push(ps.clone()); + failed_recoverable.push(original_root_ps.as_ref().unwrap_or(ps).clone()); } let archived_roots = sessions_persistence::normalize_project_roots( @@ -2887,6 +2916,21 @@ fn start_native_restore_startup( persisted_working, ); + let original_ps = ps; + let mut current_ps = ps.clone(); + let mode = if wake { commands::session::SourceRestoreMode::Wake } + else { commands::session::SourceRestoreMode::Defer }; + let source_spawn = match resolve_current_persisted_restore( + &mut current_ps, mode, &settings_snapshot, settings_state_clone.runtime_source_settings_path(), + ) { + Ok(spawn) => spawn, + Err(error) => { + log::error!("[restore] source refused '{}': {error}", ps.name); + failed_recoverable.push(original_ps.clone()); + continue; + } + }; + let ps = ¤t_ps; if !wake { // Defer: create a dormant Session record (no PTY, status = Exited(0)). match restore_transaction_for_task @@ -2921,7 +2965,7 @@ fn start_native_restore_startup( } Err(e) => { log::error!("Failed to create deferred session '{}': {}", ps.name, e); - failed_recoverable.push(ps.clone()); + failed_recoverable.push(original_ps.clone()); } } continue; @@ -2929,7 +2973,14 @@ fn start_native_restore_startup( // Wake: rebuild configured-agent sessions from the persisted recipe, // while custom-shell records keep their materialized shell args. - let resolved_spawn = if ps.agent_id.is_some() + let resolved_spawn = if let Some(spawn) = source_spawn { + if let Err(error) = config::agent_command::prepare_agent_spawn_command(&spawn) { + log::error!("[restore] source preparation refused '{}': {error}", ps.name); + failed_recoverable.push(original_ps.clone()); + continue; + } + Some(spawn) + } else if ps.agent_id.is_some() && settings_snapshot.agents_layer.unreadable_path().is_some() { // #2716 (B3, 4.5b option S): skip without pushing to failed_recoverable. @@ -2952,7 +3003,7 @@ fn start_native_restore_startup( ps.name, e ); - failed_recoverable.push(ps.clone()); + failed_recoverable.push(original_ps.clone()); continue; } } @@ -3108,7 +3159,7 @@ fn start_native_restore_startup( Err(e) => { log::error!("Failed to restore session '{}': {}", ps.name, e); // Preserve for next startup attempt (CWD exists, transient failure) - failed_recoverable.push(ps.clone()); + failed_recoverable.push(original_ps.clone()); } } } @@ -9366,6 +9417,98 @@ mod tests { ); } + fn e2432_assert_current_source_restore(root: bool, deferred: bool) { + use crate::commands::session::{e2432_physical_restore_fixture, SourceRestoreMode}; + let temp = tempfile::tempdir().unwrap(); + let (mut host, path, mut persisted) = e2432_physical_restore_fixture(&temp.path().join("F"), "F-old"); + persisted.is_root_agent = root; + let (global, g_path, g) = e2432_physical_restore_fixture(&temp.path().join("G"), "G-old"); + let g_seed = g.coding_agent_binding_seed.as_ref().unwrap(); + let g_snapshot = config::settings::capture_runtime_source(&global, + &g_path, &g_seed.configuration_ref.as_ref().unwrap().source).unwrap(); + host.agents = config::settings::agent_file_projection(&g_snapshot).unwrap().agents; + host.agents_layer = config::settings::AgentsLayerState::Unreadable { + path: temp.path().join("global-invalid"), reason:"global unreadable decoy".into(), + }; + let original = serde_json::to_value(&persisted).unwrap(); + e2432_physical_restore_fixture(&temp.path().join("G"), "G-new"); + let mut carrier: PersistedSession = serde_json::from_value(original.clone()).unwrap(); + let id = carrier.coding_agent_binding_id.clone(); + let origin = carrier.coding_agent_binding_origin.clone(); + if deferred { + assert!(resolve_current_persisted_restore(&mut carrier, SourceRestoreMode::Defer, &host, Ok(path.clone())).unwrap().is_none()); + carrier = serde_json::from_value(serde_json::to_value(&carrier).unwrap()).unwrap(); + } + let first = resolve_current_persisted_restore(&mut carrier, SourceRestoreMode::Wake, &host, Ok(path.clone())).unwrap().unwrap(); + assert!(first.shell.contains("tool")); + assert_eq!(first.profile_resolution.cell.env.get("SOURCE").map(String::as_str), Some("F-old")); + assert_eq!(first.trusted_agent_id, "same"); + assert_eq!(first.backend.image.as_deref(), Some("F-old")); + assert!(first.source_seed.is_some()); + let first_seed = carrier.coding_agent_binding_seed.clone().unwrap(); + e2432_physical_restore_fixture(&temp.path().join("F"), "F-current"); + let current = resolve_current_persisted_restore(&mut carrier, SourceRestoreMode::Wake, &host, Ok(path.clone())).unwrap().unwrap(); + assert_eq!(current.profile_resolution.cell.env.get("SOURCE").map(String::as_str), Some("F-current")); + assert_ne!(carrier.coding_agent_binding_seed, Some(first_seed)); + assert_eq!(carrier.coding_agent_binding_id, id); + assert_eq!(carrier.coding_agent_binding_origin, origin); + assert_eq!(serde_json::to_value(&persisted).unwrap(), original); + let source_file = path.with_file_name(config::instance_artifacts::AGENTS_INSTANCE_FILE_NAME); + let valid = std::fs::read(&source_file).unwrap(); + for replacement in [Some(b"{\"agents\":[]}".to_vec()), None] { + if let Some(bytes) = replacement { std::fs::write(&source_file, bytes).unwrap(); } + else { std::fs::remove_file(&source_file).unwrap(); } + let before = serde_json::to_value(&carrier).unwrap(); + assert!(resolve_current_persisted_restore(&mut carrier, SourceRestoreMode::Wake, &host, Ok(path.clone())).is_err()); + assert_eq!(serde_json::to_value(&carrier).unwrap(), before); + std::fs::write(&source_file, &valid).unwrap(); + } + let mut changed: serde_json::Value = serde_json::from_slice(&valid).unwrap(); + changed["agents"][0]["baseCommand"] = serde_json::json!("changed-base"); + std::fs::write(&source_file, serde_json::to_vec(&changed).unwrap()).unwrap(); + assert!(resolve_current_persisted_restore(&mut carrier, SourceRestoreMode::Wake, &host, Ok(path.clone())).is_err()); + std::fs::write(&source_file, &valid).unwrap(); + carrier.coding_agent_binding_conflict = true; + assert!(resolve_current_persisted_restore(&mut carrier, SourceRestoreMode::Wake, &host, Ok(path.clone())).is_err()); + carrier.coding_agent_binding_conflict = false; + carrier.coding_agent_binding_seed.as_mut().unwrap().identity_revision = "malformed".into(); + assert!(resolve_current_persisted_restore(&mut carrier, SourceRestoreMode::Wake, &host, Ok(path.clone())).is_err()); + assert!(matches!(prepare_persisted_restore(&persisted, SourceRestoreMode::Wake, + &host, Err("source unreadable".into()), None), crate::commands::session::SourceRestoreDecision::Refuse { .. })); + let legacy = PersistedSession::default(); + assert!(matches!(prepare_persisted_restore(&legacy, SourceRestoreMode::Wake, + &host, Err("no source".into()), None), crate::commands::session::SourceRestoreDecision::Legacy)); + } + + #[test] + fn e2432_restore_root_source() { e2432_assert_current_source_restore(true, false); } + + #[test] + fn e2432_restore_ordinary_source() { e2432_assert_current_source_restore(false, false); } + + #[test] + fn e2432_restore_deferred_source() { + e2432_assert_current_source_restore(false, true); + e2432_assert_current_source_restore(true, true); + } + + #[test] + fn e2432_restore_production_source_first_wiring() { + let source = include_str!("lib.rs"); + let start = source.find("fn start_native_restore_startup(").unwrap(); + let body = &source[start..source.find("fn e2432_assert_current_source_restore(").unwrap()]; + let root = body.find("let root_source =").unwrap(); + let root_global = body.find("settings_snapshot.agents_layer.unreadable_path()").unwrap(); + assert!(root < root_global); + let ordinary = body.find("let source_spawn = match").unwrap(); + let ordinary_global = body.rfind("settings_snapshot.agents_layer.unreadable_path()").unwrap(); + assert!(ordinary < ordinary_global); + assert!(body[root..].contains("SourceRestoreMode::Defer")); + assert!(body[ordinary..].contains("restore_dormant_inline")); + assert!(body.contains("resolve_current_persisted_restore")); + assert!(body.contains("!root_source_failed && !root_capture_failed")); + } + fn persisted_row(name: &str, was_active: bool) -> PersistedSession { PersistedSession { name: name.to_string(), diff --git a/src-tauri/src/session/manager.rs b/src-tauri/src/session/manager.rs index d7479dba2..d792e985e 100644 --- a/src-tauri/src/session/manager.rs +++ b/src-tauri/src/session/manager.rs @@ -368,15 +368,38 @@ enum FinalizeMutation { Dormant(PendingCreateBinding, DormantRuntimeWitness, i32), } +/// Owned restart capability; metadata remains private until the atomic commit. +#[derive(Debug)] +pub(crate) struct RestartBindingSeedTransition { + target: PendingCreateBinding, + retiring_runtime: Uuid, + coding_agent_id: String, + expected_seed: Option, + expected_origin: Option, + desired_seed: serde_json::Value, +} + #[derive(Debug, Default)] pub(crate) struct LifecycleMutations { removals: Vec, mark_exited: Vec<(Uuid, i32)>, detached_intent: Vec<(Uuid, bool)>, finalizations: Vec, + restart_binding: Option, } impl LifecycleMutations { + pub(crate) fn set_restart_binding( + &mut self, + transition: RestartBindingSeedTransition, + ) -> Result<(), String> { + if self.restart_binding.is_some() { + return Err("duplicate restart binding transition".into()); + } + self.restart_binding = Some(transition); + Ok(()) + } + pub(crate) fn remove(&mut self, session_id: Uuid) { self.removals.push(session_id); } @@ -2761,6 +2784,29 @@ impl SessionManager { Ok(()) } + /// Stage only the pending runtime association; seed, origin and recipes stay intact. + pub(crate) async fn stage_pending_restart_binding( + &self, + target: PendingCreateBinding, + retiring_runtime: Uuid, + expected: CodingAgentBinding, + desired_seed: serde_json::Value, + ) -> Result { + let permit = acquire_carrier_mutation_permit().await; + let mut state = self.carrier_state_write(&permit).await; + let transition = RestartBindingSeedTransition { + target, + retiring_runtime, + coding_agent_id: expected.id, + expected_seed: expected.seed, + expected_origin: expected.origin, + desired_seed, + }; + validate_restart_binding(&state, &transition, false)?; + state.coding_agent_bindings.insert(target.session_id, transition.coding_agent_id.clone()); + Ok(transition) + } + pub(crate) async fn coding_agent_binding(&self, id: Uuid) -> Option { let state = self.state.read().await; let binding = state.coding_agent_bindings.get(&id)?; @@ -2970,6 +3016,23 @@ impl SessionManager { state.revision }; + // Prevalidate the projection while the same guard protects application. + match (target_mode, target_id, target_status.as_ref()) { + (SelectionMode::None, None, _) => {} + (SelectionMode::Live, Some(id), _) if state.sessions.contains_key(&id) => {} + (SelectionMode::Dormant, Some(id), Some(SessionStatus::Exited(_))) + if state.sessions.contains_key(&id) => {} + _ => return Err("invalid canonical selection target".into()), + } + if let Some(transition) = mutations.restart_binding.as_ref() { + validate_restart_binding(&state, transition, true)?; + if !mutations.removals.contains(&transition.retiring_runtime) + || !mutations.finalizations.iter().any(|mutation| matches!(mutation, + FinalizeMutation::Live(binding, _) if *binding == transition.target)) { + return Err("restart binding requires atomic replacement".into()); + } + } + let mut result = CommitResult::default(); let mut changed_ids = HashSet::new(); // #1149 - ids only. The record itself is built after every fallible exit @@ -2982,12 +3045,6 @@ impl SessionManager { state.pending_create.remove(&binding.session_id); clear_failed_recipe_for_runtime_binding(&mut state, binding.session_id); if let Some(record) = state.sessions.get_mut(&binding.session_id) { - if matches!(record.status, SessionStatus::Exited(_)) { - return Err(format!( - "live finalization became exited before commit: {}", - binding.session_id - )); - } record.status = SessionStatus::Running; finalized_live_ids.push(binding.session_id); result.finalized_rows.push(SessionInfo::from(&*record)); @@ -3068,7 +3125,7 @@ impl SessionManager { let target = state .sessions .get_mut(&id) - .ok_or_else(|| format!("live target disappeared during commit: {id}"))?; + .expect("live selection target prevalidated under the same guard"); target.status = SessionStatus::Active; changed_ids.insert(id); SessionSelection::live(state.epoch, next_revision, cause, id) @@ -3080,7 +3137,7 @@ impl SessionManager { }; SessionSelection::dormant(state.epoch, next_revision, cause, id, code, has_pty) } - _ => return Err("invalid canonical selection target".to_string()), + _ => unreachable!("canonical selection projection prevalidated"), }; result.selection = Some(state.selection.clone()); } @@ -3120,6 +3177,10 @@ impl SessionManager { // to `Active` still reads as working and is recorded. append_finalized_live_activity(&state, finalized_live_ids, &mut result); + // Last publication: no fallible operation or await follows. + if let Some(transition) = mutations.restart_binding { + state.binding_seeds.insert(transition.coding_agent_id, transition.desired_seed); + } Ok(result) } @@ -3277,6 +3338,39 @@ fn append_finalized_live_activity( /// Lifecycle mutation sets in removals, exits, finalizations order. type LifecycleMutationSets = (HashSet, HashSet, HashSet); +fn validate_restart_binding( + state: &SessionManagerState, + transition: &RestartBindingSeedTransition, + staged: bool, +) -> Result<(), String> { + let id = &transition.coding_agent_id; + if Uuid::parse_str(id).is_err() + || !["identityRevision", "originalAgentId"].iter().all(|key| { + transition.desired_seed.get(key).and_then(serde_json::Value::as_str) + .is_some_and(|value| !value.is_empty()) + }) { + return Err("invalid_coding_agent_binding_seed".into()); + } + if !binding_matches(state, transition.target) + || transition.target.session_id == transition.retiring_runtime + || !state.sessions.contains_key(&transition.retiring_runtime) + || state.pending_create.contains_key(&transition.retiring_runtime) + || state.coding_agent_bindings.get(&transition.retiring_runtime) != Some(id) + || state.coding_agent_bindings.get(&transition.target.session_id) + != if staged { Some(id) } else { None } + || state.coding_agent_bindings.iter().any(|(runtime, candidate)| { + candidate == id && *runtime != transition.retiring_runtime + && (!staged || *runtime != transition.target.session_id) + }) { + return Err("restart coding-agent binding owner/capability mismatch".into()); + } + if state.binding_seeds.get(id) != transition.expected_seed.as_ref() + || state.binding_origins.get(id) != transition.expected_origin.as_ref() { + return Err("restart coding-agent binding metadata changed".into()); + } + Ok(()) +} + fn validate_lifecycle_mutations( state: &SessionManagerState, mutations: &LifecycleMutations, @@ -5762,6 +5856,237 @@ mod tests { .expect("pending session") } + async fn e2432_manager_fingerprint(manager: &SessionManager) -> serde_json::Value { + let snapshot = manager.aggregate_snapshot().await; + let mut pending: Vec<_> = snapshot.pending_ids.into_iter().collect(); + pending.sort(); + serde_json::json!({ + "sessions": snapshot.sessions, "order": snapshot.order, "pending": pending, + "bindings": snapshot.coding_agent_bindings, "seeds": snapshot.binding_seeds, + "origins": snapshot.binding_origins, "recipes": snapshot.failed_binding_recipes, + "selection": snapshot.selection, + }) + } + + async fn e2432_bound_old( + manager: &SessionManager, + seed: Option, + origin: Option, + ) -> (Uuid, CodingAgentBinding) { + let (old, pending) = pending_fixture(manager, false).await; + let binding = CodingAgentBinding { id: Uuid::new_v4().to_string(), seed, origin }; + manager.install_pending_coding_agent_binding(pending, Some(binding.clone())).await.unwrap(); + let mut mutations = LifecycleMutations::default(); + mutations.finalize_dormant(pending, dormant_witness(old.id), 0); + manager.commit_selection_transition(&CommitCapability::for_test(), CommitDecision::Keep, + SelectionCause::Restore, mutations).await.unwrap(); + (old.id, binding) + } + + #[tokio::test] + async fn e2432_restart_binding_stages_without_publication_and_commits_last() { + for seed in [None, Some(serde_json::json!({"identityRevision":"old", "originalAgentId":"same", "unknown":null}))] { + for origin in [None, Some(serde_json::Value::Null), Some(serde_json::json!({"ordinal":7}))] { + let manager = SessionManager::new(); + let (old, expected) = e2432_bound_old(&manager, seed.clone(), origin).await; + let (replacement, pending) = pending_fixture(&manager, false).await; + let desired = serde_json::json!({"identityRevision":"fresh", "originalAgentId":"same"}); + let transition = manager.stage_pending_restart_binding(pending, old, expected.clone(), desired.clone()).await.unwrap(); + assert_eq!(manager.coding_agent_binding(old).await.unwrap().seed, expected.seed); + assert_eq!(manager.coding_agent_binding(replacement.id).await.unwrap().seed, expected.seed); + let snapshot = manager.aggregate_snapshot().await; + assert!(snapshot.sessions.iter().any(|row| row.id == old)); + assert!(!snapshot.sessions.iter().any(|row| row.id == replacement.id)); + let mut mutations = LifecycleMutations::default(); + mutations.finalize_live(pending, live_witness(replacement.id)); + mutations.remove(old); + mutations.set_restart_binding(transition).unwrap(); + manager.commit_selection_transition(&CommitCapability::for_test(), CommitDecision::Keep, + SelectionCause::Restore, mutations).await.unwrap(); + assert!(manager.get_session(old).await.is_none()); + assert!(manager.get_session(replacement.id).await.is_some()); + let final_binding = manager.coding_agent_binding(replacement.id).await.unwrap(); + assert_eq!(final_binding.id, expected.id); + assert_eq!(final_binding.origin, expected.origin); + assert_eq!(final_binding.seed, Some(desired)); + } + } + } + + #[tokio::test] + async fn e2432_restart_binding_real_refresh_o1_o2_rejects_without_stale_rollback() { + for refresh_after_stage in [false, true] { + let manager = SessionManager::new(); + let (old, expected) = e2432_bound_old(&manager, None, Some(serde_json::json!("O1"))).await; + let (_, pending) = pending_fixture(&manager, false).await; + let desired = serde_json::json!({"identityRevision":"desired", "originalAgentId":"same"}); + let transition = if refresh_after_stage { + Some(manager.stage_pending_restart_binding(pending, old, expected.clone(), desired.clone()).await.unwrap()) + } else { None }; + let concurrent = CodingAgentBinding { + id: expected.id.clone(), + seed: Some(serde_json::json!({"identityRevision":"O2", "originalAgentId":"same"})), + origin: Some(serde_json::json!("O2")), + }; + manager.refresh_failed_binding_recipes(vec![(concurrent.clone(), serde_json::json!({"recipe":"O2"}))]).await; + let before_rejection = e2432_manager_fingerprint(&manager).await; + if let Some(transition) = transition { + let mut mutations = LifecycleMutations::default(); + mutations.finalize_live(pending, live_witness(pending.session_id)); + mutations.remove(old); + mutations.set_restart_binding(transition).unwrap(); + assert!(manager.commit_selection_transition(&CommitCapability::for_test(), CommitDecision::Keep, + SelectionCause::Restore, mutations).await.is_err()); + } else { + assert!(manager.stage_pending_restart_binding(pending, old, expected, desired).await.is_err()); + } + assert_eq!(e2432_manager_fingerprint(&manager).await, before_rejection); + manager.rollback_pending_create(pending).await.unwrap(); + let retained = manager.coding_agent_binding(old).await.unwrap(); + assert_eq!(retained.seed, concurrent.seed); + assert_eq!(retained.origin, concurrent.origin); + assert!(manager.aggregate_snapshot().await.failed_binding_recipes.is_empty()); + } + } + + #[tokio::test] + async fn e2432_restart_binding_stage_rejections_are_nonmutating() { + for case in 0..8 { + let manager = SessionManager::new(); + let (old, expected) = e2432_bound_old(&manager, None, None).await; + let (_, pending) = pending_fixture(&manager, false).await; + let mut target = pending; + let mut retiring = old; + let mut captured = expected.clone(); + let mut desired = serde_json::json!({"identityRevision":"new", "originalAgentId":"same"}); + match case { + 0 => target.nonce = Uuid::new_v4(), + 1 => retiring = Uuid::new_v4(), + 2 => captured.id = Uuid::new_v4().to_string(), + 3 => captured.seed = Some(serde_json::Value::Null), + 4 => captured.origin = Some(serde_json::Value::Null), + 5 => desired = serde_json::json!({"identityRevision":"", "originalAgentId":"same"}), + 6 => { + let (_, extra) = pending_fixture(&manager, false).await; + manager.install_pending_coding_agent_binding(extra, Some(expected.clone())).await.unwrap(); + } + 7 => manager.install_pending_coding_agent_binding(pending, Some(expected.clone())).await.unwrap(), + _ => unreachable!(), + } + let before = e2432_manager_fingerprint(&manager).await; + assert!(manager.stage_pending_restart_binding(target, retiring, captured, desired).await.is_err(), "case {case}"); + assert_eq!(e2432_manager_fingerprint(&manager).await, before, "case {case}"); + manager.rollback_pending_create(pending).await.unwrap(); + assert!(manager.get_session(old).await.is_some()); + } + } + + #[tokio::test] + async fn e2432_restart_binding_commit_prevalidation_has_no_partial_publication() { + for case in 0..5 { + let manager = SessionManager::new(); + let (old, expected) = e2432_bound_old(&manager, None, None).await; + let (replacement, pending) = pending_fixture(&manager, false).await; + let desired = serde_json::json!({"identityRevision":"new", "originalAgentId":"same"}); + let transition = manager.stage_pending_restart_binding(pending, old, expected.clone(), desired.clone()).await.unwrap(); + let mut witness = live_witness(replacement.id); + let mut decision = CommitDecision::Live(witness); + match case { + 0 => witness.has_pty = false, + 1 => manager.state.write().await.sessions.get_mut(&replacement.id).unwrap().status = SessionStatus::Exited(1), + 2 => decision = CommitDecision::Live(live_witness(Uuid::new_v4())), + 3 => manager.state.write().await.revision = u64::MAX, + 4 => manager.refresh_failed_binding_recipes(vec![(CodingAgentBinding { + id:expected.id.clone(), seed:None, origin:Some(serde_json::json!("O2")), + }, serde_json::json!({}))]).await, + _ => unreachable!(), + } + let before = e2432_manager_fingerprint(&manager).await; + let mut mutations = LifecycleMutations::default(); + mutations.finalize_live(pending, witness); + mutations.remove(old); + mutations.set_restart_binding(transition).unwrap(); + assert!(manager.commit_selection_transition(&CommitCapability::for_test(), decision, + SelectionCause::Restore, mutations).await.is_err(), "case {case}"); + assert_eq!(e2432_manager_fingerprint(&manager).await, before, "case {case}"); + manager.rollback_pending_create(pending).await.unwrap(); + assert!(manager.get_session(old).await.is_some()); + assert_eq!(manager.coding_agent_binding(old).await.unwrap().seed, expected.seed); + } + } + + #[tokio::test] + async fn e2432_restart_binding_orphan_refresh_is_not_a_retiring_owner() { + let manager = SessionManager::new(); + let orphan = CodingAgentBinding { + id: Uuid::new_v4().to_string(), + seed: Some(serde_json::json!({"identityRevision":"current", "originalAgentId":"same"})), + origin: Some(serde_json::json!({"ordinal":2})), + }; + manager.refresh_failed_binding_recipes(vec![(orphan.clone(), serde_json::json!({}))]).await; + let snapshot = manager.aggregate_snapshot().await; + assert!(snapshot.sessions.is_empty()); + assert!(snapshot.failed_binding_recipes.is_empty()); + assert!(snapshot.coding_agent_bindings.is_empty()); + let (_, pending) = pending_fixture(&manager, false).await; + let before = e2432_manager_fingerprint(&manager).await; + assert!(manager.stage_pending_restart_binding(pending, Uuid::new_v4(), orphan.clone(), orphan.seed.clone().unwrap()).await.is_err()); + assert_eq!(e2432_manager_fingerprint(&manager).await, before); + manager.rollback_pending_create(pending).await.unwrap(); + let persisted = serde_json::json!({"id":orphan.id, "seed":orphan.seed, "origin":orphan.origin}); + let persisted: serde_json::Value = serde_json::from_slice(&serde_json::to_vec(&persisted).unwrap()).unwrap(); + for live in [false, true] { + let cold = SessionManager::new(); + let (_, birth) = pending_fixture(&cold, false).await; + cold.install_pending_coding_agent_binding(birth, Some(CodingAgentBinding { + id:persisted["id"].as_str().unwrap().to_string(), + seed:Some(persisted["seed"].clone()), origin:Some(persisted["origin"].clone()), + })).await.unwrap(); + let mut mutations = LifecycleMutations::default(); + if live { mutations.finalize_live(birth, live_witness(birth.session_id)); } + else { mutations.finalize_dormant(birth, dormant_witness(birth.session_id), 0); } + cold.commit_selection_transition(&CommitCapability::for_test(), CommitDecision::Keep, + SelectionCause::Restore, mutations).await.unwrap(); + let restored = cold.coding_agent_binding(birth.session_id).await.unwrap(); + assert_eq!(restored.id, orphan.id); + assert_eq!(restored.seed, orphan.seed); + assert_eq!(restored.origin, orphan.origin); + assert!(cold.get_session(birth.session_id).await.is_some()); + } + } + + #[tokio::test] + async fn e2432_restart_binding_second_transition_and_last_owner_retirement() { + let manager = SessionManager::new(); + let (old, expected) = e2432_bound_old(&manager, Some(serde_json::json!({ + "identityRevision":"old", "originalAgentId":"same", + })), Some(serde_json::json!("original"))).await; + let (_, pending) = pending_fixture(&manager, false).await; + let desired = serde_json::json!({"identityRevision":"desired", "originalAgentId":"same"}); + let transition = manager.stage_pending_restart_binding(pending, old, expected.clone(), desired.clone()).await.unwrap(); + let before = e2432_manager_fingerprint(&manager).await; + assert!(manager.stage_pending_restart_binding(pending, old, expected.clone(), desired.clone()).await.is_err()); + assert_eq!(e2432_manager_fingerprint(&manager).await, before); + let mut mutations = LifecycleMutations::default(); + mutations.set_restart_binding(transition).unwrap(); + let other = SessionManager::new(); + let (other_old, other_expected) = e2432_bound_old(&other, None, None).await; + let (_, other_pending) = pending_fixture(&other, false).await; + let second = other.stage_pending_restart_binding(other_pending, other_old, other_expected, desired).await.unwrap(); + assert!(mutations.set_restart_binding(second).is_err()); + manager.rollback_pending_create(pending).await.unwrap(); + assert_eq!(manager.coding_agent_binding(old).await.unwrap().seed, expected.seed); + let mut remove = LifecycleMutations::default(); + remove.remove(old); + manager.commit_selection_transition(&CommitCapability::for_test(), CommitDecision::Keep, + SelectionCause::Restore, remove).await.unwrap(); + let retired = manager.aggregate_snapshot().await; + assert!(!retired.binding_seeds.contains_key(&expected.id)); + assert!(!retired.binding_origins.contains_key(&expected.id)); + assert!(retired.coding_agent_bindings.is_empty()); + other.rollback_pending_create(other_pending).await.unwrap(); + } + fn live_witness(session_id: Uuid) -> LiveRuntimeWitness { LiveRuntimeWitness { session_id, From a6fbbf96cf2e30755304b016f05c8633cd885002 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Wed, 21 Oct 2026 12:01:55 -0300 Subject: [PATCH 03/14] fix(2899): retain plain-shell create and align Rust formatting --- src-tauri/src/commands/session.rs | 1923 ++++++++++++++++++++++------- src-tauri/src/config/settings.rs | 30 +- src-tauri/src/lib.rs | 302 ++++- src-tauri/src/session/manager.rs | 347 ++++-- 4 files changed, 2025 insertions(+), 577 deletions(-) diff --git a/src-tauri/src/commands/session.rs b/src-tauri/src/commands/session.rs index 3c770839f..1b2da2db9 100644 --- a/src-tauri/src/commands/session.rs +++ b/src-tauri/src/commands/session.rs @@ -2100,7 +2100,8 @@ async fn create_session_inner_impl( restart_stage: Option, ) -> Result { if restart_stage.is_some() - && (!defer_inline_finalization || !matches!(inline_cause, Some(SelectionCause::Restart(_)))) { + && (!defer_inline_finalization || !matches!(inline_cause, Some(SelectionCause::Restart(_)))) + { return Err("restart binding staging requires deferred restart".into()); } // #1327 - startup coding-agent updates gate: no session may open (GUI, @@ -2259,56 +2260,71 @@ async fn create_session_inner_impl( let agent_kind = CodingAgentKind::detect(&shell, &shell_args); let is_agent_owned_launch = agent_id.is_some() || agent_kind.is_some() || is_root_agent; - let source_seed = resolved_spawn.as_ref().and_then(|spawn| spawn.source_seed.as_ref()); + let source_seed = resolved_spawn + .as_ref() + .and_then(|spawn| spawn.source_seed.as_ref()); if let Some(seed) = source_seed { - validate_source_spawn_seed(resolved_spawn.as_ref().expect("source seed has spawn"), seed)?; + validate_source_spawn_seed( + resolved_spawn.as_ref().expect("source seed has spawn"), + seed, + )?; } let coding_agent_binding = if restart_stage.is_some() { coding_agent_binding.clone() } else if let Some(seed) = source_seed { - let mut binding = coding_agent_binding.clone().unwrap_or_else(|| crate::session::manager::CodingAgentBinding { - id: Uuid::new_v4().to_string(), seed: None, origin: None, + let mut binding = coding_agent_binding.clone().unwrap_or_else(|| { + crate::session::manager::CodingAgentBinding { + id: Uuid::new_v4().to_string(), + seed: None, + origin: None, + } }); - binding.seed = Some(serde_json::to_value(validate_source_spawn_seed( - resolved_spawn.as_ref().expect("source seed has spawn"), seed, - )?).map_err(|error| error.to_string())?); + binding.seed = Some( + serde_json::to_value(validate_source_spawn_seed( + resolved_spawn.as_ref().expect("source seed has spawn"), + seed, + )?) + .map_err(|error| error.to_string())?, + ); Some(binding) - } else { match coding_agent_binding.clone() { - Some(binding) => Some(binding), - None => match agent_id.as_ref() { - Some(original_agent_id) => { - let settings_state = app.state::(); - let cfg = settings_state - .current_read() - .await - .map_err(|error| error.to_string())?; - let id = Uuid::new_v4().to_string(); - let seed = match crate::config::settings::bootstrap_binding_revision( - &cfg, - original_agent_id, - ) { - Ok(identity_revision) => Some(serde_json::json!({ - "identityRevision": identity_revision, - "originalAgentId": original_agent_id, - })), - Err(error) => { - log::warn!( - "[session] binding seed unavailable for '{}': {}", - original_agent_id, - error - ); - None - } - }; - Some(crate::session::manager::CodingAgentBinding { - id, - seed, - origin: None, - }) - } - None => None, - }, - } }; + } else { + match coding_agent_binding.clone() { + Some(binding) => Some(binding), + None => match agent_id.as_ref() { + Some(original_agent_id) => { + let settings_state = app.state::(); + let cfg = settings_state + .current_read() + .await + .map_err(|error| error.to_string())?; + let id = Uuid::new_v4().to_string(); + let seed = match crate::config::settings::bootstrap_binding_revision( + &cfg, + original_agent_id, + ) { + Ok(identity_revision) => Some(serde_json::json!({ + "identityRevision": identity_revision, + "originalAgentId": original_agent_id, + })), + Err(error) => { + log::warn!( + "[session] binding seed unavailable for '{}': {}", + original_agent_id, + error + ); + None + } + }; + Some(crate::session::manager::CodingAgentBinding { + id, + seed, + origin: None, + }) + } + None => None, + }, + } + }; let resource_monitor = app.state::>().inner().clone(); let mut resource_permit = if is_agent_owned_launch { let cfg = app @@ -2449,11 +2465,17 @@ async fn create_session_inner_impl( }; let admission = if let Some(stage) = restart_stage { mgr.stage_pending_restart_binding( - pending_binding, stage.retiring_runtime, stage.expected, stage.desired_seed, - ).await.map(Some) + pending_binding, + stage.retiring_runtime, + stage.expected, + stage.desired_seed, + ) + .await + .map(Some) } else { mgr.install_pending_coding_agent_binding(pending_binding, coding_agent_binding) - .await.map(|()| None) + .await + .map(|()| None) }; let restart_binding = match admission { Ok(transition) => transition, @@ -3376,7 +3398,10 @@ async fn create_session_inner_impl( // Without this fallback, callers that pass agent_id but no label (session-requests, // web remote) would write app: "Unknown" into the per-instance config.json. // #2433 - computed under the settings read lock, written after it drops. - let write = if let Some(spawn) = resolved_spawn.as_ref().filter(|spawn| spawn.source_seed.is_some()) { + let write = if let Some(spawn) = resolved_spawn + .as_ref() + .filter(|spawn| spawn.source_seed.is_some()) + { Some(LastCodingAgentWrite { agent_id: spawn.trusted_agent_id.clone(), label: spawn.trusted_agent_label.clone(), @@ -3580,7 +3605,9 @@ pub(crate) enum SourceRestoreDecision { updated_seed: crate::config::sessions_persistence::CodingAgentBindingSeed, binding: crate::session::manager::CodingAgentBinding, }, - Refuse { diagnostic: String }, + Refuse { + diagnostic: String, + }, } /// No IO: the caller captures the authorized file and actual-target policy. @@ -3599,7 +3626,9 @@ pub(crate) fn prepare_source_restore_decision( use crate::config::settings::{self, SourceRevision}; let prepared: Result = (|| { let binding = binding?; - let Some(seed) = seed else { return Ok(SourceRestoreDecision::Legacy) }; + let Some(seed) = seed else { + return Ok(SourceRestoreDecision::Legacy); + }; let Some(reference) = seed.configuration_ref.as_ref() else { return if seed.identity_revision.starts_with("s1:") { Err("invalidSourceSeed".into()) @@ -3609,20 +3638,31 @@ pub(crate) fn prepare_source_restore_decision( }; let pair = crate::config::coding_agent_profiles::ReplicaSelectionPair { coding_agent_id: seed.original_agent_id.clone(), - requested_profile: reference.requested_profile.clone().unwrap_or_else(|| "A".into()), + requested_profile: reference + .requested_profile + .clone() + .unwrap_or_else(|| "A".into()), }; crate::config::coding_agent_profiles::validate_configuration_ref_pair(reference, &pair)?; let suffix = format!(":{}", reference.calculated_id); - let digest = seed.identity_revision.strip_prefix("s1:") - .and_then(|value| value.strip_suffix(&suffix)).ok_or("invalidSourceSeed")?; - if digest.len() != 64 || !digest.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) { + let digest = seed + .identity_revision + .strip_prefix("s1:") + .and_then(|value| value.strip_suffix(&suffix)) + .ok_or("invalidSourceSeed")?; + if digest.len() != 64 + || !digest + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + { return Err("invalidSourceSeed".into()); } let snapshot = snapshot?; let scoped_settings = settings::resolve_runtime_source_row(host, &snapshot, reference)?; let entry_hint = seed.original_agent_id.clone(); // No launch_path: the pure seam cannot reread aliases or disk defaults. - let name = crate::config::coding_agent_profiles::agent_name_from_dir(std::path::Path::new(cwd)); + let name = + crate::config::coding_agent_profiles::agent_name_from_dir(std::path::Path::new(cwd)); let resolution = crate::config::coding_agent_profiles::resolve_profile( &scoped_settings, crate::config::coding_agent_profiles::ProfileResolutionRequest { @@ -3635,8 +3675,14 @@ pub(crate) fn prepare_source_restore_decision( ); let agent = &scoped_settings.agents[0]; let mut reference = reference.clone(); - reference.profile_calculated_id = Some(settings::pair_identity(&agent.command, &agent.envs, &resolution.cell)); - let SourceRevision::Bytes { sha256 } = snapshot.revision else { return Err("sourceUnavailable".into()) }; + reference.profile_calculated_id = Some(settings::pair_identity( + &agent.command, + &agent.envs, + &resolution.cell, + )); + let SourceRevision::Bytes { sha256 } = snapshot.revision else { + return Err("sourceUnavailable".into()); + }; let updated_seed = crate::config::sessions_persistence::CodingAgentBindingSeed { identity_revision: format!("s1:{sha256}:{}", reference.calculated_id), original_agent_id: entry_hint.clone(), @@ -3647,10 +3693,16 @@ pub(crate) fn prepare_source_restore_decision( binding.seed = Some(serde_json::to_value(&updated_seed).map_err(|e| e.to_string())?); Ok(match mode { SourceRestoreMode::Wake => SourceRestoreDecision::SourceWake { - scoped_settings, entry_hint, effective_profile: resolution.effective_profile, - updated_seed, binding, + scoped_settings, + entry_hint, + effective_profile: resolution.effective_profile, + updated_seed, + binding, + }, + SourceRestoreMode::Defer => SourceRestoreDecision::SourceDeferred { + updated_seed, + binding, }, - SourceRestoreMode::Defer => SourceRestoreDecision::SourceDeferred { updated_seed, binding }, }) })(); prepared.unwrap_or_else(|diagnostic| SourceRestoreDecision::Refuse { diagnostic }) @@ -3666,14 +3718,30 @@ pub(crate) fn resolve_source_wake_spawn( seed: &crate::config::sessions_persistence::CodingAgentBindingSeed, ) -> Result { let mut spawn = crate::config::agent_command::resolve_agent_spawn_command( - scoped, hint, Some(std::path::Path::new(cwd)), Some(effective), true, + scoped, + hint, + Some(std::path::Path::new(cwd)), + Some(effective), + true, )?; - let agent = scoped.agents.iter().find(|agent| agent.id == hint).ok_or("sourceRowMissing")?; - let reference = seed.configuration_ref.as_ref().ok_or("sourceReferenceMissing")?; - let pair = crate::config::settings::pair_identity(&agent.command, &agent.envs, &spawn.profile_resolution.cell); + let agent = scoped + .agents + .iter() + .find(|agent| agent.id == hint) + .ok_or("sourceRowMissing")?; + let reference = seed + .configuration_ref + .as_ref() + .ok_or("sourceReferenceMissing")?; + let pair = crate::config::settings::pair_identity( + &agent.command, + &agent.envs, + &spawn.profile_resolution.cell, + ); if seed.original_agent_id != hint || reference.legacy_entry_hint.as_deref() != Some(hint) - || reference.calculated_id != crate::config::settings::configuration_identity(&agent.command, &agent.envs) + || reference.calculated_id + != crate::config::settings::configuration_identity(&agent.command, &agent.envs) || spawn.profile_resolution.effective_profile != effective || reference.profile_calculated_id.as_deref() != Some(pair.as_str()) { @@ -3695,12 +3763,19 @@ fn validate_source_spawn_seed( serde_json::from_value(value.clone()).map_err(|_| "invalidSourceSeed")?; let reference = seed.configuration_ref.as_ref().ok_or("invalidSourceSeed")?; let suffix = format!(":{}", reference.calculated_id); - let digest = seed.identity_revision.strip_prefix("s1:") - .and_then(|value| value.strip_suffix(&suffix)).ok_or("invalidSourceSeed")?; - if digest.len() != 64 || !digest.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) + let digest = seed + .identity_revision + .strip_prefix("s1:") + .and_then(|value| value.strip_suffix(&suffix)) + .ok_or("invalidSourceSeed")?; + if digest.len() != 64 + || !digest + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) || seed.original_agent_id != spawn.trusted_agent_id || reference.legacy_entry_hint.as_deref() != Some(seed.original_agent_id.as_str()) - || reference.profile_calculated_id != spawn.profile_resolution.cell.id_hash { + || reference.profile_calculated_id != spawn.profile_resolution.cell.id_hash + { return Err("invalidSourceSeed".into()); } let pair = crate::config::coding_agent_profiles::ReplicaSelectionPair { @@ -3716,12 +3791,20 @@ fn runtime_replica_expectation( required: bool, ) -> Result, String> { let path = std::path::Path::new(cwd); - let replica = path.file_name().and_then(|name| name.to_str()) + let replica = path + .file_name() + .and_then(|name| name.to_str()) .is_some_and(|name| name.starts_with("__agent_")); if !replica { - return if required { Err("storedSelectionRequiresExistingReplica".into()) } else { Ok(None) }; + return if required { + Err("storedSelectionRequiresExistingReplica".into()) + } else { + Ok(None) + }; } - crate::config::coding_agent_profiles::read_replica_selection_state(path).expectation().map(Some) + crate::config::coding_agent_profiles::read_replica_selection_state(path) + .expectation() + .map(Some) } /// Capture target policy before the pure seam. Only coherent source-null @@ -3731,28 +3814,50 @@ pub(crate) fn source_target_profile( reference: &crate::config::settings::ConfigurationRef, cwd: &str, ) -> Result, String> { - if let Some(letter) = &reference.requested_profile { return Ok(Some(letter.clone())); } + if let Some(letter) = &reference.requested_profile { + return Ok(Some(letter.clone())); + } let path = std::path::Path::new(cwd); let expected = runtime_replica_expectation(cwd, false)?; if let Some(expected) = expected { if expected.profile_override_source.as_deref() != Some("source-default") { - if let Some(pair) = expected.pair { return Ok(Some(pair.requested_profile)); } + if let Some(pair) = expected.pair { + return Ok(Some(pair.requested_profile)); + } } else { // Reuse the marker-aware strict reader and retain its disagreement diagnostic. let read = crate::config::coding_agent_profiles::read_source_null_profile_result(path)?; - if let Some(warning) = read.warning { log::warn!("[source-selection] {warning}"); } - if let Some(letter) = read.profile { return Ok(Some(letter)); } + if let Some(warning) = read.warning { + log::warn!("[source-selection] {warning}"); + } + if let Some(letter) = read.profile { + return Ok(Some(letter)); + } } - let validated = crate::config::coding_agent_profiles::validate_profile_selection_agent_path(scoped, path)?; - if let Some(config) = crate::config::agent_config::read_agent_local_config_json(&validated.origin_matrix_dir)? { - if let Some(letter) = config.pointer("/tooling/defaultProfile").and_then(serde_json::Value::as_str) - .and_then(crate::config::settings::normalize_profile_letter) { + let validated = + crate::config::coding_agent_profiles::validate_profile_selection_agent_path( + scoped, path, + )?; + if let Some(config) = + crate::config::agent_config::read_agent_local_config_json(&validated.origin_matrix_dir)? + { + if let Some(letter) = config + .pointer("/tooling/defaultProfile") + .and_then(serde_json::Value::as_str) + .and_then(crate::config::settings::normalize_profile_letter) + { return Ok(Some(letter)); } } } let name = crate::config::coding_agent_profiles::agent_name_from_dir(path); - Ok(name.and_then(|name| scoped.coding_agent_profiles.default_profile_by_agent.get(&name).cloned())) + Ok(name.and_then(|name| { + scoped + .coding_agent_profiles + .default_profile_by_agent + .get(&name) + .cloned() + })) } #[allow(clippy::too_many_arguments)] @@ -3763,34 +3868,79 @@ fn resolve_runtime_source_spawn( reference: &crate::config::settings::ConfigurationRef, ticket: Option<&crate::config::settings::SourceSelectionTicket>, binding: Option, -) -> Result<(AgentSpawnCommand, crate::session::manager::CodingAgentBinding), String> { - let snapshot = crate::config::settings::capture_runtime_source(settings, settings_path, &reference.source)?; +) -> Result< + ( + AgentSpawnCommand, + crate::session::manager::CodingAgentBinding, + ), + String, +> { + let snapshot = crate::config::settings::capture_runtime_source( + settings, + settings_path, + &reference.source, + )?; if let Some(ticket) = ticket { if ticket.configuration_ref != *reference || ticket.expected_source_revision != snapshot.revision - || ticket.catalog_context_revision != snapshot.context_revision { + || ticket.catalog_context_revision != snapshot.context_revision + { return Err("staleSourceSelectionTicket".into()); } } - let scoped = crate::config::settings::resolve_runtime_source_row(settings, &snapshot, reference)?; + let scoped = + crate::config::settings::resolve_runtime_source_row(settings, &snapshot, reference)?; let target_profile = source_target_profile(&scoped, reference, cwd)?; - let crate::config::settings::SourceRevision::Bytes { sha256 } = &snapshot.revision else { return Err("sourceUnavailable".into()) }; + let crate::config::settings::SourceRevision::Bytes { sha256 } = &snapshot.revision else { + return Err("sourceUnavailable".into()); + }; let seed = crate::config::sessions_persistence::CodingAgentBindingSeed { identity_revision: format!("s1:{sha256}:{}", reference.calculated_id), - original_agent_id: reference.legacy_entry_hint.clone().ok_or("missingSourceHint")?, + original_agent_id: reference + .legacy_entry_hint + .clone() + .ok_or("missingSourceHint")?, configuration_ref: Some(reference.clone()), }; let binding = binding.unwrap_or_else(|| crate::session::manager::CodingAgentBinding { - id: Uuid::new_v4().to_string(), seed: None, origin: None, + id: Uuid::new_v4().to_string(), + seed: None, + origin: None, }); - match prepare_source_restore_decision(Some(&seed), Ok(Some(binding)), None, target_profile.as_deref(), cwd, - SourceRestoreMode::Wake, settings, Ok(snapshot)) { - SourceRestoreDecision::SourceWake { scoped_settings, entry_hint, effective_profile, updated_seed, binding } => { - if ticket.is_some() && reference.requested_profile.is_some() - && reference.profile_calculated_id != updated_seed.configuration_ref.as_ref().and_then(|r| r.profile_calculated_id.clone()) { + match prepare_source_restore_decision( + Some(&seed), + Ok(Some(binding)), + None, + target_profile.as_deref(), + cwd, + SourceRestoreMode::Wake, + settings, + Ok(snapshot), + ) { + SourceRestoreDecision::SourceWake { + scoped_settings, + entry_hint, + effective_profile, + updated_seed, + binding, + } => { + if ticket.is_some() + && reference.requested_profile.is_some() + && reference.profile_calculated_id + != updated_seed + .configuration_ref + .as_ref() + .and_then(|r| r.profile_calculated_id.clone()) + { return Err("staleSourceSelectionPair".into()); } - let spawn = resolve_source_wake_spawn(&scoped_settings, &entry_hint, &effective_profile, cwd, &updated_seed)?; + let spawn = resolve_source_wake_spawn( + &scoped_settings, + &entry_hint, + &effective_profile, + cwd, + &updated_seed, + )?; Ok((spawn, binding)) } SourceRestoreDecision::Refuse { diagnostic } => Err(diagnostic), @@ -3859,30 +4009,52 @@ fn source_profile_outdated( binding: Option, ) -> Option { let seed = match binding.as_ref().and_then(|binding| binding.seed.as_ref()) { - Some(value) => match serde_json::from_value::(value.clone()) { + Some(value) => match serde_json::from_value::< + crate::config::sessions_persistence::CodingAgentBindingSeed, + >(value.clone()) + { Ok(seed) => Some(seed), Err(_) => return Some(false), }, None => None, }; - if seed.as_ref().is_some_and(|seed| seed.identity_revision.starts_with("s1:") && seed.configuration_ref.is_none()) { + if seed.as_ref().is_some_and(|seed| { + seed.identity_revision.starts_with("s1:") && seed.configuration_ref.is_none() + }) { return Some(false); } let replica = match runtime_replica_expectation(&info.working_directory, false) { Ok(replica) => replica, Err(_) => return Some(false), }; - let reference = seed.as_ref().and_then(|seed| seed.configuration_ref.as_ref()) - .or_else(|| replica.as_ref().and_then(|saved| saved.configuration_ref.as_ref()))?; + let reference = seed + .as_ref() + .and_then(|seed| seed.configuration_ref.as_ref()) + .or_else(|| { + replica + .as_ref() + .and_then(|saved| saved.configuration_ref.as_ref()) + })?; let resolved = owner.runtime_source_settings_path().and_then(|path| { - resolve_runtime_source_spawn(settings, &path, &info.working_directory, reference, None, binding.clone()) + resolve_runtime_source_spawn( + settings, + &path, + &info.working_directory, + reference, + None, + binding.clone(), + ) }); Some(match resolved { - Ok((spawn, _)) => info.profile_content_hash.clone().or_else(|| { - crate::config::coding_agent_profiles::read_replica_profile_content_hash( - std::path::Path::new(&info.working_directory), - ) - }).is_some_and(|loaded| loaded != spawn.profile_content_hash), + Ok((spawn, _)) => info + .profile_content_hash + .clone() + .or_else(|| { + crate::config::coding_agent_profiles::read_replica_profile_content_hash( + std::path::Path::new(&info.working_directory), + ) + }) + .is_some_and(|loaded| loaded != spawn.profile_content_hash), Err(_) => false, }) } @@ -3983,14 +4155,30 @@ pub async fn create_session( cols: Option, rows: Option, ) -> Result { - create_session_with_source(app, session_mgr, pty_mgr, _tg_mgr, settings, shell, - shell_args, cwd, session_name, agent_id, requested_profile, None, git_repos, - skip_auto_resume, cols, rows).await + create_session_with_source( + app, + session_mgr, + pty_mgr, + _tg_mgr, + settings, + shell, + shell_args, + cwd, + session_name, + agent_id, + requested_profile, + None, + git_repos, + skip_auto_resume, + cols, + rows, + ) + .await } #[allow(clippy::too_many_arguments)] -pub(crate) async fn create_session_with_source( - app: AppHandle, +pub(crate) async fn create_session_with_source( + app: AppHandle, session_mgr: State<'_, Arc>>, pty_mgr: State<'_, Arc>>, _tg_mgr: State<'_, TelegramBridgeState>, @@ -4016,17 +4204,38 @@ pub(crate) async fn create_session_with_source( let cwd = resolve_create_session_cwd(cwd); - let replica = runtime_replica_expectation(&cwd, false)?; - let reference = source_ticket.as_ref().map(|ticket| ticket.configuration_ref.clone()) - .or_else(|| replica.as_ref().and_then(|saved| saved.configuration_ref.clone())); + let replica = if source_ticket.is_some() || agent_id.is_some() { + runtime_replica_expectation(&cwd, false)? + } else { + None + }; + let reference = source_ticket + .as_ref() + .map(|ticket| ticket.configuration_ref.clone()) + .or_else(|| { + replica + .as_ref() + .and_then(|saved| saved.configuration_ref.clone()) + }); let resolved_spawn = if let Some(reference) = reference.as_ref() { if source_ticket.is_none() - && (agent_id.as_ref().is_some_and(|id| Some(id) != reference.legacy_entry_hint.as_ref()) - || requested_profile.as_ref().is_some_and(|letter| Some(letter) != reference.requested_profile.as_ref())) { + && (agent_id + .as_ref() + .is_some_and(|id| Some(id) != reference.legacy_entry_hint.as_ref()) + || requested_profile + .as_ref() + .is_some_and(|letter| Some(letter) != reference.requested_profile.as_ref())) + { return Err("sourceSelectionProvenanceMismatch".into()); } - let (spawn, _) = resolve_runtime_source_spawn(&cfg, &settings.runtime_source_settings_path()?, &cwd, - reference, source_ticket.as_ref(), None)?; + let (spawn, _) = resolve_runtime_source_spawn( + &cfg, + &settings.runtime_source_settings_path()?, + &cwd, + reference, + source_ticket.as_ref(), + None, + )?; crate::config::agent_command::prepare_agent_spawn_command(&spawn)?; Some(spawn) } else if let Some(aid) = agent_id.as_deref() { @@ -5155,9 +5364,23 @@ pub(crate) async fn restart_session_inner_with_intent( communication_override: Option, enforcement: crate::config::sessions_persistence::CreationGateEnforcement, ) -> Result { - restart_session_inner_with_source(app, _session_mgr, _pty_mgr, _settings, uuid, - agent_id, requested_profile, None, false, skip_auto_resume, activate_after, - intent, communication_override, enforcement).await + restart_session_inner_with_source( + app, + _session_mgr, + _pty_mgr, + _settings, + uuid, + agent_id, + requested_profile, + None, + false, + skip_auto_resume, + activate_after, + intent, + communication_override, + enforcement, + ) + .await } #[allow(clippy::too_many_arguments)] @@ -5419,7 +5642,9 @@ pub(crate) async fn execute_restart_transaction( communication_override, enforcement, } = request; - if use_stored_selection && (agent_id.is_some() || requested_profile.is_some() || source_ticket.is_some()) { + if use_stored_selection + && (agent_id.is_some() || requested_profile.is_some() || source_ticket.is_some()) + { return Err("storedSelectionMixedArguments".into()); } // 1. Read config from existing session BEFORE destroying it @@ -5461,28 +5686,46 @@ pub(crate) async fn execute_restart_transaction( ) }; - if use_stored_selection && is_root_agent { return Err("rootStoredSelectionUnsupported".into()); } + if use_stored_selection && is_root_agent { + return Err("rootStoredSelectionUnsupported".into()); + } let binding_manager = session_mgr.read().await.clone(); let original_binding = binding_manager.coding_agent_binding(uuid).await; - let old_seed = original_binding.as_ref().and_then(|binding| binding.seed.as_ref()) - .map(|seed| serde_json::from_value::(seed.clone())) - .transpose().map_err(|error| error.to_string())?; - if old_seed.as_ref().is_some_and(|seed| seed.identity_revision.starts_with("s1:") && seed.configuration_ref.is_none()) { + let old_seed = original_binding + .as_ref() + .and_then(|binding| binding.seed.as_ref()) + .map(|seed| { + serde_json::from_value::( + seed.clone(), + ) + }) + .transpose() + .map_err(|error| error.to_string())?; + if old_seed.as_ref().is_some_and(|seed| { + seed.identity_revision.starts_with("s1:") && seed.configuration_ref.is_none() + }) { return Err("invalidSourceSeed".into()); } // root_agent_dir is a read-only locator. Provision only after resolution. - let cwd = if is_root_agent { crate::config::root_agent::root_agent_dir()? } else { cwd }; + let cwd = if is_root_agent { + crate::config::root_agent::root_agent_dir()? + } else { + cwd + }; let cwd = crate::path_utils::normalize_windows_verbatim_path(&cwd); - // 2. Strip auto-injected args before restart so the new session starts from the saved recipe. let clean_args = crate::config::sessions_persistence::strip_auto_injected_args(&shell, &shell_args); if use_stored_selection { - let cfg = settings.current_read().await.map_err(|error| error.to_string())?; + let cfg = settings + .current_read() + .await + .map_err(|error| error.to_string())?; crate::config::coding_agent_profiles::validate_profile_selection_agent_path( - &cfg, std::path::Path::new(&cwd), + &cfg, + std::path::Path::new(&cwd), )?; } let replica = runtime_replica_expectation(&cwd, use_stored_selection)?; @@ -5497,13 +5740,25 @@ pub(crate) async fn execute_restart_transaction( selected_legacy_profile = Some(pair.requested_profile.clone()); saved.configuration_ref.clone() } else { - old_seed.as_ref().and_then(|seed| seed.configuration_ref.clone()) - .or_else(|| replica.as_ref().and_then(|saved| saved.configuration_ref.clone())) + old_seed + .as_ref() + .and_then(|seed| seed.configuration_ref.clone()) + .or_else(|| { + replica + .as_ref() + .and_then(|saved| saved.configuration_ref.clone()) + }) }; if let Some(reference) = reference.as_ref() { - if source_ticket.is_none() && !use_stored_selection - && (selected_legacy_agent.as_ref().is_some_and(|id| Some(id) != reference.legacy_entry_hint.as_ref()) - || selected_legacy_profile.as_ref().is_some_and(|letter| Some(letter) != reference.requested_profile.as_ref())) { + if source_ticket.is_none() + && !use_stored_selection + && (selected_legacy_agent + .as_ref() + .is_some_and(|id| Some(id) != reference.legacy_entry_hint.as_ref()) + || selected_legacy_profile + .as_ref() + .is_some_and(|letter| Some(letter) != reference.requested_profile.as_ref())) + { return Err("sourceSelectionProvenanceMismatch".into()); } } @@ -5521,83 +5776,109 @@ pub(crate) async fn execute_restart_transaction( .await .map_err(|error| error.to_string())?; if let Some(reference) = reference.as_ref() { - let (spawn, _) = resolve_runtime_source_spawn(&cfg, &settings.runtime_source_settings_path()?, &cwd, - reference, source_ticket.as_ref(), original_binding.clone())?; - let host = Some(ResolvedAgentHostShell { program: cfg.default_shell.clone(), args: cfg.default_shell_args.clone() }); + let (spawn, _) = resolve_runtime_source_spawn( + &cfg, + &settings.runtime_source_settings_path()?, + &cwd, + reference, + source_ticket.as_ref(), + original_binding.clone(), + )?; + let host = Some(ResolvedAgentHostShell { + program: cfg.default_shell.clone(), + args: cfg.default_shell_args.clone(), + }); (Some(spawn.trusted_agent_id.clone()), Some(spawn), host) } else { - let selection = matched_selection( - &cfg, - &cwd, - requested_agent_id.as_deref(), - stored_agent_id.as_deref(), - selected_requested_profile.as_deref(), - ); - let (selected_agent_id, resolved_spawn) = match selection { - MatchedSelection::NoAgent => (None, None), - MatchedSelection::Unresolved { reference } => { - return Err(format!( + let selection = matched_selection( + &cfg, + &cwd, + requested_agent_id.as_deref(), + stored_agent_id.as_deref(), + selected_requested_profile.as_deref(), + ); + let (selected_agent_id, resolved_spawn) = match selection { + MatchedSelection::NoAgent => (None, None), + MatchedSelection::Unresolved { reference } => { + return Err(format!( "unresolved_coding_agent_reference: '{}' matches no configured coding agent", reference )); - } - MatchedSelection::Agent { - agent_id, - profile_letter, - authoritative, - tier, - original_letter, - } => { - let spawn = resolve_configured_agent_spawn_for_cwd( - &cfg, - &agent_id, - &cwd, - profile_letter.as_deref(), + } + MatchedSelection::Agent { + agent_id, + profile_letter, authoritative, - )? - .map(|mut spawn| { - // A matched restart records the ORIGINAL request; the matched - // letter lives in `effective_profile`, its provenance in the - // #2451 carriers. - if authoritative { - if let Some(original) = selected_requested_profile.as_ref() { - spawn.profile_resolution.requested_profile = original.clone(); + tier, + original_letter, + } => { + let spawn = resolve_configured_agent_spawn_for_cwd( + &cfg, + &agent_id, + &cwd, + profile_letter.as_deref(), + authoritative, + )? + .map(|mut spawn| { + // A matched restart records the ORIGINAL request; the matched + // letter lives in `effective_profile`, its provenance in the + // #2451 carriers. + if authoritative { + if let Some(original) = selected_requested_profile.as_ref() { + spawn.profile_resolution.requested_profile = original.clone(); + } } - } - spawn.match_tier = tier.map(|tier| tier.as_wire_str().to_string()); - spawn.original_profile_letter = original_letter; - spawn - }); - (Some(agent_id), spawn) - } - }; - let resolved_agent_host_shell = if resolved_spawn.is_some() { - Some(ResolvedAgentHostShell { - program: cfg.default_shell.clone(), - args: cfg.default_shell_args.clone(), - }) - } else { - None - }; - (selected_agent_id, resolved_spawn, resolved_agent_host_shell) + spawn.match_tier = tier.map(|tier| tier.as_wire_str().to_string()); + spawn.original_profile_letter = original_letter; + spawn + }); + (Some(agent_id), spawn) + } + }; + let resolved_agent_host_shell = if resolved_spawn.is_some() { + Some(ResolvedAgentHostShell { + program: cfg.default_shell.clone(), + args: cfg.default_shell_args.clone(), + }) + } else { + None + }; + (selected_agent_id, resolved_spawn, resolved_agent_host_shell) } }; let restart_stage = if let Some(expected) = original_binding.as_ref() { - let desired_seed = if let Some(value) = resolved_spawn.as_ref().and_then(|spawn| spawn.source_seed.as_ref()) { + let desired_seed = if let Some(value) = resolved_spawn + .as_ref() + .and_then(|spawn| spawn.source_seed.as_ref()) + { validate_source_spawn_seed(resolved_spawn.as_ref().expect("source spawn"), value)?; Some(value.clone()) - } else if old_seed.as_ref().is_some_and(|seed| seed.configuration_ref.is_some()) { - let cfg = settings.current_read().await.map_err(|error| error.to_string())?; - let original_agent_id = selected_agent_id.as_ref().ok_or("legacyReplacementRequiresConfiguredAgent")?; + } else if old_seed + .as_ref() + .is_some_and(|seed| seed.configuration_ref.is_some()) + { + let cfg = settings + .current_read() + .await + .map_err(|error| error.to_string())?; + let original_agent_id = selected_agent_id + .as_ref() + .ok_or("legacyReplacementRequiresConfiguredAgent")?; Some(serde_json::json!({ "identityRevision": crate::config::settings::bootstrap_binding_revision(&cfg, original_agent_id)?, "originalAgentId": original_agent_id, })) - } else { None }; + } else { + None + }; desired_seed.map(|desired_seed| RestartBindingStageInput { - retiring_runtime: uuid, expected: expected.clone(), desired_seed, + retiring_runtime: uuid, + expected: expected.clone(), + desired_seed, }) - } else { None }; + } else { + None + }; crate::config::archive_gate::probe_spawn_refusal(app, &cwd).await?; // §1295 5.1b / B2: the creation gate runs AFTER `probe_spawn_refusal` but // WELL BEFORE `teardown_old_for_restart` (below). A rejected restart of a @@ -5607,7 +5888,9 @@ pub(crate) async fn execute_restart_transaction( // normalized `cwd` to `ensure_root_agent_dir()` above, which the gate // exempts by rule 1. crate::config::sessions_persistence::enforce_creation_gate(app, &cwd, enforcement).await?; - if is_root_agent { crate::config::root_agent::ensure_root_agent_dir()?; } + if is_root_agent { + crate::config::root_agent::ensure_root_agent_dir()?; + } // #2434 - prepare (isolated CODEX_HOME, OpenCode config dir) outside the // settings guard and BEFORE any teardown: a failure aborts the restart and // leaves the old session running. @@ -5926,10 +6209,12 @@ pub async fn list_sessions( .await .map_err(|error| error.to_string())?; for info in infos.iter_mut() { - let binding = Uuid::parse_str(&info.id).ok() + let binding = Uuid::parse_str(&info.id) + .ok() .and_then(|id| snapshot.coding_agent_bindings.get(&id)) .map(|id| crate::session::manager::CodingAgentBinding { - id: id.clone(), seed: snapshot.binding_seeds.get(id).cloned(), + id: id.clone(), + seed: snapshot.binding_seeds.get(id).cloned(), origin: snapshot.binding_origins.get(id).cloned(), }); info.profile_outdated = match source_profile_outdated(&cfg, &settings, info, binding) { @@ -6334,9 +6619,21 @@ pub(crate) async fn create_root_agent_inner_with_intent( intent: TrustedCreateIntent, select_after: bool, ) -> Result { - create_root_agent_inner_with_source(app, _session_mgr, _pty_mgr, _tg_mgr, _settings, - requested_agent_id, requested_profile, None, false, skip_auto_resume_for_new_session, - intent, select_after).await + create_root_agent_inner_with_source( + app, + _session_mgr, + _pty_mgr, + _tg_mgr, + _settings, + requested_agent_id, + requested_profile, + None, + false, + skip_auto_resume_for_new_session, + intent, + select_after, + ) + .await } #[allow(clippy::too_many_arguments)] @@ -6354,7 +6651,9 @@ pub(crate) async fn create_root_agent_inner_with_source( intent: TrustedCreateIntent, select_after: bool, ) -> Result { - if use_stored_selection { return Err("rootStoredSelectionUnsupported".into()); } + if use_stored_selection { + return Err("rootStoredSelectionUnsupported".into()); + } app.state::() .root_lifecycle(RootJobRequest { source_ticket, @@ -6381,12 +6680,26 @@ pub(crate) async fn execute_root_transaction( let root_agent_path = crate::config::root_agent::root_agent_dir()?; // Validate ticket and resolve read-only before provisioning, publication or teardown. let source_spawn = if let Some(ticket) = request.source_ticket.as_ref() { - let cfg = settings.current_read().await.map_err(|error| error.to_string())?; - let (spawn, _) = resolve_runtime_source_spawn(&cfg, &settings.runtime_source_settings_path()?, &root_agent_path, - &ticket.configuration_ref, Some(ticket), None)?; - let host = ResolvedAgentHostShell { program: cfg.default_shell.clone(), args: cfg.default_shell_args.clone() }; + let cfg = settings + .current_read() + .await + .map_err(|error| error.to_string())?; + let (spawn, _) = resolve_runtime_source_spawn( + &cfg, + &settings.runtime_source_settings_path()?, + &root_agent_path, + &ticket.configuration_ref, + Some(ticket), + None, + )?; + let host = ResolvedAgentHostShell { + program: cfg.default_shell.clone(), + args: cfg.default_shell_args.clone(), + }; Some((spawn, host)) - } else { None }; + } else { + None + }; let existing = session_mgr .read() .await @@ -6437,7 +6750,9 @@ pub(crate) async fn execute_root_transaction( .map(|session| SessionInfo::from(&session)) .ok_or_else(|| "Root session disappeared during reuse".to_string()); } - ExistingRootAction::ReuseLive | ExistingRootAction::WakeDormant | ExistingRootAction::DiscardMissingPty => { + ExistingRootAction::ReuseLive + | ExistingRootAction::WakeDormant + | ExistingRootAction::DiscardMissingPty => { let wake_dormant = matches!( classify_existing_root(&existing.status, has_pty), ExistingRootAction::WakeDormant @@ -6471,8 +6786,14 @@ pub(crate) async fn execute_root_transaction( } let last_coding_agent = crate::config::root_agent::read_last_coding_agent(&root_agent_path); - let (shell, shell_args, agent_id, agent_label) = if let Some((spawn, _)) = source_spawn.as_ref() { - (spawn.shell.clone(), spawn.shell_args.clone(), Some(spawn.trusted_agent_id.clone()), Some(spawn.trusted_agent_label.clone())) + let (shell, shell_args, agent_id, agent_label) = if let Some((spawn, _)) = source_spawn.as_ref() + { + ( + spawn.shell.clone(), + spawn.shell_args.clone(), + Some(spawn.trusted_agent_id.clone()), + Some(spawn.trusted_agent_label.clone()), + ) } else { let settings = settings .current_read() @@ -6519,7 +6840,9 @@ pub(crate) async fn execute_root_transaction( (shell, shell_args, agent_label) }; crate::config::root_agent::ensure_root_agent_dir()?; - if let Some(spawn) = resolved_spawn.as_ref() { crate::config::agent_command::prepare_agent_spawn_command(spawn)?; } + if let Some(spawn) = resolved_spawn.as_ref() { + crate::config::agent_command::prepare_agent_spawn_command(spawn)?; + } let info = create_session_inner_impl( app, &session_mgr, @@ -6917,7 +7240,11 @@ async fn inject_restart_resume_prompt(app: &AppHandle, target: &RestartResumeTar pub(crate) fn e2432_physical_restore_fixture( root: &std::path::Path, arguments: &str, -) -> (AppSettings, std::path::PathBuf, crate::config::sessions_persistence::PersistedSession) { +) -> ( + AppSettings, + std::path::PathBuf, + crate::config::sessions_persistence::PersistedSession, +) { use crate::config::settings::{self, SourceKind, SourceRevision}; std::fs::create_dir_all(root).unwrap(); let path = root.join(crate::config::instance_artifacts::SETTINGS_FILE_NAME); @@ -6930,35 +7257,60 @@ pub(crate) fn e2432_physical_restore_fixture( "D":{"command":"tool manual-D"} }}} }); - std::fs::write(root.join(crate::config::instance_artifacts::AGENTS_INSTANCE_FILE_NAME), serde_json::to_vec(&raw).unwrap()).unwrap(); + std::fs::write( + root.join(crate::config::instance_artifacts::AGENTS_INSTANCE_FILE_NAME), + serde_json::to_vec(&raw).unwrap(), + ) + .unwrap(); let host = AppSettings::default(); - let source = settings::list_agent_files(&host, &path).unwrap().sources.into_iter() - .find(|option| option.source.kind == SourceKind::RegisteredInstance).unwrap().source; + let source = settings::list_agent_files(&host, &path) + .unwrap() + .sources + .into_iter() + .find(|option| option.source.kind == SourceKind::RegisteredInstance) + .unwrap() + .source; let snapshot = settings::capture_runtime_source(&host, &path, &source).unwrap(); let base = settings::configuration_identity("tool", &[]); let reference = settings::ConfigurationRef { - version: 1, source, calculated_id: base.clone(), requested_profile: None, - profile_calculated_id: Some(base.clone()), legacy_entry_hint: Some("same".into()), + version: 1, + source, + calculated_id: base.clone(), + requested_profile: None, + profile_calculated_id: Some(base.clone()), + legacy_entry_hint: Some("same".into()), }; let scoped = settings::resolve_runtime_source_row(&host, &snapshot, &reference).unwrap(); - let resolution = crate::config::coding_agent_profiles::resolve_profile(&scoped, + let resolution = crate::config::coding_agent_profiles::resolve_profile( + &scoped, crate::config::coding_agent_profiles::ProfileResolutionRequest { - coding_agent_id:"same", launch_path:None, agent_matrix_name:None, - requested_profile:Some("A"), requested_profile_authoritative:true, - }); + coding_agent_id: "same", + launch_path: None, + agent_matrix_name: None, + requested_profile: Some("A"), + requested_profile_authoritative: true, + }, + ); let mut reference = reference; reference.profile_calculated_id = Some(settings::pair_identity("tool", &[], &resolution.cell)); - let SourceRevision::Bytes { sha256 } = snapshot.revision else { panic!("physical bytes") }; + let SourceRevision::Bytes { sha256 } = snapshot.revision else { + panic!("physical bytes") + }; let seed = crate::config::sessions_persistence::CodingAgentBindingSeed { - identity_revision:format!("s1:{sha256}:{base}"), original_agent_id:"same".into(), - configuration_ref:Some(reference), + identity_revision: format!("s1:{sha256}:{base}"), + original_agent_id: "same".into(), + configuration_ref: Some(reference), }; let persisted = crate::config::sessions_persistence::PersistedSession { - name:"physical".into(), working_directory:root.to_string_lossy().into_owned(), - shell:"saved-shell-decoy".into(), agent_id:Some("same".into()), - coding_agent_binding_id:Some(Uuid::new_v4().to_string()), - coding_agent_binding_seed:Some(seed), - coding_agent_binding_origin:Some(serde_json::json!({"sessionsPath":"recipe", "rawFileSHA256":"original", "originalOrdinal":3})), + name: "physical".into(), + working_directory: root.to_string_lossy().into_owned(), + shell: "saved-shell-decoy".into(), + agent_id: Some("same".into()), + coding_agent_binding_id: Some(Uuid::new_v4().to_string()), + coding_agent_binding_seed: Some(seed), + coding_agent_binding_origin: Some( + serde_json::json!({"sessionsPath":"recipe", "rawFileSHA256":"original", "originalOrdinal":3}), + ), ..Default::default() }; (host, path, persisted) @@ -7064,112 +7416,385 @@ mod tests { crate::config::settings::load_settings_from_path(&path) } - #[test] - fn e2432_restart_source_decoy() { - let temp = tempfile::tempdir().unwrap(); - let (host, path, persisted) = super::e2432_physical_restore_fixture(&temp.path().join("F"), "F"); - super::e2432_physical_restore_fixture(&temp.path().join("G"), "G"); - let seed = persisted.coding_agent_binding_seed.as_ref().unwrap(); - let reference = seed.configuration_ref.as_ref().unwrap(); - let snapshot = crate::config::settings::capture_runtime_source(&host, &path, &reference.source).unwrap(); - let decision = super::prepare_source_restore_decision(Some(seed), persisted.binding_metadata(), - Some("same"), None, &persisted.working_directory, super::SourceRestoreMode::Wake, - &host, Ok(snapshot)); - let super::SourceRestoreDecision::SourceWake { scoped_settings, entry_hint, effective_profile, updated_seed, binding } = decision - else { panic!("physical Wake must resolve") }; - let spawn = super::resolve_source_wake_spawn(&scoped_settings, &entry_hint, &effective_profile, - &persisted.working_directory, &updated_seed).unwrap(); - assert_eq!(spawn.profile_resolution.cell.env.get("SOURCE").map(String::as_str), Some("F")); - assert_eq!(spawn.backend.image.as_deref(), Some("F")); - assert_eq!(binding.id, persisted.coding_agent_binding_id.clone().unwrap()); - assert_eq!(binding.origin, persisted.coding_agent_binding_origin); + #[tokio::test] + async fn e2432_plain_shell_create_ignores_saved_source_and_malformed_selection() { + let (project, cwd, _) = strict_target_fixture(); + let (mut host, path, persisted) = + super::e2432_physical_restore_fixture(&project.path().join("instance"), "source-owned"); + host.project_paths = vec![project.path().join("project").to_string_lossy().into_owned()]; + host.default_shell = "default-control-shell".into(); + host.default_shell_args = vec!["--default-control".into()]; + std::fs::write(&path, serde_json::to_vec(&host).unwrap()).unwrap(); + let reference = persisted + .coding_agent_binding_seed + .as_ref() + .unwrap() + .configuration_ref + .as_ref() + .unwrap(); + let config_path = std::path::Path::new(&cwd).join("config.json"); + let mut config: serde_json::Value = + serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap(); + config["tooling"] = serde_json::json!({ + "currentCodingAgent":"same", "profile":"A", "instanceProfileOverride":"A", + "instanceProfileOverrideSource":"source-default", "selectionLocked":false, + "configurationRef":reference, + }); + let valid_config = config.clone(); + let session_mgr = Arc::new(tokio::sync::RwLock::new(SessionManager::new())); + let backend = Arc::new(ScriptedSpawnBackend::default()); + let pty_mgr = Arc::new(Mutex::new(crate::pty::manager::PtyManager::new_for_test( + backend.clone(), + ))); + let app = session_test_app(host.clone(), session_mgr.clone(), pty_mgr.clone()); + let manager = session_mgr.read().await.clone(); + + for malformed in [false, true] { + config = valid_config.clone(); + if malformed { + config["tooling"]["configurationRef"] = serde_json::json!({"version":"invalid"}); + } + std::fs::write(&config_path, serde_json::to_vec(&config).unwrap()).unwrap(); + assert_eq!( + super::runtime_replica_expectation(&cwd, false).is_err(), + malformed + ); + for explicit in [false, true] { + let (shell, args, expected_shell, expected_args) = if explicit { + let args = vec![ + "--keep".to_string(), + "one argument with spaces".to_string(), + ]; + ( + Some("explicit-control-shell".to_string()), + Some(args.clone()), + "explicit-control-shell".to_string(), + args, + ) + } else { + ( + None, + None, + host.default_shell.clone(), + host.default_shell_args.clone(), + ) + }; + let count = backend.spawn_count.load(Ordering::SeqCst); + let info = super::create_session_with_source( + app.handle().clone(), + app.state::>>(), + app.state::>>(), + app.state::(), + app.state::(), + shell, + args, + Some(cwd.clone()), + Some("plain-shell-control".into()), + None, + None, + None, + None, + Some(true), + None, + None, + ) + .await + .unwrap(); + let id = Uuid::parse_str(&info.id).unwrap(); + let row = manager.get_session(id).await.unwrap(); + assert_eq!(backend.spawn_count.load(Ordering::SeqCst), count + 1); + assert_eq!(row.shell, expected_shell); + assert_eq!(row.shell_args, expected_args); + assert!(row.agent_id.is_none()); + assert!(row.profile_content_hash.is_none()); + // Any source_seed admitted by the production chokepoint installs a binding. + assert!(manager.coding_agent_binding(id).await.is_none()); + } + } + + std::fs::write(&config_path, serde_json::to_vec(&valid_config).unwrap()).unwrap(); + let owner = + crate::config::settings::SettingsOwner::open_active(&path, Duration::from_secs(1)) + .unwrap(); + app.unmanage::(); + assert!(app.manage(owner)); + let snapshot = + crate::config::settings::capture_runtime_source(&host, &path, &reference.source) + .unwrap(); + let ticket = crate::config::settings::SourceSelectionTicket { + configuration_ref: reference.clone(), + expected_source_revision: snapshot.revision, + catalog_context_revision: snapshot.context_revision, + }; + for ticketed in [false, true] { + let info = super::create_session_with_source( + app.handle().clone(), + app.state::>>(), + app.state::>>(), + app.state::(), + app.state::(), + Some("must-not-replace-source".into()), + Some(vec!["--shell-decoy".into()]), + Some(cwd.clone()), + Some("configured-source-control".into()), + (!ticketed).then(|| "same".into()), + None, + ticketed.then(|| ticket.clone()), + None, + Some(true), + None, + None, + ) + .await + .unwrap(); + let id = Uuid::parse_str(&info.id).unwrap(); + let row = manager.get_session(id).await.unwrap(); + assert_eq!(row.agent_id.as_deref(), Some("same")); + assert_ne!(row.shell, "must-not-replace-source"); + let binding = manager.coding_agent_binding(id).await.unwrap(); + let seed: crate::config::sessions_persistence::CodingAgentBindingSeed = + serde_json::from_value(binding.seed.unwrap()).unwrap(); + assert_eq!( + seed.configuration_ref.as_ref().unwrap().source, + reference.source + ); + } + close_test_coordinator(&app).await; + } + + #[test] + fn e2432_restart_source_decoy() { + let temp = tempfile::tempdir().unwrap(); + let (host, path, persisted) = + super::e2432_physical_restore_fixture(&temp.path().join("F"), "F"); + super::e2432_physical_restore_fixture(&temp.path().join("G"), "G"); + let seed = persisted.coding_agent_binding_seed.as_ref().unwrap(); + let reference = seed.configuration_ref.as_ref().unwrap(); + let snapshot = + crate::config::settings::capture_runtime_source(&host, &path, &reference.source) + .unwrap(); + let decision = super::prepare_source_restore_decision( + Some(seed), + persisted.binding_metadata(), + Some("same"), + None, + &persisted.working_directory, + super::SourceRestoreMode::Wake, + &host, + Ok(snapshot), + ); + let super::SourceRestoreDecision::SourceWake { + scoped_settings, + entry_hint, + effective_profile, + updated_seed, + binding, + } = decision + else { + panic!("physical Wake must resolve") + }; + let spawn = super::resolve_source_wake_spawn( + &scoped_settings, + &entry_hint, + &effective_profile, + &persisted.working_directory, + &updated_seed, + ) + .unwrap(); + assert_eq!( + spawn + .profile_resolution + .cell + .env + .get("SOURCE") + .map(String::as_str), + Some("F") + ); + assert_eq!(spawn.backend.image.as_deref(), Some("F")); + assert_eq!( + binding.id, + persisted.coding_agent_binding_id.clone().unwrap() + ); + assert_eq!(binding.origin, persisted.coding_agent_binding_origin); let mut malformed = spawn.source_seed.clone().unwrap(); malformed["originalAgentId"] = serde_json::json!("foreign"); assert!(super::validate_source_spawn_seed(&spawn, &malformed).is_err()); malformed = spawn.source_seed.clone().unwrap(); malformed["configurationRef"]["requestedProfile"] = serde_json::json!("Z"); assert!(super::validate_source_spawn_seed(&spawn, &malformed).is_err()); - assert!(matches!(super::prepare_source_restore_decision(Some(seed), persisted.binding_metadata(), Some("same"), - None, &persisted.working_directory, super::SourceRestoreMode::Wake, &host, - Err("source unavailable".into())), super::SourceRestoreDecision::Refuse { .. })); + assert!(matches!( + super::prepare_source_restore_decision( + Some(seed), + persisted.binding_metadata(), + Some("same"), + None, + &persisted.working_directory, + super::SourceRestoreMode::Wake, + &host, + Err("source unavailable".into()) + ), + super::SourceRestoreDecision::Refuse { .. } + )); } #[test] fn e2432_source_runtime_production_wiring_guards() { let source = include_str!("session.rs"); - let create = &source[source.find("async fn create_session_inner_impl<").unwrap().. - source.find("pub(crate) enum SourceRestoreMode").unwrap()]; - assert!(create.find("validate_source_spawn_seed(").unwrap() - < create.find("let resource_monitor =").unwrap()); - assert!(create.find("if let Some(stage) = restart_stage").unwrap() - < create.find(".install_pending_coding_agent_binding(").unwrap()); + let create = &source[source.find("async fn create_session_inner_impl<").unwrap() + ..source.find("pub(crate) enum SourceRestoreMode").unwrap()]; + assert!( + create.find("validate_source_spawn_seed(").unwrap() + < create.find("let resource_monitor =").unwrap() + ); + assert!( + create.find("if let Some(stage) = restart_stage").unwrap() + < create + .find(".install_pending_coding_agent_binding(") + .unwrap() + ); assert!(create.contains("restart_binding,")); - let restart = &source[source.find("pub(crate) async fn execute_restart_transaction<").unwrap().. - source.find("pub async fn rename_session(").unwrap()]; - assert!(restart.find("resolve_runtime_source_spawn(").unwrap() - < restart.find("prepare_agent_spawn_command(").unwrap()); - assert!(restart.find("let restart_stage =").unwrap() - < restart.find("teardown_old_for_restart(").unwrap()); + let restart = &source[source + .find("pub(crate) async fn execute_restart_transaction<") + .unwrap() + ..source.find("pub async fn rename_session(").unwrap()]; + assert!( + restart.find("resolve_runtime_source_spawn(").unwrap() + < restart.find("prepare_agent_spawn_command(").unwrap() + ); + assert!( + restart.find("let restart_stage =").unwrap() + < restart.find("teardown_old_for_restart(").unwrap() + ); assert!(restart.contains("mutations.set_restart_binding(transition)")); - let root = &source[source.find("pub(crate) async fn execute_root_transaction<").unwrap().. - source.find("pub(crate) fn e2432_physical_restore_fixture(").unwrap()]; - assert!(root.find("resolve_runtime_source_spawn(").unwrap() - < root.find("ensure_root_agent_dir()?").unwrap()); - let root_dispatch = &source[source.find("pub(crate) async fn create_root_agent_inner_with_source(").unwrap().. - source.find("pub(crate) async fn execute_root_transaction<").unwrap()]; - assert!(root_dispatch.find("rootStoredSelectionUnsupported").unwrap() - < root_dispatch.find(".root_lifecycle(").unwrap()); + let root = &source[source + .find("pub(crate) async fn execute_root_transaction<") + .unwrap() + ..source + .find("pub(crate) fn e2432_physical_restore_fixture(") + .unwrap()]; + assert!( + root.find("resolve_runtime_source_spawn(").unwrap() + < root.find("ensure_root_agent_dir()?").unwrap() + ); + let root_dispatch = &source[source + .find("pub(crate) async fn create_root_agent_inner_with_source(") + .unwrap() + ..source + .find("pub(crate) async fn execute_root_transaction<") + .unwrap()]; + assert!( + root_dispatch + .find("rootStoredSelectionUnsupported") + .unwrap() + < root_dispatch.find(".root_lifecycle(").unwrap() + ); } #[test] fn e2432_source_null_contextless_pair_is_observational_and_effective_pair_is_final() { let temp = tempfile::tempdir().unwrap(); - let (mut host, path, persisted) = super::e2432_physical_restore_fixture(&temp.path().join("F"), "F-A"); + let (mut host, path, persisted) = + super::e2432_physical_restore_fixture(&temp.path().join("F"), "F-A"); let cwd = temp.path().join("_agent_matrix"); std::fs::create_dir(&cwd).unwrap(); - host.coding_agent_profiles.default_profile_by_agent.insert("matrix".into(), "B".into()); - let reference = persisted.coding_agent_binding_seed.as_ref().unwrap().configuration_ref.as_ref().unwrap(); - let snapshot = crate::config::settings::capture_runtime_source(&host, &path, &reference.source).unwrap(); + host.coding_agent_profiles + .default_profile_by_agent + .insert("matrix".into(), "B".into()); + let reference = persisted + .coding_agent_binding_seed + .as_ref() + .unwrap() + .configuration_ref + .as_ref() + .unwrap(); + let snapshot = + crate::config::settings::capture_runtime_source(&host, &path, &reference.source) + .unwrap(); let ticket = crate::config::settings::SourceSelectionTicket { - configuration_ref:reference.clone(), expected_source_revision:snapshot.revision.clone(), - catalog_context_revision:snapshot.context_revision.clone(), + configuration_ref: reference.clone(), + expected_source_revision: snapshot.revision.clone(), + catalog_context_revision: snapshot.context_revision.clone(), }; - let (spawn, binding) = super::resolve_runtime_source_spawn(&host, &path, &cwd.to_string_lossy(), - reference, Some(&ticket), None).unwrap(); + let (spawn, binding) = super::resolve_runtime_source_spawn( + &host, + &path, + &cwd.to_string_lossy(), + reference, + Some(&ticket), + None, + ) + .unwrap(); assert_eq!(spawn.profile_resolution.effective_profile, "B"); - let final_seed: crate::config::sessions_persistence::CodingAgentBindingSeed = serde_json::from_value(binding.seed.unwrap()).unwrap(); + let final_seed: crate::config::sessions_persistence::CodingAgentBindingSeed = + serde_json::from_value(binding.seed.unwrap()).unwrap(); let final_ref = final_seed.configuration_ref.unwrap(); assert_eq!(final_ref.requested_profile, None); - assert_ne!(final_ref.profile_calculated_id, reference.profile_calculated_id); - assert_eq!(spawn.source_seed.as_ref().unwrap()["configurationRef"]["profileCalculatedId"], - serde_json::json!(final_ref.profile_calculated_id)); + assert_ne!( + final_ref.profile_calculated_id, + reference.profile_calculated_id + ); + assert_eq!( + spawn.source_seed.as_ref().unwrap()["configurationRef"]["profileCalculatedId"], + serde_json::json!(final_ref.profile_calculated_id) + ); let mut explicit = ticket.clone(); explicit.configuration_ref.requested_profile = Some("A".into()); explicit.configuration_ref.profile_calculated_id = Some(format!("cid1:{}", "0".repeat(64))); - assert!(super::resolve_runtime_source_spawn(&host, &path, &cwd.to_string_lossy(), - &explicit.configuration_ref, Some(&explicit), None).is_err()); + assert!(super::resolve_runtime_source_spawn( + &host, + &path, + &cwd.to_string_lossy(), + &explicit.configuration_ref, + Some(&explicit), + None + ) + .is_err()); super::e2432_physical_restore_fixture(&temp.path().join("F"), "F-changed"); - assert!(super::resolve_runtime_source_spawn(&host, &path, &cwd.to_string_lossy(), - reference, Some(&ticket), None).is_err()); + assert!(super::resolve_runtime_source_spawn( + &host, + &path, + &cwd.to_string_lossy(), + reference, + Some(&ticket), + None + ) + .is_err()); } #[test] fn e2432_source_null_marker_refreshes_derived_aliases_but_preserves_manual_pin() { let (project, cwd, _) = strict_target_fixture(); - let (mut host, path, persisted) = super::e2432_physical_restore_fixture(&project.path().join("F"), "F-A"); - host.project_paths = vec![project.path().join("project").to_string_lossy().into_owned()]; - host.coding_agent_profiles.default_profile_by_agent.insert("dev-one".into(), "C".into()); - let reference = persisted.coding_agent_binding_seed.as_ref().unwrap().configuration_ref.as_ref().unwrap(); + let (mut host, path, persisted) = + super::e2432_physical_restore_fixture(&project.path().join("F"), "F-A"); + host.project_paths = vec![project + .path() + .join("project") + .to_string_lossy() + .into_owned()]; + host.coding_agent_profiles + .default_profile_by_agent + .insert("dev-one".into(), "C".into()); + let reference = persisted + .coding_agent_binding_seed + .as_ref() + .unwrap() + .configuration_ref + .as_ref() + .unwrap(); let config_path = std::path::Path::new(&cwd).join("config.json"); - let original: serde_json::Value = serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap(); - let origin_path = project.path().join("project/.ac/_agent_dev-one/config.json"); + let original: serde_json::Value = + serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap(); + let origin_path = project + .path() + .join("project/.ac/_agent_dev-one/config.json"); for (marker, pin, origin, expected) in [ ("source-default", "B", None, "C"), ("source-default", "B", Some("B"), "B"), ("source-default", "B", Some("C"), "C"), ("manual", "D", Some("C"), "D"), ] { - let origin = origin.map(|letter| serde_json::json!({"tooling":{"defaultProfile":letter}})) + let origin = origin + .map(|letter| serde_json::json!({"tooling":{"defaultProfile":letter}})) .unwrap_or_else(|| serde_json::json!({})); std::fs::write(&origin_path, serde_json::to_vec(&origin).unwrap()).unwrap(); let mut raw = original.clone(); @@ -7177,8 +7802,15 @@ mod tests { "instanceProfileOverride":pin, "instanceProfileOverrideSource":marker, "selectionLocked":true, "configurationRef":reference}); std::fs::write(&config_path, serde_json::to_vec(&raw).unwrap()).unwrap(); - let (spawn, _) = super::resolve_runtime_source_spawn(&host, &path, &cwd, reference, None, - persisted.binding_metadata().unwrap()).unwrap(); + let (spawn, _) = super::resolve_runtime_source_spawn( + &host, + &path, + &cwd, + reference, + None, + persisted.binding_metadata().unwrap(), + ) + .unwrap(); assert_eq!(spawn.profile_resolution.effective_profile, expected); } } @@ -7188,157 +7820,385 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let (host, path, _) = super::e2432_physical_restore_fixture(temp.path(), "global-decoy"); let catalog_path = crate::config::coding_agents_catalog::independent_catalog_path( - temp.path(), crate::config::settings::SourceKind::CatalogPersonal, - ).unwrap(); + temp.path(), + crate::config::settings::SourceKind::CatalogPersonal, + ) + .unwrap(); std::fs::create_dir_all(catalog_path.parent().unwrap()).unwrap(); let base = crate::config::settings::configuration_identity("tool", &[]); - std::fs::write(&catalog_path, serde_json::to_vec(&serde_json::json!({"schemaVersion":2, + std::fs::write( + &catalog_path, + serde_json::to_vec(&serde_json::json!({"schemaVersion":2, "agents":[{"key":"catalog-only", "baseCommand":"tool", "label":"Only", "color":"blue"}], "profileBindingVersion":1, "profilesByBaseHash":{(base.clone()):{ "A":{"additionalArguments":"tool only-here", "env":{"SOURCE":"catalog"}} - }}})).unwrap()).unwrap(); - let source = crate::config::settings::list_agent_files(&host, &path).unwrap().sources.into_iter() - .find(|option| option.source.kind == crate::config::settings::SourceKind::CatalogPersonal).unwrap().source; - let snapshot = crate::config::settings::capture_runtime_source(&host, &path, &source).unwrap(); + }}})) + .unwrap(), + ) + .unwrap(); + let source = crate::config::settings::list_agent_files(&host, &path) + .unwrap() + .sources + .into_iter() + .find(|option| { + option.source.kind == crate::config::settings::SourceKind::CatalogPersonal + }) + .unwrap() + .source; + let snapshot = + crate::config::settings::capture_runtime_source(&host, &path, &source).unwrap(); let reference = crate::config::settings::ConfigurationRef { - version:1, source, calculated_id:base.clone(), requested_profile:None, - profile_calculated_id:Some(base), legacy_entry_hint:Some("catalog-only".into()), + version: 1, + source, + calculated_id: base.clone(), + requested_profile: None, + profile_calculated_id: Some(base), + legacy_entry_hint: Some("catalog-only".into()), }; assert!(!host.agents.iter().any(|agent| agent.id == "catalog-only")); - let (spawn, binding) = super::resolve_runtime_source_spawn(&host, &path, &temp.path().to_string_lossy(), - &reference, None, None).unwrap(); + let (spawn, binding) = super::resolve_runtime_source_spawn( + &host, + &path, + &temp.path().to_string_lossy(), + &reference, + None, + None, + ) + .unwrap(); assert_eq!(spawn.trusted_agent_id, "catalog-only"); - assert_eq!(spawn.profile_resolution.cell.env.get("SOURCE").map(String::as_str), Some("catalog")); - assert!(binding.seed.unwrap()["identityRevision"].as_str().unwrap().starts_with("s1:")); + assert_eq!( + spawn + .profile_resolution + .cell + .env + .get("SOURCE") + .map(String::as_str), + Some("catalog") + ); + assert!(binding.seed.unwrap()["identityRevision"] + .as_str() + .unwrap() + .starts_with("s1:")); assert_eq!(snapshot.rows[0].entry_hint, "catalog-only"); } #[tokio::test] async fn e2432_source_restart_runs_real_deferred_create_and_atomic_commit() { let temp = tempfile::tempdir().unwrap(); - let (host, path, persisted) = super::e2432_physical_restore_fixture(&temp.path().join("F"), "F-old"); + let (host, path, persisted) = + super::e2432_physical_restore_fixture(&temp.path().join("F"), "F-old"); let session_mgr = Arc::new(tokio::sync::RwLock::new(SessionManager::new())); let backend = Arc::new(ScriptedSpawnBackend::default()); - let pty_mgr = Arc::new(Mutex::new(crate::pty::manager::PtyManager::new_for_test(backend.clone()))); + let pty_mgr = Arc::new(Mutex::new(crate::pty::manager::PtyManager::new_for_test( + backend.clone(), + ))); let app = session_test_app(host.clone(), session_mgr.clone(), pty_mgr.clone()); - let owner = crate::config::settings::SettingsOwner::open_active(&path, Duration::from_secs(1)).unwrap(); + let owner = + crate::config::settings::SettingsOwner::open_active(&path, Duration::from_secs(1)) + .unwrap(); app.unmanage::(); assert!(app.manage(owner)); - let transaction = crate::session::selection::SelectionTransaction::for_test(app.handle().clone()); - let reference = persisted.coding_agent_binding_seed.as_ref().unwrap().configuration_ref.as_ref().unwrap(); - let (spawn, binding) = super::resolve_runtime_source_spawn(&host, &path, &persisted.working_directory, - reference, None, persisted.binding_metadata().unwrap()).unwrap(); - let info = super::create_session_inner_for_restore(&transaction, &session_mgr, &pty_mgr, - spawn.shell.clone(), spawn.shell_args.clone(), persisted.working_directory.clone(), Some("F".into()), - Some("same".into()), Some(spawn.trusted_agent_label.clone()), true, Vec::new(), true, - Some(spawn), None, None, Some(false), None, Some(binding)).await.unwrap(); + let transaction = + crate::session::selection::SelectionTransaction::for_test(app.handle().clone()); + let reference = persisted + .coding_agent_binding_seed + .as_ref() + .unwrap() + .configuration_ref + .as_ref() + .unwrap(); + let (spawn, binding) = super::resolve_runtime_source_spawn( + &host, + &path, + &persisted.working_directory, + reference, + None, + persisted.binding_metadata().unwrap(), + ) + .unwrap(); + let info = super::create_session_inner_for_restore( + &transaction, + &session_mgr, + &pty_mgr, + spawn.shell.clone(), + spawn.shell_args.clone(), + persisted.working_directory.clone(), + Some("F".into()), + Some("same".into()), + Some(spawn.trusted_agent_label.clone()), + true, + Vec::new(), + true, + Some(spawn), + None, + None, + Some(false), + None, + Some(binding), + ) + .await + .unwrap(); drop(transaction); let manager = session_mgr.read().await.clone(); - let mut before = manager.coding_agent_binding(Uuid::parse_str(&info.id).unwrap()).await.unwrap(); - let root_transaction = crate::session::selection::SelectionTransaction::for_test(app.handle().clone()); + let mut before = manager + .coding_agent_binding(Uuid::parse_str(&info.id).unwrap()) + .await + .unwrap(); + let root_transaction = + crate::session::selection::SelectionTransaction::for_test(app.handle().clone()); let mut stale = crate::config::settings::SourceSelectionTicket { - configuration_ref:reference.clone(), expected_source_revision:crate::config::settings::SourceRevision::Absent {}, - catalog_context_revision:String::new(), + configuration_ref: reference.clone(), + expected_source_revision: crate::config::settings::SourceRevision::Absent {}, + catalog_context_revision: String::new(), }; let spawn_count = backend.spawn_count.load(Ordering::SeqCst); - assert!(super::execute_root_transaction(&root_transaction, super::RootJobRequest { - source_ticket:Some(stale.clone()), requested_agent_id:None, requested_profile:None, - skip_auto_resume_for_new_session:true, - intent:crate::session::selection::TrustedCreateIntent::User, select_after:false, - }).await.is_err()); + assert!(super::execute_root_transaction( + &root_transaction, + super::RootJobRequest { + source_ticket: Some(stale.clone()), + requested_agent_id: None, + requested_profile: None, + skip_auto_resume_for_new_session: true, + intent: crate::session::selection::TrustedCreateIntent::User, + select_after: false, + } + ) + .await + .is_err()); assert_eq!(backend.spawn_count.load(Ordering::SeqCst), spawn_count); drop(root_transaction); stale.catalog_context_revision = "still-stale".into(); super::e2432_physical_restore_fixture(&temp.path().join("F"), "F-current"); let settings = app.state::(); - assert!(super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, settings.inner(), - Uuid::parse_str(&info.id).unwrap(), None, None, Some(stale), false, Some(true), true, - crate::session::selection::TrustedRestartIntent::User, None, - crate::config::sessions_persistence::default_creation_gate_enforcement()).await.is_err()); - assert!(manager.get_session(Uuid::parse_str(&info.id).unwrap()).await.is_some()); - assert_eq!(super::source_profile_outdated(&host, settings.inner(), &info, Some(before.clone())), Some(true)); + assert!(super::restart_session_inner_with_source( + app.handle(), + &session_mgr, + &pty_mgr, + settings.inner(), + Uuid::parse_str(&info.id).unwrap(), + None, + None, + Some(stale), + false, + Some(true), + true, + crate::session::selection::TrustedRestartIntent::User, + None, + crate::config::sessions_persistence::default_creation_gate_enforcement() + ) + .await + .is_err()); + assert!(manager + .get_session(Uuid::parse_str(&info.id).unwrap()) + .await + .is_some()); + assert_eq!( + super::source_profile_outdated(&host, settings.inner(), &info, Some(before.clone())), + Some(true) + ); let dormant_id = Uuid::parse_str(&info.id).unwrap(); backend.kill(dormant_id).unwrap(); app.state::() - .container_lifecycle_sender().route_lost(dormant_id, 23).await.unwrap(); + .container_lifecycle_sender() + .route_lost(dormant_id, 23) + .await + .unwrap(); backend.fail_spawn(backend.spawn_count.load(Ordering::SeqCst) + 1); - assert!(super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, settings.inner(), - dormant_id, None, None, None, false, Some(true), true, - crate::session::selection::TrustedRestartIntent::User, None, - crate::config::sessions_persistence::default_creation_gate_enforcement()).await.is_err()); - assert_eq!(manager.coding_agent_binding(dormant_id).await.unwrap().seed, before.seed); + assert!(super::restart_session_inner_with_source( + app.handle(), + &session_mgr, + &pty_mgr, + settings.inner(), + dormant_id, + None, + None, + None, + false, + Some(true), + true, + crate::session::selection::TrustedRestartIntent::User, + None, + crate::config::sessions_persistence::default_creation_gate_enforcement() + ) + .await + .is_err()); + assert_eq!( + manager.coding_agent_binding(dormant_id).await.unwrap().seed, + before.seed + ); assert!(manager.aggregate_snapshot().await.pending_ids.is_empty()); assert!(manager.get_session(dormant_id).await.is_some()); backend.fail_spawn(0); let (started_sender, started) = tokio::sync::oneshot::channel(); let (release, wait) = tokio::sync::oneshot::channel(); - backend.gate_spawn(backend.spawn_count.load(Ordering::SeqCst) + 1, started_sender, wait); - let attempt = super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, settings.inner(), - dormant_id, None, None, None, false, Some(true), true, - crate::session::selection::TrustedRestartIntent::User, None, - crate::config::sessions_persistence::default_creation_gate_enforcement()); + backend.gate_spawn( + backend.spawn_count.load(Ordering::SeqCst) + 1, + started_sender, + wait, + ); + let attempt = super::restart_session_inner_with_source( + app.handle(), + &session_mgr, + &pty_mgr, + settings.inner(), + dormant_id, + None, + None, + None, + false, + Some(true), + true, + crate::session::selection::TrustedRestartIntent::User, + None, + crate::config::sessions_persistence::default_creation_gate_enforcement(), + ); let (refused, ()) = tokio::join!(attempt, async { started.await.unwrap(); let pending = manager.aggregate_snapshot().await; assert_eq!(pending.binding_seeds.get(&before.id), before.seed.as_ref()); - assert_eq!(pending.binding_origins.get(&before.id), before.origin.as_ref()); + assert_eq!( + pending.binding_origins.get(&before.id), + before.origin.as_ref() + ); assert_eq!(pending.pending_ids.len(), 1); - manager.refresh_failed_binding_recipes(vec![(crate::session::manager::CodingAgentBinding { - id:before.id.clone(), seed:None, origin:Some(serde_json::json!("O2")), - }, serde_json::json!({}))]).await; + manager + .refresh_failed_binding_recipes(vec![( + crate::session::manager::CodingAgentBinding { + id: before.id.clone(), + seed: None, + origin: Some(serde_json::json!("O2")), + }, + serde_json::json!({}), + )]) + .await; release.send(()).unwrap(); }); assert!(refused.is_err()); assert!(manager.aggregate_snapshot().await.pending_ids.is_empty()); assert!(manager.get_session(dormant_id).await.is_some()); - assert_eq!(manager.coding_agent_binding(dormant_id).await.unwrap().origin, Some(serde_json::json!("O2"))); + assert_eq!( + manager + .coding_agent_binding(dormant_id) + .await + .unwrap() + .origin, + Some(serde_json::json!("O2")) + ); before = manager.coding_agent_binding(dormant_id).await.unwrap(); - let restarted = super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, - settings.inner(), Uuid::parse_str(&info.id).unwrap(), None, None, None, false, Some(true), true, - crate::session::selection::TrustedRestartIntent::User, None, - crate::config::sessions_persistence::default_creation_gate_enforcement()).await.unwrap(); - let after = manager.coding_agent_binding(Uuid::parse_str(&restarted.id).unwrap()).await.unwrap(); + let restarted = super::restart_session_inner_with_source( + app.handle(), + &session_mgr, + &pty_mgr, + settings.inner(), + Uuid::parse_str(&info.id).unwrap(), + None, + None, + None, + false, + Some(true), + true, + crate::session::selection::TrustedRestartIntent::User, + None, + crate::config::sessions_persistence::default_creation_gate_enforcement(), + ) + .await + .unwrap(); + let after = manager + .coding_agent_binding(Uuid::parse_str(&restarted.id).unwrap()) + .await + .unwrap(); assert_eq!(after.id, before.id); assert_eq!(after.origin, before.origin); assert_ne!(after.seed, before.seed); - assert!(manager.get_session(Uuid::parse_str(&info.id).unwrap()).await.is_none()); + assert!(manager + .get_session(Uuid::parse_str(&info.id).unwrap()) + .await + .is_none()); let saved = crate::config::sessions_persistence::snapshot_sessions(&manager).await; let saved: Vec = serde_json::from_value(serde_json::to_value(saved).unwrap()).unwrap(); - assert_eq!(saved[0].coding_agent_binding_id.as_deref(), Some(after.id.as_str())); - assert_eq!(saved[0].coding_agent_binding_seed.as_ref().unwrap().configuration_ref.as_ref().unwrap().source, - reference.source); + assert_eq!( + saved[0].coding_agent_binding_id.as_deref(), + Some(after.id.as_str()) + ); + assert_eq!( + saved[0] + .coding_agent_binding_seed + .as_ref() + .unwrap() + .configuration_ref + .as_ref() + .unwrap() + .source, + reference.source + ); let g_path = crate::config::coding_agents_catalog::independent_catalog_path( - path.parent().unwrap(), crate::config::settings::SourceKind::CatalogPersonal, - ).unwrap(); + path.parent().unwrap(), + crate::config::settings::SourceKind::CatalogPersonal, + ) + .unwrap(); std::fs::create_dir_all(g_path.parent().unwrap()).unwrap(); let base = reference.calculated_id.clone(); let g_raw = serde_json::json!({"schemaVersion":2, - "agents":[{"key":"same", "label":"G", "color":"green", "baseCommand":"tool"}], - "profileBindingVersion":1, "profilesByBaseHash":{ - (base.clone()):{"A":{"additionalArguments":"tool G-current", "env":{"SOURCE":"G-current"}}} - }}); + "agents":[{"key":"same", "label":"G", "color":"green", "baseCommand":"tool"}], + "profileBindingVersion":1, "profilesByBaseHash":{ + (base.clone()):{"A":{"additionalArguments":"tool G-current", "env":{"SOURCE":"G-current"}}} + }}); std::fs::write(&g_path, serde_json::to_vec(&g_raw).unwrap()).unwrap(); - let g_source = crate::config::settings::list_agent_files(&host, &path).unwrap().sources.into_iter() - .find(|option| option.source.kind == crate::config::settings::SourceKind::CatalogPersonal).unwrap().source; - let g_snapshot = crate::config::settings::capture_runtime_source(&host, &path, &g_source).unwrap(); + let g_source = crate::config::settings::list_agent_files(&host, &path) + .unwrap() + .sources + .into_iter() + .find(|option| { + option.source.kind == crate::config::settings::SourceKind::CatalogPersonal + }) + .unwrap() + .source; + let g_snapshot = + crate::config::settings::capture_runtime_source(&host, &path, &g_source).unwrap(); let mut g_ref = reference.clone(); g_ref.source = g_source; - let g_scoped = crate::config::settings::resolve_runtime_source_row(&host, &g_snapshot, &g_ref).unwrap(); - let g_cell = crate::config::coding_agent_profiles::resolve_profile(&g_scoped, + let g_scoped = + crate::config::settings::resolve_runtime_source_row(&host, &g_snapshot, &g_ref) + .unwrap(); + let g_cell = crate::config::coding_agent_profiles::resolve_profile( + &g_scoped, crate::config::coding_agent_profiles::ProfileResolutionRequest { - coding_agent_id:"same", launch_path:None, agent_matrix_name:None, - requested_profile:Some("A"), requested_profile_authoritative:true, - }); - g_ref.profile_calculated_id = Some(crate::config::settings::pair_identity("tool", &[], &g_cell.cell)); + coding_agent_id: "same", + launch_path: None, + agent_matrix_name: None, + requested_profile: Some("A"), + requested_profile_authoritative: true, + }, + ); + g_ref.profile_calculated_id = Some(crate::config::settings::pair_identity( + "tool", + &[], + &g_cell.cell, + )); let g_ticket = crate::config::settings::SourceSelectionTicket { - configuration_ref:g_ref.clone(), expected_source_revision:g_snapshot.revision, - catalog_context_revision:g_snapshot.context_revision, + configuration_ref: g_ref.clone(), + expected_source_revision: g_snapshot.revision, + catalog_context_revision: g_snapshot.context_revision, }; - let replacement = super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, - settings.inner(), Uuid::parse_str(&restarted.id).unwrap(), None, None, Some(g_ticket), false, - Some(true), true, crate::session::selection::TrustedRestartIntent::User, None, - crate::config::sessions_persistence::default_creation_gate_enforcement()).await.unwrap(); + let replacement = super::restart_session_inner_with_source( + app.handle(), + &session_mgr, + &pty_mgr, + settings.inner(), + Uuid::parse_str(&restarted.id).unwrap(), + None, + None, + Some(g_ticket), + false, + Some(true), + true, + crate::session::selection::TrustedRestartIntent::User, + None, + crate::config::sessions_persistence::default_creation_gate_enforcement(), + ) + .await + .unwrap(); let old_id = Uuid::parse_str(&replacement.id).unwrap(); let replaced = manager.coding_agent_binding(old_id).await.unwrap(); assert_eq!(replaced.id, before.id); @@ -7348,18 +8208,49 @@ mod tests { assert_eq!(replaced_seed.configuration_ref.unwrap(), g_ref); let before_refusal = manager.coding_agent_binding(old_id).await.unwrap(); std::fs::remove_file(&g_path).unwrap(); - assert!(super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, settings.inner(), - old_id, None, None, None, false, Some(true), true, - crate::session::selection::TrustedRestartIntent::User, None, - crate::config::sessions_persistence::default_creation_gate_enforcement()).await.is_err()); + assert!(super::restart_session_inner_with_source( + app.handle(), + &session_mgr, + &pty_mgr, + settings.inner(), + old_id, + None, + None, + None, + false, + Some(true), + true, + crate::session::selection::TrustedRestartIntent::User, + None, + crate::config::sessions_persistence::default_creation_gate_enforcement() + ) + .await + .is_err()); assert!(manager.get_session(old_id).await.is_some()); - assert_eq!(manager.coding_agent_binding(old_id).await.unwrap().seed, before_refusal.seed); + assert_eq!( + manager.coding_agent_binding(old_id).await.unwrap().seed, + before_refusal.seed + ); std::fs::write(&g_path, serde_json::to_vec(&g_raw).unwrap()).unwrap(); backend.fail_spawn(backend.spawn_count.load(Ordering::SeqCst) + 1); - assert!(super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, settings.inner(), - old_id, None, None, None, false, Some(true), true, - crate::session::selection::TrustedRestartIntent::User, None, - crate::config::sessions_persistence::default_creation_gate_enforcement()).await.is_err()); + assert!(super::restart_session_inner_with_source( + app.handle(), + &session_mgr, + &pty_mgr, + settings.inner(), + old_id, + None, + None, + None, + false, + Some(true), + true, + crate::session::selection::TrustedRestartIntent::User, + None, + crate::config::sessions_persistence::default_creation_gate_enforcement() + ) + .await + .is_err()); let lost = manager.aggregate_snapshot().await; assert!(lost.pending_ids.is_empty()); assert!(!lost.sessions.iter().any(|row| row.id == old_id)); @@ -7371,90 +8262,244 @@ mod tests { #[tokio::test] async fn e2432_explicit_stored_selection_overrides_seed_only_in_requested_mode() { let (project, cwd, _) = strict_target_fixture(); - let (_, path, mut persisted) = super::e2432_physical_restore_fixture(&project.path().join("H"), "H-A"); + let (_, path, mut persisted) = + super::e2432_physical_restore_fixture(&project.path().join("H"), "H-A"); let mut host = crate::config::settings::load_settings_from_path(&path); - host.project_paths = vec![project.path().join("project").to_string_lossy().into_owned()]; + host.project_paths = vec![project + .path() + .join("project") + .to_string_lossy() + .into_owned()]; std::fs::write(&path, serde_json::to_vec(&host).unwrap()).unwrap(); persisted.working_directory = cwd.clone(); - persisted.coding_agent_binding_seed.as_mut().unwrap().configuration_ref.as_mut().unwrap().requested_profile = Some("A".into()); + persisted + .coding_agent_binding_seed + .as_mut() + .unwrap() + .configuration_ref + .as_mut() + .unwrap() + .requested_profile = Some("A".into()); let config_path = std::path::Path::new(&cwd).join("config.json"); - let mut config: serde_json::Value = serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap(); + let mut config: serde_json::Value = + serde_json::from_slice(&std::fs::read(&config_path).unwrap()).unwrap(); let g_path = crate::config::coding_agents_catalog::independent_catalog_path( - &project.path().join("project/.ac"), crate::config::settings::SourceKind::CatalogPersonal, - ).unwrap(); + &project.path().join("project/.ac"), + crate::config::settings::SourceKind::CatalogPersonal, + ) + .unwrap(); std::fs::create_dir_all(g_path.parent().unwrap()).unwrap(); let base = crate::config::settings::configuration_identity("tool", &[]); - std::fs::write(&g_path, serde_json::to_vec(&serde_json::json!({"schemaVersion":2, + std::fs::write( + &g_path, + serde_json::to_vec(&serde_json::json!({"schemaVersion":2, "agents":[{"key":"same", "label":"F", "color":"green", "baseCommand":"tool"}], "profileBindingVersion":1, "profilesByBaseHash":{(base):{ "A":{"additionalArguments":"tool F-A"}, "D":{"additionalArguments":"tool F-D"} - }}})).unwrap()).unwrap(); - let f_source = crate::config::settings::list_agent_files(&host, &path).unwrap().sources.into_iter() - .find(|option| option.source.kind == crate::config::settings::SourceKind::CatalogPersonal - && option.source.scope == crate::config::settings::SourceScope::Project).unwrap().source; - let snapshot = crate::config::settings::capture_runtime_source(&host, &path, &f_source).unwrap(); - let mut f_ref = persisted.coding_agent_binding_seed.as_ref().unwrap().configuration_ref.clone().unwrap(); + }}})) + .unwrap(), + ) + .unwrap(); + let f_source = crate::config::settings::list_agent_files(&host, &path) + .unwrap() + .sources + .into_iter() + .find(|option| { + option.source.kind == crate::config::settings::SourceKind::CatalogPersonal + && option.source.scope == crate::config::settings::SourceScope::Project + }) + .unwrap() + .source; + let snapshot = + crate::config::settings::capture_runtime_source(&host, &path, &f_source).unwrap(); + let mut f_ref = persisted + .coding_agent_binding_seed + .as_ref() + .unwrap() + .configuration_ref + .clone() + .unwrap(); f_ref.source = f_source; f_ref.requested_profile = Some("D".into()); - let scoped = crate::config::settings::resolve_runtime_source_row(&host, &snapshot, &f_ref).unwrap(); - let f_cell = crate::config::coding_agent_profiles::resolve_profile(&scoped, + let scoped = + crate::config::settings::resolve_runtime_source_row(&host, &snapshot, &f_ref).unwrap(); + let f_cell = crate::config::coding_agent_profiles::resolve_profile( + &scoped, crate::config::coding_agent_profiles::ProfileResolutionRequest { - coding_agent_id:"same", launch_path:None, agent_matrix_name:None, - requested_profile:Some("D"), requested_profile_authoritative:true, - }); - f_ref.profile_calculated_id = Some(crate::config::settings::pair_identity("tool", &[], &f_cell.cell)); + coding_agent_id: "same", + launch_path: None, + agent_matrix_name: None, + requested_profile: Some("D"), + requested_profile_authoritative: true, + }, + ); + f_ref.profile_calculated_id = Some(crate::config::settings::pair_identity( + "tool", + &[], + &f_cell.cell, + )); config["tooling"] = serde_json::json!({"currentCodingAgent":"same", "profile":"D", "instanceProfileOverride":"D", "instanceProfileOverrideSource":"manual", "selectionLocked":true, "configurationRef":f_ref}); std::fs::write(&config_path, serde_json::to_vec(&config).unwrap()).unwrap(); let session_mgr = Arc::new(tokio::sync::RwLock::new(SessionManager::new())); - let pty_mgr = Arc::new(Mutex::new(crate::pty::manager::PtyManager::new_for_test(Arc::new(ScriptedSpawnBackend::default())))); + let pty_mgr = Arc::new(Mutex::new(crate::pty::manager::PtyManager::new_for_test( + Arc::new(ScriptedSpawnBackend::default()), + ))); let app = session_test_app(host.clone(), session_mgr.clone(), pty_mgr.clone()); - let owner = crate::config::settings::SettingsOwner::open_active(&path, Duration::from_secs(1)).unwrap(); + let owner = + crate::config::settings::SettingsOwner::open_active(&path, Duration::from_secs(1)) + .unwrap(); app.unmanage::(); assert!(app.manage(owner)); - let transaction = crate::session::selection::SelectionTransaction::for_test(app.handle().clone()); + let transaction = + crate::session::selection::SelectionTransaction::for_test(app.handle().clone()); let seed = persisted.coding_agent_binding_seed.as_ref().unwrap(); - let (spawn, binding) = super::resolve_runtime_source_spawn(&host, &path, &cwd, - seed.configuration_ref.as_ref().unwrap(), None, persisted.binding_metadata().unwrap()).unwrap(); - let info = super::create_session_inner_for_restore(&transaction, &session_mgr, &pty_mgr, - spawn.shell.clone(), spawn.shell_args.clone(), cwd.clone(), Some("H".into()), Some("same".into()), - None, true, Vec::new(), true, Some(spawn), None, None, None, None, Some(binding)).await.unwrap(); + let (spawn, binding) = super::resolve_runtime_source_spawn( + &host, + &path, + &cwd, + seed.configuration_ref.as_ref().unwrap(), + None, + persisted.binding_metadata().unwrap(), + ) + .unwrap(); + let info = super::create_session_inner_for_restore( + &transaction, + &session_mgr, + &pty_mgr, + spawn.shell.clone(), + spawn.shell_args.clone(), + cwd.clone(), + Some("H".into()), + Some("same".into()), + None, + true, + Vec::new(), + true, + Some(spawn), + None, + None, + None, + None, + Some(binding), + ) + .await + .unwrap(); drop(transaction); let settings = app.state::(); let manager = session_mgr.read().await.clone(); - let original = manager.coding_agent_binding(Uuid::parse_str(&info.id).unwrap()).await.unwrap(); - assert!(super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, settings.inner(), - Uuid::parse_str(&info.id).unwrap(), Some("same".into()), None, None, true, Some(true), true, - crate::session::selection::TrustedRestartIntent::User, None, - crate::config::sessions_persistence::default_creation_gate_enforcement()).await.is_err()); - assert!(manager.get_session(Uuid::parse_str(&info.id).unwrap()).await.is_some()); - let ordinary = super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, settings.inner(), - Uuid::parse_str(&info.id).unwrap(), None, None, None, false, Some(true), true, - crate::session::selection::TrustedRestartIntent::User, None, - crate::config::sessions_persistence::default_creation_gate_enforcement()).await.unwrap(); - let ordinary_binding = manager.coding_agent_binding(Uuid::parse_str(&ordinary.id).unwrap()).await.unwrap(); - assert_eq!(ordinary_binding.seed.as_ref().unwrap()["configurationRef"]["requestedProfile"], "A"); - let stored = super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, settings.inner(), - Uuid::parse_str(&ordinary.id).unwrap(), None, None, None, true, Some(true), true, - crate::session::selection::TrustedRestartIntent::User, None, - crate::config::sessions_persistence::default_creation_gate_enforcement()).await.unwrap(); + let original = manager + .coding_agent_binding(Uuid::parse_str(&info.id).unwrap()) + .await + .unwrap(); + assert!(super::restart_session_inner_with_source( + app.handle(), + &session_mgr, + &pty_mgr, + settings.inner(), + Uuid::parse_str(&info.id).unwrap(), + Some("same".into()), + None, + None, + true, + Some(true), + true, + crate::session::selection::TrustedRestartIntent::User, + None, + crate::config::sessions_persistence::default_creation_gate_enforcement() + ) + .await + .is_err()); + assert!(manager + .get_session(Uuid::parse_str(&info.id).unwrap()) + .await + .is_some()); + let ordinary = super::restart_session_inner_with_source( + app.handle(), + &session_mgr, + &pty_mgr, + settings.inner(), + Uuid::parse_str(&info.id).unwrap(), + None, + None, + None, + false, + Some(true), + true, + crate::session::selection::TrustedRestartIntent::User, + None, + crate::config::sessions_persistence::default_creation_gate_enforcement(), + ) + .await + .unwrap(); + let ordinary_binding = manager + .coding_agent_binding(Uuid::parse_str(&ordinary.id).unwrap()) + .await + .unwrap(); + assert_eq!( + ordinary_binding.seed.as_ref().unwrap()["configurationRef"]["requestedProfile"], + "A" + ); + let stored = super::restart_session_inner_with_source( + app.handle(), + &session_mgr, + &pty_mgr, + settings.inner(), + Uuid::parse_str(&ordinary.id).unwrap(), + None, + None, + None, + true, + Some(true), + true, + crate::session::selection::TrustedRestartIntent::User, + None, + crate::config::sessions_persistence::default_creation_gate_enforcement(), + ) + .await + .unwrap(); let stored_id = Uuid::parse_str(&stored.id).unwrap(); let stored_binding = manager.coding_agent_binding(stored_id).await.unwrap(); - let stored_seed: crate::config::sessions_persistence::CodingAgentBindingSeed = serde_json::from_value(stored_binding.seed.unwrap()).unwrap(); + let stored_seed: crate::config::sessions_persistence::CodingAgentBindingSeed = + serde_json::from_value(stored_binding.seed.unwrap()).unwrap(); assert_eq!(stored_seed.configuration_ref, Some(f_ref)); assert_eq!(stored_binding.id, original.id); assert_eq!(stored_binding.origin, original.origin); - config["tooling"].as_object_mut().unwrap().remove("configurationRef"); + config["tooling"] + .as_object_mut() + .unwrap() + .remove("configurationRef"); std::fs::write(&config_path, serde_json::to_vec(&config).unwrap()).unwrap(); - let legacy = super::restart_session_inner_with_source(app.handle(), &session_mgr, &pty_mgr, settings.inner(), - stored_id, None, None, None, true, Some(true), true, - crate::session::selection::TrustedRestartIntent::User, None, - crate::config::sessions_persistence::default_creation_gate_enforcement()).await.unwrap(); - let legacy_binding = manager.coding_agent_binding(Uuid::parse_str(&legacy.id).unwrap()).await.unwrap(); + let legacy = super::restart_session_inner_with_source( + app.handle(), + &session_mgr, + &pty_mgr, + settings.inner(), + stored_id, + None, + None, + None, + true, + Some(true), + true, + crate::session::selection::TrustedRestartIntent::User, + None, + crate::config::sessions_persistence::default_creation_gate_enforcement(), + ) + .await + .unwrap(); + let legacy_binding = manager + .coding_agent_binding(Uuid::parse_str(&legacy.id).unwrap()) + .await + .unwrap(); assert_eq!(legacy_binding.id, original.id); assert_eq!(legacy_binding.origin, original.origin); - assert!(legacy_binding.seed.unwrap().get("configurationRef").is_none()); + assert!(legacy_binding + .seed + .unwrap() + .get("configurationRef") + .is_none()); close_test_coordinator(&app).await; } diff --git a/src-tauri/src/config/settings.rs b/src-tauri/src/config/settings.rs index f0d06795c..82348cbd8 100644 --- a/src-tauri/src/config/settings.rs +++ b/src-tauri/src/config/settings.rs @@ -41995,14 +41995,20 @@ pub(crate) fn resolve_runtime_source_row( if reference.version != 1 || snapshot.source != reference.source { return Err("invalidSourceReference".into()); } - let hint = reference.legacy_entry_hint.as_deref().ok_or("missingSourceHint")?; + let hint = reference + .legacy_entry_hint + .as_deref() + .ok_or("missingSourceHint")?; let mut rows = snapshot.rows.iter().filter(|row| row.entry_hint == hint); let row = rows.next().ok_or("sourceRowMissing")?; if rows.next().is_some() || !row.launchable { return Err("sourceRowMissingOrAmbiguous".into()); } let projection = agent_file_projection(snapshot)?; - let mut agents = projection.agents.into_iter().filter(|agent| agent.id == hint); + let mut agents = projection + .agents + .into_iter() + .filter(|agent| agent.id == hint); let agent = agents.next().ok_or("sourceRowIncomplete")?; if agents.next().is_some() || configuration_identity(&agent.command, &agent.envs) != reference.calculated_id @@ -42013,10 +42019,22 @@ pub(crate) fn resolve_runtime_source_row( scoped.agents = vec![agent]; scoped.agents_layer = AppSettings::default().agents_layer; scoped.coding_agent_profiles = projection.coding_agent_profiles; - scoped.coding_agent_profiles.profiles_by_agent.retain(|id, _| id == hint); - scoped.coding_agent_profiles.profile_labels_by_agent.retain(|id, _| id == hint); - scoped.coding_agent_profiles.identity_by_agent.retain(|id, _| id == hint); - scoped.coding_agent_profiles.codex_home_key_by_agent.retain(|id, _| id == hint); + scoped + .coding_agent_profiles + .profiles_by_agent + .retain(|id, _| id == hint); + scoped + .coding_agent_profiles + .profile_labels_by_agent + .retain(|id, _| id == hint); + scoped + .coding_agent_profiles + .identity_by_agent + .retain(|id, _| id == hint); + scoped + .coding_agent_profiles + .codex_home_key_by_agent + .retain(|id, _| id == hint); // Matrix-name policy is not an agent definition or a profile-cell donor. scoped.coding_agent_profiles.default_profile_by_agent = host.coding_agent_profiles.default_profile_by_agent.clone(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 80241c745..12a4b1de3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2346,9 +2346,14 @@ fn prepare_persisted_restore( target_profile: Option<&str>, ) -> commands::session::SourceRestoreDecision { commands::session::prepare_source_restore_decision( - persisted.coding_agent_binding_seed.as_ref(), persisted.binding_metadata(), - persisted.agent_id.as_deref(), target_profile, &persisted.working_directory, - mode, settings, snapshot, + persisted.coding_agent_binding_seed.as_ref(), + persisted.binding_metadata(), + persisted.agent_id.as_deref(), + target_profile, + &persisted.working_directory, + mode, + settings, + snapshot, ) } @@ -2359,30 +2364,58 @@ fn resolve_current_persisted_restore( settings_path: Result, ) -> Result, String> { use commands::session::SourceRestoreDecision; - let reference = persisted.coding_agent_binding_seed.as_ref() + let reference = persisted + .coding_agent_binding_seed + .as_ref() .and_then(|seed| seed.configuration_ref.as_ref()); - let snapshot = reference.map(|reference| settings_path.and_then(|path| config::settings::capture_runtime_source( - settings, &path, &reference.source, - ))).unwrap_or_else(|| Err("legacyHasNoSource".into())); + let snapshot = reference + .map(|reference| { + settings_path.and_then(|path| { + config::settings::capture_runtime_source(settings, &path, &reference.source) + }) + }) + .unwrap_or_else(|| Err("legacyHasNoSource".into())); let target_profile = match (reference, snapshot.as_ref()) { (Some(reference), Ok(snapshot)) => { - let scoped = config::settings::resolve_runtime_source_row(settings, snapshot, reference)?; - commands::session::source_target_profile(&scoped, reference, &persisted.working_directory)? + let scoped = + config::settings::resolve_runtime_source_row(settings, snapshot, reference)?; + commands::session::source_target_profile( + &scoped, + reference, + &persisted.working_directory, + )? } _ => None, }; let (updated_seed, binding, spawn) = match prepare_persisted_restore( - persisted, mode, settings, snapshot, target_profile.as_deref(), + persisted, + mode, + settings, + snapshot, + target_profile.as_deref(), ) { SourceRestoreDecision::Legacy => return Ok(None), SourceRestoreDecision::Refuse { diagnostic } => return Err(diagnostic), - SourceRestoreDecision::SourceWake { scoped_settings, entry_hint, effective_profile, updated_seed, binding } => { + SourceRestoreDecision::SourceWake { + scoped_settings, + entry_hint, + effective_profile, + updated_seed, + binding, + } => { let spawn = commands::session::resolve_source_wake_spawn( - &scoped_settings, &entry_hint, &effective_profile, &persisted.working_directory, &updated_seed, + &scoped_settings, + &entry_hint, + &effective_profile, + &persisted.working_directory, + &updated_seed, )?; (updated_seed, binding, Some(spawn)) } - SourceRestoreDecision::SourceDeferred { updated_seed, binding } => (updated_seed, binding, None), + SourceRestoreDecision::SourceDeferred { + updated_seed, + binding, + } => (updated_seed, binding, None), }; persisted.coding_agent_binding_seed = Some(updated_seed); persisted.coding_agent_binding_id = Some(binding.id); @@ -2390,7 +2423,6 @@ fn resolve_current_persisted_restore( Ok(spawn) } - fn start_native_restore_startup( app: tauri::AppHandle, restore_barrier: crate::session::selection::RestoreBarrierGuard, @@ -2440,10 +2472,16 @@ fn start_native_restore_startup( let mut failed_recoverable: Vec = Vec::new(); persisted.retain_mut(|ps| { if let Err(error) = ps.capture_binding_seed(&settings_snapshot) { - log::error!("[restore] binding seed unavailable for '{}': {}", ps.name, error); + log::error!( + "[restore] binding seed unavailable for '{}': {}", + ps.name, + error + ); failed_recoverable.push(ps.clone()); false - } else { true } + } else { + true + } }); let mut active_id = None; @@ -2469,21 +2507,35 @@ fn start_native_restore_startup( }) .cloned(); - let root_capture_failed = failed_recoverable.iter().any(|ps| ps.is_root_agent - || crate::config::root_agent::is_root_agent_path(&ps.working_directory)); + let root_capture_failed = failed_recoverable.iter().any(|ps| { + ps.is_root_agent + || crate::config::root_agent::is_root_agent_path(&ps.working_directory) + }); let original_root_ps = root_ps.clone(); let root_source = if let Some(ps) = root_ps.as_mut() { let mode = if should_wake_root_agent_on_restore(ps.status.as_ref()) { commands::session::SourceRestoreMode::Wake - } else { commands::session::SourceRestoreMode::Defer }; - resolve_current_persisted_restore(ps, mode, &settings_snapshot, settings_state_clone.runtime_source_settings_path()) - } else { Ok(None) }; + } else { + commands::session::SourceRestoreMode::Defer + }; + resolve_current_persisted_restore( + ps, + mode, + &settings_snapshot, + settings_state_clone.runtime_source_settings_path(), + ) + } else { + Ok(None) + }; let root_source_failed = root_source.is_err(); if let Err(error) = root_source.as_ref() { log::error!("[restore] source Root refused: {error}"); } let root_source_spawn = root_source.ok().flatten(); - if let Some(root_path) = root_agent_path.clone().filter(|_| !root_source_failed && !root_capture_failed) { + if let Some(root_path) = root_agent_path + .clone() + .filter(|_| !root_source_failed && !root_capture_failed) + { if shutdown_for_body.token().is_cancelled() { // 14.4 - exit during restore: never spawn a root PTY // once shutdown is underway (it would be orphaned until @@ -2538,7 +2590,8 @@ fn start_native_restore_startup( } Some(ps) if ps.binding_metadata().is_err() => { log::error!("[restore] ambiguous Root binding; preserving recipe"); - failed_recoverable.push(original_root_ps.as_ref().unwrap_or(ps).clone()); + failed_recoverable + .push(original_root_ps.as_ref().unwrap_or(ps).clone()); } Some(ps) if should_wake_root_agent_on_restore(ps.status.as_ref()) => { let existing_root = { @@ -2601,12 +2654,16 @@ fn start_native_restore_startup( } if should_create { let mut rebuild_failed = false; - let resolved_spawn = if let Some(spawn) = root_source_spawn.clone() { - match config::agent_command::prepare_agent_spawn_command(&spawn) { + let resolved_spawn = if let Some(spawn) = root_source_spawn.clone() + { + match config::agent_command::prepare_agent_spawn_command(&spawn) + { Ok(()) => Some(spawn), Err(error) => { log::error!("[restore] source Root preparation refused: {error}"); - failed_recoverable.push(original_root_ps.as_ref().unwrap_or(ps).clone()); + failed_recoverable.push( + original_root_ps.as_ref().unwrap_or(ps).clone(), + ); rebuild_failed = true; None } @@ -2637,7 +2694,9 @@ fn start_native_restore_startup( ps.name, e ); - failed_recoverable.push(original_root_ps.as_ref().unwrap_or(ps).clone()); + failed_recoverable.push( + original_root_ps.as_ref().unwrap_or(ps).clone(), + ); rebuild_failed = true; None } @@ -2659,9 +2718,12 @@ fn start_native_restore_startup( None }; if !rebuild_failed { - if let Err(error) = crate::config::root_agent::ensure_root_agent_dir() { + if let Err(error) = + crate::config::root_agent::ensure_root_agent_dir() + { log::error!("[restore] Root provisioning refused: {error}"); - failed_recoverable.push(original_root_ps.as_ref().unwrap_or(ps).clone()); + failed_recoverable + .push(original_root_ps.as_ref().unwrap_or(ps).clone()); rebuild_failed = true; } } @@ -2763,7 +2825,9 @@ fn start_native_restore_startup( ps.name, e ); - failed_recoverable.push(original_root_ps.as_ref().unwrap_or(ps).clone()); + failed_recoverable.push( + original_root_ps.as_ref().unwrap_or(ps).clone(), + ); } } } @@ -2822,7 +2886,8 @@ fn start_native_restore_startup( ps.name, e ); - failed_recoverable.push(original_root_ps.as_ref().unwrap_or(ps).clone()); + failed_recoverable + .push(original_root_ps.as_ref().unwrap_or(ps).clone()); } } } @@ -2918,10 +2983,16 @@ fn start_native_restore_startup( let original_ps = ps; let mut current_ps = ps.clone(); - let mode = if wake { commands::session::SourceRestoreMode::Wake } - else { commands::session::SourceRestoreMode::Defer }; + let mode = if wake { + commands::session::SourceRestoreMode::Wake + } else { + commands::session::SourceRestoreMode::Defer + }; let source_spawn = match resolve_current_persisted_restore( - &mut current_ps, mode, &settings_snapshot, settings_state_clone.runtime_source_settings_path(), + &mut current_ps, + mode, + &settings_snapshot, + settings_state_clone.runtime_source_settings_path(), ) { Ok(spawn) => spawn, Err(error) => { @@ -2975,7 +3046,10 @@ fn start_native_restore_startup( // while custom-shell records keep their materialized shell args. let resolved_spawn = if let Some(spawn) = source_spawn { if let Err(error) = config::agent_command::prepare_agent_spawn_command(&spawn) { - log::error!("[restore] source preparation refused '{}': {error}", ps.name); + log::error!( + "[restore] source preparation refused '{}': {error}", + ps.name + ); failed_recoverable.push(original_ps.clone()); continue; } @@ -9420,15 +9494,23 @@ mod tests { fn e2432_assert_current_source_restore(root: bool, deferred: bool) { use crate::commands::session::{e2432_physical_restore_fixture, SourceRestoreMode}; let temp = tempfile::tempdir().unwrap(); - let (mut host, path, mut persisted) = e2432_physical_restore_fixture(&temp.path().join("F"), "F-old"); + let (mut host, path, mut persisted) = + e2432_physical_restore_fixture(&temp.path().join("F"), "F-old"); persisted.is_root_agent = root; let (global, g_path, g) = e2432_physical_restore_fixture(&temp.path().join("G"), "G-old"); let g_seed = g.coding_agent_binding_seed.as_ref().unwrap(); - let g_snapshot = config::settings::capture_runtime_source(&global, - &g_path, &g_seed.configuration_ref.as_ref().unwrap().source).unwrap(); - host.agents = config::settings::agent_file_projection(&g_snapshot).unwrap().agents; + let g_snapshot = config::settings::capture_runtime_source( + &global, + &g_path, + &g_seed.configuration_ref.as_ref().unwrap().source, + ) + .unwrap(); + host.agents = config::settings::agent_file_projection(&g_snapshot) + .unwrap() + .agents; host.agents_layer = config::settings::AgentsLayerState::Unreadable { - path: temp.path().join("global-invalid"), reason:"global unreadable decoy".into(), + path: temp.path().join("global-invalid"), + reason: "global unreadable decoy".into(), }; let original = serde_json::to_value(&persisted).unwrap(); e2432_physical_restore_fixture(&temp.path().join("G"), "G-new"); @@ -9436,55 +9518,144 @@ mod tests { let id = carrier.coding_agent_binding_id.clone(); let origin = carrier.coding_agent_binding_origin.clone(); if deferred { - assert!(resolve_current_persisted_restore(&mut carrier, SourceRestoreMode::Defer, &host, Ok(path.clone())).unwrap().is_none()); + assert!(resolve_current_persisted_restore( + &mut carrier, + SourceRestoreMode::Defer, + &host, + Ok(path.clone()) + ) + .unwrap() + .is_none()); carrier = serde_json::from_value(serde_json::to_value(&carrier).unwrap()).unwrap(); } - let first = resolve_current_persisted_restore(&mut carrier, SourceRestoreMode::Wake, &host, Ok(path.clone())).unwrap().unwrap(); + let first = resolve_current_persisted_restore( + &mut carrier, + SourceRestoreMode::Wake, + &host, + Ok(path.clone()), + ) + .unwrap() + .unwrap(); assert!(first.shell.contains("tool")); - assert_eq!(first.profile_resolution.cell.env.get("SOURCE").map(String::as_str), Some("F-old")); + assert_eq!( + first + .profile_resolution + .cell + .env + .get("SOURCE") + .map(String::as_str), + Some("F-old") + ); assert_eq!(first.trusted_agent_id, "same"); assert_eq!(first.backend.image.as_deref(), Some("F-old")); assert!(first.source_seed.is_some()); let first_seed = carrier.coding_agent_binding_seed.clone().unwrap(); e2432_physical_restore_fixture(&temp.path().join("F"), "F-current"); - let current = resolve_current_persisted_restore(&mut carrier, SourceRestoreMode::Wake, &host, Ok(path.clone())).unwrap().unwrap(); - assert_eq!(current.profile_resolution.cell.env.get("SOURCE").map(String::as_str), Some("F-current")); + let current = resolve_current_persisted_restore( + &mut carrier, + SourceRestoreMode::Wake, + &host, + Ok(path.clone()), + ) + .unwrap() + .unwrap(); + assert_eq!( + current + .profile_resolution + .cell + .env + .get("SOURCE") + .map(String::as_str), + Some("F-current") + ); assert_ne!(carrier.coding_agent_binding_seed, Some(first_seed)); assert_eq!(carrier.coding_agent_binding_id, id); assert_eq!(carrier.coding_agent_binding_origin, origin); assert_eq!(serde_json::to_value(&persisted).unwrap(), original); - let source_file = path.with_file_name(config::instance_artifacts::AGENTS_INSTANCE_FILE_NAME); + let source_file = + path.with_file_name(config::instance_artifacts::AGENTS_INSTANCE_FILE_NAME); let valid = std::fs::read(&source_file).unwrap(); for replacement in [Some(b"{\"agents\":[]}".to_vec()), None] { - if let Some(bytes) = replacement { std::fs::write(&source_file, bytes).unwrap(); } - else { std::fs::remove_file(&source_file).unwrap(); } + if let Some(bytes) = replacement { + std::fs::write(&source_file, bytes).unwrap(); + } else { + std::fs::remove_file(&source_file).unwrap(); + } let before = serde_json::to_value(&carrier).unwrap(); - assert!(resolve_current_persisted_restore(&mut carrier, SourceRestoreMode::Wake, &host, Ok(path.clone())).is_err()); + assert!(resolve_current_persisted_restore( + &mut carrier, + SourceRestoreMode::Wake, + &host, + Ok(path.clone()) + ) + .is_err()); assert_eq!(serde_json::to_value(&carrier).unwrap(), before); std::fs::write(&source_file, &valid).unwrap(); } let mut changed: serde_json::Value = serde_json::from_slice(&valid).unwrap(); changed["agents"][0]["baseCommand"] = serde_json::json!("changed-base"); std::fs::write(&source_file, serde_json::to_vec(&changed).unwrap()).unwrap(); - assert!(resolve_current_persisted_restore(&mut carrier, SourceRestoreMode::Wake, &host, Ok(path.clone())).is_err()); + assert!(resolve_current_persisted_restore( + &mut carrier, + SourceRestoreMode::Wake, + &host, + Ok(path.clone()) + ) + .is_err()); std::fs::write(&source_file, &valid).unwrap(); carrier.coding_agent_binding_conflict = true; - assert!(resolve_current_persisted_restore(&mut carrier, SourceRestoreMode::Wake, &host, Ok(path.clone())).is_err()); + assert!(resolve_current_persisted_restore( + &mut carrier, + SourceRestoreMode::Wake, + &host, + Ok(path.clone()) + ) + .is_err()); carrier.coding_agent_binding_conflict = false; - carrier.coding_agent_binding_seed.as_mut().unwrap().identity_revision = "malformed".into(); - assert!(resolve_current_persisted_restore(&mut carrier, SourceRestoreMode::Wake, &host, Ok(path.clone())).is_err()); - assert!(matches!(prepare_persisted_restore(&persisted, SourceRestoreMode::Wake, - &host, Err("source unreadable".into()), None), crate::commands::session::SourceRestoreDecision::Refuse { .. })); + carrier + .coding_agent_binding_seed + .as_mut() + .unwrap() + .identity_revision = "malformed".into(); + assert!(resolve_current_persisted_restore( + &mut carrier, + SourceRestoreMode::Wake, + &host, + Ok(path.clone()) + ) + .is_err()); + assert!(matches!( + prepare_persisted_restore( + &persisted, + SourceRestoreMode::Wake, + &host, + Err("source unreadable".into()), + None + ), + crate::commands::session::SourceRestoreDecision::Refuse { .. } + )); let legacy = PersistedSession::default(); - assert!(matches!(prepare_persisted_restore(&legacy, SourceRestoreMode::Wake, - &host, Err("no source".into()), None), crate::commands::session::SourceRestoreDecision::Legacy)); + assert!(matches!( + prepare_persisted_restore( + &legacy, + SourceRestoreMode::Wake, + &host, + Err("no source".into()), + None + ), + crate::commands::session::SourceRestoreDecision::Legacy + )); } #[test] - fn e2432_restore_root_source() { e2432_assert_current_source_restore(true, false); } + fn e2432_restore_root_source() { + e2432_assert_current_source_restore(true, false); + } #[test] - fn e2432_restore_ordinary_source() { e2432_assert_current_source_restore(false, false); } + fn e2432_restore_ordinary_source() { + e2432_assert_current_source_restore(false, false); + } #[test] fn e2432_restore_deferred_source() { @@ -9496,12 +9667,19 @@ mod tests { fn e2432_restore_production_source_first_wiring() { let source = include_str!("lib.rs"); let start = source.find("fn start_native_restore_startup(").unwrap(); - let body = &source[start..source.find("fn e2432_assert_current_source_restore(").unwrap()]; + let body = &source[start + ..source + .find("fn e2432_assert_current_source_restore(") + .unwrap()]; let root = body.find("let root_source =").unwrap(); - let root_global = body.find("settings_snapshot.agents_layer.unreadable_path()").unwrap(); + let root_global = body + .find("settings_snapshot.agents_layer.unreadable_path()") + .unwrap(); assert!(root < root_global); let ordinary = body.find("let source_spawn = match").unwrap(); - let ordinary_global = body.rfind("settings_snapshot.agents_layer.unreadable_path()").unwrap(); + let ordinary_global = body + .rfind("settings_snapshot.agents_layer.unreadable_path()") + .unwrap(); assert!(ordinary < ordinary_global); assert!(body[root..].contains("SourceRestoreMode::Defer")); assert!(body[ordinary..].contains("restore_dormant_inline")); diff --git a/src-tauri/src/session/manager.rs b/src-tauri/src/session/manager.rs index d792e985e..3be6e1dc4 100644 --- a/src-tauri/src/session/manager.rs +++ b/src-tauri/src/session/manager.rs @@ -2803,7 +2803,9 @@ impl SessionManager { desired_seed, }; validate_restart_binding(&state, &transition, false)?; - state.coding_agent_bindings.insert(target.session_id, transition.coding_agent_id.clone()); + state + .coding_agent_bindings + .insert(target.session_id, transition.coding_agent_id.clone()); Ok(transition) } @@ -3027,8 +3029,11 @@ impl SessionManager { if let Some(transition) = mutations.restart_binding.as_ref() { validate_restart_binding(&state, transition, true)?; if !mutations.removals.contains(&transition.retiring_runtime) - || !mutations.finalizations.iter().any(|mutation| matches!(mutation, - FinalizeMutation::Live(binding, _) if *binding == transition.target)) { + || !mutations.finalizations.iter().any(|mutation| { + matches!(mutation, + FinalizeMutation::Live(binding, _) if *binding == transition.target) + }) + { return Err("restart binding requires atomic replacement".into()); } } @@ -3179,7 +3184,9 @@ impl SessionManager { // Last publication: no fallible operation or await follows. if let Some(transition) = mutations.restart_binding { - state.binding_seeds.insert(transition.coding_agent_id, transition.desired_seed); + state + .binding_seeds + .insert(transition.coding_agent_id, transition.desired_seed); } Ok(result) } @@ -3346,26 +3353,43 @@ fn validate_restart_binding( let id = &transition.coding_agent_id; if Uuid::parse_str(id).is_err() || !["identityRevision", "originalAgentId"].iter().all(|key| { - transition.desired_seed.get(key).and_then(serde_json::Value::as_str) + transition + .desired_seed + .get(key) + .and_then(serde_json::Value::as_str) .is_some_and(|value| !value.is_empty()) - }) { + }) + { return Err("invalid_coding_agent_binding_seed".into()); } if !binding_matches(state, transition.target) || transition.target.session_id == transition.retiring_runtime || !state.sessions.contains_key(&transition.retiring_runtime) - || state.pending_create.contains_key(&transition.retiring_runtime) - || state.coding_agent_bindings.get(&transition.retiring_runtime) != Some(id) - || state.coding_agent_bindings.get(&transition.target.session_id) + || state + .pending_create + .contains_key(&transition.retiring_runtime) + || state + .coding_agent_bindings + .get(&transition.retiring_runtime) + != Some(id) + || state + .coding_agent_bindings + .get(&transition.target.session_id) != if staged { Some(id) } else { None } - || state.coding_agent_bindings.iter().any(|(runtime, candidate)| { - candidate == id && *runtime != transition.retiring_runtime - && (!staged || *runtime != transition.target.session_id) - }) { + || state + .coding_agent_bindings + .iter() + .any(|(runtime, candidate)| { + candidate == id + && *runtime != transition.retiring_runtime + && (!staged || *runtime != transition.target.session_id) + }) + { return Err("restart coding-agent binding owner/capability mismatch".into()); } if state.binding_seeds.get(id) != transition.expected_seed.as_ref() - || state.binding_origins.get(id) != transition.expected_origin.as_ref() { + || state.binding_origins.get(id) != transition.expected_origin.as_ref() + { return Err("restart coding-agent binding metadata changed".into()); } Ok(()) @@ -5874,26 +5898,63 @@ mod tests { origin: Option, ) -> (Uuid, CodingAgentBinding) { let (old, pending) = pending_fixture(manager, false).await; - let binding = CodingAgentBinding { id: Uuid::new_v4().to_string(), seed, origin }; - manager.install_pending_coding_agent_binding(pending, Some(binding.clone())).await.unwrap(); + let binding = CodingAgentBinding { + id: Uuid::new_v4().to_string(), + seed, + origin, + }; + manager + .install_pending_coding_agent_binding(pending, Some(binding.clone())) + .await + .unwrap(); let mut mutations = LifecycleMutations::default(); mutations.finalize_dormant(pending, dormant_witness(old.id), 0); - manager.commit_selection_transition(&CommitCapability::for_test(), CommitDecision::Keep, - SelectionCause::Restore, mutations).await.unwrap(); + manager + .commit_selection_transition( + &CommitCapability::for_test(), + CommitDecision::Keep, + SelectionCause::Restore, + mutations, + ) + .await + .unwrap(); (old.id, binding) } #[tokio::test] async fn e2432_restart_binding_stages_without_publication_and_commits_last() { - for seed in [None, Some(serde_json::json!({"identityRevision":"old", "originalAgentId":"same", "unknown":null}))] { - for origin in [None, Some(serde_json::Value::Null), Some(serde_json::json!({"ordinal":7}))] { + for seed in [ + None, + Some( + serde_json::json!({"identityRevision":"old", "originalAgentId":"same", "unknown":null}), + ), + ] { + for origin in [ + None, + Some(serde_json::Value::Null), + Some(serde_json::json!({"ordinal":7})), + ] { let manager = SessionManager::new(); let (old, expected) = e2432_bound_old(&manager, seed.clone(), origin).await; let (replacement, pending) = pending_fixture(&manager, false).await; - let desired = serde_json::json!({"identityRevision":"fresh", "originalAgentId":"same"}); - let transition = manager.stage_pending_restart_binding(pending, old, expected.clone(), desired.clone()).await.unwrap(); - assert_eq!(manager.coding_agent_binding(old).await.unwrap().seed, expected.seed); - assert_eq!(manager.coding_agent_binding(replacement.id).await.unwrap().seed, expected.seed); + let desired = + serde_json::json!({"identityRevision":"fresh", "originalAgentId":"same"}); + let transition = manager + .stage_pending_restart_binding(pending, old, expected.clone(), desired.clone()) + .await + .unwrap(); + assert_eq!( + manager.coding_agent_binding(old).await.unwrap().seed, + expected.seed + ); + assert_eq!( + manager + .coding_agent_binding(replacement.id) + .await + .unwrap() + .seed, + expected.seed + ); let snapshot = manager.aggregate_snapshot().await; assert!(snapshot.sessions.iter().any(|row| row.id == old)); assert!(!snapshot.sessions.iter().any(|row| row.id == replacement.id)); @@ -5901,8 +5962,15 @@ mod tests { mutations.finalize_live(pending, live_witness(replacement.id)); mutations.remove(old); mutations.set_restart_binding(transition).unwrap(); - manager.commit_selection_transition(&CommitCapability::for_test(), CommitDecision::Keep, - SelectionCause::Restore, mutations).await.unwrap(); + manager + .commit_selection_transition( + &CommitCapability::for_test(), + CommitDecision::Keep, + SelectionCause::Restore, + mutations, + ) + .await + .unwrap(); assert!(manager.get_session(old).await.is_none()); assert!(manager.get_session(replacement.id).await.is_some()); let final_binding = manager.coding_agent_binding(replacement.id).await.unwrap(); @@ -5917,35 +5985,68 @@ mod tests { async fn e2432_restart_binding_real_refresh_o1_o2_rejects_without_stale_rollback() { for refresh_after_stage in [false, true] { let manager = SessionManager::new(); - let (old, expected) = e2432_bound_old(&manager, None, Some(serde_json::json!("O1"))).await; + let (old, expected) = + e2432_bound_old(&manager, None, Some(serde_json::json!("O1"))).await; let (_, pending) = pending_fixture(&manager, false).await; - let desired = serde_json::json!({"identityRevision":"desired", "originalAgentId":"same"}); + let desired = + serde_json::json!({"identityRevision":"desired", "originalAgentId":"same"}); let transition = if refresh_after_stage { - Some(manager.stage_pending_restart_binding(pending, old, expected.clone(), desired.clone()).await.unwrap()) - } else { None }; + Some( + manager + .stage_pending_restart_binding( + pending, + old, + expected.clone(), + desired.clone(), + ) + .await + .unwrap(), + ) + } else { + None + }; let concurrent = CodingAgentBinding { id: expected.id.clone(), seed: Some(serde_json::json!({"identityRevision":"O2", "originalAgentId":"same"})), origin: Some(serde_json::json!("O2")), }; - manager.refresh_failed_binding_recipes(vec![(concurrent.clone(), serde_json::json!({"recipe":"O2"}))]).await; + manager + .refresh_failed_binding_recipes(vec![( + concurrent.clone(), + serde_json::json!({"recipe":"O2"}), + )]) + .await; let before_rejection = e2432_manager_fingerprint(&manager).await; if let Some(transition) = transition { let mut mutations = LifecycleMutations::default(); mutations.finalize_live(pending, live_witness(pending.session_id)); mutations.remove(old); mutations.set_restart_binding(transition).unwrap(); - assert!(manager.commit_selection_transition(&CommitCapability::for_test(), CommitDecision::Keep, - SelectionCause::Restore, mutations).await.is_err()); + assert!(manager + .commit_selection_transition( + &CommitCapability::for_test(), + CommitDecision::Keep, + SelectionCause::Restore, + mutations + ) + .await + .is_err()); } else { - assert!(manager.stage_pending_restart_binding(pending, old, expected, desired).await.is_err()); + assert!(manager + .stage_pending_restart_binding(pending, old, expected, desired) + .await + .is_err()); } assert_eq!(e2432_manager_fingerprint(&manager).await, before_rejection); manager.rollback_pending_create(pending).await.unwrap(); let retained = manager.coding_agent_binding(old).await.unwrap(); assert_eq!(retained.seed, concurrent.seed); assert_eq!(retained.origin, concurrent.origin); - assert!(manager.aggregate_snapshot().await.failed_binding_recipes.is_empty()); + assert!(manager + .aggregate_snapshot() + .await + .failed_binding_recipes + .is_empty()); } } @@ -5958,7 +6059,8 @@ mod tests { let mut target = pending; let mut retiring = old; let mut captured = expected.clone(); - let mut desired = serde_json::json!({"identityRevision":"new", "originalAgentId":"same"}); + let mut desired = + serde_json::json!({"identityRevision":"new", "originalAgentId":"same"}); match case { 0 => target.nonce = Uuid::new_v4(), 1 => retiring = Uuid::new_v4(), @@ -5968,14 +6070,30 @@ mod tests { 5 => desired = serde_json::json!({"identityRevision":"", "originalAgentId":"same"}), 6 => { let (_, extra) = pending_fixture(&manager, false).await; - manager.install_pending_coding_agent_binding(extra, Some(expected.clone())).await.unwrap(); + manager + .install_pending_coding_agent_binding(extra, Some(expected.clone())) + .await + .unwrap(); } - 7 => manager.install_pending_coding_agent_binding(pending, Some(expected.clone())).await.unwrap(), + 7 => manager + .install_pending_coding_agent_binding(pending, Some(expected.clone())) + .await + .unwrap(), _ => unreachable!(), } let before = e2432_manager_fingerprint(&manager).await; - assert!(manager.stage_pending_restart_binding(target, retiring, captured, desired).await.is_err(), "case {case}"); - assert_eq!(e2432_manager_fingerprint(&manager).await, before, "case {case}"); + assert!( + manager + .stage_pending_restart_binding(target, retiring, captured, desired) + .await + .is_err(), + "case {case}" + ); + assert_eq!( + e2432_manager_fingerprint(&manager).await, + before, + "case {case}" + ); manager.rollback_pending_create(pending).await.unwrap(); assert!(manager.get_session(old).await.is_some()); } @@ -5988,17 +6106,38 @@ mod tests { let (old, expected) = e2432_bound_old(&manager, None, None).await; let (replacement, pending) = pending_fixture(&manager, false).await; let desired = serde_json::json!({"identityRevision":"new", "originalAgentId":"same"}); - let transition = manager.stage_pending_restart_binding(pending, old, expected.clone(), desired.clone()).await.unwrap(); + let transition = manager + .stage_pending_restart_binding(pending, old, expected.clone(), desired.clone()) + .await + .unwrap(); let mut witness = live_witness(replacement.id); let mut decision = CommitDecision::Live(witness); match case { 0 => witness.has_pty = false, - 1 => manager.state.write().await.sessions.get_mut(&replacement.id).unwrap().status = SessionStatus::Exited(1), + 1 => { + manager + .state + .write() + .await + .sessions + .get_mut(&replacement.id) + .unwrap() + .status = SessionStatus::Exited(1) + } 2 => decision = CommitDecision::Live(live_witness(Uuid::new_v4())), 3 => manager.state.write().await.revision = u64::MAX, - 4 => manager.refresh_failed_binding_recipes(vec![(CodingAgentBinding { - id:expected.id.clone(), seed:None, origin:Some(serde_json::json!("O2")), - }, serde_json::json!({}))]).await, + 4 => { + manager + .refresh_failed_binding_recipes(vec![( + CodingAgentBinding { + id: expected.id.clone(), + seed: None, + origin: Some(serde_json::json!("O2")), + }, + serde_json::json!({}), + )]) + .await + } _ => unreachable!(), } let before = e2432_manager_fingerprint(&manager).await; @@ -6006,12 +6145,29 @@ mod tests { mutations.finalize_live(pending, witness); mutations.remove(old); mutations.set_restart_binding(transition).unwrap(); - assert!(manager.commit_selection_transition(&CommitCapability::for_test(), decision, - SelectionCause::Restore, mutations).await.is_err(), "case {case}"); - assert_eq!(e2432_manager_fingerprint(&manager).await, before, "case {case}"); + assert!( + manager + .commit_selection_transition( + &CommitCapability::for_test(), + decision, + SelectionCause::Restore, + mutations + ) + .await + .is_err(), + "case {case}" + ); + assert_eq!( + e2432_manager_fingerprint(&manager).await, + before, + "case {case}" + ); manager.rollback_pending_create(pending).await.unwrap(); assert!(manager.get_session(old).await.is_some()); - assert_eq!(manager.coding_agent_binding(old).await.unwrap().seed, expected.seed); + assert_eq!( + manager.coding_agent_binding(old).await.unwrap().seed, + expected.seed + ); } } @@ -6023,30 +6179,57 @@ mod tests { seed: Some(serde_json::json!({"identityRevision":"current", "originalAgentId":"same"})), origin: Some(serde_json::json!({"ordinal":2})), }; - manager.refresh_failed_binding_recipes(vec![(orphan.clone(), serde_json::json!({}))]).await; + manager + .refresh_failed_binding_recipes(vec![(orphan.clone(), serde_json::json!({}))]) + .await; let snapshot = manager.aggregate_snapshot().await; assert!(snapshot.sessions.is_empty()); assert!(snapshot.failed_binding_recipes.is_empty()); assert!(snapshot.coding_agent_bindings.is_empty()); let (_, pending) = pending_fixture(&manager, false).await; let before = e2432_manager_fingerprint(&manager).await; - assert!(manager.stage_pending_restart_binding(pending, Uuid::new_v4(), orphan.clone(), orphan.seed.clone().unwrap()).await.is_err()); + assert!(manager + .stage_pending_restart_binding( + pending, + Uuid::new_v4(), + orphan.clone(), + orphan.seed.clone().unwrap() + ) + .await + .is_err()); assert_eq!(e2432_manager_fingerprint(&manager).await, before); manager.rollback_pending_create(pending).await.unwrap(); - let persisted = serde_json::json!({"id":orphan.id, "seed":orphan.seed, "origin":orphan.origin}); - let persisted: serde_json::Value = serde_json::from_slice(&serde_json::to_vec(&persisted).unwrap()).unwrap(); + let persisted = + serde_json::json!({"id":orphan.id, "seed":orphan.seed, "origin":orphan.origin}); + let persisted: serde_json::Value = + serde_json::from_slice(&serde_json::to_vec(&persisted).unwrap()).unwrap(); for live in [false, true] { let cold = SessionManager::new(); let (_, birth) = pending_fixture(&cold, false).await; - cold.install_pending_coding_agent_binding(birth, Some(CodingAgentBinding { - id:persisted["id"].as_str().unwrap().to_string(), - seed:Some(persisted["seed"].clone()), origin:Some(persisted["origin"].clone()), - })).await.unwrap(); + cold.install_pending_coding_agent_binding( + birth, + Some(CodingAgentBinding { + id: persisted["id"].as_str().unwrap().to_string(), + seed: Some(persisted["seed"].clone()), + origin: Some(persisted["origin"].clone()), + }), + ) + .await + .unwrap(); let mut mutations = LifecycleMutations::default(); - if live { mutations.finalize_live(birth, live_witness(birth.session_id)); } - else { mutations.finalize_dormant(birth, dormant_witness(birth.session_id), 0); } - cold.commit_selection_transition(&CommitCapability::for_test(), CommitDecision::Keep, - SelectionCause::Restore, mutations).await.unwrap(); + if live { + mutations.finalize_live(birth, live_witness(birth.session_id)); + } else { + mutations.finalize_dormant(birth, dormant_witness(birth.session_id), 0); + } + cold.commit_selection_transition( + &CommitCapability::for_test(), + CommitDecision::Keep, + SelectionCause::Restore, + mutations, + ) + .await + .unwrap(); let restored = cold.coding_agent_binding(birth.session_id).await.unwrap(); assert_eq!(restored.id, orphan.id); assert_eq!(restored.seed, orphan.seed); @@ -6058,28 +6241,52 @@ mod tests { #[tokio::test] async fn e2432_restart_binding_second_transition_and_last_owner_retirement() { let manager = SessionManager::new(); - let (old, expected) = e2432_bound_old(&manager, Some(serde_json::json!({ - "identityRevision":"old", "originalAgentId":"same", - })), Some(serde_json::json!("original"))).await; + let (old, expected) = e2432_bound_old( + &manager, + Some(serde_json::json!({ + "identityRevision":"old", "originalAgentId":"same", + })), + Some(serde_json::json!("original")), + ) + .await; let (_, pending) = pending_fixture(&manager, false).await; let desired = serde_json::json!({"identityRevision":"desired", "originalAgentId":"same"}); - let transition = manager.stage_pending_restart_binding(pending, old, expected.clone(), desired.clone()).await.unwrap(); + let transition = manager + .stage_pending_restart_binding(pending, old, expected.clone(), desired.clone()) + .await + .unwrap(); let before = e2432_manager_fingerprint(&manager).await; - assert!(manager.stage_pending_restart_binding(pending, old, expected.clone(), desired.clone()).await.is_err()); + assert!(manager + .stage_pending_restart_binding(pending, old, expected.clone(), desired.clone()) + .await + .is_err()); assert_eq!(e2432_manager_fingerprint(&manager).await, before); let mut mutations = LifecycleMutations::default(); mutations.set_restart_binding(transition).unwrap(); let other = SessionManager::new(); let (other_old, other_expected) = e2432_bound_old(&other, None, None).await; let (_, other_pending) = pending_fixture(&other, false).await; - let second = other.stage_pending_restart_binding(other_pending, other_old, other_expected, desired).await.unwrap(); + let second = other + .stage_pending_restart_binding(other_pending, other_old, other_expected, desired) + .await + .unwrap(); assert!(mutations.set_restart_binding(second).is_err()); manager.rollback_pending_create(pending).await.unwrap(); - assert_eq!(manager.coding_agent_binding(old).await.unwrap().seed, expected.seed); + assert_eq!( + manager.coding_agent_binding(old).await.unwrap().seed, + expected.seed + ); let mut remove = LifecycleMutations::default(); remove.remove(old); - manager.commit_selection_transition(&CommitCapability::for_test(), CommitDecision::Keep, - SelectionCause::Restore, remove).await.unwrap(); + manager + .commit_selection_transition( + &CommitCapability::for_test(), + CommitDecision::Keep, + SelectionCause::Restore, + remove, + ) + .await + .unwrap(); let retired = manager.aggregate_snapshot().await; assert!(!retired.binding_seeds.contains_key(&expected.id)); assert!(!retired.binding_origins.contains_key(&expected.id)); From 0b5a9f5447a66715739dcaa7d6b38e07be4dfb07 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Wed, 21 Oct 2026 12:21:29 -0300 Subject: [PATCH 04/14] style(2899): apply two reviewed fixture layout corrections --- src-tauri/src/commands/session.rs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/commands/session.rs b/src-tauri/src/commands/session.rs index 1b2da2db9..9c398d31b 100644 --- a/src-tauri/src/commands/session.rs +++ b/src-tauri/src/commands/session.rs @@ -7421,7 +7421,11 @@ mod tests { let (project, cwd, _) = strict_target_fixture(); let (mut host, path, persisted) = super::e2432_physical_restore_fixture(&project.path().join("instance"), "source-owned"); - host.project_paths = vec![project.path().join("project").to_string_lossy().into_owned()]; + host.project_paths = vec![project + .path() + .join("project") + .to_string_lossy() + .into_owned()]; host.default_shell = "default-control-shell".into(); host.default_shell_args = vec!["--default-control".into()]; std::fs::write(&path, serde_json::to_vec(&host).unwrap()).unwrap(); @@ -7461,10 +7465,7 @@ mod tests { ); for explicit in [false, true] { let (shell, args, expected_shell, expected_args) = if explicit { - let args = vec![ - "--keep".to_string(), - "one argument with spaces".to_string(), - ]; + let args = vec!["--keep".to_string(), "one argument with spaces".to_string()]; ( Some("explicit-control-shell".to_string()), Some(args.clone()), From 52bfff3d4382bd3d0c2d3fd4acea9af6f2c6623e Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Wed, 21 Oct 2026 12:33:49 -0300 Subject: [PATCH 05/14] fix(2899): import parent restore symbols in source fixture --- src-tauri/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 12a4b1de3..183466c9e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9492,6 +9492,7 @@ mod tests { } fn e2432_assert_current_source_restore(root: bool, deferred: bool) { + use super::{config, prepare_persisted_restore, resolve_current_persisted_restore}; use crate::commands::session::{e2432_physical_restore_fixture, SourceRestoreMode}; let temp = tempfile::tempdir().unwrap(); let (mut host, path, mut persisted) = From 8db5de19c69b4c3936aa6f4291f85fcfdf9e5fe7 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Wed, 21 Oct 2026 15:28:36 -0300 Subject: [PATCH 06/14] fix(runtime): bind staged source provenance after ordinary startup load --- src-tauri/src/commands/session.rs | 132 +++++++++++++++- src-tauri/src/config/settings.rs | 245 +++++++++++++++++++++++++++++- src-tauri/src/lib.rs | 106 ++++++++++++- 3 files changed, 470 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/commands/session.rs b/src-tauri/src/commands/session.rs index 9c398d31b..ea80ccdff 100644 --- a/src-tauri/src/commands/session.rs +++ b/src-tauri/src/commands/session.rs @@ -7416,6 +7416,128 @@ mod tests { crate::config::settings::load_settings_from_path(&path) } + #[tokio::test] + async fn e2432_bound_staged_enforces_creation_and_archive_guards() { + use crate::config::sessions_persistence::CreationGateEnforcement; + if std::env::var_os("AC_E2432_GATE_CHILD").is_none() { + let temp = tempfile::tempdir().unwrap(); + let config = temp.path().join("instance"); + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "commands::session::tests::e2432_bound_staged_enforces_creation_and_archive_guards", + "--nocapture", + "--test-threads=1", + ]) + .env("AC_E2432_GATE_CHILD", "1") + .env("AGENTSCOMMANDER_CONFIG_DIR", &config) + .env("AGENTSCOMMANDER_TEST_CONFIG_DIR", &config) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + return; + } + let config = std::path::PathBuf::from( + std::env::var_os("AGENTSCOMMANDER_TEST_CONFIG_DIR").unwrap(), + ); + let (temp, cwd, _) = strict_target_fixture(); + let (_outside, outside_cwd, _) = strict_target_fixture(); + let project = temp.path().join("project").to_string_lossy().into_owned(); + let missing = temp.path().join("missing-workspace"); + std::fs::create_dir(&missing).unwrap(); + let missing = missing.to_string_lossy().into_owned(); + let (mut host, path, _) = super::e2432_physical_restore_fixture(&config, "source"); + host.root_token = Some("gate-fixture".into()); + host.archived_project_paths = vec![project.clone(), missing.clone()]; + std::fs::write(&path, serde_json::to_vec(&host).unwrap()).unwrap(); + let owner = crate::config::settings::new_runtime_settings_state_at_path(host.clone(), &path) + .unwrap(); + assert!(!owner.identity().managed_operations_enabled); + assert_eq!( + owner.runtime_source_settings_path().unwrap(), + std::fs::canonicalize(&config).unwrap() + .join(crate::config::instance_artifacts::SETTINGS_FILE_NAME) + ); + let session_mgr = Arc::new(tokio::sync::RwLock::new(SessionManager::new())); + let backend = Arc::new(ScriptedSpawnBackend::default()); + let pty_mgr = Arc::new(Mutex::new(crate::pty::manager::PtyManager::new_for_test( + backend.clone(), + ))); + let app = session_test_app(host, session_mgr.clone(), pty_mgr.clone()); + app.unmanage::(); + assert!(app.manage(owner.clone())); + let before = std::fs::read(&path).unwrap(); + let error = crate::config::sessions_persistence::enforce_creation_gate( + app.handle(), + &outside_cwd, + CreationGateEnforcement::Enforce, + ) + .await + .unwrap_err(); + assert!(error.contains("sessionCreateBlocked"), "{error}"); + let error = crate::config::archive_gate::enforce_unarchived_for_spawn( + app.handle(), + &missing, + "missing-workspace", + ) + .await + .unwrap_err(); + assert!(error.contains("Cannot start a session in archived project"), "{error}"); + assert_eq!(std::fs::read(&path).unwrap(), before); + assert_eq!(backend.spawn_count.load(Ordering::SeqCst), 0); + assert!(session_mgr.read().await.list_sessions().await.is_empty()); + let outcome = super::create_session_inner_impl( + app.handle(), + &session_mgr, + &pty_mgr, + "test-shell".to_string(), + Vec::new(), + cwd, + Some("staged-auto-unarchive".to_string()), + None, + None, + true, + Vec::new(), + true, + None, + None, + None, + CreateSelectionIntent::User, + None, + None, + None, + None, + false, + None, + CreationGateEnforcement::Enforce, + None, + None, + ) + .await; + assert!(outcome.is_ok(), "{}", outcome.err().unwrap_or_default()); + assert_eq!(backend.spawn_count.load(Ordering::SeqCst), 1); + assert_eq!(session_mgr.read().await.list_sessions().await.len(), 1); + let current = owner.read().await; + assert!(current.project_paths.iter().any(|root| + crate::config::projects::normalize_for_compare(root) + == crate::config::projects::normalize_for_compare(&project) + )); + assert!(!current.archived_project_paths.contains(&project)); + assert!(current.archived_project_paths.contains(&missing)); + drop(current); + assert_ne!(std::fs::read(&path).unwrap(), before); + let disk = crate::config::settings::load_settings_from_path(&path); + assert!(disk.project_paths.iter().any(|root| + crate::config::projects::normalize_for_compare(root) + == crate::config::projects::normalize_for_compare(&project) + )); + assert!(!disk.archived_project_paths.contains(&project)); + assert!(disk.archived_project_paths.contains(&missing)); + assert!(!owner.identity().managed_operations_enabled); + assert!(owner.runtime_source_settings_path().is_ok()); + close_test_coordinator(&app).await; + } + #[tokio::test] async fn e2432_plain_shell_create_ignores_saved_source_and_malformed_selection() { let (project, cwd, _) = strict_target_fixture(); @@ -7515,7 +7637,7 @@ mod tests { std::fs::write(&config_path, serde_json::to_vec(&valid_config).unwrap()).unwrap(); let owner = - crate::config::settings::SettingsOwner::open_active(&path, Duration::from_secs(1)) + crate::config::settings::new_runtime_settings_state_at_path(host.clone(), &path) .unwrap(); app.unmanage::(); assert!(app.manage(owner)); @@ -7830,7 +7952,7 @@ mod tests { std::fs::write( &catalog_path, serde_json::to_vec(&serde_json::json!({"schemaVersion":2, - "agents":[{"key":"catalog-only", "baseCommand":"tool", "label":"Only", "color":"blue"}], + "agents":[{"key":"catalog-only", "baseCommand":"tool", "label":"Only", "color":"blue", "description":"Physical catalog row"}], "profileBindingVersion":1, "profilesByBaseHash":{(base.clone()):{ "A":{"additionalArguments":"tool only-here", "env":{"SOURCE":"catalog"}} }}})) @@ -7895,7 +8017,7 @@ mod tests { ))); let app = session_test_app(host.clone(), session_mgr.clone(), pty_mgr.clone()); let owner = - crate::config::settings::SettingsOwner::open_active(&path, Duration::from_secs(1)) + crate::config::settings::new_runtime_settings_state_at_path(host.clone(), &path) .unwrap(); app.unmanage::(); assert!(app.manage(owner)); @@ -8294,7 +8416,7 @@ mod tests { std::fs::write( &g_path, serde_json::to_vec(&serde_json::json!({"schemaVersion":2, - "agents":[{"key":"same", "label":"F", "color":"green", "baseCommand":"tool"}], + "agents":[{"key":"same", "label":"F", "color":"green", "baseCommand":"tool", "description":"Stored physical row"}], "profileBindingVersion":1, "profilesByBaseHash":{(base):{ "A":{"additionalArguments":"tool F-A"}, "D":{"additionalArguments":"tool F-D"} }}})) @@ -8349,7 +8471,7 @@ mod tests { ))); let app = session_test_app(host.clone(), session_mgr.clone(), pty_mgr.clone()); let owner = - crate::config::settings::SettingsOwner::open_active(&path, Duration::from_secs(1)) + crate::config::settings::new_runtime_settings_state_at_path(host.clone(), &path) .unwrap(); app.unmanage::(); assert!(app.manage(owner)); diff --git a/src-tauri/src/config/settings.rs b/src-tauri/src/config/settings.rs index 82348cbd8..d53579f87 100644 --- a/src-tauri/src/config/settings.rs +++ b/src-tauri/src/config/settings.rs @@ -18193,6 +18193,7 @@ pub struct SettingsOwner { control: std::sync::Mutex, released: std::sync::Condvar, lease: std::sync::Mutex>, + runtime_directory: Option, counters: SettingsOwnerCounters, } @@ -18527,13 +18528,27 @@ fn compose_captured_sources( } impl SettingsOwner { - /// Runtime provenance uses only the active owner's retained settings locator. + /// Revalidate the backend-owned runtime locator before reading a source. pub(crate) fn runtime_source_settings_path(&self) -> Result { self.revalidate().map_err(|error| error.to_string())?; - let lease = self.lease.lock().unwrap_or_else(|error| error.into_inner()); - let lease = lease.as_ref().ok_or("sourceContextUnavailable")?; - lease.verify().map_err(|_| "ownerIntegrityLost")?; - Ok(lease.settings_path.clone()) + if self.stage == SettingsOwnerStage::Active { + let lease = self.lease.lock().unwrap_or_else(|error| error.into_inner()); + let lease = lease.as_ref().ok_or("sourceContextUnavailable")?; + lease.verify().map_err(|_| "ownerIntegrityLost")?; + return Ok(lease.settings_path.clone()); + } + let expected = self + .runtime_directory + .as_ref() + .ok_or("sourceContextUnavailable")?; + let current = crate::path_identity::verify_directory(&expected.canonical_path) + .map_err(|_| "ownerIntegrityLost")?; + if !crate::path_identity::same_object(expected, ¤t) + || expected.canonical_path != current.canonical_path + { + return Err("ownerIntegrityLost".into()); + } + Ok(expected.canonical_path.join(SETTINGS_FILE_NAME)) } /// StagedCompatibility owner around already-loaded settings: the P40 @@ -18567,6 +18582,7 @@ impl SettingsOwner { }), released: std::sync::Condvar::new(), lease: std::sync::Mutex::new(lease), + runtime_directory: None, counters: SettingsOwnerCounters::default(), } } @@ -19059,12 +19075,231 @@ impl SettingsOwner { pub type SettingsState = Arc; +fn verify_runtime_settings_directory( + path: &Path, +) -> Result { + if path.file_name() != Some(std::ffi::OsStr::new(SETTINGS_FILE_NAME)) { + return Err("runtime_settings_leaf_rejected".into()); + } + let parent = path.parent().ok_or("missing_parent_directory")?; + if parent + .components() + .any(|component| component == std::path::Component::ParentDir) + { + return Err("runtime_directory_rejected".into()); + } + match crate::path_identity::verify_component_chain_reason(parent) { + Ok(()) | Err(crate::path_identity::PathRule::ComponentIsLink) => {} + Err(_) => return Err("runtime_directory_rejected".into()), + } + let physical_parent = std::fs::canonicalize(parent).map_err(|_| "runtime_directory_rejected")?; + crate::path_identity::verify_directory(&physical_parent) +} + +fn bound_runtime_settings_owner( + settings: AppSettings, + directory: crate::path_identity::VerifiedPathIdentity, +) -> SettingsState { + let mut owner = SettingsOwner::staged(settings); + owner.runtime_directory = Some(directory); + Arc::new(owner) +} + +#[cfg(test)] +pub(crate) fn new_runtime_settings_state_at_path( + settings: AppSettings, + path: &Path, +) -> Result { + let directory = verify_runtime_settings_directory(path)?; + Ok(bound_runtime_settings_owner(settings, directory)) +} + +/// Preserve the ordinary loader's effects, then bind its physical directory. +pub(crate) fn load_runtime_settings_state() -> SettingsState { + let Some(path) = settings_path() else { + log::warn!("Could not determine home directory, using defaults"); + return new_settings_state(AppSettings::default()); + }; + let settings = load_settings_from_path(&path); + match verify_runtime_settings_directory(&path) { + Ok(directory) => bound_runtime_settings_owner(settings, directory), + Err(reason) => { + log::warn!("Runtime source directory unavailable: {reason}"); + new_settings_state(settings) + } + } +} + pub fn new_settings_state(settings: AppSettings) -> SettingsState { Arc::new(SettingsOwner::staged(settings)) } #[cfg(test)] mod tests { + fn e2432_startup_child(mode: &str) { + let temp = tempfile::tempdir().unwrap(); + let config = temp.path().join("config"); + if mode == "existing" { + std::fs::create_dir(&config).unwrap(); + std::fs::write( + config.join(super::SETTINGS_FILE_NAME), + serde_json::json!({ + "voiceToTextEnabled": true, + "startOnlyCoordinators": true, + "rootToken": "startup-sentinel" + }) + .to_string(), + ) + .unwrap(); + } + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "config::settings::tests::e2432_runtime_startup_child", + "--nocapture", + "--test-threads=1", + ]) + .env("AC_E2432_STARTUP_CHILD", mode) + .env("AGENTSCOMMANDER_CONFIG_DIR", &config) + .env("AGENTSCOMMANDER_TEST_CONFIG_DIR", &config) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + } + + #[test] + fn e2432_runtime_startup_existing_config() { + e2432_startup_child("existing"); + } + + #[test] + fn e2432_runtime_startup_first_run() { + e2432_startup_child("first"); + } + + #[test] + fn e2432_runtime_startup_child() { + let Ok(mode) = std::env::var("AC_E2432_STARTUP_CHILD") else { + return; + }; + let config = std::path::PathBuf::from( + std::env::var_os("AGENTSCOMMANDER_TEST_CONFIG_DIR").unwrap(), + ); + assert_eq!(config.exists(), mode == "existing"); + let owner = super::load_runtime_settings_state(); + let loaded = owner.blocking_current_snapshot().unwrap(); + assert_eq!(owner.stage, super::SettingsOwnerStage::StagedCompatibility); + assert_eq!(owner.availability(), super::SettingsAvailability::Ready); + assert!(!owner.identity().managed_operations_enabled); + assert!(owner.lease_guard().is_none()); + assert!(owner.runtime_directory.is_some()); + let physical = std::fs::canonicalize(&config).unwrap(); + assert_eq!( + owner.runtime_source_settings_path().unwrap(), + physical.join(super::SETTINGS_FILE_NAME) + ); + let raw: serde_json::Value = serde_json::from_slice( + &std::fs::read(config.join(super::SETTINGS_FILE_NAME)).unwrap(), + ) + .unwrap(); + if mode == "existing" { + assert!(loaded.voice_to_text_enabled); + assert!(loaded.restore_coordinator_wake_state); + assert!(loaded.legacy_start_only_coordinators.is_none()); + assert!(raw.get("startOnlyCoordinators").is_none()); + assert_eq!(loaded.root_token.as_deref(), Some("startup-sentinel")); + } else { + assert!(loaded + .root_token + .as_ref() + .is_some_and(|token| !token.is_empty())); + } + assert_eq!(raw["rootToken"].as_str(), loaded.root_token.as_deref()); + let counts = super::source_transition_effect_counts(); + owner.blocking_write().voice_to_text_enabled = !loaded.voice_to_text_enabled; + assert_eq!( + owner.blocking_read().voice_to_text_enabled, + !loaded.voice_to_text_enabled + ); + assert!(matches!( + owner.with_operation_blocking("staged-runtime", |_, _| Ok(())), + Err(super::SettingsOwnerError::OperationsNotEnabled) + )); + assert_eq!(super::source_transition_effect_counts(), counts); + } + + #[test] + fn e2432_runtime_source_staged_unbound_and_directory_identity() { + let unbound = super::new_settings_state(super::AppSettings::default()); + assert_eq!( + unbound.runtime_source_settings_path().unwrap_err(), + "sourceContextUnavailable" + ); + let temp = tempfile::tempdir().unwrap(); + let directory = temp.path().join("instance"); + std::fs::create_dir(&directory).unwrap(); + let path = directory.join(super::SETTINGS_FILE_NAME); + let owner = super::new_runtime_settings_state_at_path(super::AppSettings::default(), &path) + .unwrap(); + assert!(super::new_runtime_settings_state_at_path( + super::AppSettings::default(), + &directory.join("other.json") + ) + .is_err()); + assert!(super::new_runtime_settings_state_at_path( + super::AppSettings::default(), + &directory.join("../settings.json") + ) + .is_err()); + let moved = temp.path().join("original"); + std::fs::rename(&directory, &moved).unwrap(); + assert_eq!( + owner.runtime_source_settings_path().unwrap_err(), + "ownerIntegrityLost" + ); + std::fs::create_dir(&directory).unwrap(); + assert_eq!( + owner.runtime_source_settings_path().unwrap_err(), + "ownerIntegrityLost" + ); + assert!(owner.blocking_current_snapshot().is_ok()); + assert!(!owner.identity().managed_operations_enabled); + } + + #[test] + fn e2432_runtime_source_same_directory_file_replacement() { + let temp = tempfile::tempdir().unwrap(); + let (host, path, persisted) = + crate::commands::session::e2432_physical_restore_fixture(temp.path(), "old"); + let owner = super::new_runtime_settings_state_at_path(host.clone(), &path).unwrap(); + let reference = persisted + .coding_agent_binding_seed + .unwrap() + .configuration_ref + .unwrap(); + let before = super::capture_runtime_source( + &host, + &owner.runtime_source_settings_path().unwrap(), + &reference.source, + ) + .unwrap(); + std::fs::remove_file(&path).unwrap(); + std::fs::write(&path, serde_json::to_vec(&host).unwrap()).unwrap(); + crate::commands::session::e2432_physical_restore_fixture(temp.path(), "current"); + let after = super::capture_runtime_source( + &host, + &owner.runtime_source_settings_path().unwrap(), + &reference.source, + ) + .unwrap(); + assert_ne!(before.revision, after.revision); + let scoped = super::resolve_runtime_source_row(&host, &after, &reference).unwrap(); + assert_eq!( + scoped.coding_agent_profiles.profiles_by_agent["same"]["A"].env["SOURCE"], + "current" + ); + } + mod issue_3047_schema { use super::super::*; use serde_json::json; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 183466c9e..bc0f9d852 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4637,16 +4637,18 @@ pub fn run( TelegramBridgeManager::with_captures(output_senders, capture_registry), )); - let loaded_settings = config::settings::load_settings(); + let settings = config::settings::load_runtime_settings_state(); + let loaded_settings = settings + .blocking_current_snapshot() + .expect("fresh runtime settings owner is ready"); response_close.apply_settings( loaded_settings.response_close_enabled, loaded_settings.response_close_idle_seconds, std::time::Instant::now(), ); // (#621) Snapshot registered project paths for the startup orphan-clock prune - // below (taken before the value is moved into the RwLock). + // below, from the same loaded snapshot as response-close settings. let startup_project_paths = loaded_settings.project_paths.clone(); - let settings: SettingsState = crate::config::settings::new_settings_state(loaded_settings); // #552 persisted coordinator badge clock + auto-closed marker store (loaded // once at startup; flushed by the auto-close tick and on app exit). let coordinator_clocks: crate::config::coordinator_clocks::CoordinatorClocksState = @@ -9491,6 +9493,104 @@ mod tests { ); } + #[test] + fn e2432_runtime_startup_wiring() { + let source = include_str!("lib.rs"); + let start = source + .find(" let settings = config::settings::load_runtime_settings_state();") + .unwrap(); + let end = source[start..].find(" let coordinator_clocks:").unwrap() + start; + let startup = &source[start..end]; + assert_eq!(startup.matches("load_runtime_settings_state()").count(), 1); + assert_eq!(startup.matches("blocking_current_snapshot()").count(), 1); + assert!(startup.contains("loaded_settings.response_close_enabled")); + assert!(startup.contains("loaded_settings.response_close_idle_seconds")); + assert!(startup.contains("loaded_settings.project_paths.clone()")); + assert!(!startup.contains("new_settings_state(")); + assert!(!startup.contains("load_settings()")); + let settings = include_str!("config/settings.rs"); + let start = settings + .find("pub(crate) fn load_runtime_settings_state()") + .unwrap(); + let end = settings[start..].find("pub fn new_settings_state(").unwrap() + start; + let helper = &settings[start..end]; + assert_eq!(helper.matches("settings_path()").count(), 1); + assert_eq!(helper.matches("load_settings_from_path(&path)").count(), 1); + assert!( + helper.find("load_settings_from_path(&path)").unwrap() + < helper + .find("verify_runtime_settings_directory(&path)") + .unwrap() + ); + } + + #[test] + fn e2432_runtime_directory_failure_preserves_restore_recipes() { + use super::config; + use crate::commands::session::{e2432_physical_restore_fixture, SourceRestoreMode}; + for root in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let directory = temp.path().join("F"); + let (mut host, path, mut persisted) = + e2432_physical_restore_fixture(&directory, "F-only"); + let (decoy, decoy_path, decoy_recipe) = + e2432_physical_restore_fixture(&temp.path().join("G"), "G-decoy"); + let decoy_ref = decoy_recipe + .coding_agent_binding_seed + .unwrap() + .configuration_ref + .unwrap(); + let decoy_snapshot = config::settings::capture_runtime_source( + &decoy, + &decoy_path, + &decoy_ref.source, + ) + .unwrap(); + host.agents = config::settings::agent_file_projection(&decoy_snapshot) + .unwrap() + .agents; + host.agents_layer = config::settings::AgentsLayerState::Unreadable { + path: temp.path().join("global-invalid"), + reason: "global unreadable decoy".into(), + }; + persisted.is_root_agent = root; + let owner = config::settings::new_runtime_settings_state_at_path(host.clone(), &path) + .unwrap(); + let original = serde_json::to_value(&persisted).unwrap(); + let mut positive = persisted.clone(); + assert!(super::resolve_current_persisted_restore( + &mut positive, + SourceRestoreMode::Wake, + &host, + owner.runtime_source_settings_path() + ) + .unwrap() + .is_some()); + // A real absent directory, with no injected source result or permission assumption. + std::fs::remove_dir_all(&directory).unwrap(); + for mode in [SourceRestoreMode::Wake, SourceRestoreMode::Defer] { + let mut failed = persisted.clone(); + let error = super::resolve_current_persisted_restore( + &mut failed, + mode, + &host, + owner.runtime_source_settings_path(), + ) + .unwrap_err(); + assert_eq!(error, "ownerIntegrityLost"); + assert_eq!(serde_json::to_value(&failed).unwrap(), original); + assert_eq!( + failed.coding_agent_binding_id, + persisted.coding_agent_binding_id + ); + assert_eq!( + failed.coding_agent_binding_origin, + persisted.coding_agent_binding_origin + ); + } + } + } + fn e2432_assert_current_source_restore(root: bool, deferred: bool) { use super::{config, prepare_persisted_restore, resolve_current_persisted_restore}; use crate::commands::session::{e2432_physical_restore_fixture, SourceRestoreMode}; From 7e82278912c666c0ed5384767490e2a0b6af6660 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Wed, 21 Oct 2026 16:19:27 -0300 Subject: [PATCH 07/14] style(runtime): apply sixteen reproduced formatter hunks --- src-tauri/src/commands/session.rs | 56 ++++++++++++++++++------------- src-tauri/src/config/settings.rs | 15 ++++----- src-tauri/src/lib.rs | 18 +++++----- src-tauri/src/session/manager.rs | 19 +++++++++-- 4 files changed, 64 insertions(+), 44 deletions(-) diff --git a/src-tauri/src/commands/session.rs b/src-tauri/src/commands/session.rs index 8a0f1cedb..8ba6c1b1c 100644 --- a/src-tauri/src/commands/session.rs +++ b/src-tauri/src/commands/session.rs @@ -7940,9 +7940,8 @@ mod tests { assert!(output.status.success(), "{output:?}"); return; } - let config = std::path::PathBuf::from( - std::env::var_os("AGENTSCOMMANDER_TEST_CONFIG_DIR").unwrap(), - ); + let config = + std::path::PathBuf::from(std::env::var_os("AGENTSCOMMANDER_TEST_CONFIG_DIR").unwrap()); let (temp, cwd, _) = strict_target_fixture(); let (_outside, outside_cwd, _) = strict_target_fixture(); let project = temp.path().join("project").to_string_lossy().into_owned(); @@ -7953,12 +7952,14 @@ mod tests { host.root_token = Some("gate-fixture".into()); host.archived_project_paths = vec![project.clone(), missing.clone()]; std::fs::write(&path, serde_json::to_vec(&host).unwrap()).unwrap(); - let owner = crate::config::settings::new_runtime_settings_state_at_path(host.clone(), &path) - .unwrap(); + let owner = + crate::config::settings::new_runtime_settings_state_at_path(host.clone(), &path) + .unwrap(); assert!(!owner.identity().managed_operations_enabled); assert_eq!( owner.runtime_source_settings_path().unwrap(), - std::fs::canonicalize(&config).unwrap() + std::fs::canonicalize(&config) + .unwrap() .join(crate::config::instance_artifacts::SETTINGS_FILE_NAME) ); let session_mgr = Arc::new(tokio::sync::RwLock::new(SessionManager::new())); @@ -7985,7 +7986,10 @@ mod tests { ) .await .unwrap_err(); - assert!(error.contains("Cannot start a session in archived project"), "{error}"); + assert!( + error.contains("Cannot start a session in archived project"), + "{error}" + ); assert_eq!(std::fs::read(&path).unwrap(), before); assert_eq!(backend.spawn_count.load(Ordering::SeqCst), 0); assert!(session_mgr.read().await.list_sessions().await.is_empty()); @@ -8022,19 +8026,21 @@ mod tests { assert_eq!(backend.spawn_count.load(Ordering::SeqCst), 1); assert_eq!(session_mgr.read().await.list_sessions().await.len(), 1); let current = owner.read().await; - assert!(current.project_paths.iter().any(|root| - crate::config::projects::normalize_for_compare(root) - == crate::config::projects::normalize_for_compare(&project) - )); + assert!(current + .project_paths + .iter() + .any(|root| crate::config::projects::normalize_for_compare(root) + == crate::config::projects::normalize_for_compare(&project))); assert!(!current.archived_project_paths.contains(&project)); assert!(current.archived_project_paths.contains(&missing)); drop(current); assert_ne!(std::fs::read(&path).unwrap(), before); let disk = crate::config::settings::load_settings_from_path(&path); - assert!(disk.project_paths.iter().any(|root| - crate::config::projects::normalize_for_compare(root) - == crate::config::projects::normalize_for_compare(&project) - )); + assert!(disk + .project_paths + .iter() + .any(|root| crate::config::projects::normalize_for_compare(root) + == crate::config::projects::normalize_for_compare(&project))); assert!(!disk.archived_project_paths.contains(&project)); assert!(disk.archived_project_paths.contains(&missing)); assert!(!owner.identity().managed_operations_enabled); @@ -8929,8 +8935,9 @@ mod tests { ))); let app = session_test_app(host.clone(), sessions.clone(), pty.clone()); app.unmanage::(); - let owner = crate::config::settings::new_runtime_settings_state_at_path(host.clone(), &path) - .unwrap(); + let owner = + crate::config::settings::new_runtime_settings_state_at_path(host.clone(), &path) + .unwrap(); assert!(app.manage(owner)); let transaction = crate::session::selection::SelectionTransaction::for_test(app.handle().clone()); @@ -8999,12 +9006,8 @@ mod tests { let g_seed: crate::config::sessions_persistence::CodingAgentBindingSeed = serde_json::from_value(g_spawn.source_seed.unwrap()).unwrap(); let g_ref = g_seed.configuration_ref.unwrap(); - let snapshot = crate::config::settings::capture_runtime_source( - &host, - &path, - &g_ref.source, - ) - .unwrap(); + let snapshot = + crate::config::settings::capture_runtime_source(&host, &path, &g_ref.source).unwrap(); let ticket = crate::config::settings::SourceSelectionTicket { configuration_ref: g_ref.clone(), expected_source_revision: snapshot.revision, @@ -9099,7 +9102,12 @@ mod tests { assert_eq!(backend.spawn_count.load(Ordering::SeqCst), spawn_count); assert_eq!(events.lock().unwrap().len(), 1); assert_eq!( - manager.get_session(replacement).await.unwrap().last_prompt.as_deref(), + manager + .get_session(replacement) + .await + .unwrap() + .last_prompt + .as_deref(), Some("B") ); close_test_coordinator(&app).await; diff --git a/src-tauri/src/config/settings.rs b/src-tauri/src/config/settings.rs index d53579f87..2790258eb 100644 --- a/src-tauri/src/config/settings.rs +++ b/src-tauri/src/config/settings.rs @@ -19092,7 +19092,8 @@ fn verify_runtime_settings_directory( Ok(()) | Err(crate::path_identity::PathRule::ComponentIsLink) => {} Err(_) => return Err("runtime_directory_rejected".into()), } - let physical_parent = std::fs::canonicalize(parent).map_err(|_| "runtime_directory_rejected")?; + let physical_parent = + std::fs::canonicalize(parent).map_err(|_| "runtime_directory_rejected")?; crate::path_identity::verify_directory(&physical_parent) } @@ -19182,9 +19183,8 @@ mod tests { let Ok(mode) = std::env::var("AC_E2432_STARTUP_CHILD") else { return; }; - let config = std::path::PathBuf::from( - std::env::var_os("AGENTSCOMMANDER_TEST_CONFIG_DIR").unwrap(), - ); + let config = + std::path::PathBuf::from(std::env::var_os("AGENTSCOMMANDER_TEST_CONFIG_DIR").unwrap()); assert_eq!(config.exists(), mode == "existing"); let owner = super::load_runtime_settings_state(); let loaded = owner.blocking_current_snapshot().unwrap(); @@ -19198,10 +19198,9 @@ mod tests { owner.runtime_source_settings_path().unwrap(), physical.join(super::SETTINGS_FILE_NAME) ); - let raw: serde_json::Value = serde_json::from_slice( - &std::fs::read(config.join(super::SETTINGS_FILE_NAME)).unwrap(), - ) - .unwrap(); + let raw: serde_json::Value = + serde_json::from_slice(&std::fs::read(config.join(super::SETTINGS_FILE_NAME)).unwrap()) + .unwrap(); if mode == "existing" { assert!(loaded.voice_to_text_enabled); assert!(loaded.restore_coordinator_wake_state); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 87edde7e7..f19d0f5bf 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9515,7 +9515,10 @@ mod tests { let start = settings .find("pub(crate) fn load_runtime_settings_state()") .unwrap(); - let end = settings[start..].find("pub fn new_settings_state(").unwrap() + start; + let end = settings[start..] + .find("pub fn new_settings_state(") + .unwrap() + + start; let helper = &settings[start..end]; assert_eq!(helper.matches("settings_path()").count(), 1); assert_eq!(helper.matches("load_settings_from_path(&path)").count(), 1); @@ -9543,12 +9546,9 @@ mod tests { .unwrap() .configuration_ref .unwrap(); - let decoy_snapshot = config::settings::capture_runtime_source( - &decoy, - &decoy_path, - &decoy_ref.source, - ) - .unwrap(); + let decoy_snapshot = + config::settings::capture_runtime_source(&decoy, &decoy_path, &decoy_ref.source) + .unwrap(); host.agents = config::settings::agent_file_projection(&decoy_snapshot) .unwrap() .agents; @@ -9558,8 +9558,8 @@ mod tests { }; persisted.is_root_agent = root; persisted.last_prompt = Some("cached-prompt-decoy".into()); - let owner = config::settings::new_runtime_settings_state_at_path(host.clone(), &path) - .unwrap(); + let owner = + config::settings::new_runtime_settings_state_at_path(host.clone(), &path).unwrap(); let original = serde_json::to_value(&persisted).unwrap(); let mut positive = persisted.clone(); assert!(super::resolve_current_persisted_restore( diff --git a/src-tauri/src/session/manager.rs b/src-tauri/src/session/manager.rs index ac4c0b347..5a8320853 100644 --- a/src-tauri/src/session/manager.rs +++ b/src-tauri/src/session/manager.rs @@ -6758,12 +6758,25 @@ mod tests { } assert_eq!(e2432_manager_fingerprint(&manager).await, before_rejection); // The rejected commit has returned and dropped its guard; rollback has not run. - assert_eq!(manager.aggregate_snapshot().await.binding_seeds, before_seeds); assert_eq!( - manager.get_session(old).await.unwrap().last_prompt.as_deref(), + manager.aggregate_snapshot().await.binding_seeds, + before_seeds + ); + assert_eq!( + manager + .get_session(old) + .await + .unwrap() + .last_prompt + .as_deref(), Some("B") ); - assert!(manager.get_pending_session(pending).await.unwrap().last_prompt.is_none()); + assert!(manager + .get_pending_session(pending) + .await + .unwrap() + .last_prompt + .is_none()); manager.rollback_pending_create(pending).await.unwrap(); let retained = manager.coding_agent_binding(old).await.unwrap(); assert_eq!(retained.seed, concurrent.seed); From b20df36e7c0c4d1c55096a9b5ab9c7895aa5a6bf Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Wed, 21 Oct 2026 16:45:02 -0300 Subject: [PATCH 08/14] test(runtime): correct three source-qualified fixture inputs --- src-tauri/src/commands/session.rs | 3 ++- src-tauri/src/config/settings.rs | 19 +++++++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src-tauri/src/commands/session.rs b/src-tauri/src/commands/session.rs index 8ba6c1b1c..5bd7974d2 100644 --- a/src-tauri/src/commands/session.rs +++ b/src-tauri/src/commands/session.rs @@ -7952,6 +7952,7 @@ mod tests { host.root_token = Some("gate-fixture".into()); host.archived_project_paths = vec![project.clone(), missing.clone()]; std::fs::write(&path, serde_json::to_vec(&host).unwrap()).unwrap(); + let host = crate::config::settings::load_settings_from_path(&path); let owner = crate::config::settings::new_runtime_settings_state_at_path(host.clone(), &path) .unwrap(); @@ -8774,7 +8775,7 @@ mod tests { std::fs::create_dir_all(g_path.parent().unwrap()).unwrap(); let base = reference.calculated_id.clone(); let g_raw = serde_json::json!({"schemaVersion":2, - "agents":[{"key":"same", "label":"G", "color":"green", "baseCommand":"tool"}], + "agents":[{"key":"same", "label":"G", "description":"", "color":"green", "baseCommand":"tool"}], "profileBindingVersion":1, "profilesByBaseHash":{ (base.clone()):{"A":{"additionalArguments":"tool G-current", "env":{"SOURCE":"G-current"}}} }}); diff --git a/src-tauri/src/config/settings.rs b/src-tauri/src/config/settings.rs index 2790258eb..a85b39806 100644 --- a/src-tauri/src/config/settings.rs +++ b/src-tauri/src/config/settings.rs @@ -19142,16 +19142,15 @@ mod tests { let config = temp.path().join("config"); if mode == "existing" { std::fs::create_dir(&config).unwrap(); - std::fs::write( - config.join(super::SETTINGS_FILE_NAME), - serde_json::json!({ - "voiceToTextEnabled": true, - "startOnlyCoordinators": true, - "rootToken": "startup-sentinel" - }) - .to_string(), - ) - .unwrap(); + let mut raw = serde_json::to_value(super::AppSettings::default()).unwrap(); + raw["voiceToTextEnabled"] = serde_json::json!(true); + raw["startOnlyCoordinators"] = serde_json::json!(true); + raw["rootToken"] = serde_json::json!("startup-sentinel"); + raw.as_object_mut() + .unwrap() + .remove("restoreCoordinatorWakeState"); + std::fs::write(config.join(super::SETTINGS_FILE_NAME), raw.to_string()) + .unwrap(); } let output = std::process::Command::new(std::env::current_exe().unwrap()) .args([ From 3829935a9d485294047529288173aa46ca08afe4 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Wed, 21 Oct 2026 16:52:53 -0300 Subject: [PATCH 09/14] style(runtime): join startup fixture write chain --- src-tauri/src/config/settings.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src-tauri/src/config/settings.rs b/src-tauri/src/config/settings.rs index a85b39806..24fa002ce 100644 --- a/src-tauri/src/config/settings.rs +++ b/src-tauri/src/config/settings.rs @@ -19149,8 +19149,7 @@ mod tests { raw.as_object_mut() .unwrap() .remove("restoreCoordinatorWakeState"); - std::fs::write(config.join(super::SETTINGS_FILE_NAME), raw.to_string()) - .unwrap(); + std::fs::write(config.join(super::SETTINGS_FILE_NAME), raw.to_string()).unwrap(); } let output = std::process::Command::new(std::env::current_exe().unwrap()) .args([ From bb58998852985ce31741a310a048eae542683ce4 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Wed, 21 Oct 2026 17:18:56 -0300 Subject: [PATCH 10/14] test(runtime): prove authoritative archive publication and refusal isolation --- src-tauri/src/commands/session.rs | 36 ++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/session.rs b/src-tauri/src/commands/session.rs index 5bd7974d2..4a74b8aed 100644 --- a/src-tauri/src/commands/session.rs +++ b/src-tauri/src/commands/session.rs @@ -7953,6 +7953,16 @@ mod tests { host.archived_project_paths = vec![project.clone(), missing.clone()]; std::fs::write(&path, serde_json::to_vec(&host).unwrap()).unwrap(); let host = crate::config::settings::load_settings_from_path(&path); + let project_path = + path.with_file_name(crate::config::instance_artifacts::PROJECT_PATHS_FILE_NAME); + let source_path = + path.with_file_name(crate::config::instance_artifacts::AGENTS_INSTANCE_FILE_NAME); + let before_projects = std::fs::read(&project_path).unwrap(); + let before_source = std::fs::read(&source_path).unwrap(); + assert!(host.project_paths_persistence_error.is_none()); + assert!(!host.project_paths.contains(&project)); + assert!(host.archived_project_paths.contains(&project)); + assert!(host.archived_project_paths.contains(&missing)); let owner = crate::config::settings::new_runtime_settings_state_at_path(host.clone(), &path) .unwrap(); @@ -7980,6 +7990,11 @@ mod tests { .await .unwrap_err(); assert!(error.contains("sessionCreateBlocked"), "{error}"); + assert_eq!(std::fs::read(&path).unwrap(), before); + assert_eq!(std::fs::read(&project_path).unwrap(), before_projects); + assert_eq!(std::fs::read(&source_path).unwrap(), before_source); + assert_eq!(backend.spawn_count.load(Ordering::SeqCst), 0); + assert!(session_mgr.read().await.list_sessions().await.is_empty()); let error = crate::config::archive_gate::enforce_unarchived_for_spawn( app.handle(), &missing, @@ -7992,6 +8007,8 @@ mod tests { "{error}" ); assert_eq!(std::fs::read(&path).unwrap(), before); + assert_eq!(std::fs::read(&project_path).unwrap(), before_projects); + assert_eq!(std::fs::read(&source_path).unwrap(), before_source); assert_eq!(backend.spawn_count.load(Ordering::SeqCst), 0); assert!(session_mgr.read().await.list_sessions().await.is_empty()); let outcome = super::create_session_inner_impl( @@ -8035,7 +8052,19 @@ mod tests { assert!(!current.archived_project_paths.contains(&project)); assert!(current.archived_project_paths.contains(&missing)); drop(current); - assert_ne!(std::fs::read(&path).unwrap(), before); + let published_project_bytes = std::fs::read(&project_path).unwrap(); + assert_ne!(published_project_bytes, before_projects); + let published: serde_json::Value = + serde_json::from_slice(&published_project_bytes).unwrap(); + assert!(published.is_object()); + assert!(published + .get("projectPaths") + .is_some_and(serde_json::Value::is_array)); + assert!(published + .get("archivedProjectPaths") + .is_some_and(serde_json::Value::is_array)); + assert_eq!(std::fs::read(&path).unwrap(), before); + assert_eq!(std::fs::read(&source_path).unwrap(), before_source); let disk = crate::config::settings::load_settings_from_path(&path); assert!(disk .project_paths @@ -8044,6 +8073,11 @@ mod tests { == crate::config::projects::normalize_for_compare(&project))); assert!(!disk.archived_project_paths.contains(&project)); assert!(disk.archived_project_paths.contains(&missing)); + assert!(disk.project_paths_persistence_error.is_none()); + assert!(!disk.project_paths.contains(&missing)); + assert_eq!(std::fs::read(&project_path).unwrap(), published_project_bytes); + assert_eq!(std::fs::read(&path).unwrap(), before); + assert_eq!(std::fs::read(&source_path).unwrap(), before_source); assert!(!owner.identity().managed_operations_enabled); assert!(owner.runtime_source_settings_path().is_ok()); close_test_coordinator(&app).await; From bd12900e80e9738d0563a88af1d5136fc9ff3cc2 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Wed, 21 Oct 2026 17:24:58 -0300 Subject: [PATCH 11/14] style(runtime): wrap archive publication byte assertion --- src-tauri/src/commands/session.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/session.rs b/src-tauri/src/commands/session.rs index 4a74b8aed..ae61d03d5 100644 --- a/src-tauri/src/commands/session.rs +++ b/src-tauri/src/commands/session.rs @@ -8075,7 +8075,10 @@ mod tests { assert!(disk.archived_project_paths.contains(&missing)); assert!(disk.project_paths_persistence_error.is_none()); assert!(!disk.project_paths.contains(&missing)); - assert_eq!(std::fs::read(&project_path).unwrap(), published_project_bytes); + assert_eq!( + std::fs::read(&project_path).unwrap(), + published_project_bytes + ); assert_eq!(std::fs::read(&path).unwrap(), before); assert_eq!(std::fs::read(&source_path).unwrap(), before_source); assert!(!owner.identity().managed_operations_enabled); From eca5e3ce5393dc06f2b23209682d51f87a8985ad Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Wed, 21 Oct 2026 17:52:40 -0300 Subject: [PATCH 12/14] fix(runtime): preserve owned source flows under strict Clippy --- src-tauri/src/commands/session.rs | 85 +++++++++++++++++++++---------- src-tauri/src/lib.rs | 2 +- 2 files changed, 58 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/commands/session.rs b/src-tauri/src/commands/session.rs index ae61d03d5..704e53bf9 100644 --- a/src-tauri/src/commands/session.rs +++ b/src-tauri/src/commands/session.rs @@ -492,14 +492,14 @@ impl InitialLastPrompt { } enum CreateCompletion { - Finalized(SessionInfo), - Deferred(DeferredCreateOutput), + Finalized(Box), + Deferred(Box), } impl CreateCompletion { fn into_finalized(self) -> Result { match self { - Self::Finalized(info) => Ok(info), + Self::Finalized(info) => Ok(*info), Self::Deferred(_) => Err("create unexpectedly deferred finalization".to_string()), } } @@ -3598,13 +3598,13 @@ async fn create_session_inner_impl( .lock() .unwrap_or_else(|error| error.into_inner()) .take(); - return Ok(CreateCompletion::Deferred(DeferredCreateOutput { + return Ok(CreateCompletion::Deferred(Box::new(DeferredCreateOutput { restart_binding, info, binding: pending_binding, warnings: finalization_warnings, effective_continuation, - })); + }))); } let finalized = if let Some(ticket) = create_ticket.take() { @@ -3624,7 +3624,7 @@ async fn create_session_inner_impl( .unwrap_or_else(|error| error.into_inner()) .take(); } - let completion = finalized.map(CreateCompletion::Finalized); + let completion = finalized.map(|info| CreateCompletion::Finalized(Box::new(info))); // #2232 phase 4 section 5.1: the Room demand may be raised only once // the row is **finalized and visible** to `get_session` — a // pending-create row is hidden, so a raise spawned earlier answers @@ -3730,7 +3730,7 @@ pub(crate) enum SourceRestoreMode { pub(crate) enum SourceRestoreDecision { Legacy, SourceWake { - scoped_settings: AppSettings, + scoped_settings: Box, entry_hint: String, effective_profile: String, updated_seed: crate::config::sessions_persistence::CodingAgentBindingSeed, @@ -3828,7 +3828,7 @@ pub(crate) fn prepare_source_restore_decision( binding.seed = Some(serde_json::to_value(&updated_seed).map_err(|e| e.to_string())?); Ok(match mode { SourceRestoreMode::Wake => SourceRestoreDecision::SourceWake { - scoped_settings, + scoped_settings: Box::new(scoped_settings), entry_hint, effective_profile: resolution.effective_profile, updated_seed, @@ -6116,7 +6116,7 @@ pub(crate) async fn execute_restart_transaction( ) .await; let deferred = match completion { - Ok(CreateCompletion::Deferred(deferred)) => deferred, + Ok(CreateCompletion::Deferred(deferred)) => *deferred, Ok(CreateCompletion::Finalized(_)) => { let error = "restart replacement was published before atomic finalization".to_string(); if old_lost { @@ -7978,9 +7978,12 @@ mod tests { let pty_mgr = Arc::new(Mutex::new(crate::pty::manager::PtyManager::new_for_test( backend.clone(), ))); - let app = session_test_app(host, session_mgr.clone(), pty_mgr.clone()); - app.unmanage::(); - assert!(app.manage(owner.clone())); + let app = session_test_app_with_owner_and_store( + owner.clone(), + session_mgr.clone(), + pty_mgr.clone(), + SessionTestStoreState::Ready, + ); let before = std::fs::read(&path).unwrap(); let error = crate::config::sessions_persistence::enforce_creation_gate( app.handle(), @@ -8120,7 +8123,15 @@ mod tests { let pty_mgr = Arc::new(Mutex::new(crate::pty::manager::PtyManager::new_for_test( backend.clone(), ))); - let app = session_test_app(host.clone(), session_mgr.clone(), pty_mgr.clone()); + let owner = + crate::config::settings::new_runtime_settings_state_at_path(host.clone(), &path) + .unwrap(); + let app = session_test_app_with_owner_and_store( + owner, + session_mgr.clone(), + pty_mgr.clone(), + SessionTestStoreState::Ready, + ); let manager = session_mgr.read().await.clone(); for malformed in [false, true] { @@ -8184,11 +8195,6 @@ mod tests { } std::fs::write(&config_path, serde_json::to_vec(&valid_config).unwrap()).unwrap(); - let owner = - crate::config::settings::new_runtime_settings_state_at_path(host.clone(), &path) - .unwrap(); - app.unmanage::(); - assert!(app.manage(owner)); let snapshot = crate::config::settings::capture_runtime_source(&host, &path, &reference.source) .unwrap(); @@ -8563,12 +8569,15 @@ mod tests { let pty_mgr = Arc::new(Mutex::new(crate::pty::manager::PtyManager::new_for_test( backend.clone(), ))); - let app = session_test_app(host.clone(), session_mgr.clone(), pty_mgr.clone()); let owner = crate::config::settings::new_runtime_settings_state_at_path(host.clone(), &path) .unwrap(); - app.unmanage::(); - assert!(app.manage(owner)); + let app = session_test_app_with_owner_and_store( + owner, + session_mgr.clone(), + pty_mgr.clone(), + SessionTestStoreState::Ready, + ); let transaction = crate::session::selection::SelectionTransaction::for_test(app.handle().clone()); let reference = persisted @@ -8971,12 +8980,15 @@ mod tests { let pty = Arc::new(Mutex::new(crate::pty::manager::PtyManager::new_for_test( backend.clone(), ))); - let app = session_test_app(host.clone(), sessions.clone(), pty.clone()); - app.unmanage::(); let owner = crate::config::settings::new_runtime_settings_state_at_path(host.clone(), &path) .unwrap(); - assert!(app.manage(owner)); + let app = session_test_app_with_owner_and_store( + owner, + sessions.clone(), + pty.clone(), + SessionTestStoreState::Ready, + ); let transaction = crate::session::selection::SelectionTransaction::for_test(app.handle().clone()); let first = super::create_session_inner_for_restore( @@ -9238,12 +9250,15 @@ mod tests { let pty_mgr = Arc::new(Mutex::new(crate::pty::manager::PtyManager::new_for_test( Arc::new(ScriptedSpawnBackend::default()), ))); - let app = session_test_app(host.clone(), session_mgr.clone(), pty_mgr.clone()); let owner = crate::config::settings::new_runtime_settings_state_at_path(host.clone(), &path) .unwrap(); - app.unmanage::(); - assert!(app.manage(owner)); + let app = session_test_app_with_owner_and_store( + owner, + session_mgr.clone(), + pty_mgr.clone(), + SessionTestStoreState::Ready, + ); let transaction = crate::session::selection::SelectionTransaction::for_test(app.handle().clone()); let seed = persisted.coding_agent_binding_seed.as_ref().unwrap(); @@ -11016,6 +11031,20 @@ mod tests { session_mgr: Arc>, pty_mgr: Arc>, store_state: SessionTestStoreState, + ) -> tauri::App { + session_test_app_with_owner_and_store( + crate::config::settings::new_settings_state(settings), + session_mgr, + pty_mgr, + store_state, + ) + } + + fn session_test_app_with_owner_and_store( + settings_state: crate::config::settings::SettingsState, + session_mgr: Arc>, + pty_mgr: Arc>, + store_state: SessionTestStoreState, ) -> tauri::App { let shutdown = crate::shutdown::ShutdownSignal::new(); let coordinator = crate::session::selection::SelectionCoordinator::new( @@ -11047,7 +11076,7 @@ mod tests { let target_gate_state = crate::api::message_store::PtyInputTargetGateState::for_root(store_root); let mut builder = tauri::test::mock_builder() - .manage(crate::config::settings::new_settings_state(settings)) + .manage(settings_state) .manage(Arc::new( crate::resource_monitor::ResourceMonitorState::new(), )) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f19d0f5bf..ac1338fb9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2336,7 +2336,6 @@ fn load_startup_sessions( Ok(load(&paths)) } -#[allow(clippy::too_many_arguments)] /// Production restore seam shared by root, ordinary and deferred branches. fn prepare_persisted_restore( persisted: &config::sessions_persistence::PersistedSession, @@ -2423,6 +2422,7 @@ fn resolve_current_persisted_restore( Ok(spawn) } +#[allow(clippy::too_many_arguments)] fn start_native_restore_startup( app: tauri::AppHandle, restore_barrier: crate::session::selection::RestoreBarrierGuard, From 3951fd0305fb1c90a2c4175c7c939821dab2d5ae Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Wed, 21 Oct 2026 18:17:21 -0300 Subject: [PATCH 13/14] refactor(runtime): isolate live root reuse and guarded restart validation --- src-tauri/src/commands/session.rs | 65 +++++++++++++++++++------------ src-tauri/src/session/manager.rs | 54 ++++++++++++++++--------- 2 files changed, 76 insertions(+), 43 deletions(-) diff --git a/src-tauri/src/commands/session.rs b/src-tauri/src/commands/session.rs index 704e53bf9..febd0bc5f 100644 --- a/src-tauri/src/commands/session.rs +++ b/src-tauri/src/commands/session.rs @@ -6876,6 +6876,39 @@ pub(crate) async fn create_root_agent_inner_with_source( .await } +async fn reuse_live_root_session( + transaction: &SelectionTransaction, + session_mgr: &Arc>, + session_id: Uuid, + select_after: bool, +) -> Result { + if select_after { + let decision = transaction + .live_decision(session_id) + .ok_or_else(|| "Root session has no displayable PTY".to_string())?; + let committed = transaction + .commit( + decision, + SelectionCause::UserSwitch, + LifecycleMutations::default(), + ) + .await?; + if let Some(selection) = committed.selection.as_ref() { + transaction + .persist(SelectionSource::UserSwitch, Some(session_id)) + .await; + transaction.publish_selection(selection); + } + } + session_mgr + .read() + .await + .get_session(session_id) + .await + .map(|session| SessionInfo::from(&session)) + .ok_or_else(|| "Root session disappeared during reuse".to_string()) +} + pub(crate) async fn execute_root_transaction( transaction: &SelectionTransaction, request: RootJobRequest, @@ -6934,31 +6967,13 @@ pub(crate) async fn execute_root_transaction( .has_session(session_id); match classify_existing_root(&existing.status, has_pty) { ExistingRootAction::ReuseLive if request.source_ticket.is_none() => { - if request.select_after { - let decision = transaction - .live_decision(session_id) - .ok_or_else(|| "Root session has no displayable PTY".to_string())?; - let committed = transaction - .commit( - decision, - SelectionCause::UserSwitch, - LifecycleMutations::default(), - ) - .await?; - if let Some(selection) = committed.selection.as_ref() { - transaction - .persist(SelectionSource::UserSwitch, Some(session_id)) - .await; - transaction.publish_selection(selection); - } - } - return session_mgr - .read() - .await - .get_session(session_id) - .await - .map(|session| SessionInfo::from(&session)) - .ok_or_else(|| "Root session disappeared during reuse".to_string()); + return reuse_live_root_session( + transaction, + &session_mgr, + session_id, + request.select_after, + ) + .await; } ExistingRootAction::ReuseLive | ExistingRootAction::WakeDormant diff --git a/src-tauri/src/session/manager.rs b/src-tauri/src/session/manager.rs index 5a8320853..9db54be09 100644 --- a/src-tauri/src/session/manager.rs +++ b/src-tauri/src/session/manager.rs @@ -3262,24 +3262,13 @@ impl SessionManager { }; // Prevalidate the projection while the same guard protects application. - match (target_mode, target_id, target_status.as_ref()) { - (SelectionMode::None, None, _) => {} - (SelectionMode::Live, Some(id), _) if state.sessions.contains_key(&id) => {} - (SelectionMode::Dormant, Some(id), Some(SessionStatus::Exited(_))) - if state.sessions.contains_key(&id) => {} - _ => return Err("invalid canonical selection target".into()), - } - if let Some(transition) = mutations.restart_binding.as_ref() { - validate_restart_binding(&state, transition, true)?; - if !mutations.removals.contains(&transition.retiring_runtime) - || !mutations.finalizations.iter().any(|mutation| { - matches!(mutation, - FinalizeMutation::Live(binding, _) if *binding == transition.target) - }) - { - return Err("restart binding requires atomic replacement".into()); - } - } + validate_commit_projection_and_restart_binding( + &state, + &mutations, + target_mode, + target_id, + target_status.as_ref(), + )?; apply_commit_prompt_domains(&mut state, &mutations, cause); let mut result = CommitResult::default(); @@ -3592,6 +3581,35 @@ fn append_finalized_live_activity( /// Lifecycle mutation sets in removals, exits, finalizations order. type LifecycleMutationSets = (HashSet, HashSet, HashSet); +fn validate_commit_projection_and_restart_binding( + state: &SessionManagerState, + mutations: &LifecycleMutations, + target_mode: SelectionMode, + target_id: Option, + target_status: Option<&SessionStatus>, +) -> Result<(), String> { + match (target_mode, target_id, target_status) { + (SelectionMode::None, None, _) => {} + (SelectionMode::Live, Some(id), _) if state.sessions.contains_key(&id) => {} + (SelectionMode::Dormant, Some(id), Some(SessionStatus::Exited(_))) + if state.sessions.contains_key(&id) => {} + _ => return Err("invalid canonical selection target".into()), + } + if let Some(transition) = mutations.restart_binding.as_ref() { + validate_restart_binding(state, transition, true)?; + if !mutations.removals.contains(&transition.retiring_runtime) + || !mutations.finalizations.iter().any(|mutation| { + matches!(mutation, + FinalizeMutation::Live(binding, _) if *binding == transition.target) + }) + { + return Err("restart binding requires atomic replacement".into()); + } + } + + Ok(()) +} + fn validate_restart_binding( state: &SessionManagerState, transition: &RestartBindingSeedTransition, From 1e780bb82b29efe4cca8052bb6793d791174e7e6 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Wed, 21 Oct 2026 18:38:30 -0300 Subject: [PATCH 14/14] refactor(runtime): isolate root restart request construction --- src-tauri/src/commands/session.rs | 48 ++++++++++++++++++------------- 1 file changed, 28 insertions(+), 20 deletions(-) diff --git a/src-tauri/src/commands/session.rs b/src-tauri/src/commands/session.rs index febd0bc5f..30d77e3f9 100644 --- a/src-tauri/src/commands/session.rs +++ b/src-tauri/src/commands/session.rs @@ -6909,6 +6909,33 @@ async fn reuse_live_root_session( .ok_or_else(|| "Root session disappeared during reuse".to_string()) } +fn build_root_restart_request( + request: RootJobRequest, + session_id: Uuid, + wake_dormant: bool, +) -> RestartJobRequest { + let restart_intent = match request.intent { + TrustedCreateIntent::User => TrustedRestartIntent::User, + TrustedCreateIntent::Background => TrustedRestartIntent::Background, + }; + RestartJobRequest { + source_ticket: request.source_ticket, + use_stored_selection: false, + session_id, + agent_id: request.requested_agent_id, + requested_profile: request.requested_profile, + skip_auto_resume: Some(if wake_dormant { + false + } else { + request.skip_auto_resume_for_new_session + }), + activate_after: request.select_after, + intent: restart_intent, + communication_override: None, + enforcement: crate::config::sessions_persistence::default_creation_gate_enforcement(), + } +} + pub(crate) async fn execute_root_transaction( transaction: &SelectionTransaction, request: RootJobRequest, @@ -6982,28 +7009,9 @@ pub(crate) async fn execute_root_transaction( classify_existing_root(&existing.status, has_pty), ExistingRootAction::WakeDormant ); - let restart_intent = match request.intent { - TrustedCreateIntent::User => TrustedRestartIntent::User, - TrustedCreateIntent::Background => TrustedRestartIntent::Background, - }; return execute_restart_transaction( transaction, - RestartJobRequest { - source_ticket: request.source_ticket, - use_stored_selection: false, - session_id, - agent_id: request.requested_agent_id, - requested_profile: request.requested_profile, - skip_auto_resume: Some(if wake_dormant { - false - } else { - request.skip_auto_resume_for_new_session - }), - activate_after: request.select_after, - intent: restart_intent, - communication_override: None, - enforcement: crate::config::sessions_persistence::default_creation_gate_enforcement(), - }, + build_root_restart_request(request, session_id, wake_dormant), ) .await; }