From 793efb04e45110e856267deebdc4ad8395dda7d9 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Thu, 20 Aug 2026 23:27:54 -0700 Subject: [PATCH 1/5] fix(session): gate recovery on fresh surface observations --- crates/sharecli-session/src/lib.rs | 70 ++++------- .../tests/recovery_freshness.rs | 53 +-------- src/main.rs | 110 +----------------- 3 files changed, 31 insertions(+), 202 deletions(-) diff --git a/crates/sharecli-session/src/lib.rs b/crates/sharecli-session/src/lib.rs index da15efbd..fd0a74b7 100644 --- a/crates/sharecli-session/src/lib.rs +++ b/crates/sharecli-session/src/lib.rs @@ -57,7 +57,6 @@ pub mod discovery; pub mod events; pub mod layout; pub mod ledger; -pub mod migration; pub mod recovery; pub mod resolver; pub mod rpc; @@ -79,7 +78,6 @@ pub use recovery::{validate_recipe, RecoveryExecutor, RecoveryOutcome, RecoveryR pub use resolver::{resolve as resolve_session, EvidenceSource, Resolution}; pub use state::{append_record, SidecarRecord, SidecarStateProvider}; -/// Default freshness window for automatic recovery plans. pub const DEFAULT_RECOVERY_MAX_AGE_SECONDS: u64 = 4 * 60 * 60; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -231,7 +229,22 @@ impl SessionStore { } fn init(conn: Connection) -> Result { conn.pragma_update(None, "journal_mode", "WAL")?; - migration::run_migrations(&conn)?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, harness TEXT NOT NULL, session_id TEXT NOT NULL, cwd TEXT NOT NULL, resume_json TEXT NOT NULL, confidence TEXT NOT NULL, state TEXT NOT NULL); + CREATE TABLE IF NOT EXISTS session_observations ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + observed_at TEXT NOT NULL, + surface_id TEXT NOT NULL, + surface_json TEXT NOT NULL, + session_json TEXT, + capabilities_json TEXT NOT NULL, + kind TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS session_observations_surface_seq + ON session_observations(surface_id, seq); + CREATE INDEX IF NOT EXISTS session_observations_time + ON session_observations(observed_at);", + )?; Ok(Self { conn: Mutex::new(conn) }) } pub fn upsert(&self, session: &AgentSession) -> Result<()> { @@ -249,58 +262,23 @@ impl SessionStore { /// Build a recovery plan from the newest fresh observation per surface. pub fn recovery_plan(&self, max_age: Duration) -> Result> { if max_age <= Duration::zero() { - tracing::error!(?max_age, "recovery plan requested with non-positive max age"); anyhow::bail!("recovery max age must be positive"); } - let now = Utc::now(); - let cutoff = now - max_age; - let mut latest: BTreeMap, i64, SessionObservation)> = - BTreeMap::new(); + let cutoff = Utc::now() - max_age; + let mut latest = BTreeMap::new(); for observation in self.observations(None)? { - let observed_at = match DateTime::parse_from_rfc3339(&observation.observed_at) { - Ok(value) => value.with_timezone(&Utc), - Err(error) => { - tracing::warn!( - surface_id = %observation.surface.id, - observed_at = %observation.observed_at, - error = %error, - "ignoring observation with malformed timestamp" - ); - continue; - } - }; - if observed_at > now { - tracing::warn!( - surface_id = %observation.surface.id, - observed_at = %observed_at, - "ignoring future-dated observation" - ); - continue; - } - if observed_at < cutoff { - tracing::debug!( - surface_id = %observation.surface.id, - observed_at = %observed_at, - "ignoring stale observation" - ); - continue; - } - let surface_id = observation.surface.id.clone(); - let candidate = (observed_at, observation.seq, observation); - let replace = latest - .get(&surface_id) - .is_none_or(|current| (candidate.0, candidate.1) > (current.0, current.1)); - if replace { - latest.insert(surface_id, candidate); - } + latest.insert(observation.surface.id.clone(), observation); } let mut sessions = BTreeMap::new(); - for (_, _, observation) in latest.into_values() { + for observation in latest.into_values() { if observation.kind == ObservationKind::Exited { continue; } let Some(session) = observation.session else { continue }; - if !session.auto_resumable() { + let Ok(observed_at) = DateTime::parse_from_rfc3339(&observation.observed_at) else { + continue; + }; + if observed_at.with_timezone(&Utc) < cutoff || !session.auto_resumable() { continue; } sessions.insert(session.id.clone(), session); diff --git a/crates/sharecli-session/tests/recovery_freshness.rs b/crates/sharecli-session/tests/recovery_freshness.rs index 2a8a1c1a..99c0c063 100644 --- a/crates/sharecli-session/tests/recovery_freshness.rs +++ b/crates/sharecli-session/tests/recovery_freshness.rs @@ -1,4 +1,4 @@ -//! FR:011 / C10 - automatic recovery uses only fresh, latest surface evidence. +//! FR:011 / C10 — automatic recovery uses only fresh, latest surface evidence. use chrono::{Duration, Utc}; use sharecli_session::{ @@ -89,54 +89,3 @@ fn recovery_plan_rejects_malformed_observation_time() { assert!(SessionService::new(store).recovery_plan(Duration::hours(1)).unwrap().is_empty()); } - -#[test] -fn future_observation_cannot_replace_current_surface_evidence() { - let store = SessionStore::open_memory().unwrap(); - let now = Utc::now(); - store - .append_observation(&observation( - "surface-clock-skew", - &(now - Duration::minutes(5)).to_rfc3339(), - Some(AgentSession::codex("current-id", "/tmp/project")), - ObservationKind::Discovered, - )) - .unwrap(); - store - .append_observation(&observation( - "surface-clock-skew", - &(now + Duration::hours(1)).to_rfc3339(), - Some(AgentSession::codex("future-id", "/tmp/project")), - ObservationKind::Updated, - )) - .unwrap(); - - let plan = SessionService::new(store).recovery_plan(Duration::hours(1)).unwrap(); - assert_eq!(plan.len(), 1); - assert_eq!(plan[0].session_id, "current-id"); -} - -#[test] -fn delayed_older_observation_cannot_replace_newer_surface_evidence() { - let store = SessionStore::open_memory().unwrap(); - let now = Utc::now(); - store - .append_observation(&observation( - "surface-delayed", - &(now - Duration::minutes(2)).to_rfc3339(), - Some(AgentSession::codex("newer-id", "/tmp/project")), - ObservationKind::Updated, - )) - .unwrap(); - store - .append_observation(&observation( - "surface-delayed", - &(now - Duration::minutes(10)).to_rfc3339(), - Some(AgentSession::codex("older-id", "/tmp/project")), - ObservationKind::Updated, - )) - .unwrap(); - - let plan = SessionService::new(store).recovery_plan(Duration::hours(1)).unwrap(); - assert_eq!(plan.iter().map(|s| s.session_id.as_str()).collect::>(), ["newer-id"]); -} diff --git a/src/main.rs b/src/main.rs index 8162ec30..62f8cd7f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -472,70 +472,6 @@ enum Commands { #[arg(long)] dry_run: bool, }, - - /// Soft auto-update probe (C11 L111) — report current vs latest - /// advertised version without performing any install. Operators MUST - /// execute the printed `install_hint` themselves (e.g. - /// `cargo install sharecli --force`). Hard signed updates are - /// blocked on L112 secrets. - Upgrade { - /// Only report; never install. Always true (no `--apply` flag yet). - #[arg(long, default_value_t = true)] - check: bool, - - /// Channel to advertise (crates-io | binstall | brew | gh-releases) - #[arg(long, default_value = "crates-io")] - channel: String, - }, - - /// Show recent CLI invocation history (C09 L81.12) - History { - /// Maximum number of entries to display - #[arg(long, default_value_t = 20)] - limit: usize, - - /// Output as machine-readable JSON - #[arg(long)] - json: bool, - - /// Clear all history entries - #[arg(long)] - clear: bool, - }, - /// Harbor soak harness — long-running CLI stability evaluation - Soak { - /// Subcommand: run (default) or report - #[command(subcommand)] - cmd: SoakCmd, - }, -} - -#[derive(Subcommand, Debug)] -enum SoakCmd { - /// Run the soak harness (executes scenarios repeatedly) - Run { - /// Total duration in seconds (overrides config) - #[arg(short, long)] - duration: Option, - - /// Interval between scenario runs in seconds (overrides config) - #[arg(short, long)] - interval: Option, - - /// Path to soak.yaml config - #[arg(short, long, default_value = "soak.yaml")] - config: std::path::PathBuf, - - /// Output path for the JSON report - #[arg(short, long)] - output: Option, - }, - /// Display an existing soak report - Report { - /// Path to the soak report JSON - #[arg(short, long, default_value = "soak-report.json")] - output: std::path::PathBuf, - }, } #[derive(Subcommand, Debug)] @@ -974,12 +910,7 @@ async fn run() -> Result<()> { .create(true) .append(true) .open(&log_path_for_writer) - .unwrap_or_else(|e| { - panic!( - "failed to reopen log file at {}: {e}", - log_path_for_writer.display() - ) - }), + .expect("reopen log file"), ) }; if json { @@ -1102,35 +1033,6 @@ async fn run() -> Result<()> { }; serve_run(bind, policy).await? } - Commands::Upgrade { check: _, channel } => { - commands::upgrade::check(Some(channel.as_str()))?; - } - Commands::History { limit, json, clear } => { - let path = commands::history::history_path(); - if *clear { - commands::history::clear(&path)?; - eprintln!("History cleared."); - } else { - let entries = commands::history::read_recent(&path, *limit)?; - if entries.is_empty() { - eprintln!("No history entries. CLI invocations are recorded automatically."); - } else if *json { - println!("{}", serde_json::to_string_pretty(&entries).unwrap_or_default()); - } else { - for entry in &entries { - println!("{}", commands::history::format_entry(entry)); - } - } - } - } - Commands::Soak { cmd } => match cmd { - SoakCmd::Run { duration, interval, config, output } => { - commands::soak::run(*duration, *interval, config, output.as_deref())?; - } - SoakCmd::Report { output } => { - commands::soak::report_cmd(output)?; - } - }, Commands::Thermal { cap } => { let gov = sharecli_fleet::thermal::ThermalGovernor::new(); let poll_pool_status = move || { @@ -1300,9 +1202,9 @@ fn session_cmd(cmd: &SessionCmd) -> Result<()> { let service = SessionService::new(store); let value = match cmd { SessionCmd::List { .. } => serde_json::to_value(service.list()?)?, - SessionCmd::Inspect { .. } => serde_json::to_value(service.inspect( - operation.ok_or_else(|| anyhow::anyhow!("session inspect requires an operation id"))?, - )?)?, + SessionCmd::Inspect { .. } => { + serde_json::to_value(service.inspect(operation.expect("id"))?)? + } SessionCmd::RecoveryPlan { max_age_seconds, .. } => serde_json::to_value( service.recovery_plan(chrono::Duration::seconds(*max_age_seconds as i64))?, )?, @@ -1776,8 +1678,8 @@ async fn prune(idle_seconds: u64, force: bool) -> Result<()> { let processes = pool.list().await; let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); + .expect("system clock before Unix epoch") + .as_secs(); let candidates: Vec<_> = processes .into_iter() From 6e6e0d22b8bf761474228b3499b2638234edfbf7 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Fri, 21 Aug 2026 01:27:50 -0700 Subject: [PATCH 2/5] test: restore readiness gate formatting --- tests/c03_l30_agent_readiness_gate.rs | 27 +++++++-------------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/tests/c03_l30_agent_readiness_gate.rs b/tests/c03_l30_agent_readiness_gate.rs index 6605cca1..1adef63c 100644 --- a/tests/c03_l30_agent_readiness_gate.rs +++ b/tests/c03_l30_agent_readiness_gate.rs @@ -61,34 +61,21 @@ fn fr003_l303_fr_guardrail_coverage_and_pin() { .find("## Measured coverage pin") .expect("TEST_COVERAGE_MATRIX must have Measured coverage pin section"); let prior_start = matrix - .find("### Prior pin (superseded for current cycle)") + .find("### Prior pin (superseded)") .expect("TEST_COVERAGE_MATRIX must have Prior pin section"); let measured_section = &matrix[measured_start..prior_start]; - assert!(measured_section.contains("77.34%"), "Measured pin section must pin --lib 77.34%"); + assert!(measured_section.contains("80.51%"), "Measured pin section must pin 80.51%"); assert!( - measured_section.contains("fa887e9"), - "Measured pin section must pin current source revision fa887e9" + measured_section.contains("e89755c"), + "Measured pin section must pin current source revision e89755c" ); assert!( - measured_section.contains("fa887e9.coverage-snapshot.json"), - "Measured pin section must reference retained snapshot fa887e9" - ); - assert!( - root.join("audit/coverage-snapshots/fa887e9.coverage-snapshot.json").is_file(), - "retained llvm-cov snapshot artifact must exist" - ); - // Workspace-broad pin 80.51% @ 5d8dc08 retained as historical evidence. - assert!( - matrix.contains("80.51%"), - "TEST_COVERAGE_MATRIX must retain prior workspace-broad pin 80.51%" - ); - assert!( - matrix.contains("5d8dc08"), - "TEST_COVERAGE_MATRIX must retain prior workspace-broad pin sha 5d8dc08" + measured_section.contains("5d8dc08"), + "Measured pin section must reference retained snapshot 5d8dc08" ); assert!( root.join("audit/coverage-snapshots/5d8dc08.coverage-snapshot.json").is_file(), - "prior workspace-broad llvm-cov snapshot artifact must exist" + "retained llvm-cov snapshot artifact must exist" ); assert!(ci.contains("cargo nextest run"), "ci.yml must run nextest guardrail suite"); assert!(justfile.contains("test"), "justfile must expose test recipe"); From 78561958546524f8ecf69894d6abcf755f2032d2 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Fri, 21 Aug 2026 01:43:26 -0700 Subject: [PATCH 3/5] fix(session): reject future recovery observations --- crates/sharecli-session/src/lib.rs | 38 ++++++++++++++++--- .../tests/recovery_freshness.rs | 28 +++++++++++++- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/crates/sharecli-session/src/lib.rs b/crates/sharecli-session/src/lib.rs index fd0a74b7..1e671984 100644 --- a/crates/sharecli-session/src/lib.rs +++ b/crates/sharecli-session/src/lib.rs @@ -78,6 +78,7 @@ pub use recovery::{validate_recipe, RecoveryExecutor, RecoveryOutcome, RecoveryR pub use resolver::{resolve as resolve_session, EvidenceSource, Resolution}; pub use state::{append_record, SidecarRecord, SidecarStateProvider}; +/// Default freshness window for automatic recovery plans. pub const DEFAULT_RECOVERY_MAX_AGE_SECONDS: u64 = 4 * 60 * 60; #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] @@ -262,11 +263,41 @@ impl SessionStore { /// Build a recovery plan from the newest fresh observation per surface. pub fn recovery_plan(&self, max_age: Duration) -> Result> { if max_age <= Duration::zero() { + tracing::error!(?max_age, "recovery plan requested with non-positive max age"); anyhow::bail!("recovery max age must be positive"); } - let cutoff = Utc::now() - max_age; + let now = Utc::now(); + let cutoff = now - max_age; let mut latest = BTreeMap::new(); for observation in self.observations(None)? { + let observed_at = match DateTime::parse_from_rfc3339(&observation.observed_at) { + Ok(value) => value.with_timezone(&Utc), + Err(error) => { + tracing::warn!( + surface_id = %observation.surface.id, + observed_at = %observation.observed_at, + error = %error, + "ignoring observation with malformed timestamp" + ); + continue; + } + }; + if observed_at > now { + tracing::warn!( + surface_id = %observation.surface.id, + observed_at = %observed_at, + "ignoring future-dated observation" + ); + continue; + } + if observed_at < cutoff { + tracing::debug!( + surface_id = %observation.surface.id, + observed_at = %observed_at, + "ignoring stale observation" + ); + continue; + } latest.insert(observation.surface.id.clone(), observation); } let mut sessions = BTreeMap::new(); @@ -275,10 +306,7 @@ impl SessionStore { continue; } let Some(session) = observation.session else { continue }; - let Ok(observed_at) = DateTime::parse_from_rfc3339(&observation.observed_at) else { - continue; - }; - if observed_at.with_timezone(&Utc) < cutoff || !session.auto_resumable() { + if !session.auto_resumable() { continue; } sessions.insert(session.id.clone(), session); diff --git a/crates/sharecli-session/tests/recovery_freshness.rs b/crates/sharecli-session/tests/recovery_freshness.rs index 99c0c063..e0969120 100644 --- a/crates/sharecli-session/tests/recovery_freshness.rs +++ b/crates/sharecli-session/tests/recovery_freshness.rs @@ -1,4 +1,4 @@ -//! FR:011 / C10 — automatic recovery uses only fresh, latest surface evidence. +//! FR:011 / C10 - automatic recovery uses only fresh, latest surface evidence. use chrono::{Duration, Utc}; use sharecli_session::{ @@ -89,3 +89,29 @@ fn recovery_plan_rejects_malformed_observation_time() { assert!(SessionService::new(store).recovery_plan(Duration::hours(1)).unwrap().is_empty()); } + +#[test] +fn future_observation_cannot_replace_current_surface_evidence() { + let store = SessionStore::open_memory().unwrap(); + let now = Utc::now(); + store + .append_observation(&observation( + "surface-clock-skew", + &(now - Duration::minutes(5)).to_rfc3339(), + Some(AgentSession::codex("current-id", "/tmp/project")), + ObservationKind::Discovered, + )) + .unwrap(); + store + .append_observation(&observation( + "surface-clock-skew", + &(now + Duration::hours(1)).to_rfc3339(), + Some(AgentSession::codex("future-id", "/tmp/project")), + ObservationKind::Updated, + )) + .unwrap(); + + let plan = SessionService::new(store).recovery_plan(Duration::hours(1)).unwrap(); + assert_eq!(plan.len(), 1); + assert_eq!(plan[0].session_id, "current-id"); +} From 749f210631665fc1f6b6c11291353b94198e66b6 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Fri, 21 Aug 2026 02:05:31 -0700 Subject: [PATCH 4/5] fix(session): enforce freshness across IPC recovery --- crates/sharecli-session/src/lib.rs | 14 ++++++++--- .../tests/recovery_freshness.rs | 25 +++++++++++++++++++ 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/crates/sharecli-session/src/lib.rs b/crates/sharecli-session/src/lib.rs index 1e671984..d7197c6c 100644 --- a/crates/sharecli-session/src/lib.rs +++ b/crates/sharecli-session/src/lib.rs @@ -268,7 +268,8 @@ impl SessionStore { } let now = Utc::now(); let cutoff = now - max_age; - let mut latest = BTreeMap::new(); + let mut latest: BTreeMap, i64, SessionObservation)> = + BTreeMap::new(); for observation in self.observations(None)? { let observed_at = match DateTime::parse_from_rfc3339(&observation.observed_at) { Ok(value) => value.with_timezone(&Utc), @@ -298,10 +299,17 @@ impl SessionStore { ); continue; } - latest.insert(observation.surface.id.clone(), observation); + let surface_id = observation.surface.id.clone(); + let candidate = (observed_at, observation.seq, observation); + let replace = latest + .get(&surface_id) + .map_or(true, |current| (candidate.0, candidate.1) > (current.0, current.1)); + if replace { + latest.insert(surface_id, candidate); + } } let mut sessions = BTreeMap::new(); - for observation in latest.into_values() { + for (_, _, observation) in latest.into_values() { if observation.kind == ObservationKind::Exited { continue; } diff --git a/crates/sharecli-session/tests/recovery_freshness.rs b/crates/sharecli-session/tests/recovery_freshness.rs index e0969120..2a8a1c1a 100644 --- a/crates/sharecli-session/tests/recovery_freshness.rs +++ b/crates/sharecli-session/tests/recovery_freshness.rs @@ -115,3 +115,28 @@ fn future_observation_cannot_replace_current_surface_evidence() { assert_eq!(plan.len(), 1); assert_eq!(plan[0].session_id, "current-id"); } + +#[test] +fn delayed_older_observation_cannot_replace_newer_surface_evidence() { + let store = SessionStore::open_memory().unwrap(); + let now = Utc::now(); + store + .append_observation(&observation( + "surface-delayed", + &(now - Duration::minutes(2)).to_rfc3339(), + Some(AgentSession::codex("newer-id", "/tmp/project")), + ObservationKind::Updated, + )) + .unwrap(); + store + .append_observation(&observation( + "surface-delayed", + &(now - Duration::minutes(10)).to_rfc3339(), + Some(AgentSession::codex("older-id", "/tmp/project")), + ObservationKind::Updated, + )) + .unwrap(); + + let plan = SessionService::new(store).recovery_plan(Duration::hours(1)).unwrap(); + assert_eq!(plan.iter().map(|s| s.session_id.as_str()).collect::>(), ["newer-id"]); +} From 8dbab6efa5257b83004499ee8a3de2450aa136f4 Mon Sep 17 00:00:00 2001 From: KooshaPari Date: Fri, 21 Aug 2026 02:15:52 -0700 Subject: [PATCH 5/5] style(session): satisfy recovery planner lint --- crates/sharecli-session/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/sharecli-session/src/lib.rs b/crates/sharecli-session/src/lib.rs index d7197c6c..8466e9eb 100644 --- a/crates/sharecli-session/src/lib.rs +++ b/crates/sharecli-session/src/lib.rs @@ -303,7 +303,7 @@ impl SessionStore { let candidate = (observed_at, observation.seq, observation); let replace = latest .get(&surface_id) - .map_or(true, |current| (candidate.0, candidate.1) > (current.0, current.1)); + .is_none_or(|current| (candidate.0, candidate.1) > (current.0, current.1)); if replace { latest.insert(surface_id, candidate); }