diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index 8714f7486..720eb209e 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -68,6 +68,28 @@ pub use spawn_gate::{SpawnGate, SpawnGateError}; /// Defaults to "always false" (behavior-preserving for constructors that don't wire it). pub type TerminalLivenessProbe = std::sync::Arc bool + Send + Sync>; +/// Door 3 (resume-validation): the gate-fired callback shape -- +/// `(provider, stale_session_id)`. In production, `freshell-server`'s main.rs +/// implements it as pane-ledger `retire_missing` + `tracing::warn!`. +pub type OnStaleResume = std::sync::Arc; + +/// Door 3 (resume-validation): the injected ASYNC sidecar-liveness probe -- +/// `(mode, session_id) -> is that session live inside a fresh-agent sidecar`. +/// The REST create gate's registry consult is `TerminalRegistry`/PTY-scoped +/// and structurally BLIND to sessions live inside the fresh-agent sidecars +/// (sidecars never get PTY rows), so the in-gate liveness precondition needs +/// this second arm. Precedent: [`TerminalLivenessProbe`] solves the SAME +/// cross-crate liveness problem in the opposite direction -- same shape, made +/// async. Constructed by `freshell-server`'s `main.rs` over the SAME sidecar +/// instances the WS door's D7 join consults, so this crate never imports +/// `freshell-ws`. `None` (not wired, e.g. bare unit-test states) => the arm +/// contributes false. +pub type SidecarLivenessProbe = std::sync::Arc< + dyn Fn(&str, &str) -> std::pin::Pin + Send>> + + Send + + Sync, +>; + /// Handle pairing the shared gate with the permit-wait timeout /// (`CreateProtectConfig.spawn_timeout_ms`, resolved once in main.rs so both /// doors share one env snapshot). @@ -224,6 +246,20 @@ pub struct FreshAgentState { /// `identity_sink`). `None` = ungated (unwired unit tests keep legacy /// behavior; production always wires it). spawn_gate: Arc>, + /// Door 3 (resume-validation): the disk-existence probe consulted before + /// a REST create turns a cached resume id into resume argv. Injected as + /// [`freshell_platform::resume_gate::ResumeProbeFn`] because this crate + /// must NOT depend on `freshell-ws` (whose probe trait would be circular + /// -- see this Cargo.toml's freshell-sessions comment). `None` = feature + /// off = today's behavior (every pre-existing test). + pub(crate) resume_probe: Option, + /// Door 3: called with `(provider, stale_session_id)` when the gate + /// fires. `freshell-server`'s main.rs implements it as pane-ledger + /// `retire_missing` + `tracing::warn!`. `None` = no-op. + pub(crate) on_stale_resume: Option, + /// Door 3: arm 2 of the in-gate liveness precondition (see + /// [`SidecarLivenessProbe`]). `None` = arm contributes false. + pub(crate) sidecar_liveness: Option, } /// A fresh-agent pane (the `paneContent` subset the opencode T2 path needs). @@ -292,6 +328,9 @@ impl FreshAgentState { codex_locator: None, identity_sink: Arc::new(std::sync::OnceLock::new()), spawn_gate: Arc::new(std::sync::OnceLock::new()), + resume_probe: None, + on_stale_resume: None, + sidecar_liveness: None, } } @@ -480,6 +519,37 @@ impl FreshAgentState { self } + /// Door 3 (resume-validation): wire the disk-existence probe the REST + /// create pipeline consults before turning a cached resume id into + /// resume argv. `freshell-server`'s main.rs builds it over the SAME + /// `SessionExistenceProbe` the WS doors use. Unwired = feature off = + /// today's behavior. + pub fn with_resume_probe( + mut self, + probe: freshell_platform::resume_gate::ResumeProbeFn, + ) -> Self { + self.resume_probe = Some(probe); + self + } + + /// Door 3: wire the gate-fired callback (`(provider, stale_session_id)`), + /// implemented by `freshell-server`'s main.rs as pane-ledger + /// `retire_missing` + `tracing::warn!`. + pub fn with_on_stale_resume(mut self, cb: OnStaleResume) -> Self { + self.on_stale_resume = Some(cb); + self + } + + /// Door 3: wire arm 2 of the in-gate liveness precondition (see + /// [`SidecarLivenessProbe`]). MUST stay a consuming `with_*` builder like + /// its siblings, NOT a `set_*`/`OnceLock` late-bind: `FreshOpencodeState` + /// holds a `FreshAgentState` clone by value, so a late-bound handle back + /// to the sidecars would create a real Arc cycle. + pub fn with_sidecar_liveness(mut self, probe: SidecarLivenessProbe) -> Self { + self.sidecar_liveness = Some(probe); + self + } + /// SESSION-09 fix-forward: replace this state's own `sessions_revision` /// counter with a SHARED one -- in production, `freshell-server` wires /// this to the SAME `Arc` as `freshell_ws::WsState::sessions_revision` diff --git a/crates/freshell-freshagent/src/terminal_tabs.rs b/crates/freshell-freshagent/src/terminal_tabs.rs index 8593f7408..edeac17e2 100644 --- a/crates/freshell-freshagent/src/terminal_tabs.rs +++ b/crates/freshell-freshagent/src/terminal_tabs.rs @@ -529,6 +529,102 @@ pub(crate) fn derive_resume_identity( )) } +/// Door 3 (resume-validation): what [`validate_rest_resume`] decided for a +/// REST create's cached resume id. Mirrors `freshell-ws`'s +/// `ResumeValidationOutcome` (Task 6), including the claude-prealloc flag +/// (the healed pane_content stamping falls out of +/// `plausible_resume_session_id` on the minted id instead). +struct RestResumeOutcome { + resume_session_id: Option, + launch_intent: LaunchIntent, + /// True when the gate minted a fresh claude id as an absence fallback. + /// The REST consumer must run the PIN 2 pre-spawn ledger write exactly + /// as a natural fresh-claude create would (main #584), even though a + /// resume_session_id is present (it is minted, not resumed). + claude_fresh_prealloc: bool, + /// Some(stale_id) iff the gate fired: caller clears the accepted wire + /// ref (never stamp the stale sessionRef), invokes `on_stale_resume`, + /// and injects the notice into the returned `paneContent`. + stale_session_id: Option, + notice: Option, +} + +/// The Proceed shape — shared by [`validate_rest_resume`] and the wiring +/// site's live-candidate skip (a LIVE session must never be gated). +fn rest_resume_passthrough( + resume_session_id: Option, + launch_intent: LaunchIntent, +) -> RestResumeOutcome { + RestResumeOutcome { + resume_session_id, + launch_intent, + claude_fresh_prealloc: false, + stale_session_id: None, + notice: None, + } +} + +/// Door 3 gate (resume-validation): before the REST create pipeline turns a +/// cached session id into resume argv, ask the disk-existence probe. On +/// POSITIVE absence, fall back to the same shape a genuinely fresh pane of +/// that mode uses (claude → new UUID + `Start`; amplifier → new UUID + +/// `Resume`; codex/opencode → `None`). Unknown/unavailable, unvalidated +/// providers, and `probe: None` (feature not wired — bare unit-test states) +/// all fail open. Body mirrors `freshell_ws::resume_validation:: +/// validate_wire_resume`, using the `ResumeProbeFn` injection shape because +/// this crate must not depend on `freshell-ws`. SYNC by design: the wiring +/// site runs it inside `tokio::task::spawn_blocking` (A13 — the probe does +/// real filesystem walks). Minted UUIDs MUST be RFC-4122 v4 (`Uuid::new_v4()`, +/// the crate's existing mint convention) — `is_canonical_claude_session_id` +/// enforces version 1..=5 + RFC-4122 variant, so a v7 or nil UUID would fail +/// [`plausible_resume_session_id`] and break the healed identity stamping. +fn validate_rest_resume( + mode: &str, + resume_session_id: Option, + launch_intent: LaunchIntent, + probe: Option<&freshell_platform::resume_gate::ResumeProbeFn>, +) -> RestResumeOutcome { + use freshell_platform::resume_gate::{ + evaluate_resume_gate, provider_validated, stale_resume_notice, ResumeGateDecision, + }; + let Some(probe) = probe else { + return rest_resume_passthrough(resume_session_id, launch_intent); + }; + let Some(sid) = resume_session_id.clone().filter(|s| !s.is_empty()) else { + return rest_resume_passthrough(resume_session_id, launch_intent); + }; + if !provider_validated(mode) { + return rest_resume_passthrough(resume_session_id, launch_intent); + } + let answer = probe(mode, &sid); + match evaluate_resume_gate(mode, answer.existence, answer.ever_observed_on_disk) { + ResumeGateDecision::Proceed => rest_resume_passthrough(resume_session_id, launch_intent), + ResumeGateDecision::SpawnFresh => { + let notice = stale_resume_notice(mode, &sid); + let (fresh_id, intent, claude_fresh_prealloc) = match mode { + // Mirror the genuine fresh-pane shapes (same per-provider + // fallbacks as the WS door's validate_wire_resume). The + // claude arm MINTS a fresh id, so it must also carry the + // prealloc marker (PIN 2 coupling, main #584). + "claude" => (Some(Uuid::new_v4().to_string()), LaunchIntent::Start, true), + "amplifier" => ( + Some(Uuid::new_v4().to_string()), + LaunchIntent::Resume, + false, + ), + _ => (None, LaunchIntent::Resume, false), + }; + RestResumeOutcome { + resume_session_id: fresh_id, + launch_intent: intent, + claude_fresh_prealloc, + stale_session_id: Some(sid), + notice: Some(notice), + } + } + } +} + /// The successful result of [`spawn_terminal_pane`]: the `paneContent` JSON + the /// resolved `mode`/`shell`/`cwd`/`terminal_id`, everything a caller (tab-create or /// pane-split) needs to build its own `ui.command` payload and success envelope @@ -792,9 +888,91 @@ pub(crate) async fn spawn_terminal_pane( } } - let (mut resume_session_id, accepted_session_ref, session_ref_locator_present) = + let (mut resume_session_id, mut accepted_session_ref, session_ref_locator_present) = derive_resume_identity(body, &mode)?; + // Door 3 (resume-validation): gate the cached resume id on disk existence + // BEFORE the amplifier ensure_session below (or the re-stub would + // resurrect the stale dir) and before CliLaunchInputs is built. + // + // In-gate liveness precondition (MANDATORY — ordering hazard, the A11 + // door-1 hazard with the constraint INVERTED: in THIS pipeline the + // amplifier ensure comes BEFORE the REST D7 live-session guard and the D8 + // lease, so the gate cannot get liveness protection by after-D7 placement; + // placed here, a gate fire on a LIVE candidate would clear + // accepted_session_ref / replace resume_session_id and FALSIFY the D7-REST + // applicability filter — its loud RESTORE_UNAVAILABLE/CONFLICT reject and + // the D8 lease silently bypassed, and on_stale_resume → retire_missing + // would destroy the Bound ledger row of a RUNNING session. Reachable: a + // live zero-turn codex session genuinely has no rollout on disk.): + // a LIVE session must never be gated. TWO ARMS, both load-bearing: + // the registry arm reuses the REST D7 guard's own consult (below, which + // owns the loud reject for registry-live sessions downstream); the + // sidecar arm exists because that consult is PTY-scoped and blind to + // sessions live inside the fresh-agent sidecars — the very + // live-zero-turn-with-no-rollout-on-disk case. Dropping either arm + // silently un-protects a class of live sessions (the live-session tests + // pin BOTH arms). + let candidate_is_live = match resume_session_id.as_deref() { + None => false, + Some(sid) => { + // Arm 1 (registry): the SAME consult the REST D7 guard below + // performs — shared, not reimplemented. + let registry_live = registry + .live_session_owner(state.session_identity.as_deref(), &mode, sid) + .is_some(); + // Arm 2 (sidecar): the injected async probe. None (not wired, + // e.g. bare unit-test states) => arm contributes false. + let sidecar_live = if registry_live { + true // short-circuit: already live + } else { + match &state.sidecar_liveness { + Some(probe) => probe(&mode, sid).await, + None => false, + } + }; + registry_live || sidecar_live + } + }; + // Today's hardcoded intent for this pipeline (see the CliLaunchInputs + // comment in settle_gated_create) — the gate's claude fallback is the + // one path that rewrites it to Start. + let launch_intent = LaunchIntent::Resume; + // Probe does real filesystem walks — never inline on the async runtime + // (A13); run the sync helper in spawn_blocking. A LIVE candidate skips + // the gate entirely (passthrough — same shape validate_rest_resume + // returns for Proceed), so the unchanged create flows into the D7-REST + // guard and D8 lease exactly as today. + let rest_outcome = if candidate_is_live { + rest_resume_passthrough(resume_session_id.take(), launch_intent) + } else { + let probe = state.resume_probe.clone(); + let mode_for_gate = mode.clone(); + let rid = resume_session_id.take(); + let intent = launch_intent; + tokio::task::spawn_blocking(move || { + validate_rest_resume(&mode_for_gate, rid, intent, probe.as_ref()) + }) + .await + .expect("resume validation task panicked") + }; + let mut resume_session_id = rest_outcome.resume_session_id; + let launch_intent = rest_outcome.launch_intent; + if let Some(stale) = rest_outcome.stale_session_id.as_deref() { + // MANDATORY stale-ref guard (V7 row 10): the pane_content identity + // stamping PREFERS accepted_session_ref — left in place, the STALE + // wire ref would be stamped into the new tab's pane_content, + // poisoning client persistence + tabs-sync replay and re-firing the + // gate every restart. Clearing it makes stamping fall through to the + // minted-ref branch, so gate-fired claude/amplifier panes are born + // with the HEALED ref and codex/opencode panes with no ref. + accepted_session_ref = None; + if let Some(cb) = &state.on_stale_resume { + cb(&mode, stale); + } + } + let resume_notice = rest_outcome.notice; + // Fresh-claude preallocation (kata hbsa): WS parity. The WS door's // fresh-claude special case (freshell-ws/src/terminal.rs, LIVE-PATH LAW // spec §2.1(3)) mints a server-preallocated --session-id for every fresh @@ -806,15 +984,25 @@ pub(crate) async fn spawn_terminal_pane( // the id — the pre-spawn ledger write and its spawn-failure delete (Task 5 // call sites in settle_gated_create) are BOTH gated on this exact flag, // never on `mode == "claude"`. - let claude_fresh_prealloc = freshell_platform::should_preallocate_fresh_claude( + // + // The MINT stays keyed on main's raw predicate ("no resume id"): a + // gate-fired create already carries the gate-minted id, and re-minting + // would overwrite it with a second UUID. + let claude_prealloc_mint = freshell_platform::should_preallocate_fresh_claude( &mode, body.get("restore").and_then(serde_json::Value::as_bool), session_ref_locator_present, resume_session_id.as_deref(), ); - if claude_fresh_prealloc { + if claude_prealloc_mint { resume_session_id = Some(Uuid::new_v4().to_string()); } + // Door 3 (resume-validation) × PIN 2: a gate-fired claude fallback ALSO + // minted a fresh id (in `validate_rest_resume`), so it must get the same + // PIN 2 pre-spawn write / failure delete / `Start` intent as a natural + // fresh claude create — fold the outcome flag in (WS door parity: + // freshell-ws/src/terminal.rs does this exact OR-fold). + let claude_fresh_prealloc = claude_prealloc_mint || rest_outcome.claude_fresh_prealloc; // Hoisted spawn-environment inputs, computed ONCE (Task 8's WS pattern, // REST twin): the amplifier windows-arm guard below and the spawn-spec @@ -1109,6 +1297,8 @@ pub(crate) async fn spawn_terminal_pane( shell_str, cwd, resume_session_id, + launch_intent, + resume_notice, accepted_session_ref, claude_fresh_prealloc, pane_identity: state.pane_identity.clone(), @@ -1144,6 +1334,13 @@ struct GatedSettleInputs { shell_str: Option, cwd: Option, resume_session_id: Option, + /// Door 3 (resume-validation): the gate's outcome intent — today's + /// hardcoded `Resume` everywhere EXCEPT the gate-fired claude fallback, + /// whose minted fresh id launches with `Start`. + launch_intent: LaunchIntent, + /// Door 3: the operator-visible stale-resume notice when the gate fired, + /// injected into the returned `paneContent` as `reconcileNotice`. + resume_notice: Option, accepted_session_ref: Option, /// Fresh-claude preallocation (kata hbsa): `true` iff THIS create minted /// its own `--session-id` (the [`freshell_platform::should_preallocate_fresh_claude`] @@ -1194,6 +1391,8 @@ async fn settle_gated_create(inputs: GatedSettleInputs) -> Result Result Result