diff --git a/src-tauri/src/commands/session.rs b/src-tauri/src/commands/session.rs index c1d190276..30d77e3f9 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, @@ -485,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()), } } @@ -1814,6 +1821,7 @@ pub async fn create_session_inner( crate::config::sessions_persistence::default_creation_gate_enforcement(), None, None, + None, ) .await? .into_finalized() @@ -1865,6 +1873,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, initial_last_prompt.map(InitialLastPrompt::from_persisted), ) .await? @@ -2167,8 +2177,14 @@ 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, initial_last_prompt: 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()); + } { let manager = session_mgr.read().await; crate::config::sessions_persistence::initialize_prompt_retention(&manager).await; @@ -2331,41 +2347,70 @@ 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() { - 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, - }) + 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, + )?; + } + 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, } - None => 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())?, + ); + 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, + }, + } }; let resource_monitor = app.state::>().inner().clone(); let mut resource_permit = if is_agent_owned_launch { @@ -2505,16 +2550,30 @@ async fn create_session_inner_impl( return Err(e); } }; - if let Err(error) = mgr - .install_pending_coding_agent_binding(pending_binding, coding_agent_binding) + let admission = if let Some(stage) = restart_stage { + mgr.stage_pending_restart_binding( + pending_binding, + stage.retiring_runtime, + stage.expected, + stage.desired_seed, + ) .await - { - if let Some(transaction) = inline_transaction { - transaction.rollback_inline_create(pending_binding).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() @@ -3434,7 +3493,16 @@ 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( @@ -3530,12 +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() { @@ -3555,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 @@ -3652,6 +3721,368 @@ 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: Box, + 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")?; + 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 { + scoped_settings: Box::new(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 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.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, +) -> 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, @@ -3706,6 +4137,63 @@ 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::< + 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() + }) { + 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 @@ -3801,6 +4289,48 @@ 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() @@ -3809,7 +4339,41 @@ 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 = 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())) + { + return Err("sourceSelectionProvenanceMismatch".into()); + } + 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() { build_configured_agent_spawn_for_cwd(&cfg, aid, &cwd, requested_profile.as_deref())? } else { None @@ -4830,6 +5394,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, @@ -4935,10 +5501,48 @@ 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, @@ -5165,6 +5769,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, @@ -5174,6 +5780,11 @@ 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, @@ -5213,29 +5824,85 @@ pub(crate) async fn execute_restart_transaction( ) }; + 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::ensure_root_agent_dir()? + 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; + 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; + 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 @@ -5246,61 +5913,122 @@ pub(crate) async fn execute_restart_transaction( .current_read() .await .map_err(|error| error.to_string())?; - 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!( + 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(), + }); + (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!( "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(), - }) + 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()) + { + 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 }; - (selected_agent_id, resolved_spawn, resolved_agent_host_shell) + 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 + // 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. @@ -5357,8 +6085,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, @@ -5384,11 +6111,12 @@ pub(crate) async fn execute_restart_transaction( None, enforcement, coding_agent_binding, + restart_stage, None, ) .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 { @@ -5438,6 +6166,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); + } + } if deferred.effective_continuation && !restart_start_fresh { let manager = session_mgr.read().await; if let (Some(source), Some(target)) = ( @@ -5619,7 +6356,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 @@ -5627,7 +6366,18 @@ 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) } @@ -6032,6 +6782,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, @@ -6078,8 +6829,44 @@ 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, @@ -6089,6 +6876,66 @@ pub(crate) async fn create_root_agent_inner_with_intent( .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()) +} + +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, @@ -6100,7 +6947,29 @@ 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.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 + }; let existing = session_mgr .read() .await @@ -6124,58 +6993,25 @@ 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 => { - 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()); + ExistingRootAction::ReuseLive if request.source_ticket.is_none() => { + return reuse_live_root_session( + transaction, + &session_mgr, + session_id, + request.select_after, + ) + .await; } - ExistingRootAction::WakeDormant | ExistingRootAction::DiscardMissingPty => { + ExistingRootAction::ReuseLive + | ExistingRootAction::WakeDormant + | ExistingRootAction::DiscardMissingPty => { let wake_dormant = matches!( 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 { - 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; } @@ -6183,7 +7019,15 @@ 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 @@ -6194,7 +7038,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 @@ -6226,6 +7072,10 @@ 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, @@ -6252,6 +7102,7 @@ pub(crate) async fn execute_root_transaction( crate::config::sessions_persistence::default_creation_gate_enforcement(), None, None, + None, ) .await? .into_finalized()?; @@ -6619,6 +7470,86 @@ 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 { #[tokio::test] @@ -6972,43 +7903,1535 @@ mod tests { CreateSelectionIntent::Suppress.trusted().is_none(), "a suppressed create never becomes a trusted finalizing create" ); - } - - #[test] - fn count_working_members_counts_live_and_busy_only() { - // tuples are (live /*PTY-backed*/, waiting_for_input) - assert_eq!(count_working_members(&[]), 0, "empty -> 0"); - assert_eq!( - count_working_members(&[(true, true), (true, true)]), - 0, - "all live but idle (waiting) -> 0" + } + + #[test] + fn count_working_members_counts_live_and_busy_only() { + // tuples are (live /*PTY-backed*/, waiting_for_input) + assert_eq!(count_working_members(&[]), 0, "empty -> 0"); + assert_eq!( + count_working_members(&[(true, true), (true, true)]), + 0, + "all live but idle (waiting) -> 0" + ); + assert_eq!( + count_working_members(&[(true, false), (true, false), (true, false)]), + 3, + "all live and busy -> N" + ); + assert_eq!( + count_working_members(&[(true, true), (true, false)]), + 1, + "only the live+busy member counts" + ); + assert_eq!( + count_working_members(&[(false, false), (false, false)]), + 0, + "busy but dead (no live PTY) -> 0" + ); + assert_eq!( + count_working_members(&[(true, false), (false, false), (true, true)]), + 1, + "mixed: one live+busy, one dead+busy, one live+idle -> 1" + ); + } + + fn physical_binding_test_settings(dir: &std::path::Path) -> AppSettings { + let path = dir.join(crate::config::instance_artifacts::SETTINGS_FILE_NAME); + std::fs::write(&path, serde_json::to_vec(&test_settings()).unwrap()).unwrap(); + 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 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(); + 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_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(), + &outside_cwd, + CreationGateEnforcement::Enforce, + ) + .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, + "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!(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( + 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, + 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); + 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 + .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!(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; + } + + #[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 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] { + 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 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 { .. } + )); + } + + #[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", "description":"Physical catalog row"}], + "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 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 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), + None, + ) + .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", "description":"", "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_source_replacement_publishes_latest_prompt_and_seed() { + use tauri::Listener; + let temp = tempfile::tempdir().unwrap(); + let (host, path, persisted) = + super::e2432_physical_restore_fixture(&temp.path().join("F"), "F"); + let source_path = path + .parent() + .unwrap() + .join(crate::config::instance_artifacts::AGENTS_INSTANCE_FILE_NAME); + let mut raw: serde_json::Value = + serde_json::from_slice(&std::fs::read(&source_path).unwrap()).unwrap(); + raw["agents"][0]["baseCommand"] = serde_json::json!("codex"); + raw["codingAgentProfiles"]["profilesByAgent"]["same"]["A"]["command"] = + serde_json::json!("codex"); + std::fs::write(&source_path, serde_json::to_vec(&raw).unwrap()).unwrap(); + let base = crate::config::settings::configuration_identity("codex", &[]); + let mut reference = persisted + .coding_agent_binding_seed + .as_ref() + .unwrap() + .configuration_ref + .clone() + .unwrap(); + reference.calculated_id = base.clone(); + reference.profile_calculated_id = Some(base.clone()); + let (spawn, binding) = super::resolve_runtime_source_spawn( + &host, + &path, + &persisted.working_directory, + &reference, + None, + persisted.binding_metadata().unwrap(), + ) + .unwrap(); + let sessions = Arc::new(tokio::sync::RwLock::new(SessionManager::new())); + let backend = Arc::new(ScriptedSpawnBackend::default()); + let pty = Arc::new(Mutex::new(crate::pty::manager::PtyManager::new_for_test( + backend.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, + 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( + &transaction, + &sessions, + &pty, + spawn.shell.clone(), + spawn.shell_args.clone(), + persisted.working_directory.clone(), + Some("F prompt".into()), + Some("same".into()), + Some(spawn.trusted_agent_label.clone()), + true, + Vec::new(), + true, + Some(spawn), + None, + None, + Some(false), + None, + Some(binding), + None, + ) + .await + .unwrap(); + drop(transaction); + let old = Uuid::parse_str(&first.id).unwrap(); + let manager = sessions.read().await.clone(); + let before = manager.coding_agent_binding(old).await.unwrap(); + manager.set_last_prompt(old, "A".into()).await; + 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 g_raw = serde_json::json!({"schemaVersion":2, + "agents":[{"key":"same", "baseCommand":"codex", "label":"G", + "description":"prompt source G", "color":"green"}], + "profileBindingVersion":1, "profilesByBaseHash":{(base):{ + "A":{"additionalArguments":"codex", "env":{"SOURCE":"G"}} + }} + }); + 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 mut g_ref = reference; + g_ref.source = g_source; + let (g_spawn, _) = super::resolve_runtime_source_spawn( + &host, + &path, + &persisted.working_directory, + &g_ref, + None, + None, + ) + .unwrap(); + 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 ticket = crate::config::settings::SourceSelectionTicket { + configuration_ref: g_ref.clone(), + expected_source_revision: snapshot.revision, + catalog_context_revision: snapshot.context_revision, + }; + let events = Arc::new(Mutex::new(Vec::::new())); + let captured = events.clone(); + app.listen("session_created", move |event| { + captured + .lock() + .unwrap() + .push(serde_json::from_str(event.payload()).unwrap()); + }); + 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 settings = app.state::(); + let attempt = super::restart_session_inner_with_source( + app.handle(), + &sessions, + &pty, + settings.inner(), + old, + None, + None, + Some(ticket), + false, + Some(false), + true, + crate::session::selection::TrustedRestartIntent::User, + None, + crate::config::sessions_persistence::default_creation_gate_enforcement(), ); + let (result, ()) = tokio::join!(attempt, async { + started.await.unwrap(); + assert_eq!( + manager.coding_agent_binding(old).await.unwrap().seed, + before.seed + ); + manager.set_last_prompt(old, "B".into()).await; + release.send(()).unwrap(); + }); + let result = result.unwrap(); + assert_eq!(result.last_prompt.as_deref(), Some("B")); + let observed = events.lock().unwrap().clone(); + assert_eq!(observed.len(), 1); + assert_eq!(observed[0]["id"], result.id); + assert_eq!(observed[0]["lastPrompt"], "B"); + let replacement = Uuid::parse_str(&result.id).unwrap(); + let after = manager.coding_agent_binding(replacement).await.unwrap(); + assert_eq!(after.id, before.id); + assert_eq!(after.origin, before.origin); + let seed: crate::config::sessions_persistence::CodingAgentBindingSeed = + serde_json::from_value(after.seed.clone().unwrap()).unwrap(); + assert_eq!(seed.configuration_ref.as_ref(), Some(&g_ref)); + assert!(manager.get_session(old).await.is_none()); + assert!(manager + .retained_prompt_snapshot() + .await + .unwrap() + .values() + .any(|entry| entry.last_prompt.as_deref() == Some("B"))); + let before_refusal = manager.aggregate_snapshot().await; + let spawn_count = backend.spawn_count.load(Ordering::SeqCst); + std::fs::remove_file(&g_path).unwrap(); + assert!(super::restart_session_inner_with_source( + app.handle(), + &sessions, + &pty, + settings.inner(), + replacement, + None, + None, + None, + false, + Some(false), + true, + crate::session::selection::TrustedRestartIntent::User, + None, + crate::config::sessions_persistence::default_creation_gate_enforcement(), + ) + .await + .is_err()); + let refused = manager.aggregate_snapshot().await; + assert_eq!(refused.binding_seeds, before_refusal.binding_seeds); + assert_eq!(refused.binding_origins, before_refusal.binding_origins); + assert_eq!(refused.selection, before_refusal.selection); + assert_eq!(backend.spawn_count.load(Ordering::SeqCst), spawn_count); + assert_eq!(events.lock().unwrap().len(), 1); assert_eq!( - count_working_members(&[(true, false), (true, false), (true, false)]), - 3, - "all live and busy -> N" + manager + .get_session(replacement) + .await + .unwrap() + .last_prompt + .as_deref(), + Some("B") ); - assert_eq!( - count_working_members(&[(true, true), (true, false)]), - 1, - "only the live+busy member counts" + 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", "description":"Stored physical row"}], + "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, + }, ); - assert_eq!( - count_working_members(&[(false, false), (false, false)]), - 0, - "busy but dead (no live PTY) -> 0" + 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 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 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), + None, + ) + .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!( - count_working_members(&[(true, false), (false, false), (true, true)]), - 1, - "mixed: one live+busy, one dead+busy, one live+idle -> 1" + ordinary_binding.seed.as_ref().unwrap()["configurationRef"]["requestedProfile"], + "A" ); - } - - fn physical_binding_test_settings(dir: &std::path::Path) -> AppSettings { - let path = dir.join(crate::config::instance_artifacts::SETTINGS_FILE_NAME); - std::fs::write(&path, serde_json::to_vec(&test_settings()).unwrap()).unwrap(); - crate::config::settings::load_settings_from_path(&path) + 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] @@ -8631,6 +11054,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( @@ -8662,7 +11099,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(), )) @@ -13828,6 +16265,7 @@ exec claude \"$@\" CreationGateEnforcement::Enforce, None, None, + None, ) .await; let err = match err { @@ -13895,6 +16333,7 @@ exec claude \"$@\" CreationGateEnforcement::Enforce, None, None, + None, ) .await; assert!( diff --git a/src-tauri/src/config/agent_command.rs b/src-tauri/src/config/agent_command.rs index 87446d885..8a6a15732 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..24fa002ce 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,6 +18528,29 @@ fn compose_captured_sources( } impl SettingsOwner { + /// 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())?; + 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 /// constructor path. Ready, with managed operations disabled. fn staged(settings: AppSettings) -> Self { @@ -18558,6 +18582,7 @@ impl SettingsOwner { }), released: std::sync::Condvar::new(), lease: std::sync::Mutex::new(lease), + runtime_directory: None, counters: SettingsOwnerCounters::default(), } } @@ -19050,12 +19075,228 @@ 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(); + 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([ + "--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; @@ -41964,6 +42205,74 @@ 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 3179ba6a3..ac1338fb9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2336,6 +2336,92 @@ fn load_startup_sessions( Ok(load(&paths)) } +/// 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: 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| { + 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, + )? + } + _ => 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) +} + #[allow(clippy::too_many_arguments)] fn start_native_restore_startup( app: tauri::AppHandle, @@ -2389,23 +2475,27 @@ fn start_native_restore_startup( sessions_persistence::block_duplicate_restore_prompt_domains(&manager, &persisted) .await; } - 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 ); + 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!( @@ -2415,7 +2505,7 @@ fn start_native_restore_startup( None } }; - let root_ps = persisted + let mut root_ps = persisted .iter() .find(|ps| { ps.is_root_agent @@ -2423,14 +2513,42 @@ 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() { @@ -2455,6 +2573,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, @@ -2477,7 +2596,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(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 = { @@ -2543,7 +2663,21 @@ 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 @@ -2569,7 +2703,9 @@ 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 } @@ -2590,6 +2726,16 @@ 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() { @@ -2682,7 +2828,9 @@ 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(), + ); } } } @@ -2746,7 +2894,8 @@ 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()); } } } @@ -2754,7 +2903,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( @@ -2840,6 +2989,27 @@ 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 @@ -2874,7 +3044,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; @@ -2882,7 +3052,17 @@ 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. @@ -2905,7 +3085,7 @@ fn start_native_restore_startup( ps.name, e ); - failed_recoverable.push(ps.clone()); + failed_recoverable.push(original_ps.clone()); continue; } } @@ -3056,7 +3236,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()); } } } @@ -4460,16 +4640,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 = @@ -9314,6 +9496,302 @@ 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; + persisted.last_prompt = Some("cached-prompt-decoy".into()); + 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}; + 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 01678a37e..9db54be09 100644 --- a/src-tauri/src/session/manager.rs +++ b/src-tauri/src/session/manager.rs @@ -473,16 +473,39 @@ 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, prompt_transfers: Vec<(Uuid, PendingCreateBinding)>, } 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 transfer_prompt(&mut self, source: Uuid, target: PendingCreateBinding) { self.prompt_transfers.push((source, target)); } @@ -3003,6 +3026,31 @@ 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)?; @@ -3213,19 +3261,15 @@ impl SessionManager { state.revision }; - // Every fallible check runs before either row receives transferred text. - if !matches!( - (target_mode, target_id, target_status.as_ref()), - (SelectionMode::None, None, _) - | (SelectionMode::Live, Some(_), _) - | ( - SelectionMode::Dormant, - Some(_), - Some(SessionStatus::Exited(_)) - ) - ) { - return Err("invalid canonical selection target".to_string()); - } + // Prevalidate the projection while the same guard protects application. + 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(); let mut changed_ids = HashSet::new(); @@ -3322,7 +3366,7 @@ impl SessionManager { let target = state .sessions .get_mut(&id) - .expect("selection target validated before mutations"); + .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) @@ -3334,7 +3378,7 @@ impl SessionManager { }; SessionSelection::dormant(state.epoch, next_revision, cause, id, code, has_pty) } - _ => unreachable!("selection target prevalidated"), + _ => unreachable!("canonical selection projection prevalidated"), }; result.selection = Some(state.selection.clone()); } @@ -3374,6 +3418,12 @@ 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) } @@ -3531,6 +3581,85 @@ 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, + 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 prompt_domain_exclusive( state: &SessionManagerState, key: &PromptKey, @@ -6484,6 +6613,444 @@ 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; + manager.set_last_prompt(old, "B".into()).await; + let before_seeds = manager.aggregate_snapshot().await.binding_seeds; + assert_eq!(before_seeds.get(&concurrent.id), concurrent.seed.as_ref()); + 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.transfer_prompt(old, pending); + 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); + // 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(), + Some("B") + ); + 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); + 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, diff --git a/src-tauri/src/session/selection.rs b/src-tauri/src/session/selection.rs index 7a16057db..bc72e5925 100644 --- a/src-tauri/src/session/selection.rs +++ b/src-tauri/src/session/selection.rs @@ -7273,6 +7273,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,