diff --git a/.github/scripts/check-runtime-config-boundary.mjs b/.github/scripts/check-runtime-config-boundary.mjs index 6280394c..26997f8b 100644 --- a/.github/scripts/check-runtime-config-boundary.mjs +++ b/.github/scripts/check-runtime-config-boundary.mjs @@ -14,6 +14,10 @@ const forbidden = [ "activate_retrieval_profile_env", "CODESTORY_EMBED_LLAMACPP_URL_MANAGED", ]; +const lateStartupReads = [ + 'std::env::var(PROJECT_NETWORK_CONFIG_OPT_IN_ENV)', + 'std::env::var_os("CODESTORY_STDIO_CACHE_ROOT")', +]; function rustFiles(root) { return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => { @@ -31,6 +35,26 @@ for (const file of roots.flatMap(rustFiles)) { } } +for (const file of [ + "crates/codestory-cli/src/runtime.rs", + "crates/codestory-cli/src/stdio_transport.rs", +]) { + const source = fs.readFileSync(file, "utf8"); + for (const token of lateStartupReads) { + if (source.includes(token)) violations.push(`${file}: late startup read ${token}`); + } +} + +const runtimeSource = fs.readFileSync("crates/codestory-cli/src/runtime.rs", "utf8"); +const runtimeSelection = runtimeSource + .split("fn new_with_startup", 2)[1] + ?.split("/// Open project", 1)[0]; +for (const token of ["user_cache_root()", "for_project_auto_with_defaults("]) { + if (runtimeSelection?.includes(token)) { + violations.push(`crates/codestory-cli/src/runtime.rs: project selection ${token}`); + } +} + if (violations.length) { console.error("Production runtime configuration must remain immutable:\n" + violations.join("\n")); process.exit(1); diff --git a/CHANGELOG.md b/CHANGELOG.md index 534efc75..7abb03eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,11 @@ and cooldown across MCP runtimes. Stale heartbeats from a still-live owner remain fail-closed diagnostics surfaced by canonical MCP status; age alone never terminates the process. +- Captured CLI user-home, project-network trust, stdio cache, and sidecar + defaults once at process startup so multi-project project selection cannot + drift through later process-environment reads. Repair status, terminal + results, lock arbitration, and broker overlays now consume that same retained + sidecar root instead of polling a second environment-derived cache tree. - Renewed live local-refresh ownership during long incremental indexing, with token, PID, and process-start checks that prevent stale workers from overwriting changed ownership or terminal status. diff --git a/crates/codestory-cli/src/config.rs b/crates/codestory-cli/src/config.rs index 1d70f7e3..7d9b6a27 100644 --- a/crates/codestory-cli/src/config.rs +++ b/crates/codestory-cli/src/config.rs @@ -6,6 +6,31 @@ use std::sync::OnceLock; const PROJECT_NETWORK_CONFIG_OPT_IN_ENV: &str = "CODESTORY_ALLOW_PROJECT_NETWORK_CONFIG"; +#[derive(Debug, Clone)] +pub(crate) struct CliStartupConfig { + pub(crate) user_home: Option, + pub(crate) project_network_config_allowed: bool, + pub(crate) user_cache_root: PathBuf, + pub(crate) stdio_cache_root: Option, + pub(crate) runtime_defaults: codestory_retrieval::SidecarRuntimeDefaults, +} + +impl CliStartupConfig { + pub(crate) fn from_process_env() -> Self { + Self { + user_home: std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from), + project_network_config_allowed: std::env::var(PROJECT_NETWORK_CONFIG_OPT_IN_ENV) + .map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES")) + .unwrap_or(false), + stdio_cache_root: std::env::var_os("CODESTORY_STDIO_CACHE_ROOT").map(PathBuf::from), + user_cache_root: codestory_retrieval::user_cache_root(), + runtime_defaults: codestory_retrieval::SidecarRuntimeDefaults::from_process_env(), + } + } +} + #[derive(Debug, Clone, Default, Deserialize)] pub(crate) struct CliConfig { pub(crate) cache_dir: Option, @@ -31,47 +56,59 @@ enum ConfigSource { Project, } +#[cfg(test)] pub(crate) fn load_config(project_root: &Path) -> Result { + load_config_with_startup(project_root, &process_startup_config()) +} + +pub(crate) fn load_config_with_startup( + project_root: &Path, + startup: &CliStartupConfig, +) -> Result { let mut config = CliConfig::default(); - if let Some(home) = std::env::var_os("USERPROFILE") - .or_else(|| std::env::var_os("HOME")) - .map(PathBuf::from) - { + if let Some(home) = startup.user_home.as_ref() { merge_config_file( &mut config, &home.join(".codestory.toml"), ConfigSource::TrustedUser, + startup.project_network_config_allowed, )?; } merge_config_file( &mut config, &project_root.join(".codestory.toml"), ConfigSource::Project, + startup.project_network_config_allowed, )?; Ok(config) } -pub(crate) fn process_runtime_defaults() -> codestory_retrieval::SidecarRuntimeDefaults { +pub(crate) fn process_startup_config() -> CliStartupConfig { #[cfg(test)] { - codestory_retrieval::SidecarRuntimeDefaults::from_process_env() + CliStartupConfig::from_process_env() } #[cfg(not(test))] { - static DEFAULTS: OnceLock = OnceLock::new(); - DEFAULTS - .get_or_init(codestory_retrieval::SidecarRuntimeDefaults::from_process_env) + static STARTUP: OnceLock = OnceLock::new(); + STARTUP + .get_or_init(CliStartupConfig::from_process_env) .clone() } } -fn merge_config_file(config: &mut CliConfig, path: &Path, source: ConfigSource) -> Result<()> { +fn merge_config_file( + config: &mut CliConfig, + path: &Path, + source: ConfigSource, + project_network_config_allowed: bool, +) -> Result<()> { if !path.exists() { return Ok(()); } let raw = std::fs::read_to_string(path) .with_context(|| format!("Failed to read config {}", path.display()))?; - validate_config_trust_boundary(&raw, source, path)?; + validate_config_trust_boundary(&raw, source, path, project_network_config_allowed)?; let file_config: CliConfig = toml::from_str(&raw) .with_context(|| format!("Failed to parse config {}", path.display()))?; if file_config.cache_dir.is_some() { @@ -113,7 +150,12 @@ fn merge_config_file(config: &mut CliConfig, path: &Path, source: ConfigSource) Ok(()) } -fn validate_config_trust_boundary(raw: &str, source: ConfigSource, path: &Path) -> Result<()> { +fn validate_config_trust_boundary( + raw: &str, + source: ConfigSource, + path: &Path, + project_network_config_allowed: bool, +) -> Result<()> { if source != ConfigSource::Project { return Ok(()); } @@ -128,7 +170,7 @@ fn validate_config_trust_boundary(raw: &str, source: ConfigSource, path: &Path) ); } for field in ["summary_endpoint", "summary_model", "embedding_endpoint"] { - if table.contains_key(field) && !project_network_config_allowed() { + if table.contains_key(field) && !project_network_config_allowed { anyhow::bail!( "project config field `{field}` is not trusted; set CODESTORY_SUMMARY_ENDPOINT, CODESTORY_SUMMARY_MODEL, CODESTORY_EMBED_LLAMACPP_URL, or pass a trusted CLI option instead" ); @@ -137,12 +179,6 @@ fn validate_config_trust_boundary(raw: &str, source: ConfigSource, path: &Path) Ok(()) } -fn project_network_config_allowed() -> bool { - std::env::var(PROJECT_NETWORK_CONFIG_OPT_IN_ENV) - .map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES")) - .unwrap_or(false) -} - impl CliConfig { pub(crate) fn runtime_overrides(&self) -> codestory_retrieval::SidecarRuntimeOverrides { codestory_retrieval::SidecarRuntimeOverrides { diff --git a/crates/codestory-cli/src/main.rs b/crates/codestory-cli/src/main.rs index 7ef71604..9ab5e638 100644 --- a/crates/codestory-cli/src/main.rs +++ b/crates/codestory-cli/src/main.rs @@ -7383,7 +7383,7 @@ pub(crate) fn build_readiness_lanes_for_runtime( let local_status = doctor_sidecar_status_for_runtime(runtime, local_runtime); let agent_status = selected_agent_status .cloned() - .unwrap_or_else(|| doctor_sidecar_status_for_runtime(runtime, agent_runtime)); + .unwrap_or_else(|| doctor_sidecar_status_for_runtime(runtime, agent_runtime.clone())); let agent_verdict = readiness .iter() .find(|verdict| verdict.goal == ReadinessGoalDto::AgentPacketSearch); @@ -7401,15 +7401,14 @@ pub(crate) fn build_readiness_lanes_for_runtime( &project_arg, ), ); + apply_ready_repair_status_overlay( + &mut lanes, + &runtime.project_root, + &agent_runtime, + &project_arg, + ); if let Some(broker) = broker { apply_broker_ready_repair_overlay(&mut lanes, broker, &project_arg); - } else { - apply_ready_repair_status_overlay( - &mut lanes, - &runtime.project_root, - agent_run_id, - &project_arg, - ); } lanes } @@ -7479,10 +7478,11 @@ fn readiness_lane_output( fn apply_ready_repair_status_overlay( lanes: &mut BTreeMap, project_root: &Path, - agent_run_id: Option<&str>, + sidecar: &codestory_retrieval::SidecarRuntimeConfig, project_arg: &str, ) { - let Some(status) = ready_repair_status::active_ready_repair_status(project_root, agent_run_id) + let Some(status) = + ready_repair_status::active_ready_repair_status_for_sidecar(project_root, sidecar) else { return; }; diff --git a/crates/codestory-cli/src/readiness_broker/mod.rs b/crates/codestory-cli/src/readiness_broker/mod.rs index ce1801f3..8642ce06 100644 --- a/crates/codestory-cli/src/readiness_broker/mod.rs +++ b/crates/codestory-cli/src/readiness_broker/mod.rs @@ -31,16 +31,18 @@ pub(crate) use native_lease::{ reusable_native_embedding_resource_pid_for_snapshot, run_with_native_embedding_lease_lifecycle, transfer_native_embedding_resource_lease, }; -#[allow(unused_imports)] pub(crate) use paths::machine_resource_cache_fingerprint; #[allow(unused_imports)] -pub(crate) use reconcile::reconcile_before_enqueue; +pub(crate) use reconcile::{reconcile_before_enqueue, reconcile_before_enqueue_for_sidecar}; #[allow(unused_imports)] pub(crate) use scope::{ BROKER_SCHEMA_VERSION, agent_repair_scope, broker_operation_id, operation_scope, }; #[allow(unused_imports)] -pub(crate) use snapshot::{observe_broker_snapshot, refresh_broker_snapshot}; +pub(crate) use snapshot::{ + observe_broker_snapshot, observe_broker_snapshot_for_sidecar, refresh_broker_snapshot, + refresh_broker_snapshot_for_sidecar, +}; #[allow(unused_imports)] pub(crate) use types::{ BrokerGpuProofInput, BrokerGpuProofSnapshot, BrokerOperationSnapshot, diff --git a/crates/codestory-cli/src/readiness_broker/reconcile.rs b/crates/codestory-cli/src/readiness_broker/reconcile.rs index 6b64cce5..f328ae2f 100644 --- a/crates/codestory-cli/src/readiness_broker/reconcile.rs +++ b/crates/codestory-cli/src/readiness_broker/reconcile.rs @@ -1,5 +1,7 @@ use std::path::Path; +use codestory_retrieval::SidecarRuntimeConfig; + use crate::{local_refresh_status, ready_repair_status}; use super::operations::{ @@ -17,10 +19,36 @@ pub(crate) fn reconcile_before_enqueue( ) -> BrokerReconciliationSnapshot { #[cfg(test)] return super::paths::with_test_broker_root(|| { - reconcile_before_enqueue_inner(project_root, cache_root, run_id, cli_version) + reconcile_before_enqueue_inner(project_root, cache_root, run_id, cli_version, None) }); #[cfg(not(test))] - reconcile_before_enqueue_inner(project_root, cache_root, run_id, cli_version) + reconcile_before_enqueue_inner(project_root, cache_root, run_id, cli_version, None) +} + +pub(crate) fn reconcile_before_enqueue_for_sidecar( + project_root: &Path, + cache_root: &Path, + sidecar: &SidecarRuntimeConfig, + cli_version: &str, +) -> BrokerReconciliationSnapshot { + #[cfg(test)] + return super::paths::with_test_broker_root(|| { + reconcile_before_enqueue_inner( + project_root, + cache_root, + sidecar.run_id.as_deref(), + cli_version, + Some(sidecar), + ) + }); + #[cfg(not(test))] + reconcile_before_enqueue_inner( + project_root, + cache_root, + sidecar.run_id.as_deref(), + cli_version, + Some(sidecar), + ) } fn reconcile_before_enqueue_inner( @@ -28,8 +56,15 @@ fn reconcile_before_enqueue_inner( cache_root: &Path, run_id: Option<&str>, cli_version: &str, + sidecar: Option<&SidecarRuntimeConfig>, ) -> BrokerReconciliationSnapshot { - if let Some(active) = ready_repair_status::active_ready_repair_status(project_root, run_id) { + let active = match sidecar { + Some(sidecar) => { + ready_repair_status::active_ready_repair_status_for_sidecar(project_root, sidecar) + } + None => ready_repair_status::active_ready_repair_status(project_root, run_id), + }; + if let Some(active) = active { return BrokerReconciliationSnapshot { status: "active_repair".to_string(), cleanup_performed: false, @@ -47,18 +82,29 @@ fn reconcile_before_enqueue_inner( }; } - let cleanups = ready_repair_status::cleanup_abandoned_ready_repair_status(project_root, run_id); + let cleanups = match sidecar { + Some(sidecar) => ready_repair_status::cleanup_abandoned_ready_repair_status_for_sidecar( + project_root, + sidecar, + ), + None => ready_repair_status::cleanup_abandoned_ready_repair_status(project_root, run_id), + }; let mut stale_status_paths_removed = Vec::new(); let mut stale_lock_paths_removed = Vec::new(); let mut abandoned_repairs = Vec::new(); let mut local_refresh_cleanups = Vec::new(); - let mut unresolved_orphan_reason = - ready_repair_status::stale_live_ready_repair_status(project_root, run_id).map(|status| { - format!( - "live_ready_repair_heartbeat_stale:pid={}:phase={}", - status.pid, status.phase - ) - }); + let stale_live = match sidecar { + Some(sidecar) => { + ready_repair_status::stale_live_ready_repair_status_for_sidecar(project_root, sidecar) + } + None => ready_repair_status::stale_live_ready_repair_status(project_root, run_id), + }; + let mut unresolved_orphan_reason = stale_live.map(|status| { + format!( + "live_ready_repair_heartbeat_stale:pid={}:phase={}", + status.pid, status.phase + ) + }); for cleanup in cleanups { if cleanup.removed_status_path { stale_status_paths_removed.push(clean_path(&cleanup.status_path)); diff --git a/crates/codestory-cli/src/readiness_broker/snapshot.rs b/crates/codestory-cli/src/readiness_broker/snapshot.rs index d01a5da4..ec1a8f49 100644 --- a/crates/codestory-cli/src/readiness_broker/snapshot.rs +++ b/crates/codestory-cli/src/readiness_broker/snapshot.rs @@ -28,15 +28,16 @@ pub(crate) fn refresh_broker_snapshot(input: BrokerSnapshotInput) -> ReadinessBr fn refresh_broker_snapshot_inner(input: BrokerSnapshotInput) -> ReadinessBrokerSnapshot { let identity = codestory_workspace::project_identity_v2(&input.project_root); let runtime_identity = current_gpu_runtime_identity(&input, &identity); - refresh_broker_snapshot_with_identity(input, identity, runtime_identity.as_ref()) + refresh_broker_snapshot_with_identity(input, identity, runtime_identity.as_ref(), None) } fn refresh_broker_snapshot_with_identity( input: BrokerSnapshotInput, identity: codestory_workspace::ProjectIdentityV2, runtime_identity: Option<&BrokerGpuRuntimeIdentity>, + sidecar: Option<&codestory_retrieval::SidecarRuntimeConfig>, ) -> ReadinessBrokerSnapshot { - let mut snapshot = build_broker_snapshot(input, identity); + let mut snapshot = build_broker_snapshot(input, identity, sidecar); if let Some(proof) = snapshot.gpu_proof.as_mut() { bind_verified_runtime_identity(proof, runtime_identity); } @@ -62,18 +63,61 @@ pub(crate) fn observe_broker_snapshot(input: BrokerSnapshotInput) -> ReadinessBr observe_broker_snapshot_inner(input) } +pub(crate) fn observe_broker_snapshot_for_sidecar( + input: BrokerSnapshotInput, + sidecar: &codestory_retrieval::SidecarRuntimeConfig, +) -> ReadinessBrokerSnapshot { + #[cfg(test)] + return super::paths::with_test_broker_root(|| { + observe_broker_snapshot_for_sidecar_inner(input, sidecar) + }); + #[cfg(not(test))] + observe_broker_snapshot_for_sidecar_inner(input, sidecar) +} + +pub(crate) fn refresh_broker_snapshot_for_sidecar( + input: BrokerSnapshotInput, + sidecar: &codestory_retrieval::SidecarRuntimeConfig, +) -> ReadinessBrokerSnapshot { + #[cfg(test)] + return super::paths::with_test_broker_root(|| { + refresh_broker_snapshot_for_sidecar_inner(input, sidecar) + }); + #[cfg(not(test))] + refresh_broker_snapshot_for_sidecar_inner(input, sidecar) +} + +fn observe_broker_snapshot_for_sidecar_inner( + input: BrokerSnapshotInput, + sidecar: &codestory_retrieval::SidecarRuntimeConfig, +) -> ReadinessBrokerSnapshot { + let identity = codestory_workspace::cached_project_identity_v2(&input.project_root); + let runtime_identity = gpu_runtime_identity_for_sidecar(sidecar, &identity); + observe_broker_snapshot_with_identity(input, identity, runtime_identity.as_ref(), Some(sidecar)) +} + +fn refresh_broker_snapshot_for_sidecar_inner( + input: BrokerSnapshotInput, + sidecar: &codestory_retrieval::SidecarRuntimeConfig, +) -> ReadinessBrokerSnapshot { + let identity = codestory_workspace::project_identity_v2(&input.project_root); + let runtime_identity = gpu_runtime_identity_for_sidecar(sidecar, &identity); + refresh_broker_snapshot_with_identity(input, identity, runtime_identity.as_ref(), Some(sidecar)) +} + fn observe_broker_snapshot_inner(input: BrokerSnapshotInput) -> ReadinessBrokerSnapshot { let identity = codestory_workspace::cached_project_identity_v2(&input.project_root); let runtime_identity = current_gpu_runtime_identity(&input, &identity); - observe_broker_snapshot_with_identity(input, identity, runtime_identity.as_ref()) + observe_broker_snapshot_with_identity(input, identity, runtime_identity.as_ref(), None) } fn observe_broker_snapshot_with_identity( input: BrokerSnapshotInput, identity: codestory_workspace::ProjectIdentityV2, runtime_identity: Option<&BrokerGpuRuntimeIdentity>, + sidecar: Option<&codestory_retrieval::SidecarRuntimeConfig>, ) -> ReadinessBrokerSnapshot { - let mut snapshot = build_broker_snapshot(input, identity); + let mut snapshot = build_broker_snapshot(input, identity, sidecar); if let Some(proof) = snapshot.gpu_proof.as_mut() { bind_verified_runtime_identity(proof, runtime_identity); } @@ -106,7 +150,7 @@ pub(super) fn refresh_broker_snapshot_with_runtime_identity( ) -> ReadinessBrokerSnapshot { super::paths::with_test_broker_root(|| { let identity = codestory_workspace::project_identity_v2(&input.project_root); - refresh_broker_snapshot_with_identity(input, identity, runtime_identity) + refresh_broker_snapshot_with_identity(input, identity, runtime_identity, None) }) } @@ -117,7 +161,7 @@ pub(super) fn observe_broker_snapshot_with_runtime_identity( ) -> ReadinessBrokerSnapshot { super::paths::with_test_broker_root(|| { let identity = codestory_workspace::cached_project_identity_v2(&input.project_root); - observe_broker_snapshot_with_identity(input, identity, runtime_identity) + observe_broker_snapshot_with_identity(input, identity, runtime_identity, None) }) } @@ -144,9 +188,16 @@ fn current_gpu_runtime_identity( input.agent_run_id.as_deref(), &super::paths::broker_cache_root(), ); + gpu_runtime_identity_for_sidecar(&runtime, expected_project_identity) +} + +fn gpu_runtime_identity_for_sidecar( + runtime: &codestory_retrieval::SidecarRuntimeConfig, + expected_project_identity: &codestory_workspace::ProjectIdentityV2, +) -> Option { let state: codestory_retrieval::SidecarStateFile = serde_json::from_slice(&fs::read(&runtime.layout.state_file).ok()?).ok()?; - if !codestory_retrieval::sidecar_state_matches_runtime(&state, &runtime) { + if !codestory_retrieval::sidecar_state_matches_runtime(&state, runtime) { return None; } let state_project_identity = state.project_identity.as_ref()?; @@ -180,17 +231,24 @@ fn current_gpu_runtime_identity( fn build_broker_snapshot( input: BrokerSnapshotInput, identity: codestory_workspace::ProjectIdentityV2, + sidecar: Option<&codestory_retrieval::SidecarRuntimeConfig>, ) -> ReadinessBrokerSnapshot { let canonical_root = clean_path_text(&input.project_root); let canonical_root_hash = hash_text(&canonical_root); let project_id = identity.project_id.clone(); let cli_version = input.cli_version; let mut operations = Vec::new(); - let active_repair = ready_repair_status::active_ready_repair_status( - &input.project_root, - input.agent_run_id.as_deref(), - ) - .or_else(|| ready_repair_status::active_ready_repair_status(&input.project_root, None)); + let active_repair = match sidecar { + Some(sidecar) => ready_repair_status::active_ready_repair_status_for_sidecar( + &input.project_root, + sidecar, + ), + None => ready_repair_status::active_ready_repair_status( + &input.project_root, + input.agent_run_id.as_deref(), + ) + .or_else(|| ready_repair_status::active_ready_repair_status(&input.project_root, None)), + }; if let Some(active) = active_repair { operations.push(operation_from_ready_status( &input.project_root, @@ -198,10 +256,16 @@ fn build_broker_snapshot( active, "running", )); - } else if let Some(abandoned) = ready_repair_status::abandoned_ready_repair_status( - &input.project_root, - input.agent_run_id.as_deref(), - ) { + } else if let Some(abandoned) = match sidecar { + Some(sidecar) => ready_repair_status::abandoned_ready_repair_status_for_sidecar( + &input.project_root, + sidecar, + ), + None => ready_repair_status::abandoned_ready_repair_status( + &input.project_root, + input.agent_run_id.as_deref(), + ), + } { operations.push(operation_from_ready_status( &input.project_root, &cli_version, diff --git a/crates/codestory-cli/src/readiness_broker/tests.rs b/crates/codestory-cli/src/readiness_broker/tests.rs index 01b0df3d..8b4c7320 100644 --- a/crates/codestory-cli/src/readiness_broker/tests.rs +++ b/crates/codestory-cli/src/readiness_broker/tests.rs @@ -1140,6 +1140,76 @@ fn reconcile_before_enqueue_cleans_abandoned_repair_for_dead_pid() { ); } +#[test] +fn reconcile_before_enqueue_for_sidecar_keeps_abandoned_cleanup_in_retained_root() { + let project = tempdir().expect("project"); + let retained_cache = tempdir().expect("retained cache"); + let mutable_cache = tempdir().expect("mutable cache"); + let run_id = "shared-agent"; + let retained_sidecar = + codestory_retrieval::SidecarRuntimeConfig::for_project_profile_with_run_id_in_cache( + Some(project.path()), + codestory_retrieval::SidecarProfile::Agent, + Some(run_id), + retained_cache.path(), + ); + let mutable_sidecar = + codestory_retrieval::SidecarRuntimeConfig::for_project_profile_with_run_id_in_cache( + Some(project.path()), + codestory_retrieval::SidecarProfile::Agent, + Some(run_id), + mutable_cache.path(), + ); + let status_path = retained_sidecar + .layout + .state_file + .with_file_name("ready-repair-status.json"); + fs::create_dir_all(status_path.parent().expect("status parent")).expect("create status parent"); + fs::write( + &status_path, + serde_json::to_vec_pretty(&serde_json::json!({ + "schema_version": 1, + "status": "repairing", + "project_root": clean_path_text(project.path()), + "profile": "agent", + "run_id": run_id, + "namespace": retained_sidecar.namespace, + "compose_project": retained_sidecar.compose_project, + "phase": "graph artifact", + "pid": u32::MAX, + "started_at_epoch_ms": now_epoch_ms(), + "updated_at_epoch_ms": now_epoch_ms() + })) + .expect("status json"), + ) + .expect("write retained status"); + + let reconciliation = reconcile_before_enqueue_for_sidecar( + project.path(), + mutable_cache.path(), + &retained_sidecar, + "9.9.9", + ); + + assert_eq!(reconciliation.status, "stale_state_cleaned"); + assert_eq!(reconciliation.abandoned_repairs.len(), 1); + assert!(!status_path.exists()); + assert!( + retained_sidecar + .layout + .state_file + .with_file_name("ready-repair-result.json") + .exists() + ); + assert!( + !mutable_sidecar + .layout + .state_file + .with_file_name("ready-repair-result.json") + .exists() + ); +} + #[test] fn reconcile_before_enqueue_reports_clean_when_empty() { let project = tempdir().expect("project"); diff --git a/crates/codestory-cli/src/ready_repair_status.rs b/crates/codestory-cli/src/ready_repair_status.rs index 62a8d728..be4a52b6 100644 --- a/crates/codestory-cli/src/ready_repair_status.rs +++ b/crates/codestory-cli/src/ready_repair_status.rs @@ -470,6 +470,7 @@ fn write_ready_repair_worker_result_locked( ) } +#[cfg(test)] pub(crate) fn read_ready_repair_worker_result( project_root: &Path, run_id: Option<&str>, @@ -479,7 +480,13 @@ pub(crate) fn read_ready_repair_worker_result( SidecarProfile::Agent, run_id.or(Some(DEFAULT_AGENT_RUN_ID)), ); - fs::read_to_string(ready_repair_result_path(&sidecar)) + read_ready_repair_worker_result_for_sidecar(&sidecar) +} + +pub(crate) fn read_ready_repair_worker_result_for_sidecar( + sidecar: &SidecarRuntimeConfig, +) -> Option { + fs::read_to_string(ready_repair_result_path(sidecar)) .ok() .and_then(|text| serde_json::from_str(&text).ok()) } @@ -559,14 +566,17 @@ fn try_acquire_ready_repair_lock_at( Err(error) => return Err(error.into()), } - if let Some(status) = active_ready_repair_status(project_root, active_run_id) { + let scan_all_runs = active_run_id.is_none(); + if let Some(status) = active_ready_repair_status_for_lock(project_root, sidecar, scan_all_runs) + { return Ok(ReadyRepairLockAttempt::Busy(Box::new(ReadyRepairBusy { status: Some(status), lock_path: path, reason: None, }))); } - let stale_live_status = stale_live_ready_repair_status(project_root, active_run_id); + let stale_live_status = + stale_live_ready_repair_status_for_lock(project_root, sidecar, scan_all_runs); if !ready_repair_lock_file_is_stale(&path) { return Ok(ReadyRepairLockAttempt::Busy(Box::new(ReadyRepairBusy { @@ -584,7 +594,7 @@ fn try_acquire_ready_repair_lock_at( })), Err(error) if error.kind() == ErrorKind::AlreadyExists => { Ok(ReadyRepairLockAttempt::Busy(Box::new(ReadyRepairBusy { - status: active_ready_repair_status(project_root, active_run_id), + status: active_ready_repair_status_for_lock(project_root, sidecar, scan_all_runs), lock_path: path, reason: Some("lock_contention".to_string()), }))) @@ -696,6 +706,41 @@ pub(crate) fn active_ready_repair_status( .max_by_key(|status| status.updated_at_epoch_ms) } +pub(crate) fn active_ready_repair_status_for_sidecar( + project_root: &Path, + default_sidecar: &SidecarRuntimeConfig, +) -> Option { + let now = now_epoch_ms(); + ready_repair_status_paths_for_sidecar(default_sidecar) + .into_iter() + .filter_map(|path| read_ready_repair_status(&path, project_root, now)) + .max_by_key(|status| status.updated_at_epoch_ms) +} + +fn active_ready_repair_status_for_lock( + project_root: &Path, + sidecar: &SidecarRuntimeConfig, + scan_all_runs: bool, +) -> Option { + let now = now_epoch_ms(); + ready_repair_status_paths_for_lock(sidecar, scan_all_runs) + .into_iter() + .filter_map(|path| read_ready_repair_status(&path, project_root, now)) + .max_by_key(|status| status.updated_at_epoch_ms) +} + +fn stale_live_ready_repair_status_for_lock( + project_root: &Path, + sidecar: &SidecarRuntimeConfig, + scan_all_runs: bool, +) -> Option { + let now = now_epoch_ms(); + ready_repair_status_paths_for_lock(sidecar, scan_all_runs) + .into_iter() + .filter_map(|path| read_stale_live_ready_repair_status(&path, project_root, now)) + .max_by_key(|status| status.updated_at_epoch_ms) +} + pub(crate) fn abandoned_ready_repair_status( project_root: &Path, run_id: Option<&str>, @@ -707,6 +752,28 @@ pub(crate) fn abandoned_ready_repair_status( .max_by_key(|status| status.updated_at_epoch_ms) } +pub(crate) fn abandoned_ready_repair_status_for_sidecar( + project_root: &Path, + default_sidecar: &SidecarRuntimeConfig, +) -> Option { + let now = now_epoch_ms(); + ready_repair_status_paths_for_sidecar(default_sidecar) + .into_iter() + .filter_map(|path| read_abandoned_ready_repair_status(&path, project_root, now)) + .max_by_key(|status| status.updated_at_epoch_ms) +} + +pub(crate) fn stale_live_ready_repair_status_for_sidecar( + project_root: &Path, + default_sidecar: &SidecarRuntimeConfig, +) -> Option { + let now = now_epoch_ms(); + ready_repair_status_paths_for_sidecar(default_sidecar) + .into_iter() + .filter_map(|path| read_stale_live_ready_repair_status(&path, project_root, now)) + .max_by_key(|status| status.updated_at_epoch_ms) +} + pub(crate) fn stale_live_ready_repair_status( project_root: &Path, run_id: Option<&str>, @@ -721,19 +788,47 @@ pub(crate) fn stale_live_ready_repair_status( pub(crate) fn cleanup_abandoned_ready_repair_status( project_root: &Path, run_id: Option<&str>, +) -> Vec { + let default_sidecar = sidecar_runtime_for_project_with_run_id( + project_root, + SidecarProfile::Agent, + run_id.or(Some(DEFAULT_AGENT_RUN_ID)), + ); + cleanup_abandoned_ready_repair_status_from_paths( + project_root, + &default_sidecar, + ready_repair_status_paths(project_root, run_id), + ) +} + +pub(crate) fn cleanup_abandoned_ready_repair_status_for_sidecar( + project_root: &Path, + default_sidecar: &SidecarRuntimeConfig, +) -> Vec { + cleanup_abandoned_ready_repair_status_from_paths( + project_root, + default_sidecar, + ready_repair_status_paths_for_sidecar(default_sidecar), + ) +} + +fn cleanup_abandoned_ready_repair_status_from_paths( + project_root: &Path, + default_sidecar: &SidecarRuntimeConfig, + paths: Vec, ) -> Vec { let now = now_epoch_ms(); - ready_repair_status_paths(project_root, run_id) + paths .into_iter() .filter_map(|path| { let observed = read_abandoned_ready_repair_status(&path, project_root, now)?; let run_id = observed.run_id.as_deref().unwrap_or(DEFAULT_AGENT_RUN_ID); - let sidecar = sidecar_runtime_for_project_with_run_id( - project_root, + let sidecar = default_sidecar.with_profile_and_run_id( + Some(project_root), SidecarProfile::Agent, Some(run_id), ); - let observed_stale_locks = ready_repair_lock_paths_for_status(project_root, &observed) + let observed_stale_locks = ready_repair_lock_paths_for_sidecar(&sidecar) .into_iter() .filter_map(|lock_path| { let lock = read_ready_repair_lock_file(&lock_path)?; @@ -795,8 +890,19 @@ pub(crate) fn cleanup_abandoned_ready_repair_status( .collect() } +#[cfg(test)] pub(crate) fn ready_repair_status_cache_fingerprint(project_root: &Path) -> String { - ready_repair_status_paths(project_root, None) + ready_repair_status_cache_fingerprint_for_paths(ready_repair_status_paths(project_root, None)) +} + +pub(crate) fn ready_repair_status_cache_fingerprint_for_sidecar( + sidecar: &SidecarRuntimeConfig, +) -> String { + ready_repair_status_cache_fingerprint_for_paths(ready_repair_status_paths_for_sidecar(sidecar)) +} + +fn ready_repair_status_cache_fingerprint_for_paths(paths: Vec) -> String { + paths .into_iter() .flat_map(|path| { [ @@ -1123,16 +1229,10 @@ fn probe_process_platform(_pid: u32) -> ProcessProbe { ProcessProbe::Unknown } -fn ready_repair_lock_paths_for_status( - project_root: &Path, - status: &ReadyRepairStatus, -) -> Vec { - let run_id = status.run_id.as_deref().unwrap_or(DEFAULT_AGENT_RUN_ID); - let sidecar = - sidecar_runtime_for_project_with_run_id(project_root, SidecarProfile::Agent, Some(run_id)); +fn ready_repair_lock_paths_for_sidecar(sidecar: &SidecarRuntimeConfig) -> Vec { vec![ - ready_repair_lock_path(&sidecar), - project_ready_repair_lock_path(&sidecar), + ready_repair_lock_path(sidecar), + project_ready_repair_lock_path(sidecar), ] } @@ -1163,9 +1263,14 @@ fn ready_repair_status_paths(project_root: &Path, run_id: Option<&str>) -> Vec

Vec { + let mut paths = BTreeSet::new(); + paths.insert(ready_repair_status_path(default_sidecar)); - if let Some((sidecars_root, namespace_prefix)) = agent_sidecars_scan_root(&default_sidecar) + if let Some((sidecars_root, namespace_prefix)) = agent_sidecars_scan_root(default_sidecar) && let Ok(entries) = fs::read_dir(sidecars_root) { for entry in entries.flatten() { @@ -1185,6 +1290,17 @@ fn ready_repair_status_paths(project_root: &Path, run_id: Option<&str>) -> Vec

Vec { + if scan_all_runs { + ready_repair_status_paths_for_sidecar(sidecar) + } else { + vec![ready_repair_status_path(sidecar)] + } +} + fn agent_sidecars_scan_root(sidecar: &SidecarRuntimeConfig) -> Option<(PathBuf, String)> { let namespace_prefix = sidecar.namespace.strip_suffix(DEFAULT_AGENT_RUN_ID)?; let namespace_dir = sidecar.layout.state_file.parent()?; diff --git a/crates/codestory-cli/src/runtime.rs b/crates/codestory-cli/src/runtime.rs index 0181595a..dc1b15d7 100644 --- a/crates/codestory-cli/src/runtime.rs +++ b/crates/codestory-cli/src/runtime.rs @@ -100,20 +100,42 @@ impl std::error::Error for AmbiguousTargetError {} impl RuntimeContext { /// Open runtime services with the caller's embedding configuration. pub(crate) fn new(args: &ProjectArgs) -> Result { - Self::new_with_startup(args) + Self::new_with_startup(args, &crate::config::process_startup_config()) } /// Open runtime services for agent-facing packet/search commands. + #[cfg(test)] pub(crate) fn new_agent_sidecar(args: &ProjectArgs) -> Result { Self::new_agent_sidecar_with_selection(args, None, None) } + pub(crate) fn new_agent_sidecar_with_startup( + args: &ProjectArgs, + startup: &crate::config::CliStartupConfig, + ) -> Result { + Self::new_agent_sidecar_with_startup_and_selection(args, startup, None, None) + } + pub(crate) fn new_agent_sidecar_with_selection( args: &ProjectArgs, profile: Option, run_id: Option<&str>, ) -> Result { - let mut context = Self::new(args)?; + Self::new_agent_sidecar_with_startup_and_selection( + args, + &crate::config::process_startup_config(), + profile, + run_id, + ) + } + + fn new_agent_sidecar_with_startup_and_selection( + args: &ProjectArgs, + startup: &crate::config::CliStartupConfig, + profile: Option, + run_id: Option<&str>, + ) -> Result { + let mut context = Self::new_with_startup(args, startup)?; if profile.is_some() || run_id.is_some() { let selected = profile .map(Into::into) @@ -136,23 +158,35 @@ impl RuntimeContext { /// Open runtime services without starting managed embedding processes. pub(crate) fn new_inspect_only(args: &ProjectArgs) -> Result { - Self::new_with_startup(args) + Self::new(args) } - fn new_with_startup(args: &ProjectArgs) -> Result { + fn new_with_startup( + args: &ProjectArgs, + startup: &crate::config::CliStartupConfig, + ) -> Result { #[cfg(test)] codestory_retrieval::enable_automatic_test_cache_root_for_process(); let project_root = canonicalize_project_root(&args.project)?; - let config = crate::config::load_config(&project_root)?; + let config = crate::config::load_config_with_startup(&project_root, startup)?; let cache_override = args.cache_dir.clone().or_else(|| config.cache_dir.clone()); - let cache_root = cache_root_for_project(&project_root, cache_override.as_deref())?; - let storage_path = cache_root.join("codestory.db"); - let defaults = crate::config::process_runtime_defaults(); - let sidecar = codestory_retrieval::SidecarRuntimeConfig::for_project_auto_with_defaults( + let process_cache_root = startup + .stdio_cache_root + .as_deref() + .unwrap_or(&startup.user_cache_root); + let cache_root = cache_root_for_project_in( &project_root, - &defaults, - &config.runtime_overrides(), - ); + cache_override.as_deref(), + process_cache_root, + )?; + let storage_path = cache_root.join("codestory.db"); + let sidecar = + codestory_retrieval::SidecarRuntimeConfig::for_project_auto_with_defaults_in_cache( + &project_root, + process_cache_root, + &startup.runtime_defaults, + &config.runtime_overrides(), + ); let runtime = Runtime::new_with_config(sidecar.clone()); let events = runtime.events(); Ok(Self { @@ -464,14 +498,26 @@ pub(crate) fn canonicalize_project_root(project: &Path) -> Result { /// /// Explicit overrides are returned unchanged; otherwise the cache root is a /// stable hash of the canonical project path under the platform cache directory. +#[cfg(test)] pub(crate) fn cache_root_for_project( project_root: &Path, override_dir: Option<&Path>, +) -> Result { + cache_root_for_project_in( + project_root, + override_dir, + &codestory_retrieval::user_cache_root(), + ) +} + +fn cache_root_for_project_in( + project_root: &Path, + override_dir: Option<&Path>, + process_cache_root: &Path, ) -> Result { match override_dir { Some(path) => Ok(path.to_path_buf()), - None => Ok(codestory_retrieval::user_cache_root() - .join(fnv1a_hex(project_root.to_string_lossy().as_bytes()))), + None => Ok(process_cache_root.join(fnv1a_hex(project_root.to_string_lossy().as_bytes()))), } } @@ -941,6 +987,76 @@ mod tests { assert_eq!(runtime.storage_path, cli_cache.join("codestory.db")); } + #[test] + fn explicit_startup_snapshots_isolate_concurrent_runtime_paths_and_endpoints() { + let temp = tempdir().expect("temp dir"); + let first_project = temp.path().join("first-project"); + let second_project = temp.path().join("second-project"); + let first_cache = temp.path().join("first-cache"); + let second_cache = temp.path().join("second-cache"); + fs::create_dir_all(&first_project).expect("create first project"); + fs::create_dir_all(&second_project).expect("create second project"); + fs::write( + first_project.join(".codestory.toml"), + r#"embedding_endpoint = "http://127.0.0.1:41001/v1/embeddings""#, + ) + .expect("write first config"); + fs::write( + second_project.join(".codestory.toml"), + r#"embedding_endpoint = "http://127.0.0.1:41002/v1/embeddings""#, + ) + .expect("write second config"); + let startup = |cache_root: &Path| crate::config::CliStartupConfig { + user_home: None, + project_network_config_allowed: true, + user_cache_root: cache_root.to_path_buf(), + stdio_cache_root: Some(cache_root.to_path_buf()), + runtime_defaults: codestory_retrieval::SidecarRuntimeDefaults::default(), + }; + let first_startup = startup(&first_cache); + let second_startup = startup(&second_cache); + + let (first, second) = std::thread::scope(|scope| { + let first = scope.spawn(|| { + RuntimeContext::new_agent_sidecar_with_startup( + &ProjectArgs { + project: first_project.clone(), + cache_dir: None, + }, + &first_startup, + ) + .expect("first runtime") + }); + let second = scope.spawn(|| { + RuntimeContext::new_agent_sidecar_with_startup( + &ProjectArgs { + project: second_project.clone(), + cache_dir: None, + }, + &second_startup, + ) + .expect("second runtime") + }); + ( + first.join().expect("first runtime worker"), + second.join().expect("second runtime worker"), + ) + }); + + assert!(first.storage_path.starts_with(&first_cache)); + assert!(second.storage_path.starts_with(&second_cache)); + assert!(first.sidecar.layout.state_file.starts_with(&first_cache)); + assert!(second.sidecar.layout.state_file.starts_with(&second_cache)); + assert_eq!( + first.sidecar.embedding.endpoint, + "http://127.0.0.1:41001/v1/embeddings" + ); + assert_eq!( + second.sidecar.embedding.endpoint, + "http://127.0.0.1:41002/v1/embeddings" + ); + } + #[test] fn agent_sidecar_runtime_defaults_to_bundled_llamacpp() { let _env_lock = crate::config::config_env_test_lock(); diff --git a/crates/codestory-cli/src/stdio_transport.rs b/crates/codestory-cli/src/stdio_transport.rs index e59e0fbd..8a309d95 100644 --- a/crates/codestory-cli/src/stdio_transport.rs +++ b/crates/codestory-cli/src/stdio_transport.rs @@ -385,6 +385,7 @@ struct StdioServerSession { runtime: Option, state: StdioServerState, project_required: bool, + startup: crate::config::CliStartupConfig, } impl StdioServerSession { @@ -393,6 +394,7 @@ impl StdioServerSession { project_required: runtime.is_none(), runtime, state: StdioServerState::default(), + startup: crate::config::process_startup_config(), } } @@ -430,17 +432,18 @@ impl StdioServerSession { return Ok(()); } - let cache_dir = std::env::var_os("CODESTORY_STDIO_CACHE_ROOT") - .map(PathBuf::from) - .map(|root| { - root.join(crate::runtime::fnv1a_hex( - project_root.to_string_lossy().as_bytes(), - )) - }); - let runtime = RuntimeContext::new_agent_sidecar(&args::ProjectArgs { - project: project_root, - cache_dir, - })?; + let cache_dir = self.startup.stdio_cache_root.as_ref().cloned().map(|root| { + root.join(crate::runtime::fnv1a_hex( + project_root.to_string_lossy().as_bytes(), + )) + }); + let runtime = RuntimeContext::new_agent_sidecar_with_startup( + &args::ProjectArgs { + project: project_root, + cache_dir, + }, + &self.startup, + )?; runtime.ensure_open(args::RefreshMode::None)?; self.runtime = Some(runtime); // ponytail: stdio is serialized, so retain only the active project's small caches; @@ -841,9 +844,13 @@ fn activate_stdio_project(runtime: &RuntimeContext, state: &mut StdioServerState let project = stdio_project_args(runtime); let inspect_runtime = RuntimeContext::new_inspect_only(&project)?; let summary = inspect_runtime.open_project_summary()?; + let agent_sidecar = stdio_agent_sidecar_for_runtime(runtime); if crate::local_freshness_needs_refresh(&summary) - && crate::ready_repair_status::active_ready_repair_status(&runtime.project_root, None) - .is_none() + && crate::ready_repair_status::active_ready_repair_status_for_sidecar( + &runtime.project_root, + &agent_sidecar, + ) + .is_none() { let (_, refresh) = wait_for_stdio_local_freshness(&project, &summary)?; state.recent_local_refresh = refresh; @@ -854,7 +861,7 @@ fn activate_stdio_project(runtime: &RuntimeContext, state: &mut StdioServerState if stdio_agent_activation_needs_repair(&status) { let fingerprint = stdio_agent_repair_input_fingerprint(&status); if stdio_auto_repair_retry_blocked( - &runtime.project_root, + &agent_sidecar, &fingerprint, crate::ready_repair_status::now_epoch_ms(), stdio_auto_repair_retry_cooldown(), @@ -901,12 +908,12 @@ fn stdio_agent_repair_input_fingerprint(status: &serde_json::Value) -> String { } fn stdio_auto_repair_retry_blocked( - project_root: &Path, + sidecar: &codestory_retrieval::SidecarRuntimeConfig, fingerprint: &str, now_epoch_ms: i64, cooldown: Duration, ) -> bool { - crate::ready_repair_status::read_ready_repair_worker_result(project_root, None).is_some_and( + crate::ready_repair_status::read_ready_repair_worker_result_for_sidecar(sidecar).is_some_and( |result| stdio_auto_repair_result_blocks(&result, fingerprint, now_epoch_ms, cooldown), ) } @@ -2923,8 +2930,11 @@ fn read_stdio_status_resource_uncached( let project = stdio_project_args(runtime); let inspect_runtime = RuntimeContext::new_inspect_only(&project)?; let summary = inspect_runtime.open_project_summary()?; - let active_agent_repair = - crate::ready_repair_status::active_ready_repair_status(&runtime.project_root, None); + let agent_sidecar = stdio_agent_sidecar_for_runtime(runtime); + let active_agent_repair = crate::ready_repair_status::active_ready_repair_status_for_sidecar( + &runtime.project_root, + &agent_sidecar, + ); let recent_local_refresh = state.recent_local_refresh.take(); let local_refresh = if let Some(active_repair) = active_agent_repair.as_ref() { if crate::local_freshness_needs_refresh(&summary) { @@ -2961,18 +2971,34 @@ fn read_stdio_status_resource_uncached( .as_ref() .and_then(|publication| serde_json::to_value(publication).ok()) .unwrap_or(serde_json::Value::Null); - let value = stdio_status_with_recent_sidecar_repair( + let value = stdio_status_with_recent_sidecar_repair_for_sidecar( read_stdio_status_resource(runtime, summary, local_refresh, index_publication)?, &mut state.recent_sidecar_repair, &runtime.project_root, + &agent_sidecar, ); Ok(value) } +#[cfg(test)] fn stdio_status_with_recent_sidecar_repair( + status: serde_json::Value, + recent: &mut Option, + project_root: &Path, +) -> serde_json::Value { + let sidecar = codestory_retrieval::sidecar_runtime_for_project_with_run_id( + project_root, + codestory_retrieval::SidecarProfile::Agent, + Some(codestory_retrieval::DEFAULT_AGENT_RUN_ID), + ); + stdio_status_with_recent_sidecar_repair_for_sidecar(status, recent, project_root, &sidecar) +} + +fn stdio_status_with_recent_sidecar_repair_for_sidecar( mut status: serde_json::Value, recent: &mut Option, project_root: &Path, + sidecar: &codestory_retrieval::SidecarRuntimeConfig, ) -> serde_json::Value { let Some(repair) = recent.as_ref() else { return status; @@ -2980,11 +3006,9 @@ fn stdio_status_with_recent_sidecar_repair( let same_project = repair.project_root == project_root; let within_ttl = repair.observed_at.elapsed() <= STDIO_RECENT_REPAIR_TTL; let pid_alive = crate::ready_repair_status::process_is_running(repair.pid); - let durable_active = crate::ready_repair_status::active_ready_repair_status( - project_root, - Some(repair.run_id.as_str()), - ) - .is_some(); + let durable_active = + crate::ready_repair_status::active_ready_repair_status_for_sidecar(project_root, sidecar) + .is_some_and(|status| status.run_id.as_deref() == Some(repair.run_id.as_str())); if !same_project || !within_ttl || !(pid_alive || durable_active) { *recent = None; return status; @@ -3183,8 +3207,8 @@ fn stdio_status_cache_key_with_publication(runtime: &RuntimeContext, publication ), format!( "repair_state:{}", - crate::ready_repair_status::ready_repair_status_cache_fingerprint( - &runtime.project_root + crate::ready_repair_status::ready_repair_status_cache_fingerprint_for_sidecar( + &stdio_agent_sidecar_for_runtime(runtime) ) ), format!( @@ -4002,7 +4026,7 @@ fn build_stdio_status_readiness( local.minimum_next.clear(); local.full_repair.clear(); } - let sidecar_setup = stdio_sidecar_setup_status(&runtime.project_root); + let sidecar_setup = stdio_sidecar_setup_status_for_runtime(runtime); let readiness_lanes = crate::build_readiness_lanes_for_runtime( runtime, &readiness, @@ -4053,7 +4077,8 @@ fn build_stdio_status_broker( runtime: &RuntimeContext, selected_agent_sidecar: &args::DoctorSidecarStatusOutput, ) -> StdioStatusBrokerParts { - let readiness_broker = crate::readiness_broker::observe_broker_snapshot( + let agent_sidecar = stdio_agent_sidecar_for_runtime(runtime); + let readiness_broker = crate::readiness_broker::observe_broker_snapshot_for_sidecar( crate::readiness_broker::BrokerSnapshotInput { project_root: runtime.project_root.clone(), cache_root: runtime.cache_root.clone(), @@ -4064,6 +4089,7 @@ fn build_stdio_status_broker( )), reconciliation: None, }, + &agent_sidecar, ); let readiness_broker_json = serde_json::to_value(&readiness_broker).expect("serialize readiness broker"); @@ -4906,6 +4932,33 @@ fn stdio_sidecar_policy_updated_at(policy: Option<&serde_json::Value>) -> Option } pub(crate) fn stdio_sidecar_setup_status(project_root: &Path) -> serde_json::Value { + let sidecar = codestory_retrieval::sidecar_runtime_for_project_with_run_id( + project_root, + codestory_retrieval::SidecarProfile::Agent, + Some(codestory_retrieval::DEFAULT_AGENT_RUN_ID), + ); + stdio_sidecar_setup_status_with_sidecar(project_root, &sidecar) +} + +fn stdio_sidecar_setup_status_for_runtime(runtime: &RuntimeContext) -> serde_json::Value { + let sidecar = stdio_agent_sidecar_for_runtime(runtime); + stdio_sidecar_setup_status_with_sidecar(&runtime.project_root, &sidecar) +} + +fn stdio_agent_sidecar_for_runtime( + runtime: &RuntimeContext, +) -> codestory_retrieval::SidecarRuntimeConfig { + runtime.sidecar.with_profile_and_run_id( + Some(&runtime.project_root), + codestory_retrieval::SidecarProfile::Agent, + Some(codestory_retrieval::DEFAULT_AGENT_RUN_ID), + ) +} + +fn stdio_sidecar_setup_status_with_sidecar( + project_root: &Path, + sidecar: &codestory_retrieval::SidecarRuntimeConfig, +) -> serde_json::Value { let policy = stdio_sidecar_policy_file(); let state = stdio_sidecar_policy_state_from_file(policy.as_ref()); let prompt_required = matches!(state, "ask"); @@ -4923,19 +4976,30 @@ pub(crate) fn stdio_sidecar_setup_status(project_root: &Path) -> serde_json::Val ); let next_repair_command = env_nonempty("CODESTORY_PLUGIN_SIDECAR_NEXT_REPAIR_COMMAND").unwrap_or(default_repair); - let active_repair = crate::ready_repair_status::active_ready_repair_status(project_root, None); + let active_repair = + crate::ready_repair_status::active_ready_repair_status_for_sidecar(project_root, sidecar); let stale_live_repair = active_repair .as_ref() .is_none() - .then(|| crate::ready_repair_status::stale_live_ready_repair_status(project_root, None)) + .then(|| { + crate::ready_repair_status::stale_live_ready_repair_status_for_sidecar( + project_root, + sidecar, + ) + }) .flatten(); let abandoned_repair = active_repair .as_ref() .is_none() - .then(|| crate::ready_repair_status::abandoned_ready_repair_status(project_root, None)) + .then(|| { + crate::ready_repair_status::abandoned_ready_repair_status_for_sidecar( + project_root, + sidecar, + ) + }) .flatten(); let last_worker_result = - crate::ready_repair_status::read_ready_repair_worker_result(project_root, None); + crate::ready_repair_status::read_ready_repair_worker_result_for_sidecar(sidecar); let last_repair_command = env_nonempty("CODESTORY_PLUGIN_SIDECAR_LAST_REPAIR_COMMAND"); let active_cli_version = env_nonempty("CODESTORY_PLUGIN_CLI_VERSION") .unwrap_or_else(|| env!("CARGO_PKG_VERSION").to_string()); @@ -5092,7 +5156,7 @@ fn handle_stdio_sidecar_setup( if let Some(mismatch) = mismatch.as_ref() { return serde_json::json!({"result": stdio_workspace_mismatch_sidecar_setup(mismatch)}); } - serde_json::json!({"result": stdio_sidecar_setup_status(&runtime.project_root)}) + serde_json::json!({"result": stdio_sidecar_setup_status_for_runtime(runtime)}) } "enable" | "disable" | "ask" => match stdio_write_sidecar_policy(action) { Ok(()) => { @@ -5100,7 +5164,7 @@ fn handle_stdio_sidecar_setup( if let Some(mismatch) = mismatch.as_ref() { return serde_json::json!({"result": stdio_workspace_mismatch_sidecar_setup(mismatch)}); } - serde_json::json!({"result": stdio_sidecar_setup_status(&runtime.project_root)}) + serde_json::json!({"result": stdio_sidecar_setup_status_for_runtime(runtime)}) } Err(error) => serde_json::json!({"error": error.to_string()}), }, @@ -5169,9 +5233,11 @@ fn handle_stdio_sidecar_repair( if let Some(error) = stdio_workspace_mismatch_error(runtime) { return serde_json::json!({"error": error}); } - if let Some(status) = - crate::ready_repair_status::active_ready_repair_status(&runtime.project_root, None) - { + let repair_sidecar = stdio_agent_sidecar_for_runtime(runtime); + if let Some(status) = crate::ready_repair_status::active_ready_repair_status_for_sidecar( + &runtime.project_root, + &repair_sidecar, + ) { let run_id = status .run_id .as_deref() @@ -5201,20 +5267,43 @@ fn handle_stdio_sidecar_repair( "tool": "status", "arguments": {"project": project} }], - "sidecar_setup": stdio_sidecar_setup_status(&runtime.project_root) + "sidecar_setup": stdio_sidecar_setup_status_for_runtime(runtime) } }); } - let sidecar_setup = stdio_sidecar_setup_status(&runtime.project_root); + let sidecar_setup = stdio_sidecar_setup_status_for_runtime(runtime); if let Some(result) = stdio_sidecar_repair_policy_block_result(&sidecar_setup) { return result; } - let repair_sidecar = runtime.sidecar.with_profile_and_run_id( - Some(&runtime.project_root), - codestory_retrieval::SidecarProfile::Agent, - Some(codestory_retrieval::DEFAULT_AGENT_RUN_ID), - ); - let broker_snapshot = crate::readiness_broker::observe_broker_snapshot( + if let Some(stale) = crate::ready_repair_status::stale_live_ready_repair_status_for_sidecar( + &runtime.project_root, + &repair_sidecar, + ) { + let reconciliation = crate::readiness_broker::BrokerReconciliationSnapshot { + status: "orphan_unresolved".to_string(), + cleanup_performed: false, + stale_status_paths_removed: Vec::new(), + stale_lock_paths_removed: Vec::new(), + abandoned_repairs: Vec::new(), + local_refresh_cleanups: Vec::new(), + active_repair: None, + unresolved_orphan_reason: Some(format!( + "live_ready_repair_heartbeat_stale:pid={}:phase={}", + stale.pid, stale.phase + )), + }; + let reconciliation_json = + serde_json::to_value(&reconciliation).expect("serialize broker reconciliation"); + return stdio_sidecar_repair_reconciliation_block_result( + &runtime.project_root, + &repair_sidecar, + &sidecar_setup, + &reconciliation, + &reconciliation_json, + ) + .expect("stale live repair must block enqueue"); + } + let broker_snapshot = crate::readiness_broker::observe_broker_snapshot_for_sidecar( crate::readiness_broker::BrokerSnapshotInput { project_root: runtime.project_root.clone(), cache_root: runtime.cache_root.clone(), @@ -5223,6 +5312,7 @@ fn handle_stdio_sidecar_repair( gpu_proof: None, reconciliation: None, }, + &repair_sidecar, ); if let Some(busy) = stdio_native_embedding_resource_hard_busy( &runtime.project_root, @@ -5231,10 +5321,10 @@ fn handle_stdio_sidecar_repair( ) { return stdio_sidecar_repair_machine_busy_result(busy, &sidecar_setup); } - let broker_reconciliation = crate::readiness_broker::reconcile_before_enqueue( + let broker_reconciliation = crate::readiness_broker::reconcile_before_enqueue_for_sidecar( &runtime.project_root, &runtime.cache_root, - Some(codestory_retrieval::DEFAULT_AGENT_RUN_ID), + &repair_sidecar, env!("CARGO_PKG_VERSION"), ); let previous_abandoned_repair = broker_reconciliation @@ -5311,7 +5401,7 @@ fn handle_stdio_sidecar_repair( repair_started_at_epoch_ms, auto_retry_fingerprint, ); - let broker_snapshot = crate::readiness_broker::refresh_broker_snapshot( + let broker_snapshot = crate::readiness_broker::refresh_broker_snapshot_for_sidecar( crate::readiness_broker::BrokerSnapshotInput { project_root: runtime.project_root.clone(), cache_root: runtime.cache_root.clone(), @@ -5320,6 +5410,7 @@ fn handle_stdio_sidecar_repair( gpu_proof: None, reconciliation: None, }, + &repair_sidecar, ); state.recent_sidecar_repair = Some(StdioRecentSidecarRepair { project_root: runtime.project_root.clone(), @@ -5356,7 +5447,7 @@ fn handle_stdio_sidecar_repair( "tool": "status", "arguments": {"project": project} }], - "sidecar_setup": stdio_sidecar_setup_status(&runtime.project_root) + "sidecar_setup": stdio_sidecar_setup_status_for_runtime(runtime) } }) } diff --git a/crates/codestory-cli/tests/stdio_protocol_contracts.rs b/crates/codestory-cli/tests/stdio_protocol_contracts.rs index 29ca013b..4d0bfd55 100644 --- a/crates/codestory-cli/tests/stdio_protocol_contracts.rs +++ b/crates/codestory-cli/tests/stdio_protocol_contracts.rs @@ -1365,7 +1365,7 @@ fn test_sidecar_runtime(project: &Path, run_id: &str) -> codestory_retrieval::Si Some(project), codestory_retrieval::SidecarProfile::Agent, Some(run_id), - &test_support::test_state_root().join("cache"), + &test_support::test_state_root().join("stdio-cache"), ) } @@ -1733,7 +1733,7 @@ fn multi_project_stdio_routes_interleaved_requests_by_explicit_project() { } #[test] -fn multi_project_stdio_keeps_project_embedding_endpoints_isolated_across_a_b_a() { +fn multi_project_stdio_startup_snapshot_keeps_embedding_endpoints_isolated_across_a_b_a() { let first = tempfile::tempdir().expect("first workspace"); let second = tempfile::tempdir().expect("second workspace"); let cache_root = tempfile::tempdir().expect("multi-project cache root"); @@ -4177,6 +4177,17 @@ fn tools_call_sidecar_setup_records_successful_worker_terminal_state() { fs::canonicalize(fixture.workspace.path()).expect("canonical fixture root"); let repair_sidecar = test_sidecar_runtime(&canonical_root, codestory_retrieval::DEFAULT_AGENT_RUN_ID); + let mutable_cache_sidecar = + codestory_retrieval::SidecarRuntimeConfig::for_project_profile_with_run_id_in_cache( + Some(&canonical_root), + codestory_retrieval::SidecarProfile::Agent, + Some(codestory_retrieval::DEFAULT_AGENT_RUN_ID), + &test_support::test_state_root().join("cache"), + ); + assert_ne!( + repair_sidecar.layout.state_file, mutable_cache_sidecar.layout.state_file, + "the regression contract requires distinct retained and mutable cache roots" + ); let mut server = spawn_stdio_server(&fixture); let response = send_json( @@ -4218,6 +4229,16 @@ fn tools_call_sidecar_setup_records_successful_worker_terminal_state() { .is_some_and(|tail| tail.contains(&attempt_id)), "{setup}" ); + let retained_result_path = repair_sidecar + .layout + .state_file + .with_file_name("ready-repair-result.json"); + let retained_result: Value = serde_json::from_str( + &fs::read_to_string(&retained_result_path).expect("retained repair worker result"), + ) + .expect("retained repair worker result json"); + assert_eq!(retained_result["attempt_id"], json!(attempt_id)); + assert_eq!(retained_result["outcome"], json!("succeeded")); assert!( !repair_sidecar .layout @@ -4232,6 +4253,20 @@ fn tools_call_sidecar_setup_records_successful_worker_terminal_state() { .with_file_name("ready-repair-status.json") .exists() ); + for file_name in [ + "ready-repair-result.json", + "ready-repair-status.json", + "ready-repair-enqueue.lock", + ] { + assert!( + !mutable_cache_sidecar + .layout + .state_file + .with_file_name(file_name) + .exists(), + "stdio repair state must not leak into the mutable cache root: {file_name}" + ); + } } #[test] diff --git a/crates/codestory-retrieval/src/config.rs b/crates/codestory-retrieval/src/config.rs index a931b7dd..6096ac9d 100644 --- a/crates/codestory-retrieval/src/config.rs +++ b/crates/codestory-retrieval/src/config.rs @@ -437,11 +437,26 @@ impl SidecarRuntimeConfig { project_root: &Path, defaults: &SidecarRuntimeDefaults, overrides: &SidecarRuntimeOverrides, + ) -> Self { + Self::for_project_auto_with_defaults_in_cache( + project_root, + &user_cache_root(), + defaults, + overrides, + ) + } + + #[doc(hidden)] + pub fn for_project_auto_with_defaults_in_cache( + project_root: &Path, + cache_root: &Path, + defaults: &SidecarRuntimeDefaults, + overrides: &SidecarRuntimeOverrides, ) -> Self { let explicit_profile = env_profile(defaults); let env_run_id = env_agent_run_id(defaults); let latest_run_id = if explicit_profile.is_none() && env_run_id.is_none() { - latest_agent_run_id(project_root) + latest_agent_run_id_in_cache(project_root, cache_root) } else { None }; @@ -451,10 +466,11 @@ impl SidecarRuntimeConfig { latest_run_id, running_in_ci_agent(defaults), ); - Self::for_project_profile_with_run_id_defaults_and_overrides( + Self::for_project_profile_with_run_id_in_cache_defaults_and_overrides( Some(project_root), profile, run_id.as_deref(), + cache_root, defaults, overrides, ) @@ -1169,10 +1185,6 @@ fn agent_namespace_prefix(project_root: &Path) -> String { format!("codestory-agent-{}-", project_hash(project_root)) } -fn latest_agent_run_id(project_root: &Path) -> Option { - latest_agent_run_id_in_cache(project_root, &user_cache_root()) -} - fn latest_agent_run_id_in_cache(project_root: &Path, cache_root: &Path) -> Option { let prefix = agent_namespace_prefix(project_root); let sidecars_root = cache_root.join("sidecars"); diff --git a/docs/architecture/subsystems/cli.md b/docs/architecture/subsystems/cli.md index 704bb8d4..b3635e99 100644 --- a/docs/architecture/subsystems/cli.md +++ b/docs/architecture/subsystems/cli.md @@ -38,11 +38,13 @@ index path when embedding assets are available. ## Configuration Files -The CLI captures process environment defaults once, then loads optional -`.codestory.toml` defaults from the user home directory and the selected project -root. It merges those values into an immutable `SidecarRuntimeConfig` retained -by that project runtime; it never publishes project choices back through the -process environment. This is required for multi-project stdio: switching A/B/A +The CLI captures process environment defaults once, including the user config +home, project-network opt-in, and multi-project stdio cache root, then loads +optional `.codestory.toml` defaults from the captured user home directory and +the selected project root. It merges those values into an immutable +`SidecarRuntimeConfig` retained by that project runtime; it never publishes +project choices back through the process environment. This is required for +multi-project stdio: switching A/B/A must keep each project's endpoint, model, prefix, retrieval policy, and summary settings isolated. Cache roots, network endpoints, model selectors for source-egress calls, credentials, and source-text egress settings must come from @@ -50,7 +52,8 @@ trusted user config, explicit environment variables, or CLI options; project files cannot set `cache_dir`, `summary_endpoint`, `summary_model`, or embedding endpoint fields unless `CODESTORY_ALLOW_PROJECT_NETWORK_CONFIG=1` is set deliberately for that process. Explicit environment values have highest -precedence without being mutated. +precedence without being mutated or re-read when a stdio request switches +projects. Embedding config keys map to the runtime env names: diff --git a/docs/users/cli-reference.md b/docs/users/cli-reference.md index 3db33c1a..b1160d02 100644 --- a/docs/users/cli-reference.md +++ b/docs/users/cli-reference.md @@ -102,8 +102,9 @@ file loads first; project file overrides for project-safe preferences. Environment variables win over files. Configuration is resolved independently for each project and retained for the -life of that project runtime. Multi-project stdio does not rewrite process -environment variables when requests switch repositories. Trusted project files +life of that project runtime. Multi-project stdio captures the user home, +project-network opt-in, cache root, and runtime environment once; it neither +rewrites nor re-reads them when requests switch repositories. Trusted project files may also set `embedding_query_prefix` and `embedding_document_prefix` as part of their per-project embedding contract.