From cd3497c613622ba106cb475af8aab69a2498b2ea Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:30:48 -0700 Subject: [PATCH 01/19] fix(freshclaude): correlate sends and runtime ends by incarnation --- crates/freshell-freshagent/src/claude.rs | 5 ++ .../src/claude/send_outcome.rs | 3 ++ .../freshell-freshagent/src/claude_edges.rs | 25 +++++++-- .../tests/freshagent_claude_incarnation.rs | 52 +++++++++++++++++++ .../tests/freshagent_claude_turns.rs | 1 + 5 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 crates/freshell-ws/tests/freshagent_claude_incarnation.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 63edb3c15..98c1eefe4 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -4380,6 +4380,7 @@ impl FreshClaudeState { "text": msg.text, }); let writer = session.stdin.clone(); + let ownership_id = session.ownership_id.clone(); drop(guard); if let Some(attempt) = checkpoint_attempt_for_send(&msg) { send_req["checkpointAttempt"] = attempt; @@ -4479,6 +4480,7 @@ impl FreshClaudeState { ClaudeSendOutcome::Accepted { session_key: map_key, submitted_turn_id, + ownership_id, } } @@ -6988,6 +6990,7 @@ impl FreshClaudeState { // bounded confirmation window, confirmed gone before the exit // report offers Resume, and no zombie is left behind. It is not // this consumer's to abort: the consumer is this task. + let ownership_id = evicted.ownership_id.clone(); let processes = SidecarProcesses { child: evicted.child, stdin: evicted.stdin, @@ -7086,6 +7089,7 @@ impl FreshClaudeState { }, rings, cause.reason(), + &ownership_id, ); } }) @@ -17572,6 +17576,7 @@ rl.on('line', (line) => { ClaudeSendOutcome::Accepted { session_key: session_id.clone(), submitted_turn_id: uuid.clone(), + ownership_id: st.sessions.lock().await[&session_id].ownership_id.clone(), } ); } diff --git a/crates/freshell-freshagent/src/claude/send_outcome.rs b/crates/freshell-freshagent/src/claude/send_outcome.rs index fa52333f8..137d14426 100644 --- a/crates/freshell-freshagent/src/claude/send_outcome.rs +++ b/crates/freshell-freshagent/src/claude/send_outcome.rs @@ -24,6 +24,9 @@ pub enum ClaudeSendOutcome { session_key: String, /// The uuid the message runs under: the turn id its user line lands with. submitted_turn_id: String, + /// The exact runtime that accepted this send; an old end after recovery + /// must never settle a send belonging to its replacement. + ownership_id: String, }, /// The message was not sent; the sender got a request error with this code. Refused { code: String, message: String }, diff --git a/crates/freshell-freshagent/src/claude_edges.rs b/crates/freshell-freshagent/src/claude_edges.rs index 2a950c9ab..666416c20 100644 --- a/crates/freshell-freshagent/src/claude_edges.rs +++ b/crates/freshell-freshagent/src/claude_edges.rs @@ -72,7 +72,12 @@ pub enum ClaudeTurnEvent { Waiting { session_key: String, at: i64 }, /// The session's sidecar ended unrequested; `reason` is why /// (`cli_exited`, `stream_error`, `stream_eof` or `sidecar_exited`). - Ended { session_key: String, reason: String }, + Ended { + session_key: String, + reason: String, + /// The runtime that ended, matching the accepted send's ownership id. + ownership_id: String, + }, } /// The two attention edges (AGENTS.md "Agent Status Indicators"). @@ -245,11 +250,18 @@ impl ClaudeEdgeHub { /// The sidecar ended unrequested: ring when a turn was owed, and publish the /// end either way. - pub(crate) fn ended(&self, record: &EdgeRecord<'_>, owed: bool, reason: &str) -> Option { + pub(crate) fn ended( + &self, + record: &EdgeRecord<'_>, + owed: bool, + reason: &str, + ownership_id: &str, + ) -> Option { let at = owed.then(|| self.emit(record, EdgeKind::TurnComplete, EdgeSource::Death)); self.publish(ClaudeTurnEvent::Ended { session_key: record.session_key.to_string(), reason: reason.to_string(), + ownership_id: ownership_id.to_string(), }); at } @@ -364,7 +376,7 @@ mod tests { let complete = hub.settle(&record, settled(true, TurnOutcome::Completed)); let waiting = hub.waiting(&record); - let death = hub.ended(&record, true, "cli_exited"); + let death = hub.ended(&record, true, "cli_exited", "owner-1"); let (complete, death) = (complete.unwrap(), death.unwrap()); assert!( complete < waiting && waiting < death, @@ -441,7 +453,9 @@ mod tests { let result = hub .settle(&record, settled(true, TurnOutcome::Completed)) .unwrap(); - let death = hub.ended(&record, true, "sidecar_exited").unwrap(); + let death = hub + .ended(&record, true, "sidecar_exited", "owner-1") + .unwrap(); assert_eq!(result, ahead + 1); assert_eq!(death, ahead + 2); } @@ -460,7 +474,7 @@ mod tests { assert_eq!(silent, None); let waiting = hub.waiting(&record); assert_eq!( - hub.ended(&record, false, "stream_eof"), + hub.ended(&record, false, "stream_eof", "owner-1"), None, "an idle death is silent" ); @@ -491,6 +505,7 @@ mod tests { ClaudeTurnEvent::Ended { session_key: "key-1".into(), reason: "stream_eof".into(), + ownership_id: "owner-1".into(), }, ] ); diff --git a/crates/freshell-ws/tests/freshagent_claude_incarnation.rs b/crates/freshell-ws/tests/freshagent_claude_incarnation.rs new file mode 100644 index 000000000..7b3f69498 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_incarnation.rs @@ -0,0 +1,52 @@ +//! In-process send observers correlate a sidecar end to its exact runtime. + +#![cfg(unix)] + +mod common; + +use common::claude_rig::{ClaudeRig, RigKind, RigOptions}; +use freshell_freshagent::{claude::ClaudeSendOutcome, ClaudeTurnEvent}; +use freshell_protocol::{AgentProvider, FreshAgentSend, SessionType}; +use std::time::Duration; + +async fn accepted_owner(rig: &ClaudeRig, id: &str, request: &str) -> String { + let outcome = rig.fresh_claude.handle_send(FreshAgentSend { + provider: AgentProvider::Claude, session_id: id.into(), + session_type: SessionType::Freshclaude, text: "HANG".into(), + request_id: Some(request.into()), cwd: None, images: None, settings: None, + observed_epoch: None, observed_generation: None, checkpoint_intent: None, + }).await; + match outcome { + ClaudeSendOutcome::Accepted { ownership_id, .. } => { + assert!(!ownership_id.is_empty()); + ownership_id + } + refused => panic!("send should be accepted: {refused:?}"), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_ended_event_identifies_the_accepted_runtime_across_a_resume() { + let rig = ClaudeRig::start_with_options(RigKind::Sim, RigOptions { + durable_ledger: true, ..RigOptions::default() + }).await; + let mut conn = rig.connect().await; + let pane = conn.create("create-incarnation").await; + let durable = rig.converse(&mut conn, &pane, &["make the transcript durable"]).await; + let mut events = rig.fresh_claude.subscribe_turn_events(); + let first_owner = accepted_owner(&rig, &durable, "first-incarnation").await; + rig.kill_first_sidecar(&mut conn).await; + conn.attach(&durable).await; + let second_owner = accepted_owner(&rig, &durable, "second-incarnation").await; + assert_ne!(first_owner, second_owner, "a new process has a new ownership id"); + let ended_owner = tokio::time::timeout(Duration::from_secs(15), async { + loop { + if let ClaudeTurnEvent::Ended { ownership_id, .. } = events.recv().await.unwrap() { + break ownership_id; + } + } + }).await.expect("the dead runtime publishes its end"); + assert_eq!(ended_owner, first_owner); + assert_ne!(ended_owner, second_owner, "a delayed old end cannot match a new send"); + rig.fresh_claude.shutdown().await; +} diff --git a/crates/freshell-ws/tests/freshagent_claude_turns.rs b/crates/freshell-ws/tests/freshagent_claude_turns.rs index 02926a43a..1b22df817 100644 --- a/crates/freshell-ws/tests/freshagent_claude_turns.rs +++ b/crates/freshell-ws/tests/freshagent_claude_turns.rs @@ -1317,6 +1317,7 @@ mod sim { let ClaudeSendOutcome::Accepted { session_key, submitted_turn_id, + .. } = outcome else { panic!("the send was accepted: {outcome:?}"); From 447d6ba405e1052269c02e7c24d3a81fd4df1bca Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:40:39 -0700 Subject: [PATCH 02/19] test(freshclaude): reserve durable ownership epochs in the sidecar rig --- crates/freshell-ws/tests/common/claude_rig.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index 9be80ecb7..3cfb1ce84 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -998,7 +998,13 @@ async fn spawn_rig_server( // The ONE ownership coordinator, wired into the WS state and the claude lane // the way main.rs wires it. - let ownership = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); + let epoch = pane_ledger.as_ref().and_then(|ledger| { + ledger.reserve_boot_epoch().expect("reserve the rig server's durable boot epoch") + }); + let ownership = Arc::new(match epoch { + Some(epoch) => freshell_ownership::RuntimeOwnershipRegistry::with_epoch(epoch), + None => freshell_ownership::RuntimeOwnershipRegistry::new(), + }); let mut fresh_claude = freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)) .with_sidecar_environment(environment) .with_hosted_mcp(options.hosted_claude); From b19dc428d51ee55d6781f69a6162d27018b09a0c Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:53:17 -0700 Subject: [PATCH 03/19] feat(freshclaude): establish durable UUID identity before create acknowledgement --- crates/freshell-claude-sidecar/index.mjs | 4 +- crates/freshell-freshagent/src/claude.rs | 872 +++++++++--------- .../src/claude/create_binding.rs | 99 ++ .../src/claude/establish.rs | 108 ++- .../src/claude/tests/create_binding_tests.rs | 158 ++++ .../src/providers/fresh_agent.rs | 63 +- crates/freshell-ws/tests/common/claude_rig.rs | 4 +- .../tests/freshagent_claude_identity.rs | 200 ++++ .../tests/freshagent_claude_incarnation.rs | 51 +- port/oracle/harness/invariants.ts | 12 +- .../fixtures/fake-kilroy-runtime.mjs | 4 +- .../helpers/harness-06/kilroy-runtime.test.ts | 7 +- test/unit/port/oracle/t2-invariants.test.ts | 16 +- 13 files changed, 1100 insertions(+), 498 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude/create_binding.rs create mode 100644 crates/freshell-freshagent/src/claude/tests/create_binding_tests.rs create mode 100644 crates/freshell-ws/tests/freshagent_claude_identity.rs diff --git a/crates/freshell-claude-sidecar/index.mjs b/crates/freshell-claude-sidecar/index.mjs index 626cc621d..9a43400f9 100644 --- a/crates/freshell-claude-sidecar/index.mjs +++ b/crates/freshell-claude-sidecar/index.mjs @@ -439,7 +439,9 @@ function handleCreate(req) { return } - const sessionId = nanoid() + // Rust assigns the durable identity before create. Retain the bare-id + // fallback for an older host that has not adopted create-time UUIDs yet. + const sessionId = isUuid(req.sessionId) ? req.sessionId : nanoid() const abort = new AbortController() const state = { abort, diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 98c1eefe4..b8764fc0a 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -95,14 +95,6 @@ fn checkpoint_attempt_for_send(msg: &FreshAgentSend) -> Option { })) } -/// b8ke ext r22 F1: the pane-scoped PROVISIONAL identity for a fresh -/// create's pre-spawn claim — the createRequestId the caller already -/// carries, namespaced so it can never collide with a provider-minted -/// session id. The claim under this key owns the whole spawn window -/// (watchdog state + cancellation + typed recovery); at the `created` -/// mint the SAME ticket rekeys to the client-visible session id -/// (rekey_starting) and the RAII drop settles the window record typed. -const PENDING_CLAUDE_CREATE_PREFIX: &str = "pending-create-"; /// The ownership tag env the sidecar + its claude CLI grandchild carry (the codex analog /// is `FRESHELL_CODEX_SIDECAR_ID`); the `/proc` reaper keys on it. const CLAUDE_SIDECAR_OWNERSHIP_ENV: &str = crate::claude_spawn::CLAUDE_SIDECAR_OWNERSHIP_ENV; @@ -1970,10 +1962,10 @@ impl FreshClaudeState { // ── freshAgent.create (WS) ─────────────────────────────────────────────────────── /// Handle a `freshAgent.create` for claude/kilroy: spawn the Node sidecar, drive the - /// SDK `create` to get the BARE nanoid placeholder (the sidecar answers `created` only - /// once the CLI booted the conversation; a refused boot is a typed `create.failed`), + /// SDK `create` under a durable UUID assigned before spawn (the sidecar answers + /// `created` only once the CLI booted; a refused boot is a typed `create.failed`), /// register the session + its stdout consumer, and broadcast `freshAgent.created` - /// (with `sessionRef` for a resume) or `freshAgent.create.failed`. + /// (with the same durable `sessionRef`) or `freshAgent.create.failed`. /// Long-running (cold sidecar spawn), so the WS loop dispatches this as a detached task. /// `provenance` (D8): the WS connection's stamped identity for this create /// (`None` on conn-less lanes); threaded to the `sdk.session.init` binding write. @@ -2042,11 +2034,19 @@ impl FreshClaudeState { .filter(|r| r.provider == PROVIDER) .map(|r| r.session_id.clone()) .filter(|s| !s.is_empty()) + }) + .or_else(|| { + self.identity_sink() + .and_then(|sink| sink.lookup_by_create_request_id(PROVIDER, &request_id)) }); - let mut lease_guard: Option = None; - // kata b8ke Task 3: the coordinator claim ticket (resume lane only — - // a fresh create has no canonical durable id until - // `sdk.session.init`, and the claim happens under the resume id). + // The durable id is known before spawning, including for a zero-turn + // conversation. A re-driven create resolves its persisted lineage. + let launch_id = resume_sid + .clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let mut lease_guard: Option; + // Every create owns its durable UUID from before spawn. The ticket + // commits only after the runtime and its binding are established. // Hoisted to this scope so the commit at the registration tail can // consume it; every failure return drops it (RAII typed fail). let mut own_ticket: Option = None; @@ -2060,7 +2060,8 @@ impl FreshClaudeState { // end. let start_pid_slot = crate::ownership_lane::sidecar_pid_cancel_slot(); let mut _start_cancellation: Option = None; - if let Some(sid) = resume_sid.as_deref() { + { + let sid = launch_id.as_str(); // Task 13b (cross-kind liveness): a live terminal PTY owning // `(claude, sid)` is the one writer on that JSONL -- refuse the // resume with the retryable loser answer (the terminal may be @@ -2242,77 +2243,6 @@ impl FreshClaudeState { } } - // b8ke ext r22 F1: the FRESH create (no resume id) is - // coordinator-owned from BEFORE spawn too — the pane-scoped - // provisional claim under the createRequestId the caller already - // carries (the same pre-spawn discipline as the resume arm above: - // the cancellation handle arms with the ticket, the settle guard is - // held to this handler's end, the partial runtime registers once - // the child exists). The whole spawn window then has a Starting - // generation, watchdog state, and the typed recovery record; at - // the `created` mint the SAME ticket rekeys to the client-visible - // session id (fencing it through this handler's tail — the - // canonical Live commit stays with the consumer's - // `sdk.session.init` adoption under the CLI session id, the - // existing two-stage identity discipline). - let fresh_claim_key: Option = if resume_sid.is_none() { - let provisional_id = format!("{PENDING_CLAUDE_CREATE_PREFIX}{request_id}"); - let claim_op_id = format!("create-{request_id}"); - let claim_key = self.resolve_ownership_key(&provisional_id); - match self.begin_lane_claim_at( - &claim_key, - &claim_op_id, - None, - &Self::initiator_for(provenance.as_ref(), "freshclaude/create"), - ) { - crate::ownership_lane::LaneClaim::Granted(ticket) => { - own_ticket = Some(ticket); - _start_cancellation = Some( - crate::ownership_lane::register_start_cancellation_for_ticket( - &self.ownership, - PROVIDER, - &claim_key, - &own_ticket, - crate::ownership_lane::pid_slot_cancellation(&start_pid_slot), - ), - ); - Some(claim_key) - } - crate::ownership_lane::LaneClaim::Unwired => None, - crate::ownership_lane::LaneClaim::Adopt => { - // The pane-scoped key resolves to a LIVE same-kind - // owner — an earlier create for this pane completed - // (the key is Aliased{to: the live session} after its - // mint-time rekey); the idempotent re-drive ADOPTS the - // live session, spawning nothing. - return self - .adopt_live_create(&request_id, &claim_key, session_type) - .await; - } - crate::ownership_lane::LaneClaim::Refused(outcome) => { - // A duplicate create for this pane is still in flight - // (the pane-scoped key's Starting record) — the typed - // refusal. - tracing::warn!(target: "freshell_freshagent::claude", - request_id = %request_id, provisional_id = %provisional_id, - outcome = ?outcome, - "fresh_agent_create_refused: the ownership coordinator refused \ - the fresh create's pane-scoped pre-spawn claim (kata b8ke r22 F1)" - ); - let owner_fields = crate::ownership_lane::terminal_owner_fields_from_outcome( - &self.ownership, - &outcome, - ); - return self.fail_create_session_reserved_with_owner( - &request_id, - owner_fields.as_ref(), - ); - } - } - } else { - None - }; - // Retire-on-kill round 2/3 (focused-ep5-r1 Finding 2, -r2 Findings // 4+5): an explicit create-with-resume GENUINELY CLAIMS the durable // identity — but the claim COMMITS only in `establish_runtime`, once the @@ -2328,9 +2258,7 @@ impl FreshClaudeState { // before the spawn — so a kill that lands while the resume awaits // `created` advances the durable tombstone past it and the commit in // `establish_runtime` REFUSES instead of undoing the newer close. - let claim_dead_state = resume_sid - .as_deref() - .and_then(|sid| self.claim_dead_state_snapshot(sid)); + let claim_dead_state = self.claim_dead_state_snapshot(&launch_id); // Which conversation the resume boots (design §4.2, §4.3): a session // that has no transcript yet boots from the genesis its binding row @@ -2373,8 +2301,10 @@ impl FreshClaudeState { None => ( ClaudeBoot::resume_or_fresh(resume_sid.as_deref()), msg.cwd.clone(), - None, - None, + Some(launch_id.clone()), + resume_sid + .is_none() + .then_some(crate::claude_launch::ClaudeGenesis::Fresh), None, ), }; @@ -2399,7 +2329,7 @@ impl FreshClaudeState { &settings, self.hosted_mcp, ) - .with_session_id(boot_session_id) + .with_session_id(boot_session_id.or_else(|| Some(launch_id.clone()))) .with_model_selection(serde_json::to_value(&msg.model_selection).ok()) .with_freshell_context( crate::claude_freshell_mcp::ClaudeFreshellContext::from_ids( @@ -2416,18 +2346,18 @@ impl FreshClaudeState { EstablishPlan { lane: EstablishLane::Create, spec, - register_under: RegisterUnder::Minted, - claim: resume_sid.clone().map(|durable| DurableClaim { - durable, + register_under: RegisterUnder::Adopted(launch_id.clone()), + claim: Some(DurableClaim { + durable: launch_id.clone(), dead_state: claim_dead_state, }), - rekey_provisional_claim: fresh_claim_key.is_some(), + rekey_provisional_claim: false, recorded_settings: Some(settings), owning_operation, owning_generation: None, provenance, naming: Some(&msg), - partial_runtime_key: resume_sid.clone().or_else(|| fresh_claim_key.clone()), + partial_runtime_key: Some(launch_id.clone()), start_pid_slot: &start_pid_slot, inherit: None, genesis: boot_genesis, @@ -2461,7 +2391,7 @@ impl FreshClaudeState { // only after that boot, so the caller learns the native identity here // instead of waiting for a turn's init (design §6, §7.3). A fresh create // has no durable id until its first turn, so it carries none. - let session_ref = resume_sid.as_deref().map(claude_session_ref); + let session_ref = Some(claude_session_ref(&launch_id)); self.create_dedup .record_success( &request_id, @@ -3360,12 +3290,20 @@ impl FreshClaudeState { // b8ke focused round-2 review R2-2: the teardown's typed outcome // decides the stop commit AND the broadcast truth — an unconfirmed // tree is never a silent success. + let closed_runtime = removed.as_ref().map(|session| session.ownership_id.clone()); let teardown = if let Some(session) = removed { self.teardown_removed_session_logged(session).await } else { LoggedTeardown::Confirmed }; + let teardown = self.release_leases_after_teardown( + teardown, + retire_ids.clone(), + map_key.clone(), + closed_runtime, + ); + // Explicit kill evicts this session's requestId dedup cache entries (mirrors // `clearFreshAgentCreateCachesForSession`) -- a later duplicate `create` for the // same requestId must genuinely mint a fresh session, not replay the one just @@ -3854,12 +3792,20 @@ impl FreshClaudeState { let Some(sink) = self.identity_sink() else { return true; }; + // A create registers directly under its durable UUID. That key's + // existing close is compared with the captured claim snapshot below; + // only distinct legacy pane aliases use the unconditional seat fence. + let through_placeholders: Vec = through_placeholders + .iter() + .filter(|id| id.as_str() != durable_id) + .cloned() + .collect(); match sink .commit_claim_aliased( PROVIDER, durable_id, expect_killed_at_ms, - through_placeholders, + &through_placeholders, ) .await { @@ -4041,14 +3987,14 @@ impl FreshClaudeState { true } }); - for durable in &demoted { + for durable in demoted.iter().filter(|durable| durable.as_str() != map_key) { tombstones.record(map_key, durable, now, &row_bound); } demoted }; if !demoted.is_empty() { if let Some(sink) = self.identity_sink() { - for durable in &demoted { + for durable in demoted.iter().filter(|durable| durable.as_str() != map_key) { if let Err(e) = sink .record_alias_tombstone(PROVIDER, map_key, durable, now) .await @@ -6471,7 +6417,7 @@ impl FreshClaudeState { // reaches the post-restart kill naming this pane's bare placeholder. // Best-effort: warn-loud on failure, never a lane blocker (the // in-memory store still answers this process's kills). - if let Some(sink) = &identity_sink { + if let Some(sink) = identity_sink.as_ref().filter(|_| session_id != cli_id) { if let Err(e) = sink .record_alias_tombstone( PROVIDER, @@ -6638,6 +6584,7 @@ impl FreshClaudeState { // (rollback replacement, attach-resume) — the ledger merge keeps prior // stamps. provenance: Option, + create_binding_expected: bool, ) -> tokio::task::JoinHandle<()> { let broadcast_tx = self.broadcast_tx.clone(); let sessions = self.sessions.clone(); @@ -6836,27 +6783,40 @@ impl FreshClaudeState { // entry may not exist yet during create). if value.get("type").and_then(Value::as_str) == Some("sdk.session.init") { if let Some(cli_id) = value.get("cliSessionId").and_then(Value::as_str) { - let current_settings = { + let (current_settings, known_identity) = { let mut guard = sessions.lock().await; guard .get_mut(&session_id) .map(|session| { session.configuration.runtime_cwd = value["cwd"].as_str().map(str::to_string); - session.configuration.settings.clone() + ( + Some(session.configuration.settings.clone()), + session.cli_session_id.as_deref() == Some(cli_id), + ) }) - .or_else(|| settings.clone()) + .unwrap_or_else(|| (settings.clone(), false)) }; - state - .adopt_session_init( - cli_id, - &session_id, - &session_type, - current_settings.as_ref(), - identity_sink.clone(), - provenance.as_ref(), - ) - .await; + if known_identity && create_binding_expected { + // Create/establish already bound this exact durable + // identity. Init verifies it; no second adoption or + // lineage rewrite is needed. A retained naming bind + // may still retry once native persistence appears. + state + .bind_naming_handle_at_init(cli_id, &session_id, &session_type) + .await; + } else { + state + .adopt_session_init( + cli_id, + &session_id, + &session_type, + current_settings.as_ref(), + identity_sink.clone(), + provenance.as_ref(), + ) + .await; + } } } // Task 10b: stamp the envelope from the SHARED handle (not the captured @@ -8164,6 +8124,7 @@ fn log_claude_teardown( } } +mod create_binding; mod create_outcome; pub use create_outcome::ClaudeCreateOutcome; @@ -8224,6 +8185,8 @@ pub(crate) mod tests { use super::*; use crate::naming::SessionNaming as _; + mod create_binding_tests; + fn state() -> FreshClaudeState { let (tx, _rx) = tokio::sync::broadcast::channel::(64); FreshClaudeState::new(Arc::new(tx)) @@ -9545,10 +9508,10 @@ rl.on('line', (line) => { if (spawnLog) { fs.appendFileSync(spawnLog, `${JSON.stringify(msg)}\n`) } - if (failCreate) process.exit(1) const answer = () => { + if (failCreate) process.exit(1) counter += 1 - const sessionId = `fake-claude-session-${process.pid}-${counter}` + const sessionId = msg.sessionId ?? `fake-claude-session-${process.pid}-${counter}` liveSessions.add(sessionId) configuredSettings.set(sessionId, { model: msg.model, effort: msg.effort, permissionMode: msg.permissionMode, cwd: msg.cwd }) process.stdout.write(JSON.stringify({ type: 'created', sessionId, protocol: 2 }) + '\n') @@ -9709,6 +9672,21 @@ rl.on('line', (line) => { &self.spawn_log } + async fn create_request(&self) -> Value { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + let text = std::fs::read_to_string(self.spawn_log_path()).unwrap_or_default(); + if let Some(line) = text.lines().next() { + return serde_json::from_str(line).expect("logged create JSON"); + } + assert!( + tokio::time::Instant::now() < deadline, + "create never reached sidecar" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + /// Number of times the fake sidecar has been spawned so far (one marker line per /// process start). fn spawn_count(&self) -> usize { @@ -10363,8 +10341,14 @@ rl.on('line', (line) => { .expect("the created session row") .cli_session_id = None; + let legacy_id = "legacy-name-placeholder"; + state + .cli_index + .lock() + .await + .insert(legacy_id.into(), session_id.clone()); state - .handle_send(send_msg(&session_id, "Race the adoption")) + .handle_send(send_msg(legacy_id, "Race the adoption")) .await; let _ = env.respond_log_frames(1).await; @@ -10377,7 +10361,7 @@ rl.on('line', (line) => { assert_eq!( activities[0].target, freshell_protocol::session_names::SessionNameRef::Pending { - id: session_id.clone() + id: legacy_id.into() } ); assert_eq!(activities[0].mode, "freshclaude"); @@ -10681,10 +10665,10 @@ rl.on('line', (line) => { let mut create = dedup_create_msg("local-snapshot-owner"); create.session_type = kind; state.handle_create(create, None).await; - let created = - await_claude_created_and_session_init(&mut rx, "local-snapshot-owner").await; + let created = await_claude_created(&mut rx, "local-snapshot-owner").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let presentation = created["sessionId"].as_str().unwrap(); - let native = FRESH_CREATE_DURABLE_ID; + let native = fresh_create_durable_id; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); while state.local_snapshot_owner(native).await.is_none() { assert!( @@ -10714,6 +10698,43 @@ rl.on('line', (line) => { assert_eq!(env.spawn_count(), 2); } + /// Model a session registered by the older host before it knew its + /// native UUID. Only compatibility tests call this; current creates are + /// already durably owned and never take this path. + async fn prepare_legacy_init(st: &FreshClaudeState, map_key: &str) { + let durable = { + let mut sessions = st.sessions.lock().await; + let session = sessions.get_mut(map_key).expect("legacy fixture runtime"); + session.owning_operation = None; + session.owning_generation = None; + session.cli_session_id.take() + }; + if let Some(durable) = durable { + st.cli_index.lock().await.remove(&durable); + crate::ownership_lane::release_retained_stamp( + &st.ownership, + &st.ownership_stamps, + PROVIDER, + &durable, + "test/legacy-init-fixture", + ); + } + } + + async fn adopt_legacy_fixture(st: &FreshClaudeState, map_key: &str, durable: &str) { + prepare_legacy_init(st, map_key).await; + st.adopt_session_init( + durable, + map_key, + "freshclaude", + None, + st.identity_sink(), + None, + ) + .await; + await_alias(st, durable, map_key).await; + } + /// Drain `rx` until the `freshAgent.created` (or `.create.failed`) frame for /// `request_id` arrives (mirrors codex's `await_created`). async fn await_claude_created( @@ -10743,68 +10764,6 @@ rl.on('line', (line) => { .unwrap_or_else(|_| panic!("freshAgent.created for {request_id} resolves within budget")) } - /// Drain `rx` until BOTH the `freshAgent.created` frame for `request_id` AND that - /// session's `freshAgent.session.init` event have arrived, in EITHER order; returns - /// the created frame. - /// - /// Not [`await_claude_created`] followed by a second drain for the init event: - /// `handle_create` starts the stdout consumer BEFORE it registers the session and - /// broadcasts `freshAgent.created`, and the fake sidecar prints `sdk.session.init` - /// in the same burst as its `created` answer. On a multi-thread runtime another - /// worker can run the consumer while the handler is still between those two points, - /// so the init event can reach the bus FIRST — a created-only drain would discard it - /// and the follow-up drain would wait out its budget for a frame already consumed. - async fn await_claude_created_and_session_init( - rx: &mut tokio::sync::broadcast::Receiver, - request_id: &str, - ) -> Value { - tokio::time::timeout(std::time::Duration::from_secs(15), async { - let mut created: Option = None; - let mut init_session_ids: Vec = Vec::new(); - loop { - let frame: Value = match rx.recv().await { - // Under host load the bounded drain can fall behind the - // 64-frame bus: re-sync and keep waiting (the 15s budget - // stays the dead-man switch); `Closed` surfaces through - // the same deadline as a lost sender. - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(err) => panic!("broadcast recv failed: {err}"), - Ok(raw) => serde_json::from_str(&raw).unwrap(), - }; - if frame["requestId"] == request_id { - assert_ne!( - frame["type"], "freshAgent.create.failed", - "create for {request_id} failed: {frame}" - ); - if frame["type"] == "freshAgent.created" { - created = Some(frame); - } - } else if frame["type"] == "freshAgent.event" - && frame["event"]["type"] == "freshAgent.session.init" - { - if let Some(sid) = frame["sessionId"].as_str() { - init_session_ids.push(sid.to_string()); - } - } - if let Some(created) = &created { - if init_session_ids - .iter() - .any(|sid| created["sessionId"] == sid.as_str()) - { - return created.clone(); - } - } - } - }) - .await - .unwrap_or_else(|_| { - panic!( - "freshAgent.created and freshAgent.session.init for {request_id} \ - arrive within budget" - ) - }) - } - /// Await the create's `sdk.session.init` binding row in the test's /// FakeIdentitySink — the ROW BARRIER of the combined drain. /// @@ -10825,6 +10784,7 @@ rl.on('line', (line) => { /// budget stays a dead-man switch, never a patience raise. async fn await_claude_session_init_binding_row( fake: &std::sync::Arc, + durable_id: &str, ) -> crate::identity_sink::FreshAgentBindingUpsert { let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { @@ -10833,7 +10793,7 @@ rl.on('line', (line) => { bindings .iter() .rev() - .find(|b| b.provider == "claude" && b.session_id == FRESH_CREATE_DURABLE_ID) + .find(|b| b.provider == "claude" && b.session_id == durable_id) .cloned() }; if let Some(row) = row { @@ -10841,7 +10801,7 @@ rl.on('line', (line) => { } assert!( tokio::time::Instant::now() < deadline, - "the session-init binding row (claude, FRESH_CREATE_DURABLE_ID) \ + "the create binding row (claude, {durable_id}) \ never landed within budget" ); tokio::time::sleep(std::time::Duration::from_millis(10)).await; @@ -11034,11 +10994,10 @@ rl.on('line', (line) => { #[tokio::test(flavor = "multi_thread")] async fn a_fresh_claude_create_window_holds_a_coordinator_starting_record() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let _env = FakeClaudeSidecarEnv::install_with_knobs(Some(2_000), false); + let env = FakeClaudeSidecarEnv::install_with_knobs(Some(2_000), false); let (mut st, mut rx) = state_with_bus(); let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); st.set_ownership(registry.clone()); - let provisional = "pending-create-req-r22-claude-window"; let task_st = st.clone(); let create = tokio::spawn(async move { @@ -11047,19 +11006,22 @@ rl.on('line', (line) => { .await; }); - // THE WINDOW: the provisional record is Starting{FreshAgent} while + let launch = env.create_request().await; + let durable = launch["sessionId"].as_str().unwrap(); + assert!(uuid::Uuid::parse_str(durable).is_ok()); + // THE WINDOW: the durable record is Starting{FreshAgent} while // the create is held open (red pre-r22: no record at all). let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( - registry.observe(PROVIDER, provisional).state, + registry.observe(PROVIDER, durable).state, freshell_ownership::OwnershipState::Starting { .. } ) { break; } assert!( tokio::time::Instant::now() < deadline, - "the fresh claude create's window never showed the provisional \ + "the fresh claude create's window never showed the durable \ Starting record (r22 F1)" ); tokio::time::sleep(std::time::Duration::from_millis(10)).await; @@ -11069,7 +11031,7 @@ rl.on('line', (line) => { assert!(matches!( registry.begin_start( PROVIDER, - provisional, + durable, freshell_ownership::RuntimeOwnerKind::Terminal, "op-r22-claude-compete", None, @@ -11080,19 +11042,22 @@ rl.on('line', (line) => { )); // Let the create finish: the created answer arrives and the SAME - // ticket rekeys to the minted session id (the provisional key + // ticket rekeys to the minted session id (the durable key // becomes the resolution alias). create.await.expect("the create task joins"); let created = await_claude_created(&mut rx, "req-r22-claude-window").await; let session_id = created["sessionId"].as_str().unwrap().to_string(); - assert!( - matches!( - registry.observe(PROVIDER, provisional).state, - freshell_ownership::OwnershipState::Aliased { to, .. } if to == session_id, - ), - "the provisional key is the rekey family's resolution alias — got {:?}", - registry.observe(PROVIDER, provisional).state - ); + assert_eq!(session_id, durable); + assert!(matches!( + registry.observe(PROVIDER, durable).state, + freshell_ownership::OwnershipState::Live { .. } + )); + assert!(matches!( + registry + .observe(PROVIDER, "pending-create-req-r22-claude-window") + .state, + freshell_ownership::OwnershipState::Vacant + )); } /// A fresh create whose pane-scoped claim the coordinator refuses (another @@ -11105,22 +11070,11 @@ rl.on('line', (line) => { let (mut st, mut rx) = state_with_bus(); let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); st.set_ownership(registry.clone()); - assert!(matches!( - registry.begin_start( - PROVIDER, - "pending-create-req-refused-claim", - freshell_ownership::RuntimeOwnerKind::Terminal, - "op-holding-the-pane-key", - None, - "test", - freshell_ownership::now_epoch_ms(), - ), - freshell_ownership::BeginOutcome::Granted { .. } - )); + let mut msg = dedup_create_msg("req-refused-claim"); + msg.observed_epoch = Some(registry.boot_epoch() + 1); + msg.observed_generation = Some(0); - let outcome = st - .handle_create(dedup_create_msg("req-refused-claim"), None) - .await; + let outcome = st.handle_create(msg, None).await; let mut answers = Vec::new(); while let Ok(frame) = rx.try_recv() { @@ -11161,7 +11115,7 @@ rl.on('line', (line) => { fresh, ClaudeCreateOutcome::Created { session_id: frame["sessionId"].as_str().unwrap().to_string(), - session_ref: None, + session_ref: Some(claude_session_ref(frame["sessionId"].as_str().unwrap())), } ); @@ -11186,15 +11140,14 @@ rl.on('line', (line) => { /// ticket — the record is never left wedged in Starting and the create /// answers the typed failure. #[tokio::test(flavor = "multi_thread")] - async fn a_fresh_claude_create_spawn_failure_settles_the_provisional_record() { + async fn a_fresh_claude_create_spawn_failure_settles_the_durable_record() { let _guard = CLAUDE_ENV_LOCK.lock().await; // The FAIL knob: the sidecar dies before `created` (a spawn-time // provider rejection — EOF). - let _env = FakeClaudeSidecarEnv::install_with_knobs(None, true); + let env = FakeClaudeSidecarEnv::install_with_knobs(Some(1500), true); let (mut st, mut rx) = state_with_bus(); let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); st.set_ownership(registry.clone()); - let provisional = "pending-create-req-r22-claude-fail"; let task_st = st.clone(); let create = tokio::spawn(async move { @@ -11203,24 +11156,26 @@ rl.on('line', (line) => { .await; }); + let launch = env.create_request().await; + let durable = launch["sessionId"].as_str().unwrap(); // The window's record exists before the failure lands. let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( - registry.observe(PROVIDER, provisional).state, + registry.observe(PROVIDER, durable).state, freshell_ownership::OwnershipState::Starting { .. } ) { break; } assert!( tokio::time::Instant::now() < deadline, - "the spawn-failure create never showed the provisional record" + "the spawn-failure create never showed the durable record" ); tokio::time::sleep(std::time::Duration::from_millis(10)).await; } // The failure lands: the ticket's RAII typed fail settles the - // provisional record — never a wedged Starting. + // durable record — never a wedged Starting. create.await.expect("the create task joins"); let mut failed_code = None; while let Ok(frame) = rx.try_recv() { @@ -11238,11 +11193,11 @@ rl.on('line', (line) => { ); assert!( matches!( - registry.observe(PROVIDER, provisional).state, + registry.observe(PROVIDER, durable).state, freshell_ownership::OwnershipState::Vacant ), - "the provisional record settles typed (Vacant) — got {:?}", - registry.observe(PROVIDER, provisional).state + "the durable record settles typed (Vacant) — got {:?}", + registry.observe(PROVIDER, durable).state ); } @@ -11251,13 +11206,14 @@ rl.on('line', (line) => { // handoff caller's bounded retry of that transient Blocked outcome. async fn begin_test_terminal_handoff( registry: &freshell_ownership::RuntimeOwnershipRegistry, + durable_id: &str, operation_id: &str, ) -> u64 { let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { match registry.begin_handoff( "claude", - FRESH_CREATE_DURABLE_ID, + durable_id, freshell_ownership::RuntimeOwnerKind::Terminal, operation_id, None, @@ -11332,12 +11288,13 @@ rl.on('line', (line) => { create_f1.model = Some("opus".into()); st.handle_create(create_f1, None).await; let created = await_claude_created(&mut rx, "req-d3-f1").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); // The adoption publishes + commits ownership under the durable id // (non-default settings make the binding RECORDABLE so the // ledger-bound assertions are non-vacuous). let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - while !sink.was_recorded("claude", FRESH_CREATE_DURABLE_ID) { + while !sink.was_recorded("claude", fresh_create_durable_id) { assert!( tokio::time::Instant::now() < deadline, "the create's adoption never recorded the binding" @@ -11346,8 +11303,12 @@ rl.on('line', (line) => { } // THE REFUSAL WINDOW: the handoff owns the key's transition. - let _handoff_generation = - begin_test_terminal_handoff(®istry, "handoff-blocking-d3-f1").await; + let _handoff_generation = begin_test_terminal_handoff( + ®istry, + fresh_create_durable_id, + "handoff-blocking-d3-f1", + ) + .await; st.handle_kill(kill_msg(&placeholder)).await; @@ -11369,7 +11330,7 @@ rl.on('line', (line) => { // row was never closed (pre-d3 the close retired it before the // claim). The load-settings path still resolves the record. assert!( - sink.was_recorded("claude", FRESH_CREATE_DURABLE_ID), + sink.was_recorded("claude", fresh_create_durable_id), "the durable ledger row stays recorded (bound) on refusal" ); // THE deterministic closed-vs-bound probe: the fake's retire log is @@ -11380,14 +11341,14 @@ rl.on('line', (line) => { .lock() .unwrap() .iter() - .all(|(_, id)| id != FRESH_CREATE_DURABLE_ID), + .all(|(_, id)| id != fresh_create_durable_id), "the durable ledger row was NOT closed on refusal — the retire \ log must not name it" ); // And the runtime is still live in the map (has_live_session keys // through cli_index — the DURABLE id is the index's entry). assert!( - st.has_live_session(FRESH_CREATE_DURABLE_ID).await, + st.has_live_session(fresh_create_durable_id).await, "the refused kill left the session live" ); } @@ -11412,14 +11373,20 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-e3r1-f1"), None) .await; let created = await_claude_created(&mut rx, "req-e3r1-f1").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); - await_create_ownership_committed(&st, FRESH_CREATE_DURABLE_ID).await; + await_create_ownership_committed(&st, fresh_create_durable_id).await; // THE COMPLETED HANDOFF (simulated at the coordinator level): the // stamp is consumed and a TERMINAL owner is committed under the // durable id. - crate::ownership_lane::take_retained_stamp(&st.ownership_stamps, FRESH_CREATE_DURABLE_ID); - let generation = begin_test_terminal_handoff(®istry, "handoff-completed-e3r1").await; + crate::ownership_lane::take_retained_stamp(&st.ownership_stamps, fresh_create_durable_id); + let generation = begin_test_terminal_handoff( + ®istry, + fresh_create_durable_id, + "handoff-completed-e3r1", + ) + .await; let terminal_owner = freshell_ownership::OwnerIdentity { kind: freshell_ownership::RuntimeOwnerKind::Terminal, terminal_id: Some("t-replacement".into()), @@ -11430,7 +11397,7 @@ rl.on('line', (line) => { assert!(matches!( registry.commit_live( "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, "handoff-completed-e3r1", generation, terminal_owner.clone(), @@ -11450,7 +11417,7 @@ rl.on('line', (line) => { ); // The replacement TERMINAL owner is untouched — the coordinator // still holds it Live (never fabricated into a Vacant commit). - match registry.observe("claude", FRESH_CREATE_DURABLE_ID).state { + match registry.observe("claude", fresh_create_durable_id).state { freshell_ownership::OwnershipState::Live { owner, .. } => { assert_eq!(owner.kind, freshell_ownership::RuntimeOwnerKind::Terminal); assert_eq!(owner.terminal_id.as_deref(), Some("t-replacement")); @@ -11482,19 +11449,20 @@ rl.on('line', (line) => { create.model = Some("opus".into()); st.handle_create(create, None).await; let created = await_claude_created(&mut rx, "req-e3r1-f2").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); - await_create_adoption_committed(&st, &sink, FRESH_CREATE_DURABLE_ID).await; + await_create_adoption_committed(&st, &sink, fresh_create_durable_id).await; assert!( crate::ownership_lane::peek_retained_stamp( &st.ownership_stamps, - FRESH_CREATE_DURABLE_ID + fresh_create_durable_id ) .is_some(), "the create's commit retained the release stamp" ); // The clean close failure (identity-conditional on the durable id). - sink.fail_retires_for("claude", FRESH_CREATE_DURABLE_ID); + sink.fail_retires_for("claude", fresh_create_durable_id); st.handle_kill(kill_msg(&placeholder)).await; let frame = await_specific_frame(&mut rx, "freshAgent.killed", &placeholder).await; @@ -11505,14 +11473,14 @@ rl.on('line', (line) => { ); // THE ABORT restored the registry to Live{FreshAgent} ... assert!(matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Live { .. } )); // ... AND the stamp is back (pre-e3r1: consumed forever). assert!( crate::ownership_lane::peek_retained_stamp( &st.ownership_stamps, - FRESH_CREATE_DURABLE_ID + fresh_create_durable_id ) .is_some(), "the abort RESTORED the consumed release stamp — the exit watcher \ @@ -11524,12 +11492,12 @@ rl.on('line', (line) => { &Some(Arc::clone(®istry)), &st.ownership_stamps, "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, "claude-exit", ); assert!( matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Vacant ), "the natural exit released the restored stamp — the owner is \ @@ -11559,18 +11527,19 @@ rl.on('line', (line) => { create.model = Some("opus".into()); st.handle_create(create, None).await; let created = await_claude_created(&mut rx, "req-e3r3-f8").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); - await_create_adoption_committed(&st, &sink, FRESH_CREATE_DURABLE_ID).await; + await_create_adoption_committed(&st, &sink, fresh_create_durable_id).await; let stamp = crate::ownership_lane::peek_retained_stamp( &st.ownership_stamps, - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, ) .expect("the create's stamp"); let runtime_pid = stamp.owner.pid.expect("the sidecar pid"); // Arm the close failure + the pre-restore park; the kill parks // AFTER its granted take (the stamp is consumed, the sidecar LIVE). - sink.fail_retires_for("claude", FRESH_CREATE_DURABLE_ID); + sink.fail_retires_for("claude", fresh_create_durable_id); let pause = std::sync::Arc::new(AdoptionTestPause { notify: tokio::sync::Notify::new(), reached: std::sync::atomic::AtomicBool::new(false), @@ -11624,7 +11593,7 @@ rl.on('line', (line) => { ); assert!( matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Vacant ), "the dead sidecar ends VACANT — the registry followed the same \ @@ -11633,7 +11602,7 @@ rl.on('line', (line) => { assert!( crate::ownership_lane::peek_retained_stamp( &st.ownership_stamps, - FRESH_CREATE_DURABLE_ID + fresh_create_durable_id ) .is_none(), "the stamp is NOT restored for the dead sidecar (the atomic \ @@ -11663,16 +11632,17 @@ rl.on('line', (line) => { create.model = Some("opus".into()); st.handle_create(create, None).await; let created = await_claude_created(&mut rx, "req-e3r4-f1").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); - await_create_adoption_committed(&st, &sink, FRESH_CREATE_DURABLE_ID).await; + await_create_adoption_committed(&st, &sink, fresh_create_durable_id).await; // THE NO-STAMP SHAPE: consume the retained stamp (the handoff's // kill_for_handoff consumption) — the kill runs the no-stamp // fallback with a LIVE owner observed. - crate::ownership_lane::take_retained_stamp(&st.ownership_stamps, FRESH_CREATE_DURABLE_ID); + crate::ownership_lane::take_retained_stamp(&st.ownership_stamps, fresh_create_durable_id); // The clean close failure. - sink.fail_retires_for("claude", FRESH_CREATE_DURABLE_ID); + sink.fail_retires_for("claude", fresh_create_durable_id); st.handle_kill(kill_msg(&placeholder)).await; let frame = await_specific_frame(&mut rx, "freshAgent.killed", &placeholder).await; assert_eq!( @@ -11687,7 +11657,7 @@ rl.on('line', (line) => { // key vacated, licensing a second writer). assert!( matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Live { owner, .. } if owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent, ), @@ -11696,7 +11666,7 @@ rl.on('line', (line) => { ); // And the session stays live in the lane. assert!( - st.has_live_session(FRESH_CREATE_DURABLE_ID).await, + st.has_live_session(fresh_create_durable_id).await, "the runtime still runs — nothing was killed" ); @@ -11710,7 +11680,7 @@ rl.on('line', (line) => { { let stamp = crate::ownership_lane::peek_retained_stamp( &st.ownership_stamps, - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, ) .expect("the recovered stamp (the original identity)"); // Kill the sidecar so the exit shape is real. @@ -11735,12 +11705,12 @@ rl.on('line', (line) => { &Some(Arc::clone(®istry)), &st.ownership_stamps, "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, "claude-exit", ); assert!( matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Vacant ), "the natural exit RELEASES the key — the recovered stamp \ @@ -11771,13 +11741,19 @@ rl.on('line', (line) => { create.model = Some("opus".into()); st.handle_create(create, None).await; let created = await_claude_created(&mut rx, "req-e3r2-f1").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); - await_create_adoption_committed(&st, &sink, FRESH_CREATE_DURABLE_ID).await; + await_create_adoption_committed(&st, &sink, fresh_create_durable_id).await; // THE MID-HANDOFF WINDOW: kill_for_handoff consumed the stamp // while the coordinator record stays Handoff. - crate::ownership_lane::take_retained_stamp(&st.ownership_stamps, FRESH_CREATE_DURABLE_ID); - let generation = begin_test_terminal_handoff(®istry, "handoff-in-flight-e3r2").await; + crate::ownership_lane::take_retained_stamp(&st.ownership_stamps, fresh_create_durable_id); + let generation = begin_test_terminal_handoff( + ®istry, + fresh_create_durable_id, + "handoff-in-flight-e3r2", + ) + .await; // THE CONCURRENT KILL: typed refusal, NOTHING durable. st.handle_kill(kill_msg(&placeholder)).await; @@ -11793,11 +11769,11 @@ rl.on('line', (line) => { .lock() .unwrap() .iter() - .all(|(_, id)| id != FRESH_CREATE_DURABLE_ID), + .all(|(_, id)| id != fresh_create_durable_id), "NOTHING durable closed — the ledger row stays bound" ); assert!( - st.has_live_session(FRESH_CREATE_DURABLE_ID).await, + st.has_live_session(fresh_create_durable_id).await, "the session stays live for the handoff" ); @@ -11813,7 +11789,7 @@ rl.on('line', (line) => { assert!(matches!( registry.commit_live( "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, "handoff-in-flight-e3r2", generation, terminal_owner, @@ -11841,11 +11817,12 @@ rl.on('line', (line) => { create.model = Some("opus".into()); st.handle_create(create, None).await; let created = await_claude_created(&mut rx, "req-e3r2-f2").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); while crate::ownership_lane::peek_retained_stamp( &st.ownership_stamps, - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, ) .is_none() { @@ -11881,7 +11858,7 @@ rl.on('line', (line) => { // (its registry release no-ops against our Stopping). let raced_stamp = crate::ownership_lane::take_retained_stamp( &st.ownership_stamps, - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, ); assert!(raced_stamp.is_some(), "the stamp existed at the park"); @@ -11903,7 +11880,7 @@ rl.on('line', (line) => { "the kill completed coherently under the held claim: {frame}" ); assert!(matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Vacant )); } @@ -11927,14 +11904,15 @@ rl.on('line', (line) => { create.model = Some("opus".into()); st.handle_create(create, None).await; let created = await_claude_created(&mut rx, "req-e3r2-f4").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); - await_create_adoption_committed(&st, &sink, FRESH_CREATE_DURABLE_ID).await; + await_create_adoption_committed(&st, &sink, fresh_create_durable_id).await; // THE RUNTIME EXITS (the sidecar's recorded pid is killed outright // — it is dead by the time the kill's unwind verdict runs). let stamp = crate::ownership_lane::peek_retained_stamp( &st.ownership_stamps, - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, ) .expect("the create's stamp"); let runtime_pid = stamp.owner.pid.expect("the sidecar pid"); @@ -11959,7 +11937,7 @@ rl.on('line', (line) => { } // The clean close failure → the unwind's verdict: DEAD → Vacant. - sink.fail_retires_for("claude", FRESH_CREATE_DURABLE_ID); + sink.fail_retires_for("claude", fresh_create_durable_id); st.handle_kill(kill_msg(&placeholder)).await; let frame = await_specific_frame(&mut rx, "freshAgent.killed", &placeholder).await; assert_eq!( @@ -11971,7 +11949,7 @@ rl.on('line', (line) => { // restored a DEAD runtime as Live with no future watcher). assert!( matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Vacant ), "the exited runtime ends VACANT — never resurrected as Live" @@ -11981,7 +11959,7 @@ rl.on('line', (line) => { assert!( crate::ownership_lane::peek_retained_stamp( &st.ownership_stamps, - FRESH_CREATE_DURABLE_ID + fresh_create_durable_id ) .is_none(), "no release stamp is restored for a dead runtime" @@ -12004,20 +11982,21 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-e3r1-f3"), None) .await; let created = await_claude_created(&mut rx, "req-e3r1-f3").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let _placeholder = created["sessionId"].as_str().unwrap().to_string(); - await_create_ownership_committed(&st, FRESH_CREATE_DURABLE_ID).await; + await_create_ownership_committed(&st, fresh_create_durable_id).await; // THE UNOWNED-LIVE shape: the coordinator key goes Vacant (a // confirmed-kill release) while the lane map still holds the live // session. let stamp = crate::ownership_lane::take_retained_stamp( &st.ownership_stamps, - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, ) .expect("the create's stamp"); registry.force_release_for_confirmed_kill( "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, &freshell_ownership::ReleaseClaim { operation_id: stamp.operation_id.clone(), generation: stamp.generation, @@ -12026,7 +12005,7 @@ rl.on('line', (line) => { "test-release", ); assert!(matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Vacant )); @@ -12034,20 +12013,20 @@ rl.on('line', (line) => { // key is Vacant) and the ADOPT must COMMIT the live runtime as the // owner. let mut resume = dedup_create_msg("req-e3r1-f3-resume"); - resume.resume_session_id = Some(FRESH_CREATE_DURABLE_ID.to_string()); + resume.resume_session_id = Some(fresh_create_durable_id.to_string()); st.handle_create(resume, None).await; let adopted = await_specific_frame(&mut rx, "freshAgent.created", "req-e3r1-f3-resume").await; assert_eq!( adopted["sessionId"], - json!(FRESH_CREATE_DURABLE_ID), + json!(fresh_create_durable_id), "the live session was adopted under its durable id (no fresh spawn): {adopted}" ); // THE F3 CONTRACT: the adopted runtime is the AUTHORITATIVE Live // owner — the key is NOT Vacant (pre-e3r1: the ticket's drop // restored Vacant while the runtime kept running). - match registry.observe("claude", FRESH_CREATE_DURABLE_ID).state { + match registry.observe("claude", fresh_create_durable_id).state { freshell_ownership::OwnershipState::Live { owner, .. } => { assert_eq!(owner.kind, freshell_ownership::RuntimeOwnerKind::FreshAgent); } @@ -12059,7 +12038,7 @@ rl.on('line', (line) => { !matches!( registry.begin_start( "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, freshell_ownership::RuntimeOwnerKind::Terminal, "op-second-writer", None, @@ -12086,13 +12065,14 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-d3-f2"), None).await; let created = await_claude_created(&mut rx, "req-d3-f2").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); while st .cli_index .lock() .await - .get(FRESH_CREATE_DURABLE_ID) + .get(fresh_create_durable_id) .is_none() { assert!( @@ -12104,7 +12084,8 @@ rl.on('line', (line) => { // The Handoff window: the map entry still exists (the runtime has // not stopped yet) — the pre-d3 fast paths adopted/no-op'd here. - begin_test_terminal_handoff(®istry, "handoff-blocking-d3-f2").await; + begin_test_terminal_handoff(®istry, fresh_create_durable_id, "handoff-blocking-d3-f2") + .await; // A delayed CREATE (resume through the live session): the claim // sees the Handoff state and answers the typed refusal — never @@ -12112,7 +12093,7 @@ rl.on('line', (line) => { // The delayed create addresses the DURABLE id (the pane's // sessionRef) — the claim resolves and sees the Handoff state. let mut delayed_create = dedup_create_msg("req-d3-f2-delayed"); - delayed_create.resume_session_id = Some(FRESH_CREATE_DURABLE_ID.to_string()); + delayed_create.resume_session_id = Some(fresh_create_durable_id.to_string()); st.handle_create(delayed_create, None).await; let refused = await_specific_frame(&mut rx, "freshAgent.create.failed", "req-d3-f2-delayed").await; @@ -12518,6 +12499,7 @@ rl.on('line', (line) => { let created = await_claude_created(&mut rx, "req-close-before-teardown").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + adopt_legacy_fixture(&st, &placeholder, durable).await; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let present = st @@ -12603,6 +12585,7 @@ rl.on('line', (line) => { let created = await_claude_created(&mut rx, "req-alias-close-fails").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + adopt_legacy_fixture(&st, &placeholder, durable).await; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let present = st @@ -12688,6 +12671,7 @@ rl.on('line', (line) => { let created = await_claude_created(&mut rx, "req-one-envelope").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + adopt_legacy_fixture(&st, &placeholder, durable).await; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let present = st @@ -12839,7 +12823,6 @@ rl.on('line', (line) => { // ── b8ke focused episode-2 round-1: the adoption's atomicity tests ───── /// The fake sidecar's durable id for a create with NO resume boot. - const FRESH_CREATE_DURABLE_ID: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; /// A minimal thread-local tracing capture (the session_handoff /// tracing_capture pattern, trimmed to the events the e2r1 tests need). @@ -12970,6 +12953,7 @@ rl.on('line', (line) => { let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); st.set_ownership(registry.clone()); + let fresh_create_durable_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; // The FOREIGN OWNER: a live terminal under the durable id the // fresh create's sidecar is about to mint. let foreign = freshell_ownership::OwnerIdentity { @@ -12981,7 +12965,7 @@ rl.on('line', (line) => { }; let freshell_ownership::BeginOutcome::Granted { generation } = registry.begin_start( "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, freshell_ownership::RuntimeOwnerKind::Terminal, "op-foreign-owner", None, @@ -12993,7 +12977,7 @@ rl.on('line', (line) => { assert!(matches!( registry.commit_live( "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, "op-foreign-owner", generation, foreign @@ -13007,6 +12991,16 @@ rl.on('line', (line) => { .await; let created = await_claude_created(&mut rx, "req-e2r3-f3").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); + prepare_legacy_init(&st, &placeholder).await; + st.adopt_session_init( + fresh_create_durable_id, + &placeholder, + "freshclaude", + None, + None, + None, + ) + .await; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let removed = !st.sessions.lock().await.contains_key(&placeholder); @@ -13026,13 +13020,13 @@ rl.on('line', (line) => { st.cli_index .lock() .await - .get(FRESH_CREATE_DURABLE_ID) + .get(fresh_create_durable_id) .is_none(), "the refused adoption publishes nothing" ); // The foreign owner is NEVER stolen. assert!(matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Live { owner, .. } if owner.kind == freshell_ownership::RuntimeOwnerKind::Terminal )); @@ -13074,11 +13068,12 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-e2r4-f4"), None) .await; let created = await_claude_created(&mut rx, "req-e2r4-f4").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Live { owner, .. } if owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent ) { @@ -13133,7 +13128,7 @@ rl.on('line', (line) => { assert!(matches!( registry.rekey_live( "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, "new-fork-id-e2r4", &placeholder, freshell_ownership::OwnerIdentity { @@ -13149,7 +13144,7 @@ rl.on('line', (line) => { freshell_ownership::CommitOutcome::Committed )); assert_eq!( - st.resolve_ownership_key(FRESH_CREATE_DURABLE_ID), + st.resolve_ownership_key(fresh_create_durable_id), "new-fork-id-e2r4", "the superseded old id resolves to the client-visible canonical id" ); @@ -13159,7 +13154,7 @@ rl.on('line', (line) => { "the canonical id resolves to itself" ); // The snapshot for the OLD id reports the CANONICAL record. - let snap = st.ownership_snapshot("claude", FRESH_CREATE_DURABLE_ID); + let snap = st.ownership_snapshot("claude", fresh_create_durable_id); assert_eq!( snap.state, registry.observe("claude", "new-fork-id-e2r4").state, @@ -13189,8 +13184,9 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-r13-f3-window"), None) .await; let created = await_claude_created(&mut rx, "req-r13-f3-window").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let map_key = created["sessionId"].as_str().unwrap().to_string(); - let old_dur = FRESH_CREATE_DURABLE_ID; + let old_dur = fresh_create_durable_id; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( @@ -13313,8 +13309,9 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-r38-f1-race"), None) .await; let created = await_claude_created(&mut rx, "req-r38-f1-race").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let map_key = created["sessionId"].as_str().unwrap().to_string(); - let old_dur = FRESH_CREATE_DURABLE_ID; + let old_dur = fresh_create_durable_id; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( @@ -13444,8 +13441,9 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-r13-f3-stale"), None) .await; let created = await_claude_created(&mut rx, "req-r13-f3-stale").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let map_key = created["sessionId"].as_str().unwrap().to_string(); - let old_dur = FRESH_CREATE_DURABLE_ID; + let old_dur = fresh_create_durable_id; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( @@ -13541,8 +13539,9 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-r29-f2-stale-arrival"), None) .await; let created = await_claude_created(&mut rx, "req-r29-f2-stale-arrival").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let map_key = created["sessionId"].as_str().unwrap().to_string(); - let old_dur = FRESH_CREATE_DURABLE_ID; + let old_dur = fresh_create_durable_id; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( @@ -13645,8 +13644,9 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-ep5-r4-f3-cycle"), None) .await; let created = await_claude_created(&mut rx, "req-ep5-r4-f3-cycle").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let map_key = created["sessionId"].as_str().unwrap().to_string(); - let old_dur = FRESH_CREATE_DURABLE_ID; + let old_dur = fresh_create_durable_id; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( @@ -13786,8 +13786,9 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-r29-f2-half-fence"), None) .await; let created = await_claude_created(&mut rx, "req-r29-f2-half-fence").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let map_key = created["sessionId"].as_str().unwrap().to_string(); - let old_dur = FRESH_CREATE_DURABLE_ID; + let old_dur = fresh_create_durable_id; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( @@ -13853,8 +13854,9 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-e2r3-f1"), None) .await; let created = await_claude_created(&mut rx, "req-e2r3-f1").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); - let old_dur = FRESH_CREATE_DURABLE_ID; + let old_dur = fresh_create_durable_id; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( @@ -14000,11 +14002,12 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-postcap-f3"), None) .await; let created = await_claude_created(&mut rx, "req-postcap-f3").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let _placeholder = created["sessionId"].as_str().unwrap().to_string(); // The first create's adoption must COMMIT before the pre-commit // park arms — otherwise it parks there too, and both the `reached` // flag and the stale-start recovery below see the WRONG claim. - await_create_ownership_committed(&st, FRESH_CREATE_DURABLE_ID).await; + await_create_ownership_committed(&st, fresh_create_durable_id).await; // The pre-commit park: a SECOND session (fresh map key — the // ordinary-create shape, no prior durable id) whose adoption @@ -14110,6 +14113,7 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-e2r3-f2"), None) .await; let created = await_claude_created(&mut rx, "req-e2r3-f2").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); // The first adoption publishes. let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); @@ -14117,7 +14121,7 @@ rl.on('line', (line) => { .cli_index .lock() .await - .get(FRESH_CREATE_DURABLE_ID) + .get(fresh_create_durable_id) .is_none() { assert!( @@ -14184,13 +14188,14 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-e2r2-f2a"), None) .await; let created = await_claude_created(&mut rx, "req-e2r2-f2a").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder_a = created["sessionId"].as_str().unwrap().to_string(); // Let the fresh create's OWN adoption commit first (it is the first // claimant — this part is the pre-existing behavior). let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Live { .. } ) { break; @@ -14329,6 +14334,7 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-e2r2-f3"), None) .await; let created = await_claude_created(&mut rx, "req-e2r2-f3").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); // The durable id the sidecar will mint must differ from the FIRST // create's own adoption. The FIRST create's adoption must PROVABLY @@ -14336,7 +14342,7 @@ rl.on('line', (line) => { // pause and the `reached` flag would prove the WRONG task parked // (the e2r3 F10 fix: exactly one parker, unambiguously the one // under test). - let first_dur = FRESH_CREATE_DURABLE_ID; + let first_dur = fresh_create_durable_id; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); while st.cli_index.lock().await.get(first_dur).is_none() { assert!( @@ -14423,7 +14429,7 @@ rl.on('line', (line) => { async fn the_init_adoption_claim_records_the_provenance_initiator() { let _guard = CLAUDE_ENV_LOCK.lock().await; let _env = FakeClaudeSidecarEnv::install(); - let (mut st, _rx) = state_with_bus(); + let (mut st, mut rx) = state_with_bus(); let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); st.set_ownership(registry.clone()); @@ -14437,12 +14443,14 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-e2r7-f7"), provenance) .await; + let created = await_claude_created(&mut rx, "req-e2r7-f7").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); // The deferred adoption claims under the minted durable id once the // create registers the runtime. let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Live { owner, .. } if owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent ) { @@ -14458,7 +14466,7 @@ rl.on('line', (line) => { let claim = events .iter() .find(|(event, _initiator, op)| { - event == "ownership.start.begin" && op.starts_with("claude-init-adopt-") + event == "ownership.start.begin" && op == "create-resume-req-e2r7-f7" }) .expect("the adoption's claim event is captured"); assert_eq!( @@ -14552,35 +14560,7 @@ rl.on('line', (line) => { let created = await_claude_created(&mut rx, "req-kill-refused").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); - // Seed the lane's believed runtime identity exactly as a committed - // lane claim does (claim → commit Live{FreshAgent} → retained stamp - // under the placeholder — the kill's stop-claim source). - let mut seed_ticket = match crate::ownership_lane::begin_lane_claim( - &st.ownership, - PROVIDER, - &placeholder, - "test-refused-kill-seed", - None, - "test", - crate::session_lease::now_epoch_ms(), - ) { - crate::ownership_lane::LaneClaim::Granted(ticket) => Some(ticket), - _ => panic!("fixture: the seed claim must be granted on a vacant key"), - }; - assert!( - crate::ownership_lane::commit_lane_claim( - &st.ownership, - &st.ownership_stamps, - Some(&st.broadcast_tx), - PROVIDER, - &placeholder, - &mut seed_ticket, - &placeholder, - None, - ) - .is_ok(), - "fixture: the seed commit lands Live{{FreshAgent}}" - ); + await_create_ownership_committed(&st, &placeholder).await; // The refusal precondition: a handoff owns the placeholder's // transition, so the kill's fenced stop is typed-blocked. let freshell_ownership::BeginOutcome::Granted { .. } = registry.begin_handoff( @@ -14682,35 +14662,7 @@ rl.on('line', (line) => { let created = await_claude_created(&mut rx, "req-kill-unconfirmed").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); - // Seed the lane's believed runtime identity exactly as a committed - // lane claim does (the kill's stop-claim source). - let mut seed_ticket = match crate::ownership_lane::begin_lane_claim( - &st.ownership, - PROVIDER, - &placeholder, - "test-unconfirmed-kill-seed", - None, - "test", - crate::session_lease::now_epoch_ms(), - ) { - crate::ownership_lane::LaneClaim::Granted(ticket) => Some(ticket), - _ => panic!("fixture: the seed claim must be granted on a vacant key"), - }; - assert!( - crate::ownership_lane::commit_lane_claim( - &st.ownership, - &st.ownership_stamps, - Some(&st.broadcast_tx), - PROVIDER, - &placeholder, - &mut seed_ticket, - &placeholder, - None, - ) - .is_ok(), - "fixture: the seed commit lands Live{{FreshAgent}}" - ); - + await_create_ownership_committed(&st, &placeholder).await; // Park a TERM-immune tagged "CLI grandchild" under the sidecar's // ownership tag (the FR3 fixture): it deterministically outlives the // one-round confirmation window. @@ -14849,6 +14801,7 @@ rl.on('line', (line) => { // The fake sidecar's default durable cliSessionId (its script emits it // on sdk.session.init when no resume boot was supplied). let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + adopt_legacy_fixture(&st, &placeholder, durable).await; // The REAL consumer fold adopts the alias (poll, bounded) — the kill // must race NOTHING here: the alias exists before the exit. let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); @@ -14940,6 +14893,7 @@ rl.on('line', (line) => { st.handle_create(msg, None).await; let created = await_claude_created(&mut rx, "req-orphan-kill").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); + adopt_legacy_fixture(&st, &placeholder, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa").await; // The adoption write has been INVOKED (production's spawn-launch // point) — it is in flight, not yet applied. @@ -15010,6 +14964,7 @@ rl.on('line', (line) => { .await; let created = await_claude_created(&mut rx, "req-claim-kill").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); + adopt_legacy_fixture(&st, &placeholder, durable).await; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let present = st @@ -15194,6 +15149,7 @@ rl.on('line', (line) => { .await; let created = await_claude_created(&mut rx, "req-reopen-1").await; let placeholder_old = created["sessionId"].as_str().unwrap().to_string(); + adopt_legacy_fixture(&st, &placeholder_old, durable).await; await_alias(&st, durable, &placeholder_old).await; st.handle_kill(kill_msg(&placeholder_old)).await; assert!( @@ -15281,6 +15237,7 @@ rl.on('line', (line) => { .await; let created = await_claude_created(&mut rx, "req-live-owner").await; let placeholder_live = created["sessionId"].as_str().unwrap().to_string(); + adopt_legacy_fixture(&st, &placeholder_live, durable).await; await_alias(&st, durable, &placeholder_live).await; // Fabricate the surviving stale record: the OLD epoch's placeholder @@ -15330,6 +15287,7 @@ rl.on('line', (line) => { .await; let created = await_claude_created(&mut rx, "req-evict-gap").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); + adopt_legacy_fixture(&st, &placeholder, durable).await; await_alias(&st, durable, &placeholder).await; // THE WINDOW: the eviction's first half (sessions-map removal) has @@ -15391,6 +15349,7 @@ rl.on('line', (line) => { .await; let created = await_claude_created(&mut rx, "req-evict-cs").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); + adopt_legacy_fixture(&st, &placeholder, durable).await; await_alias(&st, durable, &placeholder).await; // The consumer-exit shape per the report: the session entry is GONE @@ -15480,6 +15439,7 @@ rl.on('line', (line) => { st.handle_create(msg, None).await; let created = await_claude_created(&mut rx, "req-fence-orphan").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); + adopt_legacy_fixture(&st, &placeholder, durable).await; gate.entered .recv_timeout(std::time::Duration::from_secs(15)) .expect("the adoption write entered (in flight)"); @@ -16740,7 +16700,7 @@ rl.on('line', (line) => { /// (placeholder → durable), so a restart can never lose it before any /// exit-eviction would demote it. #[tokio::test(flavor = "multi_thread")] - async fn the_create_resume_registration_persists_the_alias_record() { + async fn a_create_resume_uses_its_durable_id_without_writing_a_self_alias() { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); let (st, mut rx) = state_with_bus(); @@ -16757,28 +16717,14 @@ rl.on('line', (line) => { let created = await_claude_created(&mut rx, "req-alias-persist").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); - // The REGISTRATION'S write is observable immediately; the adoption - // corrector echoes the same pair on the init fold. - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - let records = fake.alias_tombstone_records("claude", &placeholder); - if records.iter().any(|(d, _)| d == durable) { - break; - } - assert!( - tokio::time::Instant::now() < deadline, - "the alias mapping never reached the durable store" - ); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - } + assert_eq!(placeholder, durable); + st.handle_send(send_msg(durable, "__emit_assistant__")) + .await; + await_frame_of_inner_type(&mut rx, "freshAgent.assistant").await; + assert!(fake.alias_tombstone_records("claude", durable).is_empty()); assert!( - fake.alias_record_writes - .lock() - .unwrap() - .iter() - .any(|(p, ph, d)| p == "claude" && ph == &placeholder && d == durable), - "the registration persisted the alias: {:?}", - fake.alias_record_writes.lock().unwrap() + fake.alias_record_writes.lock().unwrap().is_empty(), + "a create and its confirming init need no alias when both ids are the UUID" ); drop(env); } @@ -17807,6 +17753,7 @@ rl.on('line', (line) => { crate::claude_turns::SharedLedger::default(), crate::attention::AttentionClock::default(), None, // provenance: test lane is conn-less (D8) + false, ); // The replace-resend is the LAST scripted line: observing its input proves the @@ -17963,13 +17910,15 @@ rl.on('line', (line) => { let created_frame = await_claude_created(&mut rx, "req-cli-idx-1").await; let created = created_frame["sessionId"].as_str().unwrap().to_string(); + st.handle_send(send_msg(&created, "__emit_assistant__")) + .await; // The fake emits sdk.session.init with the durable uuid; poll until the // consumer has recorded it (bounded). let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { { let idx = st.cli_index.lock().await; - if idx.get("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") == Some(&created) { + if idx.get(created.as_str()) == Some(&created) { break; } } @@ -17988,7 +17937,7 @@ rl.on('line', (line) => { .unwrap() .cli_session_id .as_deref(), - Some("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + Some(created.as_str()) ); // Kill evicts the index entry. st.handle_kill(FreshAgentKill { @@ -18058,13 +18007,16 @@ rl.on('line', (line) => { // returns (including its `Deferred` arm, claude.rs:8046-8062), so // the frame orders nothing about the row; the row BARRIER below is // what proves the row landed. - await_claude_created_and_session_init(&mut rx, "req-binding-init").await; - let b = await_claude_session_init_binding_row(&fake).await; + let created = await_claude_created(&mut rx, "req-binding-init").await; + let b = + await_claude_session_init_binding_row(&fake, created["sessionId"].as_str().unwrap()) + .await; assert_eq!(b.provider, "claude"); assert_eq!(b.mode, "kilroy", "sessionType flavour preserved in the row"); assert_eq!( - b.session_id, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "keyed by cliSessionId" + b.session_id, + created["sessionId"].as_str().unwrap(), + "keyed by the create-time UUID" ); assert_eq!(b.settings.model.as_deref(), Some("opus-x")); assert_eq!(b.settings.permission_mode.as_deref(), Some("plan")); @@ -18087,8 +18039,10 @@ rl.on('line', (line) => { let mut msg = dedup_create_msg("req-binding-plugins"); msg.plugins = Some(vec!["/workspace/.claude/plugins/local".to_string()]); state.handle_create(msg, None).await; - await_claude_created_and_session_init(&mut rx, "req-binding-plugins").await; - let row = await_claude_session_init_binding_row(&fake).await; + let created = await_claude_created(&mut rx, "req-binding-plugins").await; + let row = + await_claude_session_init_binding_row(&fake, created["sessionId"].as_str().unwrap()) + .await; assert_eq!( row.settings.plugins, Some(vec!["/workspace/.claude/plugins/local".to_string()]) @@ -18100,8 +18054,10 @@ rl.on('line', (line) => { let mut msg = dedup_create_msg("req-binding-no-plugins"); msg.plugins = Some(Vec::new()); state.handle_create(msg, None).await; - await_claude_created_and_session_init(&mut rx, "req-binding-no-plugins").await; - let row = await_claude_session_init_binding_row(&fake).await; + let created = await_claude_created(&mut rx, "req-binding-no-plugins").await; + let row = + await_claude_session_init_binding_row(&fake, created["sessionId"].as_str().unwrap()) + .await; assert_eq!( row.settings, crate::identity_sink::FreshAgentSettings::default(), @@ -18138,8 +18094,11 @@ rl.on('line', (line) => { // returns (including its `Deferred` arm, claude.rs:8046-8062), so // the frame orders nothing about the row; the row BARRIER below is // what proves the row landed. - await_claude_created_and_session_init(&mut rx, "req-binding-blank").await; - let b = await_claude_session_init_binding_row(&fake).await; + let created = await_claude_created(&mut rx, "req-binding-blank").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); + let b = + await_claude_session_init_binding_row(&fake, created["sessionId"].as_str().unwrap()) + .await; // THE r27 F4 CONTRACT: the lineage row IS recorded (blank settings // verbatim) — the no-skip truth. @@ -18152,12 +18111,12 @@ rl.on('line', (line) => { // Task 3 keying keeps a later resume from arming a FALSE // SETTINGS_RESET for this legitimately-default session. assert!( - fake.load_settings("claude", FRESH_CREATE_DURABLE_ID) + fake.load_settings("claude", fresh_create_durable_id) .is_none(), "lineage-only row answers no settings snapshot" ); assert!( - !fake.was_recorded("claude", FRESH_CREATE_DURABLE_ID), + !fake.was_recorded("claude", fresh_create_durable_id), "lineage-only row must not count as recorded (false SETTINGS_RESET)" ); drop(env); @@ -18182,15 +18141,26 @@ rl.on('line', (line) => { let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); st.set_identity_sink(fake.clone()); - // Park the adoption's binding write (keyed by the fixed fake cli id). - let stall = fake.arm_binding_stall("claude", FRESH_CREATE_DURABLE_ID); + let mut msg = dedup_create_msg("req-r27-f4-order"); + msg.model = Some("opus-x".to_string()); + st.handle_create(msg, None).await; + let created = await_claude_created(&mut rx, "req-r27-f4-order").await; + let map_key = created["sessionId"].as_str().unwrap().to_string(); + prepare_legacy_init(&st, &map_key).await; + let fresh_create_durable_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + let stall = fake.arm_binding_stall("claude", fresh_create_durable_id); let st2 = st.clone(); + let identity = st.identity_sink(); let create_task = tokio::spawn(async move { - // Settings-bearing so the write is recordable under BOTH the - // pre- and post-fix contracts (the red must park, not hang). - let mut msg = dedup_create_msg("req-r27-f4-order"); - msg.model = Some("opus-x".to_string()); - st2.handle_create(msg, None).await; + st2.adopt_session_init( + fresh_create_durable_id, + &map_key, + "freshclaude", + None, + identity, + None, + ) + .await; }); stall .entered @@ -18203,18 +18173,18 @@ rl.on('line', (line) => { // could BEGIN while the binding was blocked. assert!( matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Starting { .. } ), "the binding write runs INSIDE the coordinator operation (the \ pre-commit Starting claim) — got {:?}", - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state + registry.observe("claude", fresh_create_durable_id).state ); assert!( matches!( registry.begin_handoff( "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, freshell_ownership::RuntimeOwnerKind::Terminal, "competing-mid-write", None, @@ -18229,7 +18199,7 @@ rl.on('line', (line) => { // Restore the key for the release (the probe handoff never entered). let _ = registry.fail( "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, "competing-mid-write", u64::MAX, false, @@ -18237,11 +18207,9 @@ rl.on('line', (line) => { // Release: the publish + commit complete, the create answers green. let _ = stall.release.send(()); - let created = await_claude_created(&mut rx, "req-r27-f4-order").await; - assert_eq!(created["type"], "freshAgent.created", "{created}"); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); while !matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Live { .. } ) { assert!( @@ -18271,13 +18239,26 @@ rl.on('line', (line) => { st.set_identity_sink(fake.clone()); // The ledger refuses every write (disk-full/permission shape). - fake.set_fail_writes(true); let mut msg = dedup_create_msg("req-r27-f4-fail"); msg.model = Some("opus-x".to_string()); st.handle_create(msg, None).await; let created = await_claude_created(&mut rx, "req-r27-f4-fail").await; + let fresh_create_durable_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; assert_eq!(created["type"], "freshAgent.created", "{created}"); + prepare_legacy_init(&st, &created["sessionId"].as_str().unwrap()).await; + while rx.try_recv().is_ok() {} + fake.set_fail_writes(true); + st.adopt_session_init( + fresh_create_durable_id, + created["sessionId"].as_str().unwrap(), + "freshclaude", + None, + st.identity_sink(), + None, + ) + .await; + // The adoption runs on the sidecar's init event: the binding write // fails → the runtime is torn down and the adoption ABANDONS // BEFORE the publish/commit. Bounded-poll for the settled state. @@ -18288,7 +18269,7 @@ rl.on('line', (line) => { let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let settled = matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Vacant ); let session_gone = !st.has_live_session(&created_session_id).await; @@ -18298,7 +18279,7 @@ rl.on('line', (line) => { assert!( tokio::time::Instant::now() < deadline, "the failed binding write never settled (state {:?})", - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state + registry.observe("claude", fresh_create_durable_id).state ); tokio::time::sleep(std::time::Duration::from_millis(10)).await; } @@ -18307,7 +18288,7 @@ rl.on('line', (line) => { while let Ok(raw) = rx.try_recv() { let frame: Value = serde_json::from_str(&raw).unwrap(); if frame["type"] == "session.runtimeOwner" - && frame["sessionId"] == json!(FRESH_CREATE_DURABLE_ID) + && frame["sessionId"] == json!(fresh_create_durable_id) { saw_owner = true; } @@ -18348,8 +18329,10 @@ rl.on('line', (line) => { // returns (including its `Deferred` arm, claude.rs:8046-8062), so // the frame orders nothing about the row; the row BARRIER below is // what proves the row landed. - await_claude_created_and_session_init(&mut rx, "req-binding-prov").await; - let b = await_claude_session_init_binding_row(&fake).await; + let created = await_claude_created(&mut rx, "req-binding-prov").await; + let b = + await_claude_session_init_binding_row(&fake, created["sessionId"].as_str().unwrap()) + .await; assert_eq!( b.asserted_stamps().client_instance_id.as_deref(), Some("client-claude") @@ -18929,7 +18912,7 @@ rl.on('line', (line) => { request_id: &str, ) -> String { st.handle_create(dedup_create_msg(request_id), None).await; - let created = await_claude_created_and_session_init(rx, request_id).await; + let created = await_claude_created(rx, request_id).await; let session_id = created["sessionId"].as_str().unwrap().to_string(); st.sessions .lock() @@ -18963,11 +18946,10 @@ rl.on('line', (line) => { async fn a_resumed_dead_session_no_longer_reads_as_ended() { let _guard = CLAUDE_ENV_LOCK.lock().await; let _env = FakeClaudeSidecarEnv::install(); - // The fake sidecar's session reports this durable id. - const DURABLE: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; let (st, mut rx) = state_with_bus(); let old_pane_id = crash_a_new_session(&st, &mut rx, "req-dead-resume").await; - for id in [old_pane_id.as_str(), DURABLE] { + let durable = old_pane_id.as_str(); + for id in [old_pane_id.as_str(), durable] { assert_eq!( st.live_snapshot_overlay(id).await["status"], "exited", @@ -18976,18 +18958,21 @@ rl.on('line', (line) => { } let home = tempfile::tempdir().unwrap(); - write_fake_transcript(home.path(), DURABLE); + write_fake_transcript(home.path(), durable); std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); - st.handle_attach(attach_msg_with_resume("pane-after-reload", DURABLE)) + st.handle_attach(attach_msg_with_resume("pane-after-reload", durable)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); - assert!(st.has_live_session(DURABLE).await, "the attach resumed it"); + assert!(st.has_live_session(durable).await, "the attach resumed it"); - assert_ne!(st.live_snapshot_overlay(DURABLE).await["status"], "exited"); - assert_eq!( - st.live_snapshot_overlay(&old_pane_id).await, - json!({}), - "the old pane id no longer names an ended session" + assert_ne!(st.live_snapshot_overlay(durable).await["status"], "exited"); + assert_ne!( + st.live_snapshot_overlay(&old_pane_id).await["status"], + "exited" + ); + assert!( + st.dead_sessions.find(&old_pane_id).is_none(), + "resuming forgets the old death under every id" ); } @@ -18998,13 +18983,12 @@ rl.on('line', (line) => { async fn a_resumed_session_keeps_the_attention_clock_of_its_death() { let _guard = CLAUDE_ENV_LOCK.lock().await; let _env = FakeClaudeSidecarEnv::install(); - // The fake sidecar's session reports this durable id. - const DURABLE: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; let (st, mut rx) = state_with_bus(); st.handle_create(dedup_create_msg("req-clock-carry"), None) .await; - let created = await_claude_created_and_session_init(&mut rx, "req-clock-carry").await; + let created = await_claude_created(&mut rx, "req-clock-carry").await; let pane_id = created["sessionId"].as_str().unwrap().to_string(); + let durable = pane_id.as_str(); // A turn in flight (the fake answers no plain send), so the death rings. st.handle_send(send_msg(&pane_id, "a turn the death cuts off")) .await; @@ -19021,14 +19005,14 @@ rl.on('line', (line) => { let death_at = death["event"]["at"].as_i64().expect("a numeric at"); let home = tempfile::tempdir().unwrap(); - write_fake_transcript(home.path(), DURABLE); + write_fake_transcript(home.path(), durable); std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); - st.handle_attach(attach_msg_with_resume("pane-after-reload", DURABLE)) + st.handle_attach(attach_msg_with_resume("pane-after-reload", durable)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); - assert!(st.has_live_session(DURABLE).await, "the attach resumed it"); + assert!(st.has_live_session(durable).await, "the attach resumed it"); - let key = st.resolve_session_key(DURABLE).await.expect("live key"); + let key = st.resolve_session_key(durable).await.expect("live key"); let floor = st .sessions .lock() @@ -19057,7 +19041,7 @@ rl.on('line', (line) => { let (st, mut rx) = state_with_bus(); st.handle_create(dedup_create_msg("req-reap-resume"), None) .await; - let created = await_claude_created_and_session_init(&mut rx, "req-reap-resume").await; + let created = await_claude_created(&mut rx, "req-reap-resume").await; let pane_id = created["sessionId"].as_str().unwrap().to_string(); // The fake never answers a plain send: the turn stays in flight. st.handle_send(send_msg(&pane_id, "a turn the sidecar dies in")) @@ -19308,6 +19292,7 @@ rl.on('line', (line) => { Arc::clone(&ledger), clock.clone(), None, // provenance: test lane is conn-less (D8) + false, ); st.sessions.lock().await.insert( map_key.to_string(), @@ -20020,10 +20005,7 @@ rl.on('line', (line) => { "the send waited, then ran against the POST-rollback session: {send_frame}" ); assert!( - send_frame["sessionId"] - .as_str() - .unwrap_or_default() - .starts_with("fake-claude-session-"), + send_frame["sessionId"] == adopted, "the send addressed the freshly-adopted sidecar: {send_frame}" ); // SESSION_NOT_FOUND is structurally impossible on this lane: the send diff --git a/crates/freshell-freshagent/src/claude/create_binding.rs b/crates/freshell-freshagent/src/claude/create_binding.rs new file mode 100644 index 000000000..3835023e7 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/create_binding.rs @@ -0,0 +1,99 @@ +//! A create establishes recoverable identity before its acknowledgement. The +//! native transcript may not exist until the first prompt; Fresh genesis and +//! the create-request lineage make that zero-turn identity durable meanwhile. + +use super::*; + +impl FreshClaudeState { + pub(super) async fn record_create_binding( + &self, + session_id: &str, + msg: &FreshAgentCreate, + provenance: Option<&crate::BindProvenance>, + ticket: &Option, + ) -> std::io::Result<()> { + let Some(sink) = self.identity_sink() else { + return Ok(()); + }; + let (settings, genesis) = { + let sessions = self.sessions.lock().await; + let session = sessions.get(session_id).ok_or_else(|| { + std::io::Error::other("Claude stopped while recording its create identity") + })?; + ( + session.configuration.settings.clone(), + session.launch_genesis.clone(), + ) + }; + let generation = ticket.as_ref().map(|ticket| ticket.generation()); + sink.record_binding(crate::identity_sink::FreshAgentBindingUpsert { + provider: PROVIDER.into(), + session_id: session_id.into(), + mode: session_type_str(msg.session_type).into(), + create_request_id: Some(msg.request_id.clone()), + resolves_pending: None, + supersedes: None, + name_transition: None, + genesis: genesis.map_or(crate::identity_sink::GenesisUpdate::Keep, |recorded| { + crate::identity_sink::GenesisUpdate::Set(recorded.genesis) + }), + provenance: provenance.cloned().into(), + observed_epoch: generation.and( + self.ownership + .as_ref() + .map(|registry| registry.boot_epoch()), + ), + observed_generation: generation, + authoritative: false, + settings, + }) + .await + } + + /// The persisted binding/genesis verifies durable routing independently of + /// native transcript flush. Naming can bind to this identity at create. + pub(super) async fn bind_create_name( + &self, + session_id: &str, + session_type: &str, + ) -> Option<( + freshell_protocol::session_names::SessionNameRef, + freshell_protocol::session_names::SessionNameRecord, + )> { + if session_type == "kilroy" + || !self.identity_sink().is_some_and(|sink| { + sink.load_genesis(PROVIDER, session_id).is_some() + || sink.was_recorded(PROVIDER, session_id) + }) + { + return None; + } + let handle = self.naming_handles.lock().await.get(session_id).cloned()?; + let target = freshell_protocol::session_names::SessionNameRef::Session { + provider: freshell_protocol::session_names::NamedProvider::Claude, + session_id: session_id.into(), + }; + let transition = crate::naming::NameTransition::binding( + crate::naming::NameTransitionReason::InitialMaterialization, + freshell_protocol::native_location::NativeAcquisition { + location: freshell_protocol::native_location::NativeLocation::Claude { + config_root: self.transcript_roots().first()?.display().to_string(), + transcript_path: None, + project_directory_key: None, + transcript_cwd: None, + effective_project_key_override: None, + }, + evidence: freshell_protocol::native_location::NativeEvidenceKind::PersistedMetadata, + persistence: freshell_protocol::native_location::NativePersistence::Verified, + }, + handle, + ); + let update = + crate::naming::fold_identity_transition(&self.naming(), &transition, target.clone()) + .await?; + if update.record.name_ref == target { + self.naming_handles.lock().await.remove(session_id); + } + Some((update.record.name_ref.clone(), update.record)) + } +} diff --git a/crates/freshell-freshagent/src/claude/establish.rs b/crates/freshell-freshagent/src/claude/establish.rs index e21992a37..5cdb86c8e 100644 --- a/crates/freshell-freshagent/src/claude/establish.rs +++ b/crates/freshell-freshagent/src/claude/establish.rs @@ -51,8 +51,6 @@ impl EstablishLane { /// The map key a new session registers under. pub(super) enum RegisterUnder { - /// The id the sidecar minted (`created.sessionId`): a create. - Minted, /// The attaching pane's id: an attach-resume (the client routes the pane's /// frames by it). Pane(String), @@ -183,6 +181,8 @@ pub(super) enum EstablishError { Spawn(String), /// The `create` line could not be written. Write(String), + /// The create-time identity could not be made durable. + Binding(String), /// The sidecar did not answer `created` (a typed refusal, a protocol /// mismatch, or no answer). Boot(CreateFailure), @@ -200,7 +200,7 @@ impl EstablishError { /// What went wrong, in words. pub(super) fn message(&self, lane: EstablishLane) -> String { match self { - Self::Spawn(detail) | Self::Write(detail) => detail.clone(), + Self::Spawn(detail) | Self::Write(detail) | Self::Binding(detail) => detail.clone(), Self::Boot(failure) => failure.message().to_string(), Self::ClosedInFlight => { "session closed while the resume was in flight; torn down".to_string() @@ -224,6 +224,7 @@ impl EstablishError { match self { Self::Spawn(_) => "CLAUDE_SIDECAR_START_FAILED", Self::Write(_) => "CLAUDE_SIDECAR_WRITE_FAILED", + Self::Binding(_) => "LEDGER_WRITE_FAILED", Self::Boot(failure) => failure.code(), Self::ClosedInFlight | Self::LeaseRevoked @@ -244,6 +245,7 @@ impl EstablishError { match self { Self::Spawn(_) => "spawn", Self::Write(_) => "write", + Self::Binding(_) => "binding", Self::Boot(_) => "boot", Self::ClosedInFlight => "closed_in_flight", Self::LeaseRevoked => "lease_revoked", @@ -273,6 +275,43 @@ pub(super) struct StartWatch<'a> { } impl FreshClaudeState { + /// A completed lease belongs to its exact sidecar incarnation until the + /// process tree is confirmed gone. This continuation runs even on hosted + /// lanes without an ownership coordinator; a replacement's lease is fenced + /// by its map key and ownership tag. + pub(super) fn release_leases_after_teardown( + &self, + teardown: LoggedTeardown, + durables: Vec, + map_key: String, + ownership_id: Option, + ) -> LoggedTeardown { + let leases = self.leases.clone(); + let release = move || { + if let Some(ownership_id) = ownership_id.as_deref() { + for durable in &durables { + leases.clear_binding_if_matches(PROVIDER, durable, &map_key, ownership_id); + } + } + }; + match teardown { + LoggedTeardown::Confirmed => { + release(); + LoggedTeardown::Confirmed + } + LoggedTeardown::NotConfirmed { escalation } => LoggedTeardown::NotConfirmed { + escalation: tokio::spawn(async move { + let confirmed = escalation.await.unwrap_or(false); + if confirmed { + release(); + } + confirmed + }), + }, + other => other, + } + } + /// Claim the provider lease on `durable` for the launch `request_id`, the /// one claim every launch of a durable id makes (create-resume, attach, /// handoff resume, rollback). A holder whose lease expired with a recorded @@ -296,7 +335,14 @@ impl FreshClaudeState { )); } crate::session_lease::FreshSessionClaim::BoundLive { live_session_key } => { - return LaunchLease::BoundLive { live_session_key }; + return if self.sessions.lock().await.contains_key(&live_session_key) { + LaunchLease::BoundLive { live_session_key } + } else { + // Teardown still owns the completed lease. It releases + // only after confirmed death; do not acknowledge its + // absent runtime as an adopted live session. + LaunchLease::Held + }; } crate::session_lease::FreshSessionClaim::Held { .. } => return LaunchLease::Held, crate::session_lease::FreshSessionClaim::ExpiredNeedsKill { pid, ownership_id } => { @@ -606,7 +652,6 @@ impl FreshClaudeState { let sidecar_pid = child.id(); let minted = created.session_id; let (map_key, adopted) = match register_under { - RegisterUnder::Minted => (minted.clone(), None), RegisterUnder::Pane(pane) => (pane, None), RegisterUnder::Adopted(id) => (id.clone(), Some(id)), }; @@ -614,9 +659,9 @@ impl FreshClaudeState { // Unified agent names (Task 2): a create's pending handle is admitted with // the minted id BEFORE any failure arm, so no failure strands a handle the // pane already carries (retries re-ensure the same handle idempotently). - let naming_projection = match naming { + let mut naming_projection = match naming { Some(msg) => { - self.admit_create_handle(msg, &spec.session_type, &minted) + self.admit_create_handle(msg, &spec.session_type, &map_key) .await } None => None, @@ -736,7 +781,8 @@ impl FreshClaudeState { Arc::clone(&pending), Arc::clone(&ledger), clock.clone(), - provenance, + provenance.clone(), + naming.is_some(), ); self.test_holds.registration().await; sessions.insert( @@ -798,7 +844,11 @@ impl FreshClaudeState { } } // Round 6 (Finding 2): the seat→durable mapping is durable at registration. - if let Some(durable) = claim.as_ref().map(|claim| claim.durable.as_str()) { + if let Some(durable) = claim + .as_ref() + .map(|claim| claim.durable.as_str()) + .filter(|durable| *durable != map_key) + { if let Some(sink) = self.identity_sink() { if let Err(e) = sink .record_alias_tombstone( @@ -846,6 +896,46 @@ impl FreshClaudeState { } } + // A created session is already a durable identity, including before + // its first prompt. Write lineage/genesis while the ownership claim + // still fences the registration, before Live or the acknowledgement. + if let Some(create) = naming { + if let Err(error) = self + .record_create_binding(&map_key, create, provenance.as_ref(), own_ticket) + .await + { + if let Some(session) = { self.sessions.lock().await.remove(&map_key) } { + let _teardown: LoggedTeardown = + self.teardown_removed_session_logged(session).await; + } + self.evict_cli_index_aliases(&map_key).await; + if let Some(mut guard) = lease_guard.take() { + guard.fail(); + } + if let Some(claim) = claim.as_ref().filter(|claim| claim.dead_state.is_some()) { + self.rollback_session_claim(&claim.durable).await; + } + return Err(EstablishError::Binding(error.to_string())); + } + if let Some(claim) = claim.as_ref() { + if self.claim_dead_state_advanced(&claim.durable, claim.dead_state) { + if let Some(session) = { self.sessions.lock().await.remove(&map_key) } { + let _teardown: LoggedTeardown = + self.teardown_removed_session_logged(session).await; + } + self.evict_cli_index_aliases(&map_key).await; + if let Some(mut guard) = lease_guard.take() { + guard.fail(); + } + return Err(EstablishError::ClosedInFlight); + } + } + naming_projection = self + .bind_create_name(&map_key, &spec.session_type) + .await + .or(naming_projection); + } + // Task 12/13: bind the durable id to the live session and release the // lease in one lock scope. A revoked lease (an expired handle-less // holder) means the session must not be kept. diff --git a/crates/freshell-freshagent/src/claude/tests/create_binding_tests.rs b/crates/freshell-freshagent/src/claude/tests/create_binding_tests.rs new file mode 100644 index 000000000..3e92d56a7 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/tests/create_binding_tests.rs @@ -0,0 +1,158 @@ +use super::*; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn create_binding_and_name_are_durable_before_live_and_created() { + let _env_lock = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install_with_knobs(Some(1500), false); + let (mut state, mut rx) = state_with_bus(); + let identity = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(identity.clone()); + state.set_ownership(Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new())); + let names = crate::naming::test_support::RecordingSink::new(); + state.set_session_naming(names.clone()); + let mut msg = dedup_create_msg("create-bound-before-live"); + msg.naming_handle = Some("pre-create-name".into()); + let task = { + let state = state.clone(); + tokio::spawn(async move { state.handle_create(msg, None).await }) + }; + let line = env.create_request().await; + let id = line["sessionId"].as_str().unwrap().to_string(); + assert!(uuid::Uuid::parse_str(&id).is_ok()); + let gate = identity.arm_binding_stall(PROVIDER, &id); + gate.entered.recv_timeout(Duration::from_secs(10)).unwrap(); + assert!(matches!( + state.ownership_snapshot(PROVIDER, &id).state, + freshell_ownership::OwnershipState::Starting { .. } + )); + while let Ok(raw) = rx.try_recv() { + assert_ne!( + serde_json::from_str::(&raw).unwrap()["type"], + "freshAgent.created" + ); + } + gate.release.send(()).unwrap(); + let outcome = task.await.unwrap(); + assert!( + matches!(outcome, ClaudeCreateOutcome::Created { ref session_id, .. } if session_id == &id) + ); + let created = await_claude_created(&mut rx, "create-bound-before-live").await; + assert_eq!( + created["nameRef"], + json!({"kind":"session", "provider":"claude", "sessionId":id}) + ); + assert!(matches!( + state.ownership_snapshot(PROVIDER, &id).state, + freshell_ownership::OwnershipState::Live { .. } + )); + let updates = names + .get(vec![ + freshell_protocol::session_names::SessionNameRef::Pending { + id: "pre-create-name".into(), + }, + ]) + .await + .unwrap(); + assert_eq!( + updates[0].record.name_ref, + freshell_protocol::session_names::SessionNameRef::Session { + provider: freshell_protocol::session_names::NamedProvider::Claude, + session_id: id.clone() + } + ); + assert!(state.naming_handles.lock().await.get(&id).is_none()); + assert!(identity + .geneses + .lock() + .unwrap() + .contains_key(&(PROVIDER.into(), id))); + state.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_binding_write_failure_never_publishes_created_or_commits_live() { + let _env_lock = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install_with_knobs(Some(1500), false); + let (mut state, mut rx) = state_with_bus(); + let identity = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(identity.clone()); + state.set_ownership(Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new())); + let task = { + let state = state.clone(); + tokio::spawn(async move { + state + .handle_create(dedup_create_msg("failed-create-binding"), None) + .await + }) + }; + let line = env.create_request().await; + let id = line["sessionId"].as_str().unwrap(); + let gate = identity.arm_post_commit_stall(PROVIDER, id); + gate.applied.recv_timeout(Duration::from_secs(10)).unwrap(); + identity.set_fail_writes(true); + gate.release.send(()).unwrap(); + let outcome = task.await.unwrap(); + assert!( + matches!(outcome, ClaudeCreateOutcome::Failed { ref code, .. } if code == "LEDGER_WRITE_FAILED"), + "{outcome:?}" + ); + assert!(!state.sessions.lock().await.contains_key(id)); + assert!(!matches!( + state.ownership_snapshot(PROVIDER, id).state, + freshell_ownership::OwnershipState::Live { .. } + )); + while let Ok(raw) = rx.try_recv() { + assert_ne!( + serde_json::from_str::(&raw).unwrap()["type"], + "freshAgent.created" + ); + } + assert!(identity.bindings.lock().unwrap().is_empty()); +} + +#[cfg(target_os = "linux")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_confirmed_late_teardown_releases_the_lease_without_an_ownership_registry() { + let _env_lock = CLAUDE_ENV_LOCK.lock().await; + let _env = FakeClaudeSidecarEnv::install(); + let (mut state, mut rx) = state_with_bus(); + state.set_handoff_confirm_rounds_for_test(Some(1)); + state.set_identity_sink(Arc::new(crate::identity_sink::FakeIdentitySink::default())); + state + .handle_create(dedup_create_msg("unwired-lease-create"), None) + .await; + let created = await_claude_created(&mut rx, "unwired-lease-create").await; + let id = created["sessionId"].as_str().unwrap().to_string(); + let ownership_id = state.sessions.lock().await[&id].ownership_id.clone(); + let (mut descendant, _ready) = spawn_term_immune_tagged_child(&ownership_id).await; + state.handle_kill(kill_msg(&id)).await; + assert!(!state.sessions.lock().await.contains_key(&id)); + tokio::time::timeout(Duration::from_secs(15), descendant.wait()) + .await + .expect("the late teardown confirms the descendant died") + .unwrap(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + let mut reopen = dedup_create_msg("unwired-lease-reopen"); + reopen.resume_session_id = Some(id.clone()); + let outcome = state.handle_create(reopen, None).await; + match outcome { + ClaudeCreateOutcome::Created { .. } => { + assert!( + state.sessions.lock().await.contains_key(&id), + "reopen must establish a runtime, never acknowledge a stale lease binding" + ); + break; + } + ClaudeCreateOutcome::Failed { code, .. } => { + assert_eq!(code, "SESSION_RESERVED"); + assert!( + tokio::time::Instant::now() < deadline, + "confirmed cleanup never released its completed lease" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + } + state.shutdown().await; +} diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index 1d4ed6484..91f84e8e1 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -1668,9 +1668,10 @@ lines.on('line', line=>{ ); } if deferred_identity { - assert!( - started.native_session_id.is_none(), - "unrecorded native identity must remain unknown" + assert_eq!( + started.native_session_id.as_deref(), + observed["sessionId"].as_str(), + "fresh native identity is known before any prompt or init" ); } assert_eq!( @@ -1826,6 +1827,54 @@ lines.on('line', line=>{ Arc::clone(&claude.transport).stop().await.unwrap(); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_fresh_claude_start_resolves_its_uuid_before_any_prompt() { + let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; + let claude = SimulatedClaude::start(&[]).await; + let mut events = claude.transport.take_event_stream().expect("event stream"); + let profile = claude.profile(None); + let started = + tokio::time::timeout(Duration::from_secs(20), claude.transport.start(&profile)) + .await + .expect("fresh start cannot wait for a prompt") + .expect("fresh start succeeds"); + let durable = started + .native_session_id + .expect("identity is known at create"); + assert!(uuid::Uuid::parse_str(&durable).is_ok(), "{durable}"); + assert!(claude.ledger_rows("prompt").is_empty()); + let query = claude.ledger_rows("query"); + assert_eq!(query.len(), 1); + assert_eq!(query[0]["options"]["sessionId"], durable); + claude + .transport + .dispatch( + &RequestId::parse("fresh-first-turn").unwrap(), + "the first prompt", + &profile, + ) + .await + .unwrap(); + let mut announced = Vec::new(); + tokio::time::timeout(Duration::from_secs(20), async { + while let Some(event) = events.recv().await { + match event { + AgentEvent::Started { native_session_id } => announced.push(native_session_id), + AgentEvent::Provider { payload } + if payload["event"]["type"] == "freshAgent.turn.complete" => + { + break + } + _ => {} + } + } + }) + .await + .expect("the turn completes"); + assert_eq!(announced, vec![durable]); + Arc::clone(&claude.transport).stop().await.unwrap(); + } + /// G15 on the hosted lane: every start carries the pane's plugins, and an /// undo starts a replacement CLI. The hosted session host keeps no pane /// ledger, so the replacement must take the live session's settings @@ -2049,7 +2098,7 @@ const lines=createInterface({{input:process.stdin}}); const emit=frame=>process.stdout.write(JSON.stringify(frame)+'\n'); lines.on('line', line=>{{ const req=JSON.parse(line); - if(req.type==='create')emit({{type:'created',requestId:req.requestId,sessionId:'owned-configure',protocol:2}}); + if(req.type==='create')emit({{type:'created',requestId:req.requestId,sessionId:req.sessionId??'owned-configure',protocol:2}}); if(req.type==='configure'){{ appendFileSync(process.env.FRESHELL_TEST_CONFIGURE_RECEIPT,JSON.stringify(req)+'\n'); emit({receipt}); @@ -2167,7 +2216,11 @@ lines.on('line', line=>{{ let metadata = run .settled .expect("the provider's metadata frame reaches the host journal"); - assert_eq!(metadata["sessionId"], "owned-configure"); + assert_eq!(metadata["sessionId"], run.applied[0]["sessionId"]); + assert_eq!( + metadata["sessionId"].as_str(), + run.profile.native_session_id.as_deref() + ); assert_eq!(metadata["model"], "opus"); assert_eq!(metadata["effort"], "high"); } diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index 3cfb1ce84..14a093f28 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -999,7 +999,9 @@ async fn spawn_rig_server( // The ONE ownership coordinator, wired into the WS state and the claude lane // the way main.rs wires it. let epoch = pane_ledger.as_ref().and_then(|ledger| { - ledger.reserve_boot_epoch().expect("reserve the rig server's durable boot epoch") + ledger + .reserve_boot_epoch() + .expect("reserve the rig server's durable boot epoch") }); let ownership = Arc::new(match epoch { Some(epoch) => freshell_ownership::RuntimeOwnershipRegistry::with_epoch(epoch), diff --git a/crates/freshell-ws/tests/freshagent_claude_identity.rs b/crates/freshell-ws/tests/freshagent_claude_identity.rs new file mode 100644 index 000000000..5fe76e258 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_identity.rs @@ -0,0 +1,200 @@ +//! The durable identity exists at create, including before the first prompt. + +mod common; + +use common::claude_rig::{ClaudeRig, RigKind, RigOptions}; +use freshell_ws::pane_ledger::RowState; +use serde_json::json; + +async fn create_identity(kind: RigKind) { + for flavor in ["freshclaude", "kilroy"] { + let rig = ClaudeRig::start_with_options( + kind, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let created = conn + .request_create("create-identity", json!({"sessionType":flavor})) + .await; + let id = created["sessionId"].as_str().unwrap(); + assert!( + uuid::Uuid::parse_str(id).is_ok(), + "create must expose its durable UUID: {created}" + ); + assert_eq!( + created["sessionRef"], + json!({"provider":"claude", "sessionId":id}) + ); + let ledger = rig.pane_ledger.as_ref().unwrap(); + let binding = ledger + .lookup_by_session("claude", id) + .expect("bound at create"); + assert_eq!(binding.row.state, RowState::Bound); + assert_eq!(binding.row.mode, flavor); + assert_eq!( + ledger.binding_genesis("claude", id), + Some(json!({"kind":"fresh"})) + ); + assert_eq!( + ledger + .lookup_by_create_request_id("claude", "create-identity") + .unwrap() + .session_id, + id + ); + let (status, snapshot) = rig.snapshot(id).await; + assert_eq!(status, 200, "{snapshot}"); + assert_eq!(snapshot["turns"], json!([])); + conn.send( + json!({"type":"freshAgent.send", "provider":"claude", "sessionType":flavor, + "sessionId":id, "requestId":"first-send", "text":"first prompt"}), + ) + .await; + conn.wait_for("the first turn", |frame| { + frame["event"]["type"] == "freshAgent.result" + }) + .await; + conn.read_for(std::time::Duration::from_millis(200)).await; + assert_eq!(conn.init_session_id(id), id); + assert_eq!( + ledger + .lookup_by_create_request_id("claude", "create-identity") + .unwrap() + .session_id, + id, + "a repeated SDK init preserves create-request lineage" + ); + assert!( + !conn + .frames + .iter() + .any(|frame| frame["type"] == "freshAgent.session.materialized"), + "a create-time UUID never materializes into a second identity" + ); + rig.fresh_claude.shutdown().await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn create_binds_one_durable_uuid_before_any_prompt_for_claude_and_kilroy() { + create_identity(RigKind::Sim).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn real_cli_create_keeps_one_uuid_before_and_after_the_first_prompt() { + create_identity(RigKind::Cli).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn redriving_create_after_restart_recovers_the_same_zero_turn_uuid() { + let mut rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut first = rig.connect().await; + let created = first.request_create("same-create", json!({})).await; + let id = created["sessionId"].as_str().unwrap().to_string(); + rig.restart().await; + let mut second = rig.connect().await; + let replay = second.request_create("same-create", json!({})).await; + assert_eq!(replay["type"], "freshAgent.created", "{replay}"); + assert_eq!( + replay["sessionId"], id, + "restart must not mint another conversation" + ); + assert_eq!(replay["sessionRef"]["sessionId"], id); + let launches = rig.ledger_rows("query"); + assert_eq!(launches.len(), 2); + assert_eq!( + launches[1]["options"]["sessionId"], id, + "zero-turn recovery boots Fresh under its durable id" + ); + assert!(launches[1]["options"]["resume"].is_null()); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn attaching_after_restart_reopens_a_zero_turn_uuid_with_fresh_boot() { + let mut rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut first = rig.connect().await; + let created = first.request_create("zero-turn-attach", json!({})).await; + let id = created["sessionId"].as_str().unwrap().to_string(); + rig.restart().await; + let mut second = rig.connect().await; + second.attach(&id).await; + let (status, snapshot) = rig.snapshot(&id).await; + assert_eq!(status, 200, "{snapshot}"); + assert_eq!(snapshot["turns"], json!([])); + let launches = rig.ledger_rows("query"); + assert_eq!(launches.len(), 2); + assert_eq!(launches[1]["options"]["sessionId"], id); + assert!(launches[1]["options"]["resume"].is_null()); + rig.fresh_claude.shutdown().await; +} + +#[cfg(target_os = "linux")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_explicit_reopen_reclaims_the_same_closed_zero_turn_uuid() { + for flavor in ["freshclaude", "kilroy"] { + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let created = conn + .request_create("zero-turn-close", json!({"sessionType":flavor})) + .await; + let id = created["sessionId"].as_str().unwrap(); + conn.send(json!({"type":"freshAgent.kill", "provider":"claude", + "sessionType":flavor, "sessionId":id})) + .await; + let killed = conn + .wait_for("the zero-turn close", |frame| { + frame["type"] == "freshAgent.killed" + }) + .await; + assert_eq!(killed["success"], true, "{killed}"); + let reopened = conn + .request_create( + "zero-turn-reopen", + json!({ + "sessionType":flavor, "sessionRef":{"provider":"claude", "sessionId":id} + }), + ) + .await; + assert_eq!(reopened["type"], "freshAgent.created", "{reopened}"); + assert_eq!(reopened["sessionId"], id); + let ledger = rig.pane_ledger.as_ref().unwrap(); + assert_eq!( + ledger.lookup_by_session("claude", id).unwrap().row.state, + RowState::Bound + ); + let (status, snapshot) = rig.snapshot(id).await; + assert_eq!(status, 200, "{snapshot}"); + assert_eq!(snapshot["turns"], json!([])); + let launches = rig.ledger_rows("query"); + assert_eq!(launches.len(), 2); + assert_eq!(launches[1]["options"]["sessionId"], id); + assert!(launches[1]["options"]["resume"].is_null()); + rig.fresh_claude.shutdown().await; + } +} diff --git a/crates/freshell-ws/tests/freshagent_claude_incarnation.rs b/crates/freshell-ws/tests/freshagent_claude_incarnation.rs index 7b3f69498..06b4c776b 100644 --- a/crates/freshell-ws/tests/freshagent_claude_incarnation.rs +++ b/crates/freshell-ws/tests/freshagent_claude_incarnation.rs @@ -10,12 +10,22 @@ use freshell_protocol::{AgentProvider, FreshAgentSend, SessionType}; use std::time::Duration; async fn accepted_owner(rig: &ClaudeRig, id: &str, request: &str) -> String { - let outcome = rig.fresh_claude.handle_send(FreshAgentSend { - provider: AgentProvider::Claude, session_id: id.into(), - session_type: SessionType::Freshclaude, text: "HANG".into(), - request_id: Some(request.into()), cwd: None, images: None, settings: None, - observed_epoch: None, observed_generation: None, checkpoint_intent: None, - }).await; + let outcome = rig + .fresh_claude + .handle_send(FreshAgentSend { + provider: AgentProvider::Claude, + session_id: id.into(), + session_type: SessionType::Freshclaude, + text: "HANG".into(), + request_id: Some(request.into()), + cwd: None, + images: None, + settings: None, + observed_epoch: None, + observed_generation: None, + checkpoint_intent: None, + }) + .await; match outcome { ClaudeSendOutcome::Accepted { ownership_id, .. } => { assert!(!ownership_id.is_empty()); @@ -27,26 +37,41 @@ async fn accepted_owner(rig: &ClaudeRig, id: &str, request: &str) -> String { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn an_ended_event_identifies_the_accepted_runtime_across_a_resume() { - let rig = ClaudeRig::start_with_options(RigKind::Sim, RigOptions { - durable_ledger: true, ..RigOptions::default() - }).await; + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; let mut conn = rig.connect().await; let pane = conn.create("create-incarnation").await; - let durable = rig.converse(&mut conn, &pane, &["make the transcript durable"]).await; + let durable = rig + .converse(&mut conn, &pane, &["make the transcript durable"]) + .await; let mut events = rig.fresh_claude.subscribe_turn_events(); let first_owner = accepted_owner(&rig, &durable, "first-incarnation").await; rig.kill_first_sidecar(&mut conn).await; conn.attach(&durable).await; let second_owner = accepted_owner(&rig, &durable, "second-incarnation").await; - assert_ne!(first_owner, second_owner, "a new process has a new ownership id"); + assert_ne!( + first_owner, second_owner, + "a new process has a new ownership id" + ); let ended_owner = tokio::time::timeout(Duration::from_secs(15), async { loop { if let ClaudeTurnEvent::Ended { ownership_id, .. } = events.recv().await.unwrap() { break ownership_id; } } - }).await.expect("the dead runtime publishes its end"); + }) + .await + .expect("the dead runtime publishes its end"); assert_eq!(ended_owner, first_owner); - assert_ne!(ended_owner, second_owner, "a delayed old end cannot match a new send"); + assert_ne!( + ended_owner, second_owner, + "a delayed old end cannot match a new send" + ); rig.fresh_claude.shutdown().await; } diff --git a/port/oracle/harness/invariants.ts b/port/oracle/harness/invariants.ts index 4d9ef63b4..fd343f278 100644 --- a/port/oracle/harness/invariants.ts +++ b/port/oracle/harness/invariants.ts @@ -166,16 +166,8 @@ export const PROVIDER_ID_SHAPES: Record` placeholder, the - // freshclaude create returns the SDK bridge's BARE nanoid - // (server/sdk-bridge.ts createSession → `const sessionId = nanoid()`, surfaced - // verbatim by the claude adapter + runtime-manager). nanoid()'s default is 21 - // url-safe chars ([A-Za-z0-9_-]); the {16,32} bound tolerates length drift - // while still excluding the 36-char durable UUID below (so placeholder≠durable). - // The pre-existing `^freshclaude-` here was aspirational and never validated - // against a live claude run — see notes/t2-claude-haiku.md. - placeholder: /^[A-Za-z0-9_-]{16,32}$/, + // Fresh Claude and Kilroy use one durable UUID from creation onward. + placeholder: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, durable: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, }, codex: { diff --git a/test/e2e-browser/fixtures/fake-kilroy-runtime.mjs b/test/e2e-browser/fixtures/fake-kilroy-runtime.mjs index 742aa5111..31901f473 100644 --- a/test/e2e-browser/fixtures/fake-kilroy-runtime.mjs +++ b/test/e2e-browser/fixtures/fake-kilroy-runtime.mjs @@ -112,9 +112,9 @@ rl.on('line', (line) => { async function handle(msg) { if (msg.type === 'create') { - const sessionId = nanoid() + const sessionId = msg.sessionId ?? nanoid() const resumeSessionId = msg.boot?.resume - const cliSessionId = resumeSessionId ?? CLI_SESSION_ID + const cliSessionId = resumeSessionId ?? msg.sessionId ?? CLI_SESSION_ID const cwd = msg.cwd ?? process.cwd() sessions.set(sessionId, sessionState(cliSessionId, cwd)) // `created` MUST be the first line written for this request. diff --git a/test/e2e-browser/helpers/harness-06/kilroy-runtime.test.ts b/test/e2e-browser/helpers/harness-06/kilroy-runtime.test.ts index f2936bc35..82ad61a54 100644 --- a/test/e2e-browser/helpers/harness-06/kilroy-runtime.test.ts +++ b/test/e2e-browser/helpers/harness-06/kilroy-runtime.test.ts @@ -59,17 +59,18 @@ async function readEverythingSoFar(rt: KilroyRuntime, sessionId: string): Promis describe('harness-06 fake kilroy runtime', () => { it('answers create with created-first, init, idle — and records the invocation', async () => { const { rt, logPath } = await make() - rt.send({ type: 'create', requestId: 'r-1', cwd: '/tmp/fixture-cwd', model: 'claude-opus-4-6' }) + const sessionId = '11111111-1111-4111-8111-111111111111' + rt.send({ type: 'create', sessionId, requestId: 'r-1', cwd: '/tmp/fixture-cwd', model: 'claude-opus-4-6' }) const created = (await rt.nextEvent('created')) as { requestId: string; sessionId: string } expect(created.requestId).toBe('r-1') - expect(created.sessionId).toMatch(/^[A-Za-z0-9_-]{16,32}$/) // bare nanoid shape + expect(created.sessionId).toBe(sessionId) const init = (await rt.nextEvent('sdk.session.init', undefined, 5000)) as { sessionId: string; cliSessionId: string; model: string; cwd: string } expect(init.sessionId).toBe(created.sessionId) - expect(init.cliSessionId).toMatch(/^[0-9a-f-]{36}$/) // canonical durable UUID + expect(init.cliSessionId).toBe(sessionId) expect(init.cwd).toBe('/tmp/fixture-cwd') const idle = (await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle', 5000)) diff --git a/test/unit/port/oracle/t2-invariants.test.ts b/test/unit/port/oracle/t2-invariants.test.ts index 4b0cd0f6a..590ba9eef 100644 --- a/test/unit/port/oracle/t2-invariants.test.ts +++ b/test/unit/port/oracle/t2-invariants.test.ts @@ -158,7 +158,7 @@ function goodClaudeObservation(overrides: Partial = {}): T2Observ sentinelToken: 'freshell-t2-ok', sessionCreated: true, - initialSessionId: 'V1StGXR8Z5jdHi6B_myT0', // bare nanoid (21 url-safe chars) + initialSessionId: '11111111-1111-4111-8111-111111111111', // durable at create durableSessionId: '11111111-1111-4111-8111-111111111111', // canonical Claude UUID sessionRef: { provider: 'claude', sessionId: '11111111-1111-4111-8111-111111111111' }, @@ -233,10 +233,10 @@ describe('assertT2Invariants — claude/Haiku (SDK completion edge)', () => { expect(report.results.find((r) => r.name === 'provider.emits-completion-signal')!.ok).toBe(false) }) - // The claude placeholder is the SDK bridge's bare nanoid — NOT `freshclaude-...`. + // Claude uses its durable UUID from create. const claudeShapeMutations: Array<{ name: string; invariant: string; patch: Partial }> = [ - { name: 'placeholder too short (not a nanoid)', invariant: 'session.created', patch: { initialSessionId: 'x' } }, - { name: 'placeholder is a UUID (36 chars, must be the durable field)', invariant: 'session.created', patch: { initialSessionId: '11111111-1111-4111-8111-111111111111' } }, + { name: 'create id is not a UUID', invariant: 'session.created', patch: { initialSessionId: 'x' } }, + { name: 'create id is a retired nanoid', invariant: 'session.created', patch: { initialSessionId: 'V1StGXR8Z5jdHi6B_myT0' } }, { name: 'durable id not a UUID', invariant: 'session.durable-id-shape', patch: { durableSessionId: 'not-a-uuid' } }, { name: 'turn not accepted', invariant: 'turn.accepted', patch: { turnAccepted: false } }, { name: 'reply not persisted', invariant: 'turn.completed', patch: { turnCompleted: false } }, @@ -256,11 +256,9 @@ describe('assertT2Invariants — claude/Haiku (SDK completion edge)', () => { }) } - it('accepts the real bare-nanoid placeholder shape and rejects the old aspirational prefix-only id', () => { - // A bare nanoid passes; a value that is ONLY the retired `freshclaude-` prefix - // with an out-of-range body would not — we assert the real shape is honored. - expect(PROVIDER_ID_SHAPES.claude.placeholder.test('V1StGXR8Z5jdHi6B_myT0')).toBe(true) - expect(PROVIDER_ID_SHAPES.claude.placeholder.test('11111111-1111-4111-8111-111111111111')).toBe(false) + it('accepts a durable UUID from create and rejects a retired nanoid', () => { + expect(PROVIDER_ID_SHAPES.claude.placeholder.test('V1StGXR8Z5jdHi6B_myT0')).toBe(false) + expect(PROVIDER_ID_SHAPES.claude.placeholder.test('11111111-1111-4111-8111-111111111111')).toBe(true) expect(PROVIDER_ID_SHAPES.claude.durable.test('11111111-1111-4111-8111-111111111111')).toBe(true) }) }) From 084dee2f8edc2e6eccbad396b759ac50540eeab6 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:57:32 -0700 Subject: [PATCH 04/19] test(freshclaude): keep browser fixture identities on the requested UUID --- .../fixtures/fake-claude-sidecar.mjs | 4 ++-- .../providers/fake-claude-sdk-sidecar.mjs | 6 ++--- .../fake-claude-sdk-sidecar-control.test.ts | 24 +++++++++++++++++-- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/test/e2e-browser/fixtures/fake-claude-sidecar.mjs b/test/e2e-browser/fixtures/fake-claude-sidecar.mjs index 5863e24ba..a890dd3c6 100644 --- a/test/e2e-browser/fixtures/fake-claude-sidecar.mjs +++ b/test/e2e-browser/fixtures/fake-claude-sidecar.mjs @@ -115,11 +115,11 @@ rl.on('line', (line) => { } logRequest(msg) if (msg.type === 'create') { - const sessionId = `fc-e2e-${process.pid}-${Date.now()}` + const sessionId = msg.sessionId ?? `fc-e2e-${process.pid}-${Date.now()}` // Resume continuity: a resumed session keeps its durable id (what the real // CLI's transcript filename stem provides across restarts). const resumeSessionId = msg.boot?.resume - const cliSessionId = resumeSessionId ?? CLI_SESSION_ID + const cliSessionId = resumeSessionId ?? msg.sessionId ?? CLI_SESSION_ID const cwd = msg.cwd ?? process.cwd() sessions.set(sessionId, { cliSessionId, cwd, model: msg.model ?? 'claude-opus-4-6', effort: msg.effort, permissionMode: msg.permissionMode }) // Ensure the transcript file EXISTS from create (the attach arm's diff --git a/test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs b/test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs index 777f67607..d2ce0ae85 100755 --- a/test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs +++ b/test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs @@ -344,7 +344,7 @@ async function handleInput(line) { } if (msg.type === 'create') { createCounter += 1 - const sessionId = `${provider}-fake-${process.pid}-${createCounter}` + const sessionId = msg.sessionId ?? `${provider}-fake-${process.pid}-${createCounter}` activeSessionId = sessionId // kata 1wxv Task 7 (fork-at-point, s2rk correction): a `forkSession:true` // create mints a NEW durable cliSessionId — real `claude --fork-session` @@ -363,11 +363,11 @@ async function handleInput(line) { const resumeIsPath = resumeRaw !== null && (resumeRaw.includes('/') || resumeRaw.endsWith('.jsonl')) const resumePath = !forking && resumeIsPath ? resumeRaw : null - const cliSessionId = forking + const cliSessionId = msg.sessionId ?? (forking ? randomUUID() : (resumePath ? path.basename(resumePath).replace(/\.jsonl$/, '') - : (resumeRaw ?? program.sessionId ?? randomUUID())) + : (resumeRaw ?? program.sessionId ?? randomUUID()))) const cwd = msg.cwd ?? process.cwd() sessions.set(sessionId, { cliSessionId, cwd, pendingEntries: [], transcriptOverride: resumePath, diff --git a/test/e2e-browser/helpers/fake-claude-sdk-sidecar-control.test.ts b/test/e2e-browser/helpers/fake-claude-sdk-sidecar-control.test.ts index 32eab1e64..af9dcfddb 100644 --- a/test/e2e-browser/helpers/fake-claude-sdk-sidecar-control.test.ts +++ b/test/e2e-browser/helpers/fake-claude-sdk-sidecar-control.test.ts @@ -37,8 +37,8 @@ type Launched = { stop: () => Promise } -function launch(program: unknown, extraEnv: Record = {}): Launched { - const proc = spawn(process.execPath, [FIXTURE], { +function launch(program: unknown, extraEnv: Record = {}, fixture = FIXTURE): Launched { + const proc = spawn(process.execPath, [fixture], { env: { ...process.env, HOME: tmp, @@ -587,3 +587,23 @@ describe('fake-claude-sdk-sidecar fork-at-point arm (kata 1wxv e2e fixture)', () } }) }) + +describe('create-time UUID contract shared by browser Claude fixtures', () => { + it.each([ + ['SDK fixture', FIXTURE], + ['simple fixture', path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs')], + ])('%s keeps the requested UUID on created, init, and its transcript', async (_name, fixture) => { + const sessionId = '11111111-1111-4111-8111-111111111111' + const config = path.join(tmp, 'claude') + const fx = launch({}, { CLAUDE_CONFIG_DIR: config }, fixture) + try { + fx.send({ type: 'create', protocol: 2, sessionId, requestId: 'uuid-create', cwd: tmp, boot: { kind: 'fresh' } }) + expect(await fx.waitLine((frame) => frame.type === 'created', 'created')).toMatchObject({ sessionId }) + expect(await fx.waitLine((frame) => frame.type === 'sdk.session.init', 'init')).toMatchObject({ sessionId, cliSessionId: sessionId }) + fx.send({ type: 'send', sessionId, uuid: '22222222-2222-4222-8222-222222222222', text: 'hello' }) + await fx.waitLine((frame) => frame.type === 'sdk.result', 'result') + const files = fs.readdirSync(config, { recursive: true }).map(String) + expect(files.some((file) => file.endsWith(`${sessionId}.jsonl`))).toBe(true) + } finally { await fx.stop() } + }) +}) From 429712b4a4377b11f8801f4d0222a700629c70d6 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:06:48 -0700 Subject: [PATCH 05/19] fix(freshclaude): retain ownership and retry details from refusals --- crates/freshell-freshagent/src/claude.rs | 9 +- .../src/claude/send_outcome.rs | 24 ++- .../src/claude/tests/refusal_tests.rs | 198 ++++++++++++++++++ crates/freshell-freshagent/src/codex.rs | 2 +- crates/freshell-freshagent/src/lib.rs | 33 ++- crates/freshell-freshagent/src/opencode_ws.rs | 10 +- crates/freshell-ownership/src/lib.rs | 10 + crates/freshell-ws/src/terminal.rs | 2 + 8 files changed, 273 insertions(+), 15 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude/tests/refusal_tests.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index b8764fc0a..8056182a4 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -2220,7 +2220,7 @@ impl FreshClaudeState { // The additive owner fields ride the refusal when the // coordinator can name the owner (Task 6: the post-handoff // refusal says the terminal owner). - let owner_fields = crate::ownership_lane::terminal_owner_fields_from_outcome( + let owner_fields = crate::ownership_lane::fresh_agent_owner_fields_from_outcome( &self.ownership, &outcome, ); @@ -4198,12 +4198,12 @@ impl FreshClaudeState { ) { crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), crate::ownership_lane::LaneOpGuard::Unwired => None, - crate::ownership_lane::LaneOpGuard::Refused { message } => { + crate::ownership_lane::LaneOpGuard::Refused { message, owner } => { tracing::warn!(target: "freshell_freshagent::claude", session_id = %session_id, owner_key = %owner_key, request_id = ?request_id, "fresh_agent_send_refused: ownership guard refused"); - return self.refuse_send(&request_id, "SESSION_RESERVED", &message); + return self.refuse_reserved_send(&request_id, &message, owner); } } } else { @@ -5068,6 +5068,8 @@ impl FreshClaudeState { ClaudeSendOutcome::Refused { code: "INVALID_SESSION_ID".to_string(), message: crate::claude_dead_sessions::SEND_TO_DEAD_SESSION_MESSAGE.to_string(), + retryable: None, + owner: None, } } @@ -8186,6 +8188,7 @@ pub(crate) mod tests { use crate::naming::SessionNaming as _; mod create_binding_tests; + mod refusal_tests; fn state() -> FreshClaudeState { let (tx, _rx) = tokio::sync::broadcast::channel::(64); diff --git a/crates/freshell-freshagent/src/claude/send_outcome.rs b/crates/freshell-freshagent/src/claude/send_outcome.rs index 137d14426..de413b67b 100644 --- a/crates/freshell-freshagent/src/claude/send_outcome.rs +++ b/crates/freshell-freshagent/src/claude/send_outcome.rs @@ -29,7 +29,12 @@ pub enum ClaudeSendOutcome { ownership_id: String, }, /// The message was not sent; the sender got a request error with this code. - Refused { code: String, message: String }, + Refused { + code: String, + message: String, + retryable: Option, + owner: Option, + }, } impl FreshClaudeState { @@ -45,6 +50,23 @@ impl FreshClaudeState { ClaudeSendOutcome::Refused { code: code.to_string(), message: message.to_string(), + retryable: None, + owner: None, + } + } + + pub(super) fn refuse_reserved_send( + &self, + request_id: &Option, + message: &str, + owner: Option, + ) -> ClaudeSendOutcome { + self.send_error(request_id, "SESSION_RESERVED", message); + ClaudeSendOutcome::Refused { + code: "SESSION_RESERVED".into(), + message: message.into(), + retryable: Some(true), + owner, } } diff --git a/crates/freshell-freshagent/src/claude/tests/refusal_tests.rs b/crates/freshell-freshagent/src/claude/tests/refusal_tests.rs new file mode 100644 index 000000000..cddbb9d65 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/tests/refusal_tests.rs @@ -0,0 +1,198 @@ +use super::*; + +fn terminal_owner(registry: &freshell_ownership::RuntimeOwnershipRegistry, id: &str) -> u64 { + let freshell_ownership::BeginOutcome::Granted { generation } = registry.begin_start( + PROVIDER, + id, + freshell_ownership::RuntimeOwnerKind::Terminal, + "terminal-owner", + None, + "test", + 0, + ) else { + panic!("terminal start must be granted") + }; + assert!(matches!( + registry.commit_live( + PROVIDER, + id, + "terminal-owner", + generation, + freshell_ownership::OwnerIdentity { + kind: freshell_ownership::RuntimeOwnerKind::Terminal, + terminal_id: Some("terminal-original".into()), + live_session_key: None, + pid: None, + ownership_id: None, + } + ), + freshell_ownership::CommitOutcome::Committed + )); + generation +} + +#[tokio::test] +async fn a_claude_create_refusal_names_the_terminal_that_refused_it() { + let (mut state, _rx) = state_with_bus(); + let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::with_epoch(41)); + let id = uuid::Uuid::new_v4().to_string(); + let generation = terminal_owner(®istry, &id); + state.set_ownership(registry); + let mut msg = dedup_create_msg("refused-by-terminal"); + msg.resume_session_id = Some(id); + let outcome = state.handle_create(msg, None).await; + match outcome { + ClaudeCreateOutcome::Failed { + code, + retryable, + owner, + .. + } => { + assert_eq!(code, "SESSION_RESERVED"); + assert_eq!(retryable, Some(true)); + assert_eq!( + owner, + Some(crate::ownership_lane::TerminalOwnerFields { + owner_kind: "terminal", + owner_epoch: 41, + owner_generation: generation, + }) + ); + } + created => panic!("foreign ownership must refuse before any spawn: {created:?}"), + } +} + +#[test] +fn a_stale_guard_refusal_keeps_the_owner_captured_by_the_atomic_decision() { + let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::with_epoch(41)); + let id = uuid::Uuid::new_v4().to_string(); + let generation = terminal_owner(®istry, &id); + let refusal = crate::ownership_lane::arm_reclaimless_op_guard( + &Some(registry.clone()), + PROVIDER, + &id, + "stale-operation", + Some(freshell_ownership::ObservedFence { + epoch: 41, + generation: generation - 1, + }), + "test", + ); + let crate::ownership_lane::LaneOpGuard::Refused { owner, .. } = refusal else { + panic!("stale guard must refuse") + }; + let freshell_ownership::BeginOutcome::Granted { generation: newer } = registry.begin_handoff( + PROVIDER, + &id, + freshell_ownership::RuntimeOwnerKind::FreshAgent, + "move-after-refusal", + None, + "test", + 0, + ) else { + panic!("later ownership transition starts") + }; + assert!(matches!( + registry.commit_live( + PROVIDER, + &id, + "move-after-refusal", + newer, + freshell_ownership::OwnerIdentity { + kind: freshell_ownership::RuntimeOwnerKind::FreshAgent, + terminal_id: None, + live_session_key: Some(id.clone()), + pid: None, + ownership_id: None, + } + ), + freshell_ownership::CommitOutcome::Committed + )); + assert_eq!( + owner, + Some(crate::ownership_lane::TerminalOwnerFields { + owner_kind: "terminal", + owner_epoch: 41, + owner_generation: generation, + }), + "a later ownership lookup must not replace the refusal's captured owner" + ); +} + +#[tokio::test] +async fn a_fenced_claude_send_returns_retry_and_owner_details_from_its_refusal() { + let (mut state, mut rx) = state_with_bus(); + let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::with_epoch(41)); + let id = uuid::Uuid::new_v4().to_string(); + let generation = terminal_owner(®istry, &id); + state.set_ownership(registry); + insert_fake_claude_session(&state, &id).await; + let mut msg = send_msg(&id, "must not reach another owner's process"); + msg.request_id = Some("refused-send".into()); + msg.observed_epoch = Some(41); + msg.observed_generation = Some(generation - 1); + let outcome = state.handle_send(msg).await; + assert!( + matches!(outcome, ClaudeSendOutcome::Refused { ref code, retryable: Some(true), + owner: Some(crate::ownership_lane::TerminalOwnerFields { + owner_kind: "terminal", owner_epoch: 41, owner_generation, + }), .. } if code == "SESSION_RESERVED" && owner_generation == generation), + "{outcome:?}" + ); + let wire: Value = serde_json::from_str(&rx.try_recv().unwrap()).unwrap(); + assert_eq!(wire["type"], "error"); + assert_eq!(wire["requestId"], "refused-send"); + assert!( + wire.get("ownerKind").is_none(), + "the existing wire response stays unchanged" + ); + state.shutdown().await; +} + +#[test] +fn a_fresh_lane_blocked_outcome_preserves_its_captured_fresh_agent_kind() { + let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::with_epoch(41)); + let id = uuid::Uuid::new_v4().to_string(); + let freshell_ownership::BeginOutcome::Granted { generation } = registry.begin_start( + PROVIDER, + &id, + freshell_ownership::RuntimeOwnerKind::FreshAgent, + "fresh-owner", + None, + "test", + 0, + ) else { + panic!("fresh start") + }; + assert!(matches!( + registry.commit_live( + PROVIDER, + &id, + "fresh-owner", + generation, + freshell_ownership::OwnerIdentity { + kind: freshell_ownership::RuntimeOwnerKind::FreshAgent, + terminal_id: None, + live_session_key: Some(id.clone()), + pid: None, + ownership_id: None, + } + ), + freshell_ownership::CommitOutcome::Committed + )); + let outcome = freshell_ownership::BeginOutcome::Blocked { + state: registry.observe(PROVIDER, &id).state, + retry_after_ms: 25, + }; + let owner = + crate::ownership_lane::fresh_agent_owner_fields_from_outcome(&Some(registry), &outcome); + assert_eq!( + owner, + Some(crate::ownership_lane::TerminalOwnerFields { + owner_kind: "fresh-agent", + owner_epoch: 41, + owner_generation: generation, + }) + ); +} diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index ed3631379..fd0fcd9fd 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -3449,7 +3449,7 @@ impl FreshCodexState { ) { crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), crate::ownership_lane::LaneOpGuard::Unwired => None, - crate::ownership_lane::LaneOpGuard::Refused { message } => { + crate::ownership_lane::LaneOpGuard::Refused { message, .. } => { tracing::warn!(target: "freshell_freshagent::codex", session_id = %session_id, request_id = ?request_id, "fresh_agent_send_refused: ownership guard refused"); diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index dff7664ca..003fb1e56 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -467,7 +467,10 @@ pub mod ownership_lane { /// `Live{FreshAgent}` (a terminal owner or a vacated/crashed /// shape), or the supplied observed pair is stale. Every arm /// answers the lanes' typed `SESSION_RESERVED` surface. - Refused { message: String }, + Refused { + message: String, + owner: Option, + }, } /// Arm the reclaim-less op guard (see [`LaneOpGuard`]). The @@ -508,6 +511,7 @@ pub mod ownership_lane { pre-restart epoch) — the operation aborts typed"); return LaneOpGuard::Refused { message: STALE_OP_GUARD_MESSAGE.to_string(), + owner: terminal_owner_fields_from_snapshot(&snap), }; } } @@ -575,9 +579,11 @@ pub mod ownership_lane { the operation aborts typed; the client re-observes and \ retries with the pair" ); + let owner = terminal_owner_fields_from_snapshot(&under); drop(guard); return LaneOpGuard::Refused { message: FENCE_REQUIRED_OP_GUARD_MESSAGE.to_string(), + owner, }; } // Verify `Live{FreshAgent}` UNDER the guard — stable now @@ -586,7 +592,7 @@ pub mod ownership_lane { // completed handoff) refuses typed here, never mutating // another kind's session. let under = registry.observe(provider, session_id); - match under.state { + match &under.state { freshell_ownership::OwnershipState::Live { owner, .. } if owner.kind == RuntimeOwnerKind::FreshAgent => { @@ -601,9 +607,11 @@ pub mod ownership_lane { outcome = "refused", failure_reason = "NOT_FRESH_AGENT_LIVE", "the op guard armed over a non-fresh-agent owner — the \ operation aborts typed and the guard releases"); + let owner = terminal_owner_fields_from_snapshot(&under); drop(guard); LaneOpGuard::Refused { message: KIND_OP_GUARD_MESSAGE.to_string(), + owner, } } } @@ -620,11 +628,13 @@ pub mod ownership_lane { nothing is mutated"); LaneOpGuard::Refused { message: LIFECYCLE_OP_GUARD_MESSAGE.to_string(), + owner: owner_fields_from_state(&state, registry.boot_epoch()), } } freshell_ownership::AttachGuardOutcome::StaleGeneration { - current_epoch: _, + current_epoch, current_generation, + current_state, } => { tracing::warn!(target: "freshell_ownership", operation_id = %operation_id, provider = %provider, @@ -636,6 +646,7 @@ pub mod ownership_lane { stale) — the operation aborts typed, nothing is mutated"); LaneOpGuard::Refused { message: STALE_OP_GUARD_MESSAGE.to_string(), + owner: owner_fields_from_state(¤t_state, current_epoch), } } } @@ -1727,6 +1738,7 @@ pub mod ownership_lane { freshell_ownership::AttachGuardOutcome::StaleGeneration { current_epoch, current_generation, + .. } => { tracing::warn!(target: "freshell_ownership", operation_id = %operation_id, provider = %provider, @@ -1788,6 +1800,7 @@ pub mod ownership_lane { freshell_ownership::AttachGuardOutcome::StaleGeneration { current_epoch, current_generation, + .. } => { tracing::warn!(target: "freshell_ownership", operation_id = %operation_id, provider = %provider, @@ -1914,6 +1927,9 @@ pub mod ownership_lane { owner_epoch: registry.boot_epoch(), }) } + BeginOutcome::Blocked { state, .. } => { + owner_fields_from_state(state, registry.as_ref()?.boot_epoch()) + } _ => terminal_owner_fields_from_outcome(registry, outcome), } } @@ -1923,7 +1939,14 @@ pub mod ownership_lane { pub fn terminal_owner_fields_from_snapshot( snapshot: &freshell_ownership::OwnershipSnapshot, ) -> Option { - match &snapshot.state { + owner_fields_from_state(&snapshot.state, snapshot.epoch) + } + + fn owner_fields_from_state( + state: &freshell_ownership::OwnershipState, + epoch: u64, + ) -> Option { + match state { freshell_ownership::OwnershipState::Live { owner, generation, .. } => Some(TerminalOwnerFields { @@ -1932,7 +1955,7 @@ pub mod ownership_lane { RuntimeOwnerKind::FreshAgent => "fresh-agent", }, owner_generation: *generation, - owner_epoch: snapshot.epoch, + owner_epoch: epoch, }), _ => None, } diff --git a/crates/freshell-freshagent/src/opencode_ws.rs b/crates/freshell-freshagent/src/opencode_ws.rs index b75687db6..699ce85f6 100644 --- a/crates/freshell-freshagent/src/opencode_ws.rs +++ b/crates/freshell-freshagent/src/opencode_ws.rs @@ -2163,7 +2163,7 @@ impl FreshOpencodeState { ) { crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), crate::ownership_lane::LaneOpGuard::Unwired => None, - crate::ownership_lane::LaneOpGuard::Refused { message } => { + crate::ownership_lane::LaneOpGuard::Refused { message, .. } => { drop(session); tracing::warn!(target: "freshell_freshagent::opencode", session_id = %session_id, request_id = ?request_id, @@ -3042,7 +3042,7 @@ impl FreshOpencodeState { ) { crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), crate::ownership_lane::LaneOpGuard::Unwired => None, - crate::ownership_lane::LaneOpGuard::Refused { message } => { + crate::ownership_lane::LaneOpGuard::Refused { message, .. } => { session.pending_sends.pop_front(); tracing::warn!(target: "freshell_freshagent::opencode", session_id = %lookup_id, request_id = ?msg.request_id, @@ -5258,7 +5258,7 @@ impl FreshOpencodeState { ) { crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), crate::ownership_lane::LaneOpGuard::Unwired => None, - crate::ownership_lane::LaneOpGuard::Refused { message } => { + crate::ownership_lane::LaneOpGuard::Refused { message, .. } => { tracing::warn!(target: "freshell_freshagent::opencode", session_id = %session_id, real_id = %real_id, "fresh_agent_compact_refused: the op guard refused to arm"); @@ -5739,7 +5739,7 @@ impl FreshOpencodeState { ) { crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), crate::ownership_lane::LaneOpGuard::Unwired => None, - crate::ownership_lane::LaneOpGuard::Refused { message } => { + crate::ownership_lane::LaneOpGuard::Refused { message, .. } => { tracing::warn!(target: "freshell_freshagent::opencode", session_id = %msg.session_id, real_id = %real_id, "fresh_agent_fork_refused: the op guard refused to arm"); @@ -6330,7 +6330,7 @@ impl FreshOpencodeState { ) { crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), crate::ownership_lane::LaneOpGuard::Unwired => None, - crate::ownership_lane::LaneOpGuard::Refused { message } => { + crate::ownership_lane::LaneOpGuard::Refused { message, .. } => { tracing::warn!(target: "freshell_freshagent::opencode", session_id = %op.session_id, real_id = %real_id, request_id = %op.request_id, diff --git a/crates/freshell-ownership/src/lib.rs b/crates/freshell-ownership/src/lib.rs index 3e6860c9e..10e48b689 100644 --- a/crates/freshell-ownership/src/lib.rs +++ b/crates/freshell-ownership/src/lib.rs @@ -857,6 +857,8 @@ pub enum AttachGuardOutcome { StaleGeneration { current_epoch: u64, current_generation: u64, + /// The state at this atomic decision, for callers reporting its owner. + current_state: OwnershipState, }, } @@ -874,10 +876,12 @@ impl std::fmt::Debug for AttachGuardOutcome { Self::StaleGeneration { current_epoch, current_generation, + current_state, } => f .debug_struct("StaleGeneration") .field("current_epoch", current_epoch) .field("current_generation", current_generation) + .field("current_state", current_state) .finish(), } } @@ -1543,6 +1547,7 @@ impl RuntimeOwnershipRegistry { return AttachGuardOutcome::StaleGeneration { current_epoch: self.epoch, current_generation, + current_state: record.state.clone(), }; } } @@ -1636,6 +1641,10 @@ impl RuntimeOwnershipRegistry { return AttachGuardOutcome::StaleGeneration { current_epoch: self.epoch, current_generation: 0, + current_state: inner + .get(&key) + .map(|record| record.state.clone()) + .unwrap_or(OwnershipState::Vacant), }; } let Some(record) = inner.get_mut(&key) else { @@ -1701,6 +1710,7 @@ impl RuntimeOwnershipRegistry { return AttachGuardOutcome::StaleGeneration { current_epoch: self.epoch, current_generation, + current_state: record.state.clone(), }; } // (4) THE EXPECTED OWNER — the kind always; every identity field diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index 804836cae..f81a612bc 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -1582,6 +1582,7 @@ async fn handle_client_text( freshell_ownership::AttachGuardOutcome::StaleGeneration { current_epoch, current_generation, + .. } => { tracing::warn!(target: "freshell_ws::terminal", terminal_id = %attach.terminal_id, @@ -5284,6 +5285,7 @@ pub(crate) async fn handle_create( freshell_ownership::AttachGuardOutcome::StaleGeneration { current_epoch, current_generation, + .. } => { tracing::warn!( target: "freshell_ws::terminal", From 1a61986ab6e04ad0f11dad5739fd8a972aaaddaf Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:45:32 -0700 Subject: [PATCH 06/19] feat(freshclaude): recover stopped conversations through one runtime path --- crates/freshell-freshagent/src/claude.rs | 511 +++++++++--------- .../src/claude/establish.rs | 18 +- .../freshell-freshagent/src/claude/genesis.rs | 137 ++++- .../src/claude/recovery.rs | 471 ++++++++++++++++ .../src/claude/tests/recovery_tests.rs | 201 +++++++ .../src/claude_dead_sessions.rs | 54 +- .../tests/freshagent_claude_lifecycle.rs | 61 ++- .../tests/freshagent_claude_recovery.rs | 483 +++++++++++++++++ 8 files changed, 1635 insertions(+), 301 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude/recovery.rs create mode 100644 crates/freshell-freshagent/src/claude/tests/recovery_tests.rs create mode 100644 crates/freshell-ws/tests/freshagent_claude_recovery.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 8056182a4..7a52f74a5 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -148,6 +148,7 @@ pub struct FreshClaudeState { /// handoff resume's RAII drop-guard must be able to remove its flag from /// a synchronous Drop (an aborted resume future — round-3 review I-1). resuming: Arc>>, + resuming_changed: Arc, /// Test seam (Task 6 round-3 review I-1): park `kill_for_handoff` with /// the kill ISSUED (retained stamp taken) but nothing else disturbed — /// the prior-reap-window abort test's deterministic hold. `None` in @@ -704,15 +705,20 @@ impl ClaudeSession { /// goes through Drop). struct ResumingFlagGuard { resuming: Arc>>, + changed: Arc, session_id: String, } impl Drop for ResumingFlagGuard { fn drop(&mut self) { - self.resuming + let removed = self + .resuming .lock() .expect("resuming lock") .remove(&self.session_id); + if removed { + self.changed.notify_waiters(); + } } } @@ -756,6 +762,7 @@ impl FreshClaudeState { cli_index: Arc::new(TokioMutex::new(HashMap::new())), create_dedup: Arc::new(FreshAgentCreateDedup::new()), resuming: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())), + resuming_changed: Arc::new(tokio::sync::Notify::new()), identity_sink: Arc::new(std::sync::OnceLock::new()), leases: Arc::new(crate::session_lease::FreshAgentSessionLeases::new()), ownership: None, @@ -1418,18 +1425,11 @@ impl FreshClaudeState { // and a leaked flag would refuse every later retry with "another // resume is in flight" forever. The guard removes it on every exit // path, drop included. - let _resuming_flag = { - let mut resuming = self.resuming.lock().expect("resuming lock"); - if !resuming.insert(session_id.to_string()) { - return Err(( - "another resume is in flight for this session".to_string(), - session_id.to_string(), - )); - } - ResumingFlagGuard { - resuming: Arc::clone(&self.resuming), - session_id: session_id.to_string(), - } + let Some(_resuming_flag) = self.try_resume_flight(session_id) else { + return Err(( + "another resume is in flight for this session".to_string(), + session_id.to_string(), + )); }; // D8 same-kind lease claim (the provider backstop — the coordinator // claim is the runner's, under-ticket). @@ -1473,7 +1473,10 @@ impl FreshClaudeState { let outcome = self .resume_for_attach( &msg, - session_id, + DurableClaim { + durable: session_id.to_string(), + dead_state: self.claim_dead_state_snapshot(session_id), + }, &mut lease_guard, &mut own_ticket, // b8ke focused episode-2 round-2 F2: the RUNNER's handoff op @@ -4140,15 +4143,33 @@ impl FreshClaudeState { Err(err) => return self.refuse_send(&request_id, err.code(), err.message()), }; - // Task 10b: a durable claude UUID resolves through `cli_index` to the live - // session -- no more SESSION_NOT_FOUND for a durable id the index maps. - // An undo or redo never hides the session from this resolution (C13: - // the replacement registers, and the original's ids alias it, in one - // step under the map locks), so a miss is a session that is gone. + let resolved = match self + .ensure_runtime( + &session_id, + msg.session_type, + msg.cwd.as_deref(), + send_fence, + ) + .await + { + Ok(key) => key, + Err(error) => { + return self.refuse_runtime_resume( + &request_id, + &session_id, + msg.session_type, + error, + ) + } + }; let Some((map_key, turn_lock, ledger, durable_before_wait)) = - self.turn_locked_handles(&session_id).await + self.turn_locked_handles(&resolved).await else { - return self.refuse_send_to_missing_session(&request_id, &session_id, msg.session_type); + return self.refuse_send_after_runtime_stopped( + &request_id, + &session_id, + msg.session_type, + ); }; // r2 serialization discipline: hold the session turn lock across the // busy-set AND the sidecar write — the check-then-set window vs @@ -4170,7 +4191,11 @@ impl FreshClaudeState { ) .await else { - return self.refuse_send_to_missing_session(&request_id, &session_id, msg.session_type); + return self.refuse_send_after_runtime_stopped( + &request_id, + &session_id, + msg.session_type, + ); }; // Arm the coordinator guard only after the send has waited behind any // earlier turn. A lifecycle transition during that wait advances the @@ -4274,7 +4299,7 @@ impl FreshClaudeState { }), None => { drop(guard); - return self.refuse_send_to_missing_session( + return self.refuse_send_after_runtime_stopped( &request_id, &session_id, msg.session_type, @@ -4315,7 +4340,11 @@ impl FreshClaudeState { let Some(session) = guard.get(&map_key) else { drop(guard); self.take_back_unwritten_send(&ledger, &stamp, session_type, &submitted_turn_id); - return self.refuse_send_to_missing_session(&request_id, &session_id, msg.session_type); + return self.refuse_send_after_runtime_stopped( + &request_id, + &session_id, + msg.session_type, + ); }; // Address the sidecar by ITS id for this session (== the map key for created // sessions; differs for resumed-on-attach sessions, Task 6). @@ -4369,7 +4398,7 @@ impl FreshClaudeState { .sessions .lock() .await - .get(&session_id) + .get(&map_key) .and_then(|s| s.cli_session_id.clone()); stashed_handle .map( @@ -4439,29 +4468,16 @@ impl FreshClaudeState { session_type: &str, ) -> Result<(), String> { let request_id = uuid::Uuid::new_v4().to_string(); - let (response, next, writer, frame) = { + let (response, next, writer, frame, ownership_id) = { let mut sessions = self.sessions.lock().await; let session = sessions .get_mut(map_key) .ok_or("Claude session is no longer available.")?; - let mut next = session.configuration.settings.clone(); - if let Some(value) = &requested.model { - if next.model.as_ref() != Some(value) && requested.effort.is_none() { - next.effort = None; - } - next.model = Some(value.clone()); - } - if let Some(value) = &requested.effort { - next.effort = Some(value.clone()); - } - if let Some(value) = &requested.permission_mode { - next.permission_mode = Some(value.clone()); - } - if let Some(value) = &requested.cwd { - if next.cwd.is_some() || session.configuration.runtime_cwd.as_ref() != Some(value) { - next.cwd = Some(value.clone()); - } - } + let next = recovery::updated_settings( + &session.configuration.settings, + requested, + session.configuration.runtime_cwd.as_deref(), + ); if next == session.configuration.settings && !session.configuration.needs_configure { return Ok(()); } @@ -4479,7 +4495,13 @@ impl FreshClaudeState { "permissionMode": next.permission_mode, "cwd": next.cwd, }, }); - (rx, next, session.stdin.clone(), frame) + ( + rx, + next, + session.stdin.clone(), + frame, + session.ownership_id.clone(), + ) }; if let Err(err) = writer.write(&frame).await { if let Some(session) = self.sessions.lock().await.get_mut(map_key) { @@ -4509,6 +4531,12 @@ impl FreshClaudeState { let session = sessions .get_mut(map_key) .ok_or("Claude session is no longer available.")?; + if session.ownership_id != ownership_id { + return Err( + "Claude changed while applying settings. Your message was not sent; try again." + .into(), + ); + } session.configuration.pending = None; session.configuration.needs_configure = applied.is_err(); if applied.is_ok() { @@ -4544,10 +4572,34 @@ impl FreshClaudeState { let session_type = session_type_str(msg.session_type); let request_id = msg.request_id.clone(); + // kata z7j7: sandbox has NO claude-side concept — log observably + // (id + boolean flag only), mirroring the send path's arm. + if msg.settings.sandbox.is_some() { + tracing::info!( + session_id = %session_id, + has_sandbox = true, + "freshagent.claude.configure-unsupported-settings-ignored" + ); + } + + let resolved = match self.configure_without_runtime(&msg).await { + Ok(recovery::ConfigurationTarget::Stored) => return, + Ok(recovery::ConfigurationTarget::Live(key)) => key, + Err(error) => { + self.emit_resume_control_error(&session_id, msg.session_type, &request_id, error); + return; + } + }; let Some((map_key, turn_lock, _, durable_before_wait)) = - self.turn_locked_handles(&session_id).await + self.turn_locked_handles(&resolved).await else { - self.send_error(&request_id, "SESSION_NOT_FOUND", "claude session not found"); + self.emit_control_error( + &session_id, + msg.session_type, + &request_id, + "CLAUDE_SETTINGS_FAILED", + "Claude stopped before applying settings. Try again.", + ); return; }; let _turn = turn_lock.lock().await; @@ -4564,7 +4616,13 @@ impl FreshClaudeState { ) .await else { - self.send_error(&request_id, "SESSION_NOT_FOUND", "claude session not found"); + self.emit_control_error( + &session_id, + msg.session_type, + &request_id, + "CLAUDE_SETTINGS_FAILED", + "Claude stopped before applying settings. Try again.", + ); return; }; // The pane follows a replacement through its `materialized` re-key, so @@ -4576,16 +4634,6 @@ impl FreshClaudeState { }; let map_key = live_key; - // kata z7j7: sandbox has NO claude-side concept — log observably - // (id + boolean flag only), mirroring the send path's arm. - if msg.settings.sandbox.is_some() { - tracing::info!( - session_id = %session_id, - has_sandbox = true, - "freshagent.claude.configure-unsupported-settings-ignored" - ); - } - let before = self.stated_settings(&map_key).await; if let Err(err) = self .configure_for_send(&map_key, &msg.settings, session_type) @@ -4593,7 +4641,13 @@ impl FreshClaudeState { { // The refusal/failure is user-facing session state (busy turn, // sidecar death): the pane banner is the surface, on every device. - self.emit_fresh_agent_error(&session_id, session_type, "CLAUDE_SETTINGS_FAILED", &err); + self.emit_control_error( + &session_id, + msg.session_type, + &request_id, + "CLAUDE_SETTINGS_FAILED", + &err, + ); return; } let after = self.stated_settings(&map_key).await; @@ -4833,11 +4887,45 @@ impl FreshClaudeState { let session_id = msg.session_id.clone(); let session_type = session_type_str(msg.session_type); - // The `handle_send` resolve discipline: a miss is a session that is gone. + let fence = + match crate::ownership_lane::wire_fence(msg.observed_epoch, msg.observed_generation) { + Ok(fence) => fence, + Err(error) => { + self.emit_control_error( + &session_id, + msg.session_type, + &msg.request_id, + error.code(), + error.message(), + ); + return; + } + }; + let resolved = match self + .ensure_runtime(&session_id, msg.session_type, msg.cwd.as_deref(), fence) + .await + { + Ok(key) => key, + Err(error) => { + self.emit_resume_control_error( + &session_id, + msg.session_type, + &msg.request_id, + error, + ); + return; + } + }; let Some((map_key, turn_lock, ledger, durable_before_wait)) = - self.turn_locked_handles(&session_id).await + self.turn_locked_handles(&resolved).await else { - self.broadcast(&lost_session_frame(&session_id, msg.session_type)); + self.emit_control_error( + &session_id, + msg.session_type, + &msg.request_id, + "CLAUDE_COMPACT_FAILED", + "Claude stopped before compaction. Try again.", + ); return; }; // Held across the busy-set AND the sidecar write (the handle_send r2 @@ -4857,7 +4945,13 @@ impl FreshClaudeState { ) .await else { - self.broadcast(&lost_session_frame(&session_id, msg.session_type)); + self.emit_control_error( + &session_id, + msg.session_type, + &msg.request_id, + "CLAUDE_COMPACT_FAILED", + "Claude stopped before compaction. Try again.", + ); return; }; let session_id = if live_key == map_key { @@ -4866,6 +4960,32 @@ impl FreshClaudeState { live_key.clone() }; let map_key = live_key; + let _ownership_guard = if fence.is_some() && self.ownership.is_some() { + let owner_key = self.send_owner_key(&map_key, &session_id, fence).await; + match crate::ownership_lane::arm_reclaimless_op_guard( + &self.ownership, + PROVIDER, + owner_key.as_deref().unwrap_or(&map_key), + &format!("compact-{}", uuid::Uuid::new_v4()), + fence, + "freshclaude/compact", + ) { + crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), + crate::ownership_lane::LaneOpGuard::Unwired => None, + crate::ownership_lane::LaneOpGuard::Refused { message, .. } => { + self.emit_control_error( + &session_id, + msg.session_type, + &msg.request_id, + "SESSION_RESERVED", + &message, + ); + return; + } + } + } else { + None + }; // Arm before the write (as a send does): the compact is owed from now on. let uuid = crate::claude_turns::mint_send_uuid(); let stamp = self.envelope_stamp(&map_key).await; @@ -4879,7 +4999,13 @@ impl FreshClaudeState { let Some(session) = guard.get(&map_key) else { drop(guard); self.take_back_unwritten_send(&ledger, &stamp, session_type, &uuid); - self.broadcast(&lost_session_frame(&session_id, msg.session_type)); + self.emit_control_error( + &session_id, + msg.session_type, + &msg.request_id, + "CLAUDE_COMPACT_FAILED", + "Claude stopped before compaction. Try again.", + ); return; }; let text = match msg.instructions.as_deref().map(str::trim) { @@ -4902,7 +5028,13 @@ impl FreshClaudeState { if let Err(err) = writer.write(&send_req).await { // The write never went out: take back exactly this compact. self.take_back_unwritten_send(&ledger, &stamp, session_type, &uuid); - self.emit_fresh_agent_error(&session_id, session_type, "INTERNAL_ERROR", &err); + self.emit_control_error( + &session_id, + msg.session_type, + &msg.request_id, + "CLAUDE_COMPACT_FAILED", + &err, + ); } } @@ -5040,37 +5172,21 @@ impl FreshClaudeState { self.sessions.lock().await.contains_key(&key) } - /// A send whose session is not live (C09, G05). A send to a dead session is - /// refused visibly: the lost-session frame for the id the pane sent (the - /// client starts its recovery from it) and a request error that hands the - /// message back to the composer. Until the transparent resume (C20) it is - /// never accepted. Any other id keeps the `SESSION_NOT_FOUND` request error. - fn refuse_send_to_missing_session( + /// A runtime that disappears after a successful resolution is a transient + /// failure. Only a clean transcript/genesis lookup can prove a conversation + /// missing, so keep the user's prompt and allow another recovery attempt. + fn refuse_send_after_runtime_stopped( &self, request_id: &Option, session_id: &str, session_type: SessionType, ) -> ClaudeSendOutcome { - let Some(dead) = self.dead_sessions.find(session_id) else { - return self.refuse_send(request_id, "SESSION_NOT_FOUND", "claude session not found"); - }; - tracing::warn!(target: "freshell_freshagent::claude", - provider = PROVIDER, session_id = %session_id, - session_type = session_type_str(session_type), request_id = ?request_id, - reason = %dead.reason, died_at_ms = dead.at_ms, - "freshagent.claude.send_to_dead_session"); - self.broadcast(&lost_session_frame(session_id, session_type)); - self.send_request_error( - request_id, - ErrorCode::InvalidSessionId, - crate::claude_dead_sessions::SEND_TO_DEAD_SESSION_MESSAGE, - ); - ClaudeSendOutcome::Refused { - code: "INVALID_SESSION_ID".to_string(), - message: crate::claude_dead_sessions::SEND_TO_DEAD_SESSION_MESSAGE.to_string(), - retryable: None, - owner: None, - } + self.refuse_runtime_resume(request_id, session_id, session_type, + recovery::RuntimeResumeError::Unavailable { + code: "CLAUDE_RUNTIME_UNAVAILABLE", + message: "Claude stopped before accepting your message. Your message was not sent; try again.".into(), + owner: None, + }) } pub(crate) async fn local_snapshot_owner( @@ -5345,154 +5461,28 @@ impl FreshClaudeState { return; } } - { - let mut resuming = self.resuming.lock().expect("resuming lock"); - if !resuming.insert(durable.clone()) { - return; // a concurrent attach is resuming this exact durable id - } - } - // Task 13: the attach-resume path SPAWNS, so it claims the D8 lease first (the - // in-process `resuming` single-flight above stays -- it is cheap and covers the - // attach-vs-attach window; the lease serializes against CREATE-path holders). - // Losers get `freshAgent.error { code: "SESSION_RESERVED" }` -- the established - // non-lost error channel (never INVALID_SESSION_ID, which would kill the pane). - // - // Task 13b (cross-kind liveness): a live terminal PTY owning `(claude, durable)` - // is the one writer on that JSONL -- refuse the attach-resume the same way. - if (self.terminal_liveness)(PROVIDER, &durable) { - tracing::warn!(target: "freshell_freshagent::claude", session_id = %durable, - "fresh_agent_attach_refused: a live terminal PTY owns this session (Task 13b cross-kind live-guard)"); - self.resuming - .lock() - .expect("resuming lock") - .remove(&durable); - self.emit_fresh_agent_error( - &msg.session_id, - session_type_str(msg.session_type), - "SESSION_RESERVED", - "Another resume for this session is in flight", - ); + let Some(_flight) = self.try_resume_flight(&durable) else { return; - } - // kata b8ke Task 3: the coordinator claim comes FIRST — before the - // provider lease — with the attach's delayed-request fence (round-2 - // lifecycle audit: attach can cold-resume an untracked session, - // registering a runtime). AdoptLive converges via the same rebind - // arm the lease's BoundLive uses. - // b8ke delta round-2 F2: the attach-start's watchdog machinery - // (cancellation + settle) is registered inside - // `resume_for_attach` — the function that owns the spawn and the - // registration window (and the under-ticket handoff-resume caller - // passes no ticket, so the registration no-ops there). - let mut own_ticket = match self.begin_lane_claim_at( - &durable, - &format!("attach-{}", uuid::Uuid::new_v4()), - attach_fence, - "freshclaude/attach", - ) { - crate::ownership_lane::LaneClaim::Granted(ticket) => Some(ticket), - crate::ownership_lane::LaneClaim::Unwired => None, - crate::ownership_lane::LaneClaim::Adopt => { - self.resuming - .lock() - .expect("resuming lock") - .remove(&durable); - if !self - .try_rebind_to_live(&durable, session_type_str(msg.session_type)) - .await - { - self.emit_fresh_agent_error( - &msg.session_id, - session_type_str(msg.session_type), - "SESSION_RESERVED", - "Another resume for this session is in flight", - ); - } - return; - } - crate::ownership_lane::LaneClaim::Refused(outcome) => { - tracing::warn!(target: "freshell_freshagent::claude", - session_id = %durable, outcome = ?outcome, - "fresh_agent_attach_refused: the ownership coordinator refused the \ - claim (kata b8ke cross-kind authority)" - ); - self.resuming - .lock() - .expect("resuming lock") - .remove(&durable); - self.emit_fresh_agent_error( - &msg.session_id, - session_type_str(msg.session_type), - "SESSION_RESERVED", - "Another resume for this session is in flight", - ); - return; - } }; - let attach_request_id = format!("attach-{}", uuid::Uuid::new_v4()); - let lease = self.claim_launch_lease(&durable, &attach_request_id).await; - let mut lease_guard = match lease { - LaunchLease::Acquired(guard) => Some(guard), - refused => { - self.resuming - .lock() - .expect("resuming lock") - .remove(&durable); - // The winner completed while we contended: converge via the - // same rebind + ack the cli_index arm performs; if the binding - // is stale (session just died), or another launch holds the - // lease, answer RESERVED -- the client re-drive converges on - // the reopened key. - let rebound = matches!(refused, LaunchLease::BoundLive { .. }) - && self - .try_rebind_to_live(&durable, session_type_str(msg.session_type)) - .await; - if !rebound { + match self + .resume_untracked(&msg, &durable, attach_fence, true) + .await + { + Ok(()) => {} + Err(recovery::RuntimeResumeError::Lost | recovery::RuntimeResumeError::Closed) => { + self.broadcast(&lost_session_frame(&msg.session_id, msg.session_type)); + } + Err(recovery::RuntimeResumeError::Unavailable { code, message, .. }) => { + if code == "SESSION_RESERVED" { self.emit_fresh_agent_error( &msg.session_id, session_type_str(msg.session_type), - "SESSION_RESERVED", - "Another resume for this session is in flight", + code, + &message, ); + } else { + self.send_error(&None, code, &message); } - return; - } - }; - // b8ke focused episode-2 round-2 F2: the attach's OWN claim owns - // the transition while it is in flight — recorded for the - // session-init adoption's Blocked correlation. - let attach_owning_operation = own_ticket.as_ref().map(|t| t.operation_id().to_string()); - let outcome = self - .resume_for_attach( - &msg, - &durable, - &mut lease_guard, - &mut own_ticket, - attach_owning_operation, - // b8ke ext r27 F2: the attach path holds its OWN ticket — - // no handoff generation to thread. - None, - ) - .await; - self.resuming - .lock() - .expect("resuming lock") - .remove(&durable); - // Any leftover armed guard means the resume ended WITHOUT registering a session - // (its own teardown already ran on every error path) -- release the key. - if let Some(mut g) = lease_guard.take() { - g.fail(); - } - // kata b8ke Task 3: same for the coordinator ticket — a leftover - // (uncommitted) claim drops here (RAII typed fail reopens the key). - drop(own_ticket); - match outcome { - Ok(()) => {} - Err(ResumeClaudeError::NotFound) => { - self.broadcast(&lost_session_frame(&msg.session_id, msg.session_type)); - } - Err(ResumeClaudeError::Transient(err)) => { - self.send_error(&None, "CLAUDE_ATTACH_RESUME_FAILED", &err); } } } @@ -5549,7 +5539,7 @@ impl FreshClaudeState { async fn resume_for_attach( &self, msg: &FreshAgentAttach, - durable: &str, + claim: DurableClaim, lease_guard: &mut Option, // kata b8ke Task 3: the attach's coordinator claim ticket — committed // at the registration tail below; every error return leaves it armed @@ -5574,6 +5564,8 @@ impl FreshClaudeState { // generation instead of preserving the prior one. handoff_generation: Option, ) -> Result<(), ResumeClaudeError> { + let durable = claim.durable.as_str(); + let claim_dead_state = claim.dead_state; // b8ke delta round-2 F2: the attach-start's REAL watchdog machinery // — the sidecar pid slot (the registered cancellation SIGTERMs the // spawned child; the resume's own gates then tear down and unwind) @@ -5599,10 +5591,10 @@ impl FreshClaudeState { let sink = self.identity_sink(); let (boot, transcript_cwd, minted_id) = self.decide_attach_boot(durable, sink.as_ref())?; // The genesis the boot was decided from, for its one typed retry (§4.2). - let recorded_genesis = sink - .as_ref() - .and_then(|sink| sink.load_genesis(PROVIDER, durable)) + let recorded_genesis = self + .recovery_genesis(durable, sink.as_ref()) .map(|recorded| recorded.genesis); + let dead = self.dead_sessions.find(durable); // P1.13 (Task 10): recover the pane's recorded settings from the ledger so a // resume-in-place does not silently revert model/permissionMode/effort (or, // G15, drop its plugins). V7/A10 alarm gate: alarm ONLY when the ledger @@ -5611,7 +5603,8 @@ impl FreshClaudeState { // resume_for_attach exists FOR them) stay silent. let recovered = sink .as_ref() - .and_then(|s| s.load_settings("claude", durable)); + .and_then(|s| s.load_settings("claude", durable)) + .or_else(|| dead.as_ref().map(|dead| dead.settings.clone())); if recovered.is_none() && sink .as_ref() @@ -5639,8 +5632,11 @@ impl FreshClaudeState { // so a kill landing while this resume is in flight advances the durable // tombstone past it and the commit REFUSES instead of undoing the newer // close. - let claim_dead_state = self.claim_dead_state_snapshot(durable); let session_type = session_type_str(msg.session_type); + let launch_genesis = minted_id + .is_some() + .then(|| recorded_genesis.clone()) + .flatten(); let spec = ClaudeLaunchSpec::new( format!("attach-resume-{}", uuid::Uuid::new_v4()), session_type, @@ -5654,7 +5650,8 @@ impl FreshClaudeState { crate::claude_freshell_mcp::ClaudeFreshellContext::from_ids( msg.tab_id.as_deref(), msg.pane_id.as_deref(), - ), + ) + .or_else(|| dead.as_ref().and_then(|dead| dead.freshell_context.clone())), ); // Register under the CLIENT's id: the consumer stamps the map key on every // envelope and the frozen client routes by envelope sessionId @@ -5685,8 +5682,12 @@ impl FreshClaudeState { naming: None, partial_runtime_key: Some(durable.to_string()), start_pid_slot: &start_pid_slot, - inherit: None, - genesis: None, + inherit: dead.map(|dead| InheritedRuntime { + turn_lock: dead.turn_lock, + clock: dead.clock, + ledger: crate::claude_turns::SharedLedger::default(), + }), + genesis: launch_genesis, retry: Some(BootRetry { durable: durable.to_string(), genesis: recorded_genesis, @@ -6570,6 +6571,7 @@ impl FreshClaudeState { &self, mut reader: tokio::io::Lines>, session_id: String, + ownership_id: String, session_type: String, sidecar_session_id: String, settings: Option, @@ -6745,7 +6747,7 @@ impl FreshClaudeState { // Core design §4.1: a session booted from its genesis // writes its transcript around its first result; clear // the genesis once the transcript is on disk. - state.spawn_genesis_clear_after_result(&session_id); + state.spawn_genesis_clear_after_result(&session_id, &ownership_id); } // A protocol fake that predates C18 still sends its own // edges: the server mints every edge itself, so they are @@ -6940,6 +6942,10 @@ impl FreshClaudeState { durables: &removed_durables, cli_session_id: evicted.cli_session_id.as_deref(), settings: &evicted.configuration.settings, + ownership_id: &evicted.ownership_id, + launch_genesis: evicted.launch_genesis.as_ref(), + turn_lock: &evicted.turn_lock, + freshell_context: evicted.configuration.freshell_context.as_ref(), commands: evicted.configuration.commands.as_ref(), reason, clock: &clock, @@ -8128,6 +8134,7 @@ fn log_claude_teardown( mod create_binding; mod create_outcome; +mod recovery; pub use create_outcome::ClaudeCreateOutcome; mod send_outcome; @@ -8144,7 +8151,8 @@ mod sidecar_writer; mod stop; mod turn_status; use establish::{ - BootRetry, DurableClaim, EstablishLane, EstablishPlan, LaunchLease, RegisterUnder, + BootRetry, DurableClaim, EstablishLane, EstablishPlan, InheritedRuntime, LaunchLease, + RegisterUnder, }; #[cfg(test)] @@ -8188,6 +8196,7 @@ pub(crate) mod tests { use crate::naming::SessionNaming as _; mod create_binding_tests; + mod recovery_tests; mod refusal_tests; fn state() -> FreshClaudeState { @@ -17748,6 +17757,7 @@ rl.on('line', (line) => { let consumer = st.spawn_consumer( reader, "fold-session".to_string(), + "test-fold-session".to_string(), "freshclaude".to_string(), "fold-session".to_string(), None, @@ -19101,9 +19111,9 @@ rl.on('line', (line) => { assert_ne!(st.live_snapshot_overlay(&pane_id).await["status"], "exited"); } - /// C09: closing an ended session forgets it: its snapshot no longer says - /// `exited`, and a later send is an ordinary unknown-session refusal, not the - /// ended session's lost frame. + /// Closing an ended session forgets its process state. With neither a + /// transcript nor a retained genesis, a later send is visibly lost and must + /// never recreate it as a new conversation. #[tokio::test(flavor = "multi_thread")] async fn closing_a_dead_session_forgets_it() { let _guard = CLAUDE_ENV_LOCK.lock().await; @@ -19126,16 +19136,16 @@ rl.on('line', (line) => { assert!( frames .iter() - .all(|frame| frame["event"]["code"] != "INVALID_SESSION_ID"), - "no lost frame for a closed session: {frames:?}" + .any(|frame| frame["event"]["code"] == "INVALID_SESSION_ID"), + "a cleanly missing closed conversation is lost: {frames:?}" ); assert!( frames.iter().any(|frame| frame["type"] == "error" && frame["requestId"] == "send-after-close" && frame["message"] .as_str() - .is_some_and(|m| m.starts_with("SESSION_NOT_FOUND"))), - "the send is refused as unknown: {frames:?}" + .is_some_and(|m| m.contains("could not be found"))), + "the unsent prompt is returned with the missing conversation: {frames:?}" ); } @@ -19287,6 +19297,7 @@ rl.on('line', (line) => { let consumer = st.spawn_consumer( reader, map_key.to_string(), + format!("test-{map_key}"), "freshclaude".to_string(), map_key.to_string(), None, diff --git a/crates/freshell-freshagent/src/claude/establish.rs b/crates/freshell-freshagent/src/claude/establish.rs index 5cdb86c8e..ca4acbd52 100644 --- a/crates/freshell-freshagent/src/claude/establish.rs +++ b/crates/freshell-freshagent/src/claude/establish.rs @@ -746,22 +746,27 @@ impl FreshClaudeState { let pending = Arc::new(std::sync::Mutex::new(ClaudePending::default())); // A resume after an unrequested death continues the dead session's // attention clock (design §5.5): its edges stay above the death's. - let resumed_clock = || { + let resumed_runtime = || { [Some(map_key.as_str()), durable.as_deref()] .into_iter() .flatten() .find_map(|id| self.dead_sessions.find(id)) - .map(|dead| dead.clock) }; let InheritedRuntime { turn_lock, ledger, // The session's attention clock (see the field doc). clock, - } = inherit.unwrap_or_else(|| InheritedRuntime { - turn_lock: Arc::new(TokioMutex::new(())), - ledger: crate::claude_turns::SharedLedger::default(), - clock: resumed_clock().unwrap_or_default(), + } = inherit.unwrap_or_else(|| { + let dead = resumed_runtime(); + InheritedRuntime { + turn_lock: dead + .as_ref() + .map(|dead| dead.turn_lock.clone()) + .unwrap_or_else(|| Arc::new(TokioMutex::new(()))), + ledger: crate::claude_turns::SharedLedger::default(), + clock: dead.map(|dead| dead.clock).unwrap_or_default(), + } }); let (stdin, fault) = crate::claude_sidecar_io::SidecarWriter::spawn( stdin, @@ -774,6 +779,7 @@ impl FreshClaudeState { let consumer = self.spawn_consumer( reader, map_key.clone(), + ownership_id.clone(), spec.session_type.clone(), minted.clone(), recorded_settings, diff --git a/crates/freshell-freshagent/src/claude/genesis.rs b/crates/freshell-freshagent/src/claude/genesis.rs index 093469491..f5bf19fdc 100644 --- a/crates/freshell-freshagent/src/claude/genesis.rs +++ b/crates/freshell-freshagent/src/claude/genesis.rs @@ -67,6 +67,16 @@ pub(crate) async fn clear_genesis_once_observed( session_id: &str, attempts: u32, interval: Duration, +) -> GenesisClear { + observe_and_clear_genesis(Some(sink), roots, session_id, attempts, interval).await +} + +async fn observe_and_clear_genesis( + sink: Option<&crate::identity_sink::SharedPaneIdentitySink>, + roots: Vec, + session_id: &str, + attempts: u32, + interval: Duration, ) -> GenesisClear { for attempt in 1..=attempts { let (store, id) = (roots.clone(), session_id.to_string()); @@ -79,9 +89,12 @@ pub(crate) async fn clear_genesis_once_observed( .await .unwrap_or(false); if found { - return match sink.clear_genesis(PROVIDER, session_id).await { - Ok(()) => GenesisClear::Cleared { attempt }, - Err(error) => GenesisClear::Failed(error.to_string()), + return match sink { + Some(sink) => match sink.clear_genesis(PROVIDER, session_id).await { + Ok(()) => GenesisClear::Cleared { attempt }, + Err(error) => GenesisClear::Failed(error.to_string()), + }, + None => GenesisClear::Cleared { attempt }, }; } if attempt < attempts { @@ -154,12 +167,20 @@ impl FreshClaudeState { { return Some(recorded); } - let key = self.resolve_session_key(any_id).await?; - self.sessions - .lock() - .await - .get(&key) - .and_then(|session| session.launch_genesis.clone()) + let live = match self.resolve_session_key(any_id).await { + Some(key) => self + .sessions + .lock() + .await + .get(&key) + .and_then(|session| session.launch_genesis.clone()), + None => None, + }; + live.or_else(|| { + self.dead_sessions + .find(any_id) + .and_then(|dead| dead.launch_genesis) + }) } /// The session's snapshot from disk, read from this lane's transcript store: @@ -188,12 +209,26 @@ impl FreshClaudeState { /// or forked boot writes under. A found transcript whose row still records a /// genesis clears it (the transcript is observed). Lost and unreadable map to /// the attach's errors (unreadable is logged and never lost). + pub(super) fn recovery_genesis( + &self, + durable: &str, + sink: Option<&crate::identity_sink::SharedPaneIdentitySink>, + ) -> Option { + match sink { + Some(sink) => sink.load_genesis(PROVIDER, durable), + None => self + .dead_sessions + .find(durable) + .and_then(|dead| dead.launch_genesis), + } + } + pub(super) fn decide_attach_boot( &self, durable: &str, sink: Option<&crate::identity_sink::SharedPaneIdentitySink>, ) -> Result<(ClaudeBoot, Option, Option), ResumeClaudeError> { - let recorded = sink.and_then(|sink| sink.load_genesis(PROVIDER, durable)); + let recorded = self.recovery_genesis(durable, sink); self.decide_recorded_boot("attach_resume", durable, sink, recorded.as_ref()) } @@ -215,10 +250,7 @@ impl FreshClaudeState { durable: &str, ) -> Result, ResumeClaudeError> { let sink = self.identity_sink(); - let Some(recorded) = sink - .as_ref() - .and_then(|sink| sink.load_genesis(PROVIDER, durable)) - else { + let Some(recorded) = self.recovery_genesis(durable, sink.as_ref()) else { return Ok(None); }; let (boot, cwd, session_id) = @@ -258,9 +290,17 @@ impl FreshClaudeState { cwd, session_id, } => { - if let (Some(recorded), Some(sink)) = (recorded, sink) { + if found { + // A transcript is positive evidence, including for lanes + // without a persistent identity sink. Never retain Fresh + // as a fallback after this conversation has been observed. + self.dead_sessions.clear_genesis(durable, None); + } + if let Some(recorded) = recorded { if found { - self.clear_found_genesis(sink, durable, recorded); + if let Some(sink) = sink { + self.clear_found_genesis(sink, durable, recorded); + } } else { tracing::info!( component = "freshagent.claude", @@ -316,24 +356,71 @@ impl FreshClaudeState { /// reader) entirely: resolving the durable id takes the session locks, which /// the reader must never wait on. A session with no genesis costs one memory /// read. - pub(super) fn spawn_genesis_clear_after_result(&self, session_key: &str) { - let Some(sink) = self.identity_sink() else { - return; - }; - let (state, session_key) = (self.clone(), session_key.to_string()); + pub(super) fn spawn_genesis_clear_after_result(&self, session_key: &str, ownership_id: &str) { + let sink = self.identity_sink(); + let (state, session_key, ownership_id) = ( + self.clone(), + session_key.to_string(), + ownership_id.to_string(), + ); tokio::spawn(async move { - let durable = state.durable_session_id(&session_key).await; - let Some(recorded) = sink.load_genesis(PROVIDER, &durable) else { + let captured = { + let sessions = state.sessions.lock().await; + sessions + .get(&session_key) + .filter(|session| session.ownership_id == ownership_id) + .map(|session| { + ( + session + .cli_session_id + .clone() + .unwrap_or_else(|| session_key.clone()), + session.launch_genesis.clone(), + ) + }) + } + .or_else(|| { + state + .dead_sessions + .find(&session_key) + .filter(|dead| dead.ownership_id == ownership_id) + .map(|dead| { + ( + dead.cli_session_id.unwrap_or_else(|| session_key.clone()), + dead.launch_genesis, + ) + }) + }); + let Some((durable, runtime_genesis)) = captured else { + return; + }; + let Some(recorded) = sink + .as_ref() + .and_then(|sink| sink.load_genesis(PROVIDER, &durable)) + .or(runtime_genesis) + else { return; }; - let outcome = clear_genesis_once_observed( - &sink, + let outcome = observe_and_clear_genesis( + sink.as_ref(), state.transcript_roots(), &durable, GENESIS_CLEAR_ATTEMPTS, GENESIS_CLEAR_INTERVAL, ) .await; + if matches!(outcome, GenesisClear::Cleared { .. }) { + let mut sessions = state.sessions.lock().await; + if let Some(session) = sessions.get_mut(&session_key).filter(|session| { + session.ownership_id == ownership_id + && session.cli_session_id.as_deref().unwrap_or(&session_key) == durable + }) { + session.launch_genesis = None; + } + state + .dead_sessions + .clear_genesis(&durable, Some(&ownership_id)); + } log_genesis_clear(&durable, recorded.genesis.kind(), "result", &outcome); }); } diff --git a/crates/freshell-freshagent/src/claude/recovery.rs b/crates/freshell-freshagent/src/claude/recovery.rs new file mode 100644 index 000000000..264d734d6 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/recovery.rs @@ -0,0 +1,471 @@ +//! Recover a known conversation through the existing attach/establish path. + +use super::*; + +impl FreshClaudeState { + pub(super) fn try_resume_flight(&self, durable: &str) -> Option { + let mut active = self.resuming.lock().expect("resuming lock"); + if !active.insert(durable.to_string()) { + return None; + } + Some(ResumingFlagGuard { + resuming: self.resuming.clone(), + changed: self.resuming_changed.clone(), + session_id: durable.to_string(), + }) + } + + pub(super) async fn wait_resume_flight(&self, durable: &str) -> ResumingFlagGuard { + loop { + let changed = self.resuming_changed.notified(); + tokio::pin!(changed); + // Register before inspecting the active set: a fast owner or an + // aborted future can finish between that inspection and the await. + changed.as_mut().enable(); + if let Some(flight) = self.try_resume_flight(durable) { + return flight; + } + changed.await; + } + } +} + +#[derive(Debug)] +pub(super) enum RuntimeResumeError { + Lost, + Closed, + Unavailable { + code: &'static str, + message: String, + owner: Option, + }, +} + +impl RuntimeResumeError { + fn reserved(owner: Option) -> Self { + Self::Unavailable { + code: "SESSION_RESERVED", + message: "Another resume for this session is in flight".into(), + owner, + } + } +} + +impl From for RuntimeResumeError { + fn from(error: ResumeClaudeError) -> Self { + match error { + ResumeClaudeError::NotFound => Self::Lost, + ResumeClaudeError::Transient(message) => Self::Unavailable { + code: "CLAUDE_ATTACH_RESUME_FAILED", + message, + owner: None, + }, + } + } +} + +impl FreshClaudeState { + /// The cold attach path, also used by send/compact recovery. Its caller + /// owns the shared resume flight. All launches still go through + /// resume_for_attach -> establish_runtime, with the same lease and fence. + pub(super) async fn resume_untracked( + &self, + msg: &FreshAgentAttach, + durable: &str, + fence: Option, + reopen_closed: bool, + ) -> Result<(), RuntimeResumeError> { + let claim_dead_state = self.claim_dead_state_snapshot(durable); + if !reopen_closed && claim_dead_state.is_some() { + return Err(RuntimeResumeError::Closed); + } + if (self.terminal_liveness)(PROVIDER, durable) { + return Err(RuntimeResumeError::reserved( + crate::ownership_lane::terminal_owner_fields_from_snapshot( + &self.ownership_snapshot(PROVIDER, durable), + ), + )); + } + let mut ticket = match self.begin_lane_claim_at( + durable, + &format!("attach-{}", uuid::Uuid::new_v4()), + fence, + "freshclaude/attach", + ) { + crate::ownership_lane::LaneClaim::Granted(ticket) => Some(ticket), + crate::ownership_lane::LaneClaim::Unwired => None, + crate::ownership_lane::LaneClaim::Adopt => { + return if self + .try_rebind_to_live(durable, session_type_str(msg.session_type)) + .await + { + Ok(()) + } else { + Err(RuntimeResumeError::reserved(None)) + }; + } + crate::ownership_lane::LaneClaim::Refused(outcome) => { + tracing::warn!(target: "freshell_freshagent::claude", + session_id = %durable, outcome = ?outcome, + "fresh_agent_attach_refused: the ownership coordinator refused the claim"); + return Err(RuntimeResumeError::reserved( + crate::ownership_lane::fresh_agent_owner_fields_from_outcome( + &self.ownership, + &outcome, + ), + )); + } + }; + let request_id = format!("attach-{}", uuid::Uuid::new_v4()); + let mut lease = match self.claim_launch_lease(durable, &request_id).await { + LaunchLease::Acquired(lease) => Some(lease), + refused => { + return if matches!(refused, LaunchLease::BoundLive { .. }) + && self + .try_rebind_to_live(durable, session_type_str(msg.session_type)) + .await + { + Ok(()) + } else { + Err(RuntimeResumeError::reserved(None)) + }; + } + }; + let owning_operation = ticket + .as_ref() + .map(|ticket| ticket.operation_id().to_string()); + let outcome = self + .resume_for_attach( + msg, + DurableClaim { + durable: durable.to_string(), + dead_state: claim_dead_state, + }, + &mut lease, + &mut ticket, + owning_operation, + None, + ) + .await; + if let Some(mut remaining) = lease.take() { + remaining.fail(); + } + drop(ticket); + outcome.map_err(Into::into) + } + + pub(super) async fn ensure_runtime( + &self, + wire_id: &str, + session_type: SessionType, + cwd: Option<&str>, + fence: Option, + ) -> Result { + if let Some(key) = self.resolve_session_key(wire_id).await { + return Ok(key); + } + let durable = self + .recovery_durable_id(wire_id) + .ok_or(RuntimeResumeError::Lost)?; + let _flight = self.wait_resume_flight(&durable).await; + if let Some(key) = self.resolve_session_key(&durable).await { + return Ok(key); + } + let dead = self.dead_sessions.find(&durable); + tracing::info!(target: "freshell_freshagent::claude", + event = "freshagent.claude.runtime_recovery_started", provider = PROVIDER, + session_id = %durable, previous_ownership_id = ?dead.as_ref().map(|dead| dead.ownership_id.as_str()), + previous_end_reason = ?dead.as_ref().map(|dead| dead.reason.as_str()), + "recovering the conversation through the attach runtime path"); + let msg = FreshAgentAttach { + provider: freshell_protocol::AgentProvider::Claude, + session_id: if is_canonical_claude_uuid(wire_id) { + durable.clone() + } else { + wire_id.to_string() + }, + session_type, + cwd: cwd.map(str::to_string), + observed_epoch: fence.map(|fence| fence.epoch), + observed_generation: fence.map(|fence| fence.generation), + resume_session_id: None, + session_ref: Some(claude_session_ref(&durable)), + tab_id: None, + pane_id: None, + }; + self.resume_untracked(&msg, &durable, fence, false).await?; + self.resolve_session_key(&msg.session_id) + .await + .ok_or_else(|| RuntimeResumeError::Unavailable { + code: "CLAUDE_ATTACH_RESUME_FAILED", + message: "Claude stopped while recovering the session. Try again.".into(), + owner: None, + }) + } + + pub(super) fn refuse_runtime_resume( + &self, + request_id: &Option, + session_id: &str, + session_type: SessionType, + error: RuntimeResumeError, + ) -> ClaudeSendOutcome { + match error { + RuntimeResumeError::Lost => { + let message = "The Claude conversation for this session could not be found."; + self.broadcast(&lost_session_frame(session_id, session_type)); + self.send_request_error(request_id, ErrorCode::InvalidSessionId, message); + ClaudeSendOutcome::Refused { + code: "INVALID_SESSION_ID".into(), + message: message.into(), + retryable: None, + owner: None, + } + } + RuntimeResumeError::Closed => { + let message = + "This Claude session was closed. Reopen the saved session to continue."; + self.send_error(request_id, "SESSION_CLOSED", message); + ClaudeSendOutcome::Refused { + code: "SESSION_CLOSED".into(), + message: message.into(), + retryable: Some(false), + owner: None, + } + } + RuntimeResumeError::Unavailable { + code, + message, + owner, + } => { + self.send_error(request_id, code, &message); + ClaudeSendOutcome::Refused { + code: code.into(), + message, + retryable: Some(true), + owner, + } + } + } + } +} + +pub(super) enum ConfigurationTarget { + Live(String), + Stored, +} + +pub(super) fn updated_settings( + current: &crate::identity_sink::FreshAgentSettings, + requested: &freshell_protocol::FreshAgentSendSettings, + runtime_cwd: Option<&str>, +) -> crate::identity_sink::FreshAgentSettings { + let mut next = current.clone(); + if let Some(model) = &requested.model { + if next.model.as_ref() != Some(model) && requested.effort.is_none() { + next.effort = None; + } + next.model = Some(model.clone()); + } + if let Some(effort) = &requested.effort { + next.effort = Some(effort.clone()); + } + if let Some(permission_mode) = &requested.permission_mode { + next.permission_mode = Some(permission_mode.clone()); + } + if let Some(cwd) = &requested.cwd { + if next.cwd.is_some() || runtime_cwd != Some(cwd.as_str()) { + next.cwd = Some(cwd.clone()); + } + } + next +} + +impl FreshClaudeState { + fn recovery_durable_id(&self, wire_id: &str) -> Option { + let sink = self.identity_sink(); + let candidate = sink + .as_ref() + .and_then(|sink| sink.chain_terminus(PROVIDER, wire_id)) + .or_else(|| { + self.dead_sessions + .find(wire_id) + .and_then(|dead| dead.cli_session_id) + }) + .or_else(|| is_canonical_claude_uuid(wire_id).then(|| wire_id.to_string()))?; + Some( + sink.and_then(|sink| sink.chain_terminus(PROVIDER, &candidate)) + .unwrap_or(candidate), + ) + } + + /// Configure an ended or not-yet-running conversation without launching a + /// process just to save preferences. The shared flight and provider lease + /// serialize it with recovery and explicit create/resume launches. + pub(super) async fn configure_without_runtime( + &self, + msg: &FreshAgentConfigure, + ) -> Result { + if let Some(key) = self.resolve_session_key(&msg.session_id).await { + return Ok(ConfigurationTarget::Live(key)); + } + let durable = self + .recovery_durable_id(&msg.session_id) + .ok_or(RuntimeResumeError::Lost)?; + let _flight = self.wait_resume_flight(&durable).await; + if let Some(key) = self.resolve_session_key(&durable).await { + return Ok(ConfigurationTarget::Live(key)); + } + let sink = self.identity_sink(); + self.decide_attach_boot(&durable, sink.as_ref())?; + if sink + .as_ref() + .is_some_and(|sink| sink.kill_tombstone_at_ms(PROVIDER, &durable).is_some()) + { + return Err(RuntimeResumeError::Closed); + } + let dead = self.dead_sessions.find(&durable); + if sink.is_none() && dead.is_none() { + // An uncached disk-only lane has nowhere to retain preferences. + // Establish the existing conversation, then configure that runtime. + let attach = FreshAgentAttach { + provider: freshell_protocol::AgentProvider::Claude, + session_id: durable.clone(), + session_type: msg.session_type, + cwd: msg.settings.cwd.clone(), + observed_epoch: None, + observed_generation: None, + resume_session_id: None, + session_ref: Some(claude_session_ref(&durable)), + tab_id: None, + pane_id: None, + }; + self.resume_untracked(&attach, &durable, None, false) + .await?; + return self + .resolve_session_key(&durable) + .await + .map(ConfigurationTarget::Live) + .ok_or_else(|| RuntimeResumeError::Unavailable { + code: "CLAUDE_SETTINGS_FAILED", + message: "Claude stopped while restoring settings. Try again.".into(), + owner: None, + }); + } + let mut lease = match self + .claim_launch_lease(&durable, &format!("configure-{}", uuid::Uuid::new_v4())) + .await + { + LaunchLease::Acquired(lease) => lease, + _ => return Err(RuntimeResumeError::reserved(None)), + }; + let turn_lock = dead + .as_ref() + .map(|dead| dead.turn_lock.clone()) + .unwrap_or_else(|| Arc::new(TokioMutex::new(()))); + let _turn = turn_lock.lock().await; + let current = sink + .as_ref() + .and_then(|sink| sink.load_settings(PROVIDER, &durable)) + .or_else(|| dead.as_ref().map(|dead| dead.settings.clone())) + .unwrap_or_default(); + let next = updated_settings(¤t, &msg.settings, None); + if current != next { + if let Some(sink) = sink.as_ref() { + sink.record_binding(crate::identity_sink::FreshAgentBindingUpsert { + provider: PROVIDER.into(), + session_id: durable.clone(), + mode: session_type_str(msg.session_type).into(), + create_request_id: None, + resolves_pending: None, + supersedes: None, + name_transition: None, + genesis: crate::identity_sink::GenesisUpdate::Keep, + provenance: crate::identity_sink::ProvenanceUpdate::Inherit, + observed_epoch: None, + observed_generation: None, + authoritative: false, + settings: next.clone(), + }) + .await + .map_err(|error| RuntimeResumeError::Unavailable { + code: "CLAUDE_SETTINGS_FAILED", + message: error.to_string(), + owner: None, + })?; + if sink.kill_tombstone_at_ms(PROVIDER, &durable).is_some() { + return Err(RuntimeResumeError::Closed); + } + } + if let Some(dead) = dead.as_ref() { + if !self.dead_sessions.set_settings_if_matches( + &durable, + &dead.ownership_id, + next.clone(), + ) { + return Err(RuntimeResumeError::Unavailable { + code: "CLAUDE_SETTINGS_FAILED", + message: "Claude changed while saving settings. Try again.".into(), + owner: None, + }); + } + } + tracing::info!(target: "freshell_freshagent::claude", provider = PROVIDER, + session_id = %durable, request_id = ?msg.request_id, + event = "freshagent.claude.settings_stored_for_recovery", + "settings saved for the next Claude runtime"); + self.broadcast_stated_settings( + session_type_str(msg.session_type), + &msg.session_id, + StatedSettings { + model: next.model, + effort: next.effort, + permission_mode: next.permission_mode, + }, + ); + } + lease.fail(); + Ok(ConfigurationTarget::Stored) + } + + pub(super) fn emit_resume_control_error( + &self, + session_id: &str, + session_type: SessionType, + request_id: &Option, + error: RuntimeResumeError, + ) { + let (code, message) = match error { + RuntimeResumeError::Lost => ( + "INVALID_SESSION_ID", + "The Claude conversation for this session could not be found.".into(), + ), + RuntimeResumeError::Closed => ( + "SESSION_CLOSED", + "This Claude session was closed. Reopen the saved session to continue.".into(), + ), + RuntimeResumeError::Unavailable { code, message, .. } => (code, message), + }; + self.emit_control_error(session_id, session_type, request_id, code, &message); + } + + pub(super) fn emit_control_error( + &self, + session_id: &str, + session_type: SessionType, + request_id: &Option, + code: &str, + message: &str, + ) { + let mut event = json!({"type":"freshAgent.error", "sessionId":session_id, "code":code, "message":message}); + if let Some(request_id) = request_id { + event["requestId"] = json!(request_id); + } + self.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { + provider: PROVIDER.into(), + session_id: session_id.into(), + session_type: session_type_str(session_type).into(), + event, + })); + } +} diff --git a/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs b/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs new file mode 100644 index 000000000..01f48640e --- /dev/null +++ b/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs @@ -0,0 +1,201 @@ +use super::*; +use std::{future::Future, task::Poll}; + +#[tokio::test] +async fn an_enabled_resume_waiter_survives_fast_owner_completion() { + let state = state(); + let owner = state.try_resume_flight("fast-resume").unwrap(); + let waiting = state.wait_resume_flight("fast-resume"); + tokio::pin!(waiting); + std::future::poll_fn(|cx| { + assert!(matches!(waiting.as_mut().poll(cx), Poll::Pending)); + Poll::Ready(()) + }) + .await; + drop(owner); + let next = tokio::time::timeout(Duration::from_secs(1), waiting) + .await + .expect("completion must wake the already-registered waiter"); + drop(next); + assert!(!state.resuming.lock().unwrap().contains("fast-resume")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn canceling_a_resume_owner_wakes_its_enabled_waiter() { + let state = state(); + let acquired = Arc::new(tokio::sync::Notify::new()); + let owner = { + let state = state.clone(); + let acquired = acquired.clone(); + tokio::spawn(async move { + let _flight = state.wait_resume_flight("canceled-resume").await; + acquired.notify_one(); + std::future::pending::<()>().await; + }) + }; + acquired.notified().await; + let waiting = state.wait_resume_flight("canceled-resume"); + tokio::pin!(waiting); + std::future::poll_fn(|cx| { + assert!(matches!(waiting.as_mut().poll(cx), Poll::Pending)); + Poll::Ready(()) + }) + .await; + owner.abort(); + assert!(owner.await.unwrap_err().is_cancelled()); + let next = tokio::time::timeout(Duration::from_secs(1), waiting) + .await + .expect("RAII cancellation must remove the flight before waking its waiter"); + drop(next); + assert!(!state.resuming.lock().unwrap().contains("canceled-resume")); +} + +#[tokio::test] +async fn a_configure_receipt_from_a_replaced_runtime_cannot_change_its_replacement() { + let (state, _rx) = state_with_bus(); + let id = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&state, &id).await; + let turn_lock = state.sessions.lock().await[&id].turn_lock.clone(); + let configuring = { + let state = state.clone(); + let id = id.clone(); + let turn_lock = turn_lock.clone(); + tokio::spawn(async move { + let _turn = turn_lock.lock().await; + state + .configure_for_send( + &id, + &freshell_protocol::FreshAgentSendSettings { + model: Some("old-runtime-model".into()), + cwd: None, + effort: None, + permission_mode: None, + sandbox: None, + }, + "freshclaude", + ) + .await + }) + }; + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + let old_reply = loop { + if let Some((_, reply)) = state + .sessions + .lock() + .await + .get_mut(&id) + .unwrap() + .configuration + .pending + .take() + { + break reply; + } + assert!( + tokio::time::Instant::now() < deadline, + "configure never reached its RPC wait" + ); + tokio::task::yield_now().await; + }; + let removed = state.sessions.lock().await.remove(&id).unwrap(); + insert_fake_claude_session(&state, &id).await; + { + let mut sessions = state.sessions.lock().await; + let replacement = sessions.get_mut(&id).unwrap(); + replacement.ownership_id = "replacement-runtime".into(); + replacement.turn_lock = turn_lock; + replacement.configuration.settings.model = Some("replacement-model".into()); + } + old_reply.send(Ok(())).unwrap(); + let outcome = configuring.await.unwrap(); + assert!( + outcome.is_err(), + "an obsolete receipt must not acknowledge a replacement's settings" + ); + assert_eq!( + state.sessions.lock().await[&id] + .configuration + .settings + .model + .as_deref(), + Some("replacement-model") + ); + let _ = state.teardown_removed_session_logged(removed).await; + state.shutdown().await; +} + +#[tokio::test] +async fn an_observed_hosted_transcript_cannot_fall_back_to_fresh_after_it_is_removed() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + let home = tempfile::tempdir().unwrap(); + let (st, mut rx) = state_with_bus(); + let mut environment: Vec<(String, String)> = std::env::vars().collect(); + environment.retain(|(key, _)| key != "CLAUDE_CONFIG_DIR"); + environment.push(( + "CLAUDE_CONFIG_DIR".into(), + home.path().to_string_lossy().into(), + )); + let st = st.with_sidecar_environment(environment); + st.handle_create(dedup_create_msg("hosted-observed-genesis"), None) + .await; + let created = await_claude_created(&mut rx, "hosted-observed-genesis").await; + let id = created["sessionId"].as_str().unwrap().to_string(); + assert!(st.sessions.lock().await[&id].launch_genesis.is_some()); + write_fake_transcript(home.path(), &id); + inject_raw_send(&st, &id, "__emit_result__").await; + await_frame_of_inner_type(&mut rx, "freshAgent.result").await; + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + while st.sessions.lock().await[&id].launch_genesis.is_some() { + assert!( + tokio::time::Instant::now() < deadline, + "observing the transcript must retire the in-memory Fresh genesis too" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + let transcript = home.path().join("projects/-t").join(format!("{id}.jsonl")); + std::fs::rename(&transcript, transcript.with_extension("saved")).unwrap(); + let pid = st.sessions.lock().await[&id].child.id().unwrap(); + // SAFETY: this process belongs to this test's isolated fake sidecar. + assert_eq!(unsafe { libc::kill(pid as i32, libc::SIGKILL) }, 0); + await_frame_of_inner_type(&mut rx, "freshAgent.status").await; + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + while st.resolve_session_key(&id).await.is_some() { + assert!( + tokio::time::Instant::now() < deadline, + "the dead runtime was not removed" + ); + tokio::task::yield_now().await; + } + assert!(matches!( + st.ensure_runtime(&id, SessionType::Freshclaude, None, None) + .await, + Err(recovery::RuntimeResumeError::Lost) + )); + assert_eq!( + env.spawn_count(), + 1, + "a conversed session is never recreated Fresh" + ); + st.shutdown().await; +} + +#[tokio::test] +async fn a_compact_with_an_obsolete_owner_fence_never_arms_or_writes_a_turn() { + let (mut st, mut rx) = state_with_bus(); + st.set_ownership(Arc::new( + freshell_ownership::RuntimeOwnershipRegistry::with_epoch(41), + )); + let id = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&st, &id).await; + let mut msg = compact_msg(&id, None); + msg.request_id = Some("stale-compact".into()); + msg.observed_epoch = Some(41); + msg.observed_generation = Some(7); + st.handle_compact(msg).await; + let frame: Value = serde_json::from_str(&rx.try_recv().unwrap()).unwrap(); + assert_eq!(frame["event"]["code"], "SESSION_RESERVED", "{frame}"); + assert_eq!(frame["event"]["requestId"], "stale-compact"); + assert!(!st.sessions.lock().await[&id].ledger.lock().unwrap().busy()); + st.shutdown().await; +} diff --git a/crates/freshell-freshagent/src/claude_dead_sessions.rs b/crates/freshell-freshagent/src/claude_dead_sessions.rs index 8b37f1cc3..967c4be05 100644 --- a/crates/freshell-freshagent/src/claude_dead_sessions.rs +++ b/crates/freshell-freshagent/src/claude_dead_sessions.rs @@ -31,6 +31,11 @@ pub(crate) struct DeadClaudeSession { pub(crate) ids: Vec, /// The settings the session last ran under. pub(crate) settings: FreshAgentSettings, + pub(crate) cli_session_id: Option, + pub(crate) ownership_id: String, + pub(crate) launch_genesis: Option, + pub(crate) turn_lock: Arc>, + pub(crate) freshell_context: Option, /// The command catalog the session last published. pub(crate) commands: Option>, /// Why it ended: the `sdk.stream.ended` reason, or `sidecar_exited` for a @@ -101,6 +106,10 @@ pub(crate) struct Death<'a> { /// The durable id the session itself held. pub(crate) cli_session_id: Option<&'a str>, pub(crate) settings: &'a FreshAgentSettings, + pub(crate) ownership_id: &'a str, + pub(crate) launch_genesis: Option<&'a crate::identity_sink::RecordedGenesis>, + pub(crate) turn_lock: &'a Arc>, + pub(crate) freshell_context: Option<&'a crate::claude_freshell_mcp::ClaudeFreshellContext>, pub(crate) commands: Option<&'a Vec>, /// The `sdk.stream.ended` reason, or `sidecar_exited`. pub(crate) reason: &'a str, @@ -135,6 +144,11 @@ impl SharedDeadSessions { let dead = DeadClaudeSession { ids: ids.clone(), settings: death.settings.clone(), + cli_session_id: death.cli_session_id.map(str::to_string), + ownership_id: death.ownership_id.to_string(), + launch_genesis: death.launch_genesis.cloned(), + turn_lock: death.turn_lock.clone(), + freshell_context: death.freshell_context.cloned(), commands: death.commands.cloned(), reason: death.reason.to_string(), at_ms: crate::rollback_record::now_ms(), @@ -157,6 +171,36 @@ impl SharedDeadSessions { } } + pub(crate) fn set_settings_if_matches( + &self, + id: &str, + ownership_id: &str, + settings: FreshAgentSettings, + ) -> bool { + let mut store = self.0.lock().expect("dead sessions lock"); + let Some(dead) = store + .entries + .iter_mut() + .find(|dead| dead.answers_to(id) && dead.ownership_id == ownership_id) + else { + return false; + }; + dead.settings = settings; + true + } + + pub(crate) fn clear_genesis(&self, id: &str, ownership_id: Option<&str>) { + let mut store = self.0.lock().expect("dead sessions lock"); + for dead in &mut store.entries { + if dead.answers_to(id) + && dead.cli_session_id.as_deref().is_none_or(|cli| cli == id) + && ownership_id.is_none_or(|owner| dead.ownership_id == owner) + { + dead.launch_genesis = None; + } + } + } + /// Forget the records answering to any of `ids`: the session is live again /// (`resumed`) or the pane closed it (`killed`). pub(crate) fn forget>(&self, ids: &[S], reason: &'static str) { @@ -180,11 +224,6 @@ pub(crate) fn exited_status_event(session_id: &str) -> Value { }) } -/// The request error a send to a dead session gets (the client shows it as the -/// reason the message was not sent). -pub(crate) const SEND_TO_DEAD_SESSION_MESSAGE: &str = - "The Claude agent process for this session exited. Resume the session to continue."; - #[cfg(test)] mod tests { use super::*; @@ -193,6 +232,11 @@ mod tests { DeadClaudeSession { ids: ids.iter().map(|id| id.to_string()).collect(), settings: FreshAgentSettings::default(), + cli_session_id: None, + ownership_id: "dead-runtime".into(), + launch_genesis: None, + turn_lock: Arc::new(tokio::sync::Mutex::new(())), + freshell_context: None, commands: None, reason: "cli_exited".into(), at_ms: 1, diff --git a/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs b/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs index e6aa94ddc..f9c9f4231 100644 --- a/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs +++ b/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs @@ -25,7 +25,7 @@ mod common; use std::time::Duration; -use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RIG_FRAME_BUDGET}; +use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions, RIG_FRAME_BUDGET}; use serde_json::{json, Value}; /// How long a test keeps reading after the exit report before it counts edges: the @@ -333,18 +333,31 @@ async fn a_sidecar_death_mid_compact_ends_exited_with_one_ring_and_an_exited_sna } } -/// A send that reaches a session whose sidecar has ended is refused visibly: the -/// pane's lost-session frame (nested `INVALID_SESSION_ID`, which starts the client's -/// recovery) and a request error that hands the message back to the composer. It is -/// never accepted and never silently dropped. Either id the pane may hold works. +/// An ended conversation with a cleanly missing transcript is refused visibly: +/// nested INVALID_SESSION_ID and the correlated unsent prompt, with no fresh +/// replacement conversation. Resolvable crashes are covered by the recovery suite. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn a_send_to_a_dead_session_gets_the_lost_frame_and_a_request_error() { - let rig = ClaudeRig::start(RigKind::Sim).await; +async fn a_send_to_a_dead_session_with_a_missing_transcript_gets_lost_and_a_request_error() { + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; let mut conn = rig.connect().await; let session_id = conn.create("create-1").await; conn.send_text(&session_id, "send-1", "first").await; conn.wait_for_event("freshAgent.turn.complete").await; let durable = durable_id(&conn, &session_id); + rig.wait_for_genesis_cleared(&durable).await; + let projects = rig.claude_config_dir.join("projects"); + std::fs::rename( + &projects, + rig.claude_config_dir.with_extension("projects-saved"), + ) + .unwrap(); let pid = sidecar_pid(&rig).await; sigkill(pid); wait_for_sidecar_exited(&mut conn, &session_id).await; @@ -375,7 +388,7 @@ async fn a_send_to_a_dead_session_gets_the_lost_frame_and_a_request_error() { assert!( error["message"] .as_str() - .is_some_and(|message| message.contains("exited")), + .is_some_and(|message| message.contains("could not be found")), "the error says why: {error}" ); } @@ -592,17 +605,35 @@ async fn the_boot_catalog_reaches_the_live_state_however_long_the_registration_t /// the session, still ends the session: the exit report arrives and the session /// reads as exited, never as a live session nobody will ever end (review PRB-C10). #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn a_cli_death_while_the_session_registers_still_ends_the_session() { +async fn a_cli_death_during_registration_refuses_create_and_leaves_no_runtime() { let rig = rig_with_program(json!([{ "on": "start", "emit": [{ "kind": "crash", "delayMs": 300 }] }])) .await; rig.fresh_claude .hold_session_registration(REGISTRATION_HOLD); let mut conn = rig.connect().await; - let session_id = conn.create("create-1").await; - - let exited = wait_for_sidecar_exited(&mut conn, &session_id).await; - assert_eq!(exited["cause"]["reason"], "cli_exited", "{exited}"); - let overlay = overlay_once(&rig, &session_id, |overlay| overlay["status"].is_string()).await; - assert_eq!(overlay["status"], "exited", "{overlay}"); + let refused = conn.request_create("create-1", json!({})).await; + assert_eq!(refused["type"], "freshAgent.create.failed", "{refused}"); + assert_eq!(refused["code"], "FRESH_AGENT_CREATE_FAILED", "{refused}"); + let boot = rig + .ledger_rows("boot") + .into_iter() + .next() + .expect("the CLI booted before crashing"); + let session_id = boot["sessionId"].as_str().unwrap(); + // Creation failed before a runtime was acknowledged; its failure is the + // client-visible outcome. Confirm cleanup directly for both child pids. + assert!(!rig.fresh_claude.has_live_session(session_id).await); + for pid in rig.runtime_pids(session_id) { + wait_until_gone(pid as i32).await; + } + conn.read_for(QUIET).await; + assert!( + !conn + .frames + .iter() + .any(|frame| frame["type"] == "freshAgent.created" && frame["requestId"] == "create-1"), + "{:#?}", + conn.frames + ); } diff --git a/crates/freshell-ws/tests/freshagent_claude_recovery.rs b/crates/freshell-ws/tests/freshagent_claude_recovery.rs new file mode 100644 index 000000000..af660399c --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_recovery.rs @@ -0,0 +1,483 @@ +//! Transparent recovery uses the production sidecar and launch/ownership path. +//! Deliberate child crashes run only in the disposable runtime sandbox. + +#![cfg(unix)] + +mod common; + +use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions, RIG_FRAME_BUDGET}; +use serde_json::{json, Value}; +use std::time::Duration; + +async fn crash_runtime(rig: &ClaudeRig, conn: &mut RigConnection, id: &str) { + let owner = rig.fresh_claude.ownership_snapshot("claude", id); + let freshell_ownership::OwnershipState::Live { owner, .. } = owner.state else { + panic!("the rig must own a live runtime before crashing it"); + }; + let pid = owner.pid.expect("the registered rig sidecar's pid") as i32; + // SAFETY: this pid is the live sidecar registered by this isolated rig. + assert_eq!(unsafe { libc::kill(pid, libc::SIGKILL) }, 0); + conn.wait_for("the sidecar's exit", |frame| { + frame["event"]["code"] == "SIDECAR_EXITED" + }) + .await; + // wait_for also searches recorded frames. Subsequent control assertions + // must observe this recovery, not the prior turn or its exit report. + conn.frames.clear(); +} + +async fn send_after_crash(zero_turn: bool, durable_ledger: bool, kind: RigKind) { + let rig = ClaudeRig::start_with_options( + kind, + RigOptions { + durable_ledger, + ..RigOptions::default() + }, + ) + .await; + let mut first = rig.connect().await; + let pane = first.create("create-recovery").await; + let durable_before = if zero_turn { + None + } else { + Some(rig.converse(&mut first, &pane, &["before the crash"]).await) + }; + crash_runtime(&rig, &mut first, &pane).await; + if zero_turn { + let (status, snapshot) = rig.snapshot(&pane).await; + assert_eq!( + status, 200, + "a stopped zero-turn conversation remains readable: {snapshot}" + ); + assert_eq!(snapshot["status"], "exited", "{snapshot}"); + } + let mut second = rig.connect().await; + second + .send_text(&pane, "after-crash", "continue after the crash") + .await; + let reply = second + .wait_for("the second device's send outcome", |frame| { + frame["requestId"] == "after-crash" + && (frame["type"] == "freshAgent.send.accepted" || frame["type"] == "error") + }) + .await; + assert_eq!(reply["type"], "freshAgent.send.accepted", "{reply}"); + second + .wait_for("the recovered turn's result", |frame| { + frame["sessionId"] == pane && frame["event"]["type"] == "freshAgent.result" + }) + .await; + second + .wait_for("the recovered turn's completion", |frame| { + frame["sessionId"] == pane && frame["event"]["type"] == "freshAgent.turn.complete" + }) + .await; + let durable_after = second.init_session_id(&pane); + if let Some(before) = durable_before { + assert_eq!( + durable_after, before, + "recovery resumes the original conversation" + ); + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + let (status, snapshot) = rig.snapshot(&durable_after).await; + let text = snapshot.to_string(); + if status == 200 + && text.contains("before the crash") + && text.contains("continue after the crash") + { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "the recovered conversation never persisted both messages: {snapshot}" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + if kind == RigKind::Sim { + let launches: Vec = rig.ledger_rows("query"); + assert_eq!( + launches.len(), + 2, + "one initial launch and one recovery launch" + ); + if zero_turn { + assert_eq!( + durable_after, pane, + "zero-turn recovery keeps its create-time UUID" + ); + assert_eq!(launches[1]["options"]["sessionId"], pane); + assert!( + launches[1]["options"]["resume"].is_null(), + "a zero-turn session boots Fresh" + ); + } else { + assert_eq!(launches[1]["options"]["resume"], durable_after); + } + } + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_second_device_send_transparently_resumes_a_crashed_conversation() { + send_after_crash(false, true, RigKind::Sim).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_zero_turn_crash_transparently_reboots_fresh_on_send() { + send_after_crash(true, true, RigKind::Sim).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_hosted_zero_turn_crash_recovers_from_its_retained_genesis() { + send_after_crash(true, false, RigKind::Sim).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_real_cli_resumes_on_a_second_device_send_after_a_crash() { + send_after_crash(false, true, RigKind::Cli).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_concurrent_send_and_attach_share_one_recovery_launch() { + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut first = rig.connect().await; + let id = first.create("concurrent-recovery-create").await; + rig.converse(&mut first, &id, &["before concurrent recovery"]) + .await; + rig.kill_first_sidecar(&mut first).await; + let mut sender = rig.connect().await; + let mut observer = rig.connect().await; + tokio::join!( + async { + sender + .send_text(&id, "concurrent-recovery-send", "continue once") + .await; + let accepted = sender + .wait_for("the recovering send", |frame| { + frame["requestId"] == "concurrent-recovery-send" + && (frame["type"] == "freshAgent.send.accepted" || frame["type"] == "error") + }) + .await; + assert_eq!(accepted["type"], "freshAgent.send.accepted", "{accepted}"); + sender.wait_for_event("freshAgent.result").await; + }, + async { + observer.attach(&id).await; + } + ); + assert_eq!( + rig.ledger_rows("query").len(), + 2, + "only one claimant may relaunch the runtime" + ); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_hosted_recovery_keeps_the_last_model_effort_and_permission_mode() { + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut first = rig.connect().await; + let created = first + .request_create( + "hosted-settings-create", + json!({ + "model":"sonnet", "effort":"high", "permissionMode":"plan", + }), + ) + .await; + let id = created["sessionId"].as_str().unwrap(); + rig.converse(&mut first, id, &["persist this conversation"]) + .await; + rig.kill_first_sidecar(&mut first).await; + let mut second = rig.connect().await; + second + .send_text( + id, + "restore-hosted-settings", + "continue with the same settings", + ) + .await; + second.wait_for_event("freshAgent.result").await; + let queries = rig.ledger_rows("query"); + assert_eq!(queries.len(), 2); + assert_eq!( + queries[1]["options"]["model"], + queries[0]["options"]["model"] + ); + let (_, snapshot) = rig.snapshot(id).await; + assert_eq!(snapshot["settings"]["model"], "sonnet", "{snapshot}"); + assert_eq!(snapshot["settings"]["effort"], "high", "{snapshot}"); + assert_eq!(snapshot["settings"]["permissionMode"], "plan", "{snapshot}"); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn configuring_a_dead_session_stores_settings_without_starting_a_process() { + for durable_ledger in [true, false] { + let mut rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger, + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let id = conn.create("dead-configure-create").await; + crash_runtime(&rig, &mut conn, &id).await; + conn.send( + json!({"type":"freshAgent.configure", "provider":"claude", "sessionType":"freshclaude", + "sessionId":id, "requestId":"dead-configure", "settings":{ + "model":"sonnet", "effort":"high", "permissionMode":"plan", + }}), + ) + .await; + let answer = conn + .wait_for("the stored settings", |frame| { + frame["event"]["type"] == "freshAgent.session.metadata" + || frame["event"]["type"] == "freshAgent.error" + || (frame["type"] == "error" && frame["requestId"] == "dead-configure") + }) + .await; + assert_eq!( + answer["event"]["type"], "freshAgent.session.metadata", + "{answer}" + ); + assert_eq!(answer["event"]["model"], "sonnet"); + assert_eq!(answer["event"]["effort"], "high"); + assert_eq!(answer["event"]["permissionMode"], "plan"); + assert_eq!( + rig.ledger_rows("query").len(), + 1, + "settings alone must not relaunch" + ); + assert!(!rig.fresh_claude.has_live_session(&id).await); + if durable_ledger { + rig.restart().await; + conn = rig.connect().await; + } + conn.send_text(&id, "send-after-dead-configure", "use the stored settings") + .await; + conn.wait_for_event("freshAgent.result").await; + let queries = rig.ledger_rows("query"); + assert_eq!(queries.len(), 2); + assert_eq!(queries[1]["options"]["model"], "sonnet"); + let (_, snapshot) = rig.snapshot(&id).await; + assert_eq!(snapshot["settings"]["effort"], "high", "{snapshot}"); + assert_eq!(snapshot["settings"]["permissionMode"], "plan", "{snapshot}"); + rig.fresh_claude.shutdown().await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn compaction_resumes_a_dead_conversation_through_the_same_runtime_path() { + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let id = conn.create("dead-compact-create").await; + rig.converse(&mut conn, &id, &["before compaction recovery"]) + .await; + crash_runtime(&rig, &mut conn, &id).await; + conn.send( + json!({"type":"freshAgent.compact", "provider":"claude", "sessionType":"freshclaude", + "sessionId":id, "requestId":"dead-compact", "instructions":"keep the conclusion"}), + ) + .await; + let answer = conn + .wait_for("the compact outcome", |frame| { + frame["event"]["type"] == "freshAgent.result" + || frame["event"]["type"] == "freshAgent.error" + }) + .await; + assert_eq!(answer["event"]["type"], "freshAgent.result", "{answer}"); + assert_eq!(rig.ledger_rows("query").len(), 2); + let prompts = rig.ledger_rows("prompt"); + assert_eq!( + prompts.last().unwrap()["text"], + "/compact keep the conclusion" + ); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_unreadable_store_is_a_transient_recovery_error_and_never_a_lost_session() { + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let id = conn.create("transient-recovery-create").await; + crash_runtime(&rig, &mut conn, &id).await; + std::fs::create_dir_all(&rig.claude_config_dir).unwrap(); + let saved = rig.claude_config_dir.with_extension("saved"); + std::fs::rename(&rig.claude_config_dir, &saved).unwrap(); + std::fs::create_dir_all(&rig.claude_config_dir).unwrap(); + // A loop is an actual read failure even as root; NotADirectory is an + // expected clean absence in the transcript-store contract. + std::os::unix::fs::symlink("projects", rig.claude_config_dir.join("projects")).unwrap(); + conn.send_text(&id, "transient-recovery-send", "keep this prompt") + .await; + let error = conn + .wait_for("the transient send refusal", |frame| { + frame["type"] == "error" && frame["requestId"] == "transient-recovery-send" + }) + .await; + assert!( + error["message"] + .as_str() + .unwrap() + .contains("CLAUDE_ATTACH_RESUME_FAILED"), + "{error}" + ); + conn.read_for(Duration::from_millis(100)).await; + assert!(!conn + .frames + .iter() + .any(|frame| frame["event"]["code"] == "INVALID_SESSION_ID")); + assert_eq!( + rig.ledger_rows("query").len(), + 1, + "an unchecked conversation never launches" + ); + std::fs::rename( + &rig.claude_config_dir, + rig.claude_config_dir.with_extension("unreadable"), + ) + .unwrap(); + std::fs::rename(saved, &rig.claude_config_dir).unwrap(); + conn.send_text(&id, "retry-after-transient", "keep this prompt") + .await; + conn.wait_for_event("freshAgent.result").await; + assert_eq!( + rig.ledger_rows("query").len(), + 2, + "a refused recovery must release its flight and lease" + ); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_cold_hosted_configuration_has_a_runtime_to_retain_its_settings() { + let mut rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let id = conn.create("cold-hosted-create").await; + rig.converse(&mut conn, &id, &["an existing hosted conversation"]) + .await; + rig.restart().await; + let mut conn = rig.connect().await; + conn.send( + json!({"type":"freshAgent.configure", "provider":"claude", "sessionType":"freshclaude", + "sessionId":id, "requestId":"cold-hosted-configure", "settings":{"model":"sonnet"}}), + ) + .await; + let answer = conn + .wait_for("the cold hosted settings outcome", |frame| { + frame["event"]["type"] == "freshAgent.session.metadata" + || frame["event"]["type"] == "freshAgent.error" + }) + .await; + assert_eq!(answer["event"]["model"], "sonnet", "{answer}"); + assert!(rig.fresh_claude.has_live_session(&id).await); + assert_eq!(rig.ledger_rows("query").len(), 2); + let (_, snapshot) = rig.snapshot(&id).await; + assert_eq!(snapshot["settings"]["model"], "sonnet", "{snapshot}"); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_cleanly_missing_conversation_is_lost_without_starting_a_new_one() { + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let id = conn.create("missing-recovery-create").await; + rig.converse(&mut conn, &id, &["a conversation that had a transcript"]) + .await; + rig.wait_for_genesis_cleared(&id).await; + crash_runtime(&rig, &mut conn, &id).await; + let projects = rig.claude_config_dir.join("projects"); + std::fs::rename( + &projects, + rig.claude_config_dir.with_extension("projects-saved"), + ) + .unwrap(); + conn.send_text(&id, "missing-recovery-send", "do not reset my conversation") + .await; + let lost = conn + .wait_for("the nested lost conversation", |frame| { + frame["event"]["code"] == "INVALID_SESSION_ID" + }) + .await; + assert_eq!(lost["sessionId"], id); + let refusal = conn + .wait_for("the correlated unsent prompt", |frame| { + frame["type"] == "error" && frame["requestId"] == "missing-recovery-send" + }) + .await; + assert_eq!(refusal["code"], "INVALID_SESSION_ID"); + assert_eq!(rig.ledger_rows("query").len(), 1); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_late_send_does_not_reopen_a_session_that_was_explicitly_closed() { + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let id = conn.create("closed-recovery-create").await; + conn.kill(&id).await; + conn.frames.clear(); + conn.send_text( + &id, + "closed-recovery-send", + "a late message from another device", + ) + .await; + let answer = conn + .wait_for("the closed-session send outcome", |frame| { + frame["requestId"] == "closed-recovery-send" + && (frame["type"] == "error" || frame["type"] == "freshAgent.send.accepted") + }) + .await; + assert_eq!( + answer["type"], "error", + "a late send must not undo an explicit close: {answer}" + ); + assert_eq!(rig.ledger_rows("query").len(), 1); + assert!(!rig.fresh_claude.has_live_session(&id).await); + // Opening the saved session is an explicit recovery action. + conn.attach(&id).await; + assert!(rig.fresh_claude.has_live_session(&id).await); + assert_eq!(rig.ledger_rows("query").len(), 2); + rig.fresh_claude.shutdown().await; +} From cee90c14fa015e417409632f2570d5af4bdec19e Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:00:29 -0700 Subject: [PATCH 07/19] fix(freshclaude): surface correlated control failures --- crates/freshell-freshagent/src/claude.rs | 78 +++++--- crates/freshell-freshagent/src/claude/stop.rs | 52 ++++- .../src/claude/tests/control_tests.rs | 94 +++++++++ .../src/claude/turn_status.rs | 13 +- .../freshell-freshagent/src/claude_turns.rs | 36 +++- .../src/claude_turns_stop_tests.rs | 34 +++- crates/freshell-freshagent/src/codex.rs | 4 + .../src/codex/controls/tests.rs | 1 + crates/freshell-freshagent/src/opencode_ws.rs | 44 +++++ .../freshell-protocol/src/client_messages.rs | 2 + .../src/providers/fresh_agent.rs | 1 + .../tests/freshagent_claude_kill_interrupt.rs | 179 +++++++++++++++++- .../tests/freshagent_claude_turns.rs | 15 +- shared/ws-protocol.ts | 1 + 14 files changed, 493 insertions(+), 61 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude/tests/control_tests.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 7a52f74a5..45ca576e8 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -4022,21 +4022,37 @@ impl FreshClaudeState { /// and the interrupt is written with the latch's `stopId`. The stopped turn's /// own result is then silent; every other turn end rings. A Stop that missed /// a command the CLI was still starting is sent again by the stdout consumer - /// ([`Self::reissue_stop`]). Success broadcasts nothing. A missing session - /// mirrors the `SESSION_NOT_FOUND` convention [`Self::handle_send`] uses. + /// ([`Self::reissue_stop`]). Success broadcasts nothing. A dead session + /// repeats its exited status; an unknown one reports a nested session error. pub async fn handle_interrupt(&self, msg: FreshAgentInterrupt) { let session_id = msg.session_id.clone(); - - // Task 10b: durable ids resolve through `cli_index` (same aliasing as send). - let Some(map_key) = self.resolve_session_key(&session_id).await else { - self.send_error(&None, "SESSION_NOT_FOUND", "claude session not found"); - return; - }; - if !self - .stop_live_session(&map_key, session_type_str(msg.session_type)) - .await - { - self.send_error(&None, "SESSION_NOT_FOUND", "claude session not found"); + if let Some(map_key) = self.resolve_session_key(&session_id).await { + if self + .stop_live_session( + &map_key, + session_type_str(msg.session_type), + &msg.request_id, + ) + .await + { + return; + } + } + if self.dead_sessions.find(&session_id).is_some() { + self.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { + provider: PROVIDER.into(), + session_id: session_id.clone(), + session_type: session_type_str(msg.session_type).into(), + event: crate::claude_dead_sessions::exited_status_event(&session_id), + })); + } else { + self.emit_control_error( + &session_id, + msg.session_type, + &msg.request_id, + "INVALID_SESSION_ID", + "The Claude conversation for this session could not be found.", + ); } } @@ -6623,16 +6639,27 @@ impl FreshClaudeState { state .reissue_stop( &session_id, - &sidecar_session_id, + &ownership_id, &stamp, &session_type, reissue, ) .await; } - LedgerStep::StopRefused { stop_id } => { + LedgerStep::StopRefused { + stop_id, + request_id, + } => { let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); - state.log_stop_refused(&stamp, &session_type, stop_id); + state.report_stop_refused( + &stamp, + &session_type, + stop_id, + request_id, + value["message"] + .as_str() + .unwrap_or("Claude could not stop the current turn. Try again."), + ); } LedgerStep::Nothing | LedgerStep::Settled(_) => {} } @@ -8195,6 +8222,7 @@ pub(crate) mod tests { use super::*; use crate::naming::SessionNaming as _; + mod control_tests; mod create_binding_tests; mod recovery_tests; mod refusal_tests; @@ -18707,15 +18735,15 @@ rl.on('line', (line) => { // ── freshAgent.interrupt (parity gap fix -- see terminal.rs's dispatch arm) ──── - /// A missing session mirrors the `SESSION_NOT_FOUND` convention already - /// established by [`FreshClaudeState::handle_send`] (and codex/opencode's own - /// `handle_interrupt`): an `error` frame, never a silent drop. + /// An unknown interrupt is a visible session error, including for an + /// older client that supplies no request id. #[tokio::test] async fn handle_interrupt_errors_for_unknown_session() { let (tx, mut rx) = tokio::sync::broadcast::channel::(64); let st = FreshClaudeState::new(Arc::new(tx)); st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: freshell_protocol::AgentProvider::Claude, session_id: "does-not-exist".to_string(), session_type: SessionType::Freshclaude, @@ -18724,14 +18752,8 @@ rl.on('line', (line) => { .await; let frame: Value = serde_json::from_str(&rx.try_recv().unwrap()).unwrap(); - assert_eq!(frame["type"], "error"); - assert!( - frame["message"] - .as_str() - .unwrap() - .contains("claude session not found"), - "{frame}" - ); + assert_eq!(frame["type"], "freshAgent.event"); + assert_eq!(frame["event"]["code"], "INVALID_SESSION_ID", "{frame}"); } /// A known session's interrupt is forwarded to the sidecar (the Rust-side half of @@ -18758,6 +18780,7 @@ rl.on('line', (line) => { await_ledger_busy(&st, &session_id, true).await; st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: freshell_protocol::AgentProvider::Claude, session_id: session_id.clone(), session_type: SessionType::Freshclaude, @@ -18823,6 +18846,7 @@ rl.on('line', (line) => { let session_id = created["sessionId"].as_str().unwrap().to_string(); st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: freshell_protocol::AgentProvider::Claude, session_id: session_id.clone(), session_type: SessionType::Freshclaude, diff --git a/crates/freshell-freshagent/src/claude/stop.rs b/crates/freshell-freshagent/src/claude/stop.rs index 51160050c..ef4cc0dba 100644 --- a/crates/freshell-freshagent/src/claude/stop.rs +++ b/crates/freshell-freshagent/src/claude/stop.rs @@ -18,7 +18,12 @@ const TARGET: &str = "freshell_freshagent::claude"; impl FreshClaudeState { /// Stop the live session `map_key` (the body of [`Self::handle_interrupt`]). /// Returns `false` when no live session is registered under it. - pub(super) async fn stop_live_session(&self, map_key: &str, session_type: &str) -> bool { + pub(super) async fn stop_live_session( + &self, + map_key: &str, + session_type: &str, + request_id: &Option, + ) -> bool { let mut sessions = self.sessions.lock().await; let Some(session) = sessions.get_mut(map_key) else { return false; @@ -35,7 +40,7 @@ impl FreshClaudeState { .ledger .lock() .expect("turn ledger lock") - .stop(stop_id.clone()); + .stop(stop_id.clone(), request_id.clone()); let Some(armed) = armed else { tracing::info!(target: TARGET, provider = PROVIDER, session_id = %stamp, session_type, outcome = "idle", @@ -64,19 +69,19 @@ impl FreshClaudeState { tracing::warn!(target: TARGET, provider = PROVIDER, session_id = %stamp, session_type, stop_id = %stop_id, outcome = "unsent", cause = %error, "freshagent.claude.stop"); - self.send_error(&None, "CLAUDE_INTERRUPT_FAILED", &error); + self.emit_stop_failure(&stamp, session_type, request_id, &error); } true } /// Send a latched Stop again (design §5.4): it missed a command the CLI was /// still starting, which now runs. Written only to the incarnation that - /// asked (`sidecar_session_id`): a session re-registered under the same key - /// since is not this Stop's. + /// asked (`ownership_id`): recovery reuses the durable session id but gives + /// the replacement process a different ownership id. pub(super) async fn reissue_stop( &self, map_key: &str, - sidecar_session_id: &str, + ownership_id: &str, stamp: &str, session_type: &str, reissue: &StopReissue, @@ -89,30 +94,59 @@ impl FreshClaudeState { let mut sessions = self.sessions.lock().await; let Some(session) = sessions .get_mut(map_key) - .filter(|session| session.sidecar_session_id == sidecar_session_id) + .filter(|session| session.ownership_id == ownership_id) else { return; }; let request = json!({ "type": "interrupt", - "sessionId": sidecar_session_id, + "sessionId": session.sidecar_session_id, "stopId": reissue.reissue_id, }); let writer = session.stdin.clone(); + let ledger = session.ledger.clone(); drop(sessions); if let Err(error) = writer.write(&request).await { + ledger + .lock() + .expect("turn ledger lock") + .stop_unsent(&reissue.stop_id); tracing::warn!(target: TARGET, provider = PROVIDER, session_id = %stamp, session_type, stop_id = %reissue.stop_id, outcome = "unsent", cause = %error, "freshagent.claude.stop"); + self.emit_stop_failure(stamp, session_type, &reissue.request_id, &error); } } /// The CLI refused the latched Stop's interrupt: the latch is disarmed and /// the turn keeps running (it rings when it ends). - pub(super) fn log_stop_refused(&self, stamp: &str, session_type: &str, stop_id: &str) { + pub(super) fn report_stop_refused( + &self, + stamp: &str, + session_type: &str, + stop_id: &str, + request_id: &Option, + message: &str, + ) { tracing::warn!(target: TARGET, provider = PROVIDER, session_id = %stamp, session_type, stop_id = %stop_id, outcome = "refused", "freshagent.claude.stop"); + self.emit_stop_failure(stamp, session_type, request_id, message); + } + + fn emit_stop_failure( + &self, + stamp: &str, + session_type: &str, + request_id: &Option, + message: &str, + ) { + let kind = if session_type == "kilroy" { + freshell_protocol::SessionType::Kilroy + } else { + freshell_protocol::SessionType::Freshclaude + }; + self.emit_control_error(stamp, kind, request_id, "CLAUDE_INTERRUPT_FAILED", message); } } diff --git a/crates/freshell-freshagent/src/claude/tests/control_tests.rs b/crates/freshell-freshagent/src/claude/tests/control_tests.rs new file mode 100644 index 000000000..5c2679e4e --- /dev/null +++ b/crates/freshell-freshagent/src/claude/tests/control_tests.rs @@ -0,0 +1,94 @@ +use super::*; + +#[tokio::test] +async fn an_unwritten_stop_reports_its_request_and_does_not_suppress_the_turn() { + let (st, mut rx) = state_with_bus(); + let id = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&st, &id).await; + let (write, read) = tokio::io::duplex(4096); + drop(read); + let ledger = { + let mut sessions = st.sessions.lock().await; + let session = sessions.get_mut(&id).unwrap(); + session.stdin = crate::claude_sidecar_io::SidecarWriter::spawn( + write, + crate::claude_sidecar_io::SidecarLogLabels::new("broken-stop", "freshclaude", None), + Duration::from_secs(1), + ) + .0; + session.ledger.lock().unwrap().arm( + "owed-turn".into(), + None, + crate::claude_turns::SendKind::Turn, + ); + session.ledger.clone() + }; + let request: FreshAgentInterrupt = serde_json::from_value(json!({ + "provider":"claude", "sessionType":"freshclaude", "sessionId":id, + "requestId":"unwritten-stop", + })) + .unwrap(); + st.handle_interrupt(request).await; + let frame: Value = serde_json::from_str(&rx.try_recv().unwrap()).unwrap(); + assert_eq!(frame["event"]["code"], "CLAUDE_INTERRUPT_FAILED", "{frame}"); + assert_eq!(frame["event"]["requestId"], "unwritten-stop", "{frame}"); + assert!(!ledger.lock().unwrap().stop_latched()); + assert!(ledger.lock().unwrap().busy()); + let result = crate::claude_turns::TurnFact::from_line( + &json!({"type":"sdk.result", "subtype":"success", "isError":false}), + ) + .unwrap(); + let crate::claude_turns::Folded::Settled(settlement) = ledger.lock().unwrap().fold(&result) + else { + panic!("the original turn still settles"); + }; + assert!(settlement.verdict.rings()); + st.shutdown().await; +} + +#[tokio::test] +async fn a_delayed_stop_reissue_cannot_interrupt_a_replacement_with_the_same_durable_id() { + use tokio::io::AsyncReadExt; + let st = state(); + let id = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&st, &id).await; + let removed = st.sessions.lock().await.remove(&id).unwrap(); + let old_ownership_id = removed.ownership_id.clone(); + st.teardown_removed_session_logged(removed).await; + insert_fake_claude_session(&st, &id).await; + let (write, mut read) = tokio::io::duplex(4096); + { + let mut sessions = st.sessions.lock().await; + let session = sessions.get_mut(&id).unwrap(); + session.ownership_id = "replacement-for-delayed-stop".into(); + assert_ne!(session.ownership_id, old_ownership_id); + session.stdin = crate::claude_sidecar_io::SidecarWriter::spawn( + write, + crate::claude_sidecar_io::SidecarLogLabels::new( + "replacement-stop", + "freshclaude", + None, + ), + Duration::from_secs(1), + ) + .0; + } + let reissue = crate::claude_turns::StopReissue { + stop_id: "obsolete-stop".into(), + request_id: None, + reissue_id: "obsolete-stop:1".into(), + case: crate::claude_turns::ReissueCase::A, + target: "old-turn".into(), + reissues: 1, + }; + st.reissue_stop(&id, &old_ownership_id, &id, "freshclaude", &reissue) + .await; + let mut bytes = [0; 4096]; + assert!( + tokio::time::timeout(Duration::from_millis(25), read.read(&mut bytes)) + .await + .is_err(), + "an old reissue wrote an interrupt to the replacement runtime" + ); + st.shutdown().await; +} diff --git a/crates/freshell-freshagent/src/claude/turn_status.rs b/crates/freshell-freshagent/src/claude/turn_status.rs index d79914b11..c8852005d 100644 --- a/crates/freshell-freshagent/src/claude/turn_status.rs +++ b/crates/freshell-freshagent/src/claude/turn_status.rs @@ -28,7 +28,10 @@ pub(super) enum LedgerStep { /// interrupt again (§5.4). Reissue(StopReissue), /// The CLI refused the latched Stop's interrupt; the turn keeps running. - StopRefused { stop_id: String }, + StopRefused { + stop_id: String, + request_id: Option, + }, } /// One settled turn, as the consumer reports it. @@ -151,7 +154,13 @@ impl FreshClaudeState { LedgerStep::Nothing } Folded::Reissue(reissue) => LedgerStep::Reissue(reissue), - Folded::StopRefused { stop_id } => LedgerStep::StopRefused { stop_id }, + Folded::StopRefused { + stop_id, + request_id, + } => LedgerStep::StopRefused { + stop_id, + request_id, + }, Folded::Settled(settlement) => { let command_ids: Vec = settlement .retired diff --git a/crates/freshell-freshagent/src/claude_turns.rs b/crates/freshell-freshagent/src/claude_turns.rs index b38df1011..844bd6e2f 100644 --- a/crates/freshell-freshagent/src/claude_turns.rs +++ b/crates/freshell-freshagent/src/claude_turns.rs @@ -453,7 +453,10 @@ pub(crate) enum Folded { Reissue(StopReissue), /// The CLI refused the latched Stop's interrupt (`ok: false`): the latch is /// disarmed, and the turn it targeted keeps running. - StopRefused { stop_id: String }, + StopRefused { + stop_id: String, + request_id: Option, + }, } /// The busy truth of one claude session (design §5.2): every command the @@ -707,7 +710,11 @@ impl ClaudeTurnLedger { /// runs by itself. Commands queued behind the running turn are not targets /// (decision 1): they run and ring. A Stop while another is latched keeps /// the earlier one's targets, so everything the user stopped stays silent. - pub(crate) fn stop(&mut self, stop_id: String) -> Option { + pub(crate) fn stop( + &mut self, + stop_id: String, + request_id: Option, + ) -> Option { if !self.busy() { return None; } @@ -731,6 +738,7 @@ impl ClaudeTurnLedger { let earlier = self.stop.take(); let mut latch = StopLatch { stop_id: stop_id.clone(), + request_id, commands: Vec::new(), cli_turn: target == StopTargetKind::CliTurn, awaiting_start: awaiting_start.clone(), @@ -777,9 +785,10 @@ impl ClaudeTurnLedger { self.stop.is_some() } - /// The CLI's receipt for an interrupt. Only the latch's own receipt counts - /// (a re-sent interrupt's, or an earlier Stop's, is not); a protocol fake - /// echoes no stop id, so its receipt is the pending latch's. + /// The original receipt advances the latch's targeting rules. A failed + /// reissue cancels that same Stop and reports its original user request; + /// a successful reissue leaves the targeting rules unchanged. Earlier + /// Stops are ignored. An id-less legacy receipt belongs to the pending latch. fn fold_receipt( &mut self, stop_id: Option<&str>, @@ -789,17 +798,27 @@ impl ClaudeTurnLedger { let Some(stop) = self.stop.as_mut() else { return Folded::Applied; }; + let reissued = stop_id.is_some_and(|id| { + stop.reissues > 0 && id == format!("{}:{}", stop.stop_id, stop.reissues) + }); let ours = match stop_id { Some(id) => id == stop.stop_id, None => stop.receipt == StopReceipt::Pending, }; - if !ours || stop.receipt != StopReceipt::Pending { + if !reissued && (!ours || stop.receipt != StopReceipt::Pending) { return Folded::Applied; } if !ok { let stop_id = stop.stop_id.clone(); + let request_id = stop.request_id.clone(); self.stop = None; - return Folded::StopRefused { stop_id }; + return Folded::StopRefused { + stop_id, + request_id, + }; + } + if reissued { + return Folded::Applied; } let lists_target = stop.awaiting_start.as_ref().is_some_and(|target| { still_queued.is_some_and(|queued| queued.iter().any(|uuid| uuid == target)) @@ -845,6 +864,7 @@ impl ClaudeTurnLedger { stop.awaiting_start = None; Folded::Reissue(StopReissue { stop_id: stop.stop_id.clone(), + request_id: stop.request_id.clone(), reissue_id: format!("{}:{}", stop.stop_id, stop.reissues), case, target, @@ -967,6 +987,7 @@ impl ReissueCase { pub(crate) struct StopReissue { /// The latched Stop. pub(crate) stop_id: String, + pub(crate) request_id: Option, /// The re-sent interrupt's own id (its receipt is not the latch's). pub(crate) reissue_id: String, pub(crate) case: ReissueCase, @@ -980,6 +1001,7 @@ pub(crate) struct StopReissue { #[derive(Clone, Debug, PartialEq, Eq)] struct StopLatch { stop_id: String, + request_id: Option, /// The commands it targets; a result that retires only these is silent. commands: Vec, /// It targets a turn the CLI runs by itself (a result retiring nothing). diff --git a/crates/freshell-freshagent/src/claude_turns_stop_tests.rs b/crates/freshell-freshagent/src/claude_turns_stop_tests.rs index 1aee9173a..8bff16606 100644 --- a/crates/freshell-freshagent/src/claude_turns_stop_tests.rs +++ b/crates/freshell-freshagent/src/claude_turns_stop_tests.rs @@ -79,7 +79,7 @@ impl Run { match self.ledger.fold(&fact) { Folded::Settled(settlement) => self.verdicts.push(settlement.verdict), Folded::Reissue(reissue) => self.reissues.push(reissue), - Folded::StopRefused { stop_id } => self.refusals.push(stop_id), + Folded::StopRefused { stop_id, .. } => self.refusals.push(stop_id), Folded::Applied | Folded::UnknownCommand => {} } self @@ -93,7 +93,7 @@ impl Run { } fn stop(&mut self, stop_id: &str) -> Option { - self.ledger.stop(stop_id.to_string()) + self.ledger.stop(stop_id.to_string(), None) } fn running(&mut self, uuid: &str) -> &mut Self { @@ -683,3 +683,33 @@ fn a_second_stop_keeps_the_first_ones_targets() { run.feed_all([receipt("stop-1", &[]), receipt("stop-2", &[]), aborted()]); assert_eq!(run.verdicts, vec![ResultVerdict::Silent]); } + +#[test] +fn a_refused_reissue_keeps_the_users_request_id_and_releases_the_turn_to_ring() { + let mut run = Run::default(); + run.arm("queued"); + run.ledger + .stop("stop-request".into(), Some("user-stop-request".into())) + .unwrap(); + run.feed(receipt("stop-request", &["queued"])); + run.feed(command("queued", "started")); + let reissue = run + .reissues + .last() + .expect("the initial Stop missed the queued command"); + assert_eq!(reissue.request_id.as_deref(), Some("user-stop-request")); + let failed = run + .ledger + .fold(&TurnFact::from_line(&refused(&reissue.reissue_id)).unwrap()); + assert_eq!( + failed, + Folded::StopRefused { + stop_id: "stop-request".into(), + request_id: Some("user-stop-request".into()), + } + ); + assert!(!run.ledger.stop_latched()); + assert!(run.ledger.busy()); + run.feed(success()); + assert!(run.verdicts.last().unwrap().rings()); +} diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index fd0fcd9fd..e8a158b11 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -13605,6 +13605,7 @@ pub(crate) mod tests { let st = st.clone(); tokio::spawn(async move { st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: freshell_protocol::AgentProvider::Codex, session_id: "thread-1".to_string(), session_type: freshell_protocol::SessionType::Freshcodex, @@ -13656,6 +13657,7 @@ pub(crate) mod tests { .await; st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: freshell_protocol::AgentProvider::Codex, session_id: "thread-1".to_string(), session_type: freshell_protocol::SessionType::Freshcodex, @@ -13679,6 +13681,7 @@ pub(crate) mod tests { let (st, mut rx) = state_with_bus(); st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: freshell_protocol::AgentProvider::Codex, session_id: "does-not-exist".to_string(), session_type: freshell_protocol::SessionType::Freshcodex, @@ -16231,6 +16234,7 @@ pub(crate) mod tests { let st = st.clone(); tokio::spawn(async move { st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: freshell_protocol::AgentProvider::Codex, session_id: "thread-cxi".to_string(), session_type: freshell_protocol::SessionType::Freshcodex, diff --git a/crates/freshell-freshagent/src/codex/controls/tests.rs b/crates/freshell-freshagent/src/codex/controls/tests.rs index dfb686e60..7c79422d0 100644 --- a/crates/freshell-freshagent/src/codex/controls/tests.rs +++ b/crates/freshell-freshagent/src/codex/controls/tests.rs @@ -200,6 +200,7 @@ async fn codex_user_controls_resolved_and_interrupted_requests_clear_without_com async move { state .handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: freshell_protocol::AgentProvider::Codex, session_id: "thread-1".to_string(), session_type: freshell_protocol::SessionType::Freshcodex, diff --git a/crates/freshell-freshagent/src/opencode_ws.rs b/crates/freshell-freshagent/src/opencode_ws.rs index 699ce85f6..0de554c5e 100644 --- a/crates/freshell-freshagent/src/opencode_ws.rs +++ b/crates/freshell-freshagent/src/opencode_ws.rs @@ -16014,6 +16014,7 @@ mod tests { // Interrupt promptly, long before the (deliberately slow) natural idle would land. tokio::time::sleep(Duration::from_millis(10)).await; st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: placeholder.to_string(), session_type: SessionType::Freshopencode, @@ -16129,6 +16130,7 @@ mod tests { // abort RPC fails (500). tokio::time::sleep(Duration::from_millis(10)).await; st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: placeholder.to_string(), session_type: SessionType::Freshopencode, @@ -16365,6 +16367,7 @@ mod tests { // interrupt must stay silent — no second bell for a turn that // already rang. st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: placeholder.to_string(), session_type: SessionType::Freshopencode, @@ -16480,6 +16483,7 @@ mod tests { let st = st.clone(); tokio::spawn(async move { st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: placeholder.to_string(), session_type: SessionType::Freshopencode, @@ -16619,6 +16623,7 @@ mod tests { let st = st.clone(); tokio::spawn(async move { st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: placeholder.to_string(), session_type: SessionType::Freshopencode, @@ -16824,6 +16829,7 @@ mod tests { let st = st.clone(); tokio::spawn(async move { st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: placeholder.to_string(), session_type: SessionType::Freshopencode, @@ -16960,6 +16966,7 @@ mod tests { tokio::time::sleep(Duration::from_millis(25)).await; st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: placeholder.to_string(), session_type: SessionType::Freshopencode, @@ -19497,6 +19504,7 @@ mod tests { // Kill the drive mid-cold-start (the ep4-r5 window): the task is // aborted before compact() returns. st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_1".to_string(), session_type: SessionType::Freshopencode, @@ -19590,6 +19598,7 @@ mod tests { } st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_1".to_string(), session_type: SessionType::Freshopencode, @@ -19678,6 +19687,7 @@ mod tests { // destroy MUST stand (the interrupt/kill tests never seeded rollback // state; this window is F4's finding). st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_real".to_string(), session_type: SessionType::Freshopencode, @@ -20257,6 +20267,7 @@ mod tests { await_summarize_posted(&http).await; st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_1".to_string(), session_type: SessionType::Freshopencode, @@ -25877,6 +25888,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(2), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q3".to_string(), session_type: SessionType::Freshopencode, @@ -25935,6 +25947,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(2), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q8".to_string(), session_type: SessionType::Freshopencode, @@ -25975,6 +25988,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(2), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q8".to_string(), session_type: SessionType::Freshopencode, @@ -26044,6 +26058,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(2), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q9".to_string(), session_type: SessionType::Freshopencode, @@ -26117,6 +26132,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(2), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q10".to_string(), session_type: SessionType::Freshopencode, @@ -26377,6 +26393,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q13".to_string(), session_type: SessionType::Freshopencode, @@ -26486,6 +26503,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q14".to_string(), session_type: SessionType::Freshopencode, @@ -26550,6 +26568,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q15".to_string(), session_type: SessionType::Freshopencode, @@ -26641,6 +26660,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q16".to_string(), session_type: SessionType::Freshopencode, @@ -26654,6 +26674,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q16".to_string(), session_type: SessionType::Freshopencode, @@ -26738,6 +26759,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q17".to_string(), session_type: SessionType::Freshopencode, @@ -26824,6 +26846,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q18".to_string(), session_type: SessionType::Freshopencode, @@ -26838,6 +26861,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q18".to_string(), session_type: SessionType::Freshopencode, @@ -26918,6 +26942,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q19".to_string(), session_type: SessionType::Freshopencode, @@ -27039,6 +27064,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q20".to_string(), session_type: SessionType::Freshopencode, @@ -27060,6 +27086,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q20".to_string(), session_type: SessionType::Freshopencode, @@ -27134,6 +27161,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q21".to_string(), session_type: SessionType::Freshopencode, @@ -27214,6 +27242,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q22".to_string(), session_type: SessionType::Freshopencode, @@ -27228,6 +27257,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q22".to_string(), session_type: SessionType::Freshopencode, @@ -27313,6 +27343,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q23".to_string(), session_type: SessionType::Freshopencode, @@ -27329,6 +27360,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q23".to_string(), session_type: SessionType::Freshopencode, @@ -27419,6 +27451,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q24".to_string(), session_type: SessionType::Freshopencode, @@ -27480,6 +27513,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q25".to_string(), session_type: SessionType::Freshopencode, @@ -27585,6 +27619,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q26".to_string(), session_type: SessionType::Freshopencode, @@ -27599,6 +27634,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q26".to_string(), session_type: SessionType::Freshopencode, @@ -27682,6 +27718,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q27".to_string(), session_type: SessionType::Freshopencode, @@ -27781,6 +27818,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q28".to_string(), session_type: SessionType::Freshopencode, @@ -27876,6 +27914,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q29".to_string(), session_type: SessionType::Freshopencode, @@ -27939,6 +27978,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q30".to_string(), session_type: SessionType::Freshopencode, @@ -28024,6 +28064,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q31".to_string(), session_type: SessionType::Freshopencode, @@ -28088,6 +28129,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q32".to_string(), session_type: SessionType::Freshopencode, @@ -28166,6 +28208,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q33".to_string(), session_type: SessionType::Freshopencode, @@ -28232,6 +28275,7 @@ mod tests { tokio::time::timeout( std::time::Duration::from_secs(5), st.handle_interrupt(FreshAgentInterrupt { + request_id: None, provider: AgentProvider::Opencode, session_id: "ses_q34".to_string(), session_type: SessionType::Freshopencode, diff --git a/crates/freshell-protocol/src/client_messages.rs b/crates/freshell-protocol/src/client_messages.rs index 3ada8213c..8563ba088 100644 --- a/crates/freshell-protocol/src/client_messages.rs +++ b/crates/freshell-protocol/src/client_messages.rs @@ -1011,6 +1011,8 @@ pub struct FreshAgentSend { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FreshAgentInterrupt { + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, pub provider: AgentProvider, pub session_id: String, pub session_type: SessionType, diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index 91f84e8e1..ae9280269 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -798,6 +798,7 @@ impl FreshAgentTransport for HostedTransport { .clone() .ok_or_else(|| "fresh-agent session is not started".to_string())?; let message = FreshAgentInterrupt { + request_id: None, provider: self.provider_wire(), session_id, session_type: self.session_type(), diff --git a/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs b/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs index 9d91492e6..a0bd283ca 100644 --- a/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs +++ b/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs @@ -82,7 +82,7 @@ rl.on('line', (line) => { } if (msg.type === 'create') { counter += 1 - const sessionId = `fake-claude-session-${process.pid}-${counter}` + const sessionId = msg.sessionId ?? `fake-claude-session-${process.pid}-${counter}` process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') } else if (msg.type === 'interrupt') { const log = process.env.FRESHELL_TEST_CLAUDE_INTERRUPT_LOG @@ -473,15 +473,12 @@ async fn claude_interrupt_frame_errors_for_an_unknown_session() { ) .await; - let frame = await_frame(&mut ws, Duration::from_secs(10), |v| v["type"] == "error").await; - assert!( - frame["message"] - .as_str() - .unwrap_or_default() - .to_lowercase() - .contains("session not found"), - "{frame}" - ); + let frame = await_frame(&mut ws, Duration::from_secs(10), |v| { + v["event"]["type"] == "freshAgent.error" + }) + .await; + assert_eq!(frame["event"]["code"], "INVALID_SESSION_ID", "{frame}"); + assert_eq!(frame["sessionId"], "does-not-exist"); } /// Sanity guard: writing to a closed log path never panics the harness (keeps clippy @@ -504,3 +501,165 @@ fn interrupt_log_append_is_plain_fs_no_special_handling_needed() { assert!(std::fs::read_to_string(&log).unwrap().contains("sanity")); let _ = std::fs::remove_dir_all(&dir); } + +#[cfg(unix)] +mod common; + +#[cfg(unix)] +mod control_outcomes { + use super::*; + use common::claude_rig::{ClaudeRig, RigKind}; + use serde_json::json; + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn unknown_configure_and_interrupt_requests_report_correlated_session_errors() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + for session_type in ["freshclaude", "kilroy"] { + for control in ["configure", "interrupt"] { + let id = uuid::Uuid::new_v4().to_string(); + let request_id = format!("unknown-{session_type}-{control}"); + let mut request = json!({"type":format!("freshAgent.{control}"), + "provider":"claude", "sessionType":session_type, "sessionId":id, "requestId":request_id}); + if control == "configure" { + request["settings"] = json!({"model":"sonnet"}); + } + conn.send(request).await; + let reply = conn + .wait_for("the unknown control refusal", |frame| { + frame["type"] == "error" || frame["event"]["type"] == "freshAgent.error" + }) + .await; + assert_eq!(reply["event"]["code"], "INVALID_SESSION_ID", "{reply}"); + assert_eq!(reply["event"]["requestId"], request_id, "{reply}"); + assert_eq!(reply["sessionId"], id); + assert_eq!(reply["sessionType"], session_type); + conn.frames.clear(); + } + } + assert!(rig.ledger_rows("query").is_empty()); + rig.fresh_claude.shutdown().await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn interrupting_a_dead_session_repeats_exited_without_restarting_it() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let id = conn.create("dead-control-create").await; + rig.kill_first_sidecar(&mut conn).await; + conn.frames.clear(); + conn.send( + json!({"type":"freshAgent.interrupt", "provider":"claude", "sessionType":"freshclaude", + "sessionId":id, "requestId":"dead-control-stop"}), + ) + .await; + let reply = conn + .wait_for("the dead Stop outcome", |frame| { + frame["type"] == "error" + || frame["event"]["type"] == "freshAgent.error" + || frame["event"]["type"] == "freshAgent.status" + }) + .await; + assert_eq!(reply["event"]["status"], "exited", "{reply}"); + assert_eq!(reply["sessionId"], id); + assert_eq!( + rig.ledger_rows("query").len(), + 1, + "Stop alone must not recover a process" + ); + assert!(!rig.fresh_claude.has_live_session(&id).await); + rig.fresh_claude.shutdown().await; + } + + async fn refused_interrupt_reports_the_request_and_does_not_silence_completion(reissued: bool) { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let wrapper = tempfile::tempdir().unwrap(); + let module = wrapper.path().join("refuse-interrupt.mjs"); + let simulator = + common::claude_rig::repo_root().join("test/fixtures/claude-cli-sim/query.mjs"); + std::fs::write( + &module, + format!( + r#" +import {{ query as originalQuery }} from {}; +export function query(params) {{ + const running = originalQuery(params); + const interrupt = running.interrupt.bind(running); + let calls = 0; + running.interrupt = async (...args) => {{ + calls += 1; + if (calls === {}) throw new Error('injected interrupt refusal'); + return interrupt(...args); + }}; + return running; +}} +"#, + serde_json::to_string(&format!("file://{}", simulator.display())).unwrap(), + if reissued { 2 } else { 1 } + ), + ) + .unwrap(); + let logs = common::log_capture::install(); + let mut extra = vec![("FRESHELL_CLAUDE_SDK_QUERY_MODULE", module.to_str().unwrap())]; + if reissued { + extra.push(("FRESHELL_CLAUDE_SIM_COLD_COMMAND", "queued")); + } + let rig = ClaudeRig::start_with_env(RigKind::Sim, &extra).await; + let mut conn = rig.connect().await; + let id = conn.create("refused-stop-create").await; + conn.send_text(&id, "the-running-turn", "TOOL:sleep=0.5") + .await; + if !reissued { + conn.wait_for("the running tool", |frame| { + frame["event"]["content"] + .as_array() + .is_some_and(|content| content.iter().any(|block| block["type"] == "tool_use")) + }) + .await; + } + conn.send( + json!({"type":"freshAgent.interrupt", "provider":"claude", "sessionType":"freshclaude", + "sessionId":id, "requestId":"refused-stop-request"}), + ) + .await; + logs.wait_for("the refused interrupt receipt", |event| { + event.message == "freshagent.claude.stop" + && event.field("session_id") == Some(&id) + && event.field("outcome") == Some("refused") + }) + .await; + conn.read_for(Duration::from_millis(100)).await; + let failure = conn + .frames + .iter() + .find(|frame| frame["event"]["code"] == "CLAUDE_INTERRUPT_FAILED") + .expect("a refused Stop must report a nested session error"); + assert_eq!(failure["event"]["requestId"], "refused-stop-request"); + conn.wait_for_event("freshAgent.result").await; + conn.wait_for_event("freshAgent.turn.complete").await; + conn.read_for(Duration::from_millis(50)).await; + assert_eq!( + conn.events_seen("freshAgent.turn.complete"), + 1, + "the failed Stop never suppresses completion" + ); + assert_eq!( + rig.fresh_claude.live_snapshot_overlay(&id).await["status"], + "idle" + ); + rig.fresh_claude.shutdown().await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_refused_interrupt_is_visible_and_does_not_silence_the_turns_result() { + refused_interrupt_reports_the_request_and_does_not_silence_completion(false).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_refused_reissued_interrupt_reports_the_original_request_and_does_not_silence_completion( + ) { + refused_interrupt_reports_the_request_and_does_not_silence_completion(true).await; + } +} diff --git a/crates/freshell-ws/tests/freshagent_claude_turns.rs b/crates/freshell-ws/tests/freshagent_claude_turns.rs index 1b22df817..ecbc11994 100644 --- a/crates/freshell-ws/tests/freshagent_claude_turns.rs +++ b/crates/freshell-ws/tests/freshagent_claude_turns.rs @@ -1335,14 +1335,21 @@ mod sim { .handle_send(send_msg("no-such-session", "send-2", "hello")) .await; assert!( - matches!(&refused, ClaudeSendOutcome::Refused { code, .. } if code == "SESSION_NOT_FOUND"), + matches!(&refused, ClaudeSendOutcome::Refused { code, .. } if code == "INVALID_SESSION_ID"), "{refused:?}" ); - // The refusal reached the sender too. - conn.wait_for("the send-2 request error", |v| { - v["type"] == "error" && v["requestId"] == "send-2" + // The pane learns the conversation is missing, and its unsent prompt + // is returned to the sender with the same typed outcome. + conn.wait_for("the missing conversation", |v| { + v["event"]["code"] == "INVALID_SESSION_ID" && v["sessionId"] == "no-such-session" }) .await; + let error = conn + .wait_for("the send-2 request error", |v| { + v["type"] == "error" && v["requestId"] == "send-2" + }) + .await; + assert_eq!(error["code"], "INVALID_SESSION_ID"); conn.kill(&session_id).await; assert_eq!(rig.fresh_claude.session_busy(&session_id).await, None); } diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts index 9eb375a16..558dd2226 100644 --- a/shared/ws-protocol.ts +++ b/shared/ws-protocol.ts @@ -947,6 +947,7 @@ export const FreshAgentSendSchema = z.object({ export const FreshAgentInterruptSchema = z.object({ type: z.literal('freshAgent.interrupt'), + requestId: z.string().optional(), sessionId: z.string().min(1), sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), provider: z.enum(['claude', 'codex', 'opencode']), From 0f577d1179841af099ec49f68b708b405e09e483 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:23:02 -0700 Subject: [PATCH 08/19] feat(freshclaude): detect quiet busy runtimes with the shared deadman --- crates/freshell-freshagent/src/claude.rs | 78 +++++-- .../freshell-freshagent/src/claude/deadman.rs | 215 ++++++++++++++++++ .../src/claude/establish.rs | 4 + .../src/claude/pending_runtime.rs | 20 +- .../src/claude/teardown_tests.rs | 2 + .../src/claude/tests/deadman_tests.rs | 161 +++++++++++++ .../src/claude/turn_status.rs | 59 +++-- .../freshell-freshagent/src/claude_edges.rs | 8 + .../freshell-freshagent/src/quiet_deadman.rs | 4 + .../tests/freshagent_claude_stuck.rs | 139 +++++++++++ 10 files changed, 653 insertions(+), 37 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude/deadman.rs create mode 100644 crates/freshell-freshagent/src/claude/tests/deadman_tests.rs create mode 100644 crates/freshell-ws/tests/freshagent_claude_stuck.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 45ca576e8..0e6371abf 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -577,6 +577,8 @@ struct ResponseSession { /// One live freshclaude session: the Node sidecar it drives + its stdout consumer. struct ClaudeSession { + session_type: String, + quiet: deadman::QuietWatch, configuration: ClaudeConfiguration, /// Per-runtime stdin writer; await only after dropping the sessions map lock. stdin: crate::claude_sidecar_io::SidecarWriter, @@ -686,10 +688,11 @@ impl ClaudeSession { /// The session's effective status (design §5.2) — the truth the attach /// acknowledgements and the snapshot overlay speak. fn current_status(&self) -> String { + let quiet = self.quiet.state.lock().expect("quiet deadman lock"); self.ledger .lock() .expect("turn ledger lock") - .effective_status(false, false) + .effective_status(false, quiet.is_stuck()) .as_str() .to_string() } @@ -4069,6 +4072,7 @@ impl FreshClaudeState { Arc>, crate::claude_turns::SharedLedger, Option, + deadman::SharedQuiet, )> { let key = self.resolve_session_key(wire_id).await?; let sessions = self.sessions.lock().await; @@ -4078,6 +4082,7 @@ impl FreshClaudeState { session.turn_lock.clone(), session.ledger.clone(), session.cli_session_id.clone(), + session.quiet.state.clone(), )) } @@ -4178,7 +4183,7 @@ impl FreshClaudeState { ) } }; - let Some((map_key, turn_lock, ledger, durable_before_wait)) = + let Some((map_key, turn_lock, ledger, durable_before_wait, quiet)) = self.turn_locked_handles(&resolved).await else { return self.refuse_send_after_runtime_stopped( @@ -4351,11 +4356,18 @@ impl FreshClaudeState { request_id.clone(), crate::claude_turns::SendKind::Turn, ); - self.publish_claude_status(&ledger, &stamp, session_type, false); + self.arm_claude_quiet(&map_key, &ledger).await; + self.publish_claude_status(&ledger, &quiet, &stamp, session_type, false); let guard = self.sessions.lock().await; let Some(session) = guard.get(&map_key) else { drop(guard); - self.take_back_unwritten_send(&ledger, &stamp, session_type, &submitted_turn_id); + self.take_back_unwritten_send( + &ledger, + &quiet, + &stamp, + session_type, + &submitted_turn_id, + ); return self.refuse_send_after_runtime_stopped( &request_id, &session_id, @@ -4382,7 +4394,13 @@ impl FreshClaudeState { if let Err(err) = writer.write(&send_req).await { // The write never went out: take back exactly this send (whatever // else is owed stays). - self.take_back_unwritten_send(&ledger, &stamp, session_type, &submitted_turn_id); + self.take_back_unwritten_send( + &ledger, + &quiet, + &stamp, + session_type, + &submitted_turn_id, + ); return self.refuse_send(&request_id, "CLAUDE_SEND_FAILED", &err); } drop(ownership_guard); @@ -4606,7 +4624,7 @@ impl FreshClaudeState { return; } }; - let Some((map_key, turn_lock, _, durable_before_wait)) = + let Some((map_key, turn_lock, _, durable_before_wait, _)) = self.turn_locked_handles(&resolved).await else { self.emit_control_error( @@ -4932,7 +4950,7 @@ impl FreshClaudeState { return; } }; - let Some((map_key, turn_lock, ledger, durable_before_wait)) = + let Some((map_key, turn_lock, ledger, durable_before_wait, quiet)) = self.turn_locked_handles(&resolved).await else { self.emit_control_error( @@ -5010,11 +5028,12 @@ impl FreshClaudeState { None, crate::claude_turns::SendKind::Compact, ); - self.publish_claude_status(&ledger, &stamp, session_type, false); + self.arm_claude_quiet(&map_key, &ledger).await; + self.publish_claude_status(&ledger, &quiet, &stamp, session_type, false); let guard = self.sessions.lock().await; let Some(session) = guard.get(&map_key) else { drop(guard); - self.take_back_unwritten_send(&ledger, &stamp, session_type, &uuid); + self.take_back_unwritten_send(&ledger, &quiet, &stamp, session_type, &uuid); self.emit_control_error( &session_id, msg.session_type, @@ -5043,7 +5062,7 @@ impl FreshClaudeState { drop(guard); if let Err(err) = writer.write(&send_req).await { // The write never went out: take back exactly this compact. - self.take_back_unwritten_send(&ledger, &stamp, session_type, &uuid); + self.take_back_unwritten_send(&ledger, &quiet, &stamp, session_type, &uuid); self.emit_control_error( &session_id, msg.session_type, @@ -6594,6 +6613,7 @@ impl FreshClaudeState { broadcast_id: Arc>, pending: Arc>, ledger: crate::claude_turns::SharedLedger, + quiet: deadman::SharedQuiet, // The session's attention clock (see [`ClaudeSession::clock`]). Held by // the consumer (like the ledger above) so the unrequested-death arm // below can mint AFTER the eviction has already torn the session record @@ -6617,12 +6637,21 @@ impl FreshClaudeState { while let Ok(Some(line)) = reader.next_line().await { let trimmed = line.trim(); if trimmed.is_empty() { + state + .note_claude_stdout(&session_id, &ownership_id, None) + .await; continue; } let Ok(value) = serde_json::from_str::(trimmed) else { + state + .note_claude_stdout(&session_id, &ownership_id, None) + .await; continue; }; if let Some(ended) = crate::claude_sidecar_exit::StreamEnded::from_line(&value) { + state + .note_claude_stdout(&session_id, &ownership_id, Some(&value)) + .await; stream_ended = Some(ended); continue; } @@ -6630,6 +6659,11 @@ impl FreshClaudeState { // §5.2) before anything else reads it: a gate, an attach // acknowledgement or the status published below sees it. let step = state.fold_into_ledger(&ledger, &value, &broadcast_id, &session_type); + if !is_pending_frame(&value) && value["type"] != "sdk.send.rejected" { + state + .note_claude_stdout(&session_id, &ownership_id, Some(&value)) + .await; + } // C18: a latched Stop that missed a command the CLI was still // starting is sent again now (design §5.4); a refused one is // disarmed (the turn keeps running and rings when it ends). @@ -6676,7 +6710,12 @@ impl FreshClaudeState { .take_back_rejected_send(&ledger, &stamp, &session_type, &rejection), fact => fact.log(&stamp, &session_type), } - state.publish_claude_status(&ledger, &stamp, &session_type, false); + if value["type"] == "sdk.send.rejected" { + state + .note_claude_stdout(&session_id, &ownership_id, Some(&value)) + .await; + } + state.publish_claude_status(&ledger, &quiet, &stamp, &session_type, false); continue; } if value.get("type").and_then(Value::as_str) == Some("sdk.status") { @@ -6805,7 +6844,7 @@ impl FreshClaudeState { LedgerStep::Settled(settled) => value["sessionBusy"] = json!(settled.busy), _ => { let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); - state.publish_claude_status(&ledger, &stamp, &session_type, false); + state.publish_claude_status(&ledger, &quiet, &stamp, &session_type, false); } } // Restart-parity (plan §2.8 item 2): record the durable Claude UUID @@ -6878,7 +6917,7 @@ impl FreshClaudeState { }; if let LedgerStep::Settled(settled) = step { state.ring_settled_turn(&record, settled); - state.publish_claude_status(&ledger, &stamp, &session_type, true); + state.publish_claude_status(&ledger, &quiet, &stamp, &session_type, true); } } // C17: the end of output ends whatever the CLI owed (its queue died @@ -6888,6 +6927,7 @@ impl FreshClaudeState { // time the unrequested-death arm below runs the ledger is cleared // and the session record evicted (the clock and id handles it needs // are consumer-owned locals that survive the eviction). + quiet.lock().expect("quiet deadman lock").disarm(); let turn_was_in_flight = ledger.lock().expect("turn ledger lock").eof(); // Consumer exit == this sidecar's stdout closed == the sidecar ended // (ledger A9): it exits after reporting its SDK stream's end @@ -8161,6 +8201,7 @@ fn log_claude_teardown( mod create_binding; mod create_outcome; +mod deadman; mod recovery; pub use create_outcome::ClaudeCreateOutcome; @@ -8224,6 +8265,7 @@ pub(crate) mod tests { mod control_tests; mod create_binding_tests; + mod deadman_tests; mod recovery_tests; mod refusal_tests; @@ -8292,6 +8334,8 @@ pub(crate) mod tests { st.sessions.lock().await.insert( session_id.to_string(), ClaudeSession { + session_type: "freshclaude".into(), + quiet: Default::default(), configuration: ClaudeConfiguration::default(), stdin: stdin.into(), child, @@ -17118,6 +17162,8 @@ rl.on('line', (line) => { st.sessions.lock().await.insert( session_id.to_string(), ClaudeSession { + session_type: "freshclaude".into(), + quiet: Default::default(), configuration: ClaudeConfiguration::default(), stdin: stdin.into(), child, @@ -17782,6 +17828,7 @@ rl.on('line', (line) => { let st = state(); insert_fake_claude_session(&st, "fold-session").await; let pending = st.sessions.lock().await["fold-session"].pending.clone(); + let quiet = st.sessions.lock().await["fold-session"].quiet.state.clone(); let consumer = st.spawn_consumer( reader, "fold-session".to_string(), @@ -17792,6 +17839,7 @@ rl.on('line', (line) => { Arc::new(std::sync::Mutex::new("fold-session".to_string())), Arc::clone(&pending), crate::claude_turns::SharedLedger::default(), + quiet, crate::attention::AttentionClock::default(), None, // provenance: test lane is conn-less (D8) false, @@ -19317,6 +19365,7 @@ rl.on('line', (line) => { let pending = Arc::new(std::sync::Mutex::new(ClaudePending::default())); let clock = crate::attention::AttentionClock::default(); let turn_lock = Arc::new(TokioMutex::new(())); + let quiet = deadman::QuietWatch::default(); let broadcast_id = Arc::new(std::sync::Mutex::new(map_key.to_string())); let consumer = st.spawn_consumer( reader, @@ -19328,6 +19377,7 @@ rl.on('line', (line) => { Arc::clone(&broadcast_id), Arc::clone(&pending), Arc::clone(&ledger), + quiet.state.clone(), clock.clone(), None, // provenance: test lane is conn-less (D8) false, @@ -19335,6 +19385,8 @@ rl.on('line', (line) => { st.sessions.lock().await.insert( map_key.to_string(), ClaudeSession { + session_type: "freshclaude".into(), + quiet, configuration: ClaudeConfiguration::default(), stdin: stdin.into(), child, diff --git a/crates/freshell-freshagent/src/claude/deadman.rs b/crates/freshell-freshagent/src/claude/deadman.rs new file mode 100644 index 000000000..df8574a23 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/deadman.rs @@ -0,0 +1,215 @@ +//! Per-runtime wiring of the shared quiet deadman. Pending user input pauses it. + +use super::*; +use crate::quiet_deadman::{wait_for_quiet_deadline, QuietDeadman}; + +pub(super) type SharedQuiet = Arc>; + +pub(super) struct QuietWatch { + pub(super) state: SharedQuiet, + waiter: Option, +} + +impl Default for QuietWatch { + fn default() -> Self { + Self { + state: QuietDeadman::new_shared(), + waiter: None, + } + } +} + +impl Drop for QuietWatch { + fn drop(&mut self) { + self.state.lock().expect("quiet deadman lock").disarm(); + if let Some(waiter) = self.waiter.take() { + waiter.abort(); + } + } +} + +fn positive_ms(environment: Option<&[(String, String)]>, name: &str) -> Option { + let value = match environment { + Some(environment) => environment + .iter() + .rev() + .find(|(key, _)| key == name) + .map(|(_, value)| value.clone()), + None => std::env::var(name).ok(), + }; + value + .and_then(|value| value.parse().ok()) + .filter(|value| *value > 0) +} + +pub(super) fn quiet_window(environment: Option<&[(String, String)]>) -> Duration { + Duration::from_millis( + positive_ms(environment, "FRESHELL_FRESHCLAUDE_QUIET_WINDOW_MS").unwrap_or(600_000), + ) +} + +pub(super) fn heartbeat_cap_ms(environment: Option<&[(String, String)]>) -> u64 { + positive_ms(environment, "FRESHELL_FRESHCLAUDE_TOOL_HEARTBEAT_CAP_MS").unwrap_or_else(|| { + positive_ms(environment, "BASH_MAX_TIMEOUT_MS") + .unwrap_or(600_000) + .saturating_add(120_000) + }) +} + +impl FreshClaudeState { + pub(super) async fn note_claude_stdout( + &self, + map_key: &str, + ownership_id: &str, + line: Option<&Value>, + ) { + let capped_tool_heartbeat = line.is_some_and(|line| { + line["type"] == "sdk.heartbeat" + && line["kind"] == "tool" + && line["elapsedMs"].as_f64().is_some_and(|elapsed| { + elapsed >= heartbeat_cap_ms(self.sidecar_environment.as_deref()) as f64 + }) + }); + if capped_tool_heartbeat { + return; + } + let mut sessions = self.sessions.lock().await; + if let Some(session) = sessions + .get_mut(map_key) + .filter(|session| session.ownership_id == ownership_id) + { + self.refresh_claude_quiet( + session, + map_key, + line.is_some_and(|line| line["type"] == "sdk.result"), + ); + } + } + + pub(super) async fn arm_claude_quiet( + &self, + map_key: &str, + ledger: &crate::claude_turns::SharedLedger, + ) { + let mut sessions = self.sessions.lock().await; + if let Some(session) = sessions + .get_mut(map_key) + .filter(|session| Arc::ptr_eq(&session.ledger, ledger)) + { + self.refresh_claude_quiet(session, map_key, false); + } + } + + /// The caller holds the sessions map, with no ledger/pending guard held. + /// Every publisher that also needs quiet and ledger locks takes quiet first. + pub(super) fn refresh_claude_quiet( + &self, + session: &mut ClaudeSession, + map_key: &str, + defer_status: bool, + ) { + let busy = session.ledger.lock().expect("turn ledger lock").busy(); + let pending = session.pending.lock().expect("pending lock").len() > 0; + let window = quiet_window(self.sidecar_environment.as_deref()); + let feed = session + .quiet + .state + .lock() + .expect("quiet deadman lock") + .feed(busy && !pending, window); + let stamp = session + .broadcast_id + .lock() + .expect("broadcast id lock") + .clone(); + if let Some(waiter) = session.quiet.waiter.take() { + waiter.abort(); + if busy && pending { + tracing::info!(provider = PROVIDER, session_id = %stamp, phase = "paused", + "freshagent.claude.quiet_deadman"); + } + } + if feed.resolved { + tracing::info!(provider = PROVIDER, session_id = %stamp, phase = "resolved", + "freshagent.claude.quiet_deadman"); + } + if let Some(armed) = feed.armed { + if armed.fresh { + tracing::info!(provider = PROVIDER, session_id = %stamp, phase = "armed", + quiet_window_ms = window.as_millis() as u64, + tool_heartbeat_cap_ms = heartbeat_cap_ms(self.sidecar_environment.as_deref()), + "freshagent.claude.quiet_deadman"); + } + let watch = self.clone().watch_claude_quiet_deadline( + map_key.to_string(), + session.ownership_id.clone(), + session.quiet.state.clone(), + session.ledger.clone(), + session.pending.clone(), + armed.generation, + ); + session.quiet.waiter = Some(tokio::spawn(watch).abort_handle()); + } + // The consumer forwards a result before its resulting status, even + // when other queued commands leave the session busy. + if feed.resolved && busy && !defer_status { + self.publish_claude_status( + &session.ledger, + &session.quiet.state, + &stamp, + &session.session_type, + false, + ); + } + } + + async fn watch_claude_quiet_deadline( + self, + map_key: String, + ownership_id: String, + quiet: SharedQuiet, + ledger: crate::claude_turns::SharedLedger, + pending: Arc>, + generation: u64, + ) { + if !wait_for_quiet_deadline(&quiet, generation, || { + let busy = ledger.lock().expect("turn ledger lock").busy(); + let pending = pending.lock().expect("pending lock").len() > 0; + busy && !pending + }) + .await + { + return; + } + let sessions = self.sessions.lock().await; + let Some(session) = sessions + .get(&map_key) + .filter(|session| session.ownership_id == ownership_id) + else { + return; + }; + let quiet = quiet.lock().expect("quiet deadman lock"); + if !quiet.is_stuck_generation(generation) { + return; + } + let mut ledger = ledger.lock().expect("turn ledger lock"); + if !ledger.busy() || session.pending.lock().expect("pending lock").len() > 0 { + return; + } + let stamp = session + .broadcast_id + .lock() + .expect("broadcast id lock") + .clone(); + self.publish_locked_status(&mut ledger, &stamp, &session.session_type, false, true); + tracing::warn!(provider = PROVIDER, session_id = %stamp, ownership_id = %ownership_id, + phase = "fired", generation, "freshagent.claude.quiet_deadman"); + self.edges.quiet_deadman(&crate::claude_edges::EdgeRecord { + session_key: &map_key, + stamp: &stamp, + sidecar_session_id: &session.sidecar_session_id, + session_type: &session.session_type, + clock: &session.clock, + }); + } +} diff --git a/crates/freshell-freshagent/src/claude/establish.rs b/crates/freshell-freshagent/src/claude/establish.rs index ca4acbd52..54e0e0a03 100644 --- a/crates/freshell-freshagent/src/claude/establish.rs +++ b/crates/freshell-freshagent/src/claude/establish.rs @@ -768,6 +768,7 @@ impl FreshClaudeState { clock: dead.map(|dead| dead.clock).unwrap_or_default(), } }); + let quiet = super::deadman::QuietWatch::default(); let (stdin, fault) = crate::claude_sidecar_io::SidecarWriter::spawn( stdin, log_labels, @@ -786,6 +787,7 @@ impl FreshClaudeState { Arc::clone(&broadcast_id), Arc::clone(&pending), Arc::clone(&ledger), + quiet.state.clone(), clock.clone(), provenance.clone(), naming.is_some(), @@ -794,6 +796,8 @@ impl FreshClaudeState { sessions.insert( map_key.clone(), ClaudeSession { + session_type: spec.session_type.clone(), + quiet, configuration: ClaudeConfiguration { settings: configuration_settings, freshell_context: spec.freshell_context.clone(), diff --git a/crates/freshell-freshagent/src/claude/pending_runtime.rs b/crates/freshell-freshagent/src/claude/pending_runtime.rs index b9e838ddb..185b8a3cf 100644 --- a/crates/freshell-freshagent/src/claude/pending_runtime.rs +++ b/crates/freshell-freshagent/src/claude/pending_runtime.rs @@ -28,8 +28,8 @@ impl FreshClaudeState { request_id: &str, kind: PendingResponse, ) { - let sessions = self.sessions.lock().await; - let Some(session) = sessions.get(&response.map_key).filter(|session| { + let mut sessions = self.sessions.lock().await; + let Some(session) = sessions.get_mut(&response.map_key).filter(|session| { session.ownership_id == response.ownership_id && Arc::ptr_eq(&session.pending, &response.pending) }) else { @@ -49,6 +49,8 @@ impl FreshClaudeState { .clone(); self.edges.pending_drained(&stamp); } + drop(pending); + self.refresh_claude_quiet(session, &response.map_key, false); } pub(super) async fn publish_pending_frame( @@ -58,9 +60,9 @@ impl FreshClaudeState { value: &Value, session_type: &str, ) { - let sessions = self.sessions.lock().await; + let mut sessions = self.sessions.lock().await; let Some(session) = sessions - .get(map_key) + .get_mut(map_key) .filter(|session| Arc::ptr_eq(&session.pending, expected_pending)) else { return; @@ -70,7 +72,13 @@ impl FreshClaudeState { .lock() .expect("broadcast id lock") .clone(); - self.publish_claude_status(&session.ledger, &stamp, session_type, false); + self.publish_claude_status( + &session.ledger, + &session.quiet.state, + &stamp, + session_type, + false, + ); let mut pending = session.pending.lock().expect("pending lock"); let change = pending.fold_frame(value); if let Some(frame) = sdk_line_to_frame(value, &stamp, session_type) { @@ -89,5 +97,7 @@ impl FreshClaudeState { Some(PendingChange::Drained) => self.edges.pending_drained(&stamp), None => {} } + drop(pending); + self.refresh_claude_quiet(session, map_key, false); } } diff --git a/crates/freshell-freshagent/src/claude/teardown_tests.rs b/crates/freshell-freshagent/src/claude/teardown_tests.rs index 6f45a8913..56567aada 100644 --- a/crates/freshell-freshagent/src/claude/teardown_tests.rs +++ b/crates/freshell-freshagent/src/claude/teardown_tests.rs @@ -99,6 +99,8 @@ async fn fake_session( while let Ok(Some(_)) = lines.next_line().await {} }); let session = ClaudeSession { + session_type: "freshclaude".into(), + quiet: Default::default(), configuration: ClaudeConfiguration::default(), stdin: stdin.into(), child, diff --git a/crates/freshell-freshagent/src/claude/tests/deadman_tests.rs b/crates/freshell-freshagent/src/claude/tests/deadman_tests.rs new file mode 100644 index 000000000..409b64d22 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/tests/deadman_tests.rs @@ -0,0 +1,161 @@ +use super::*; + +async fn clock_forward(ms: u64) { + tokio::time::advance(Duration::from_millis(ms)).await; + // Let a due waiter validate its runtime and publish before reading the bus. + tokio::task::yield_now().await; + tokio::task::yield_now().await; +} + +#[tokio::test(start_paused = true)] +async fn tool_heartbeats_feed_only_below_the_effective_cap_and_never_clear_stuck_above_it() { + let configurations = [ + (vec![], 720_000), + (vec![("BASH_MAX_TIMEOUT_MS", "300")], 120_300), + ( + vec![ + ("BASH_MAX_TIMEOUT_MS", "0"), + ("FRESHELL_FRESHCLAUDE_TOOL_HEARTBEAT_CAP_MS", "0"), + ], + 720_000, + ), + ( + vec![ + ("BASH_MAX_TIMEOUT_MS", "300"), + ("FRESHELL_FRESHCLAUDE_TOOL_HEARTBEAT_CAP_MS", "200"), + ], + 200, + ), + ]; + for (settings, cap) in configurations { + let (st, mut rx) = state_with_bus(); + let mut env: Vec<(String, String)> = settings + .into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect(); + env.push(("FRESHELL_FRESHCLAUDE_QUIET_WINDOW_MS".into(), "100".into())); + let st = st.with_sidecar_environment(env); + let id = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&st, &id).await; + let owner = { + let sessions = st.sessions.lock().await; + let session = &sessions[&id]; + session.ledger.lock().unwrap().arm( + "running".into(), + None, + crate::claude_turns::SendKind::Turn, + ); + session.ownership_id.clone() + }; + st.note_claude_stdout(&id, &owner, None).await; + clock_forward(90).await; + st.note_claude_stdout( + &id, + &owner, + Some(&json!({"type":"sdk.heartbeat", "kind":"tool", "elapsedMs":cap - 1})), + ) + .await; + clock_forward(90).await; + assert_ne!(st.live_snapshot_overlay(&id).await["status"], "stuck"); + st.note_claude_stdout( + &id, + &owner, + Some(&json!({"type":"sdk.heartbeat", "kind":"tool", "elapsedMs":cap})), + ) + .await; + clock_forward(15).await; + assert_eq!(st.live_snapshot_overlay(&id).await["status"], "stuck"); + st.note_claude_stdout( + &id, + &owner, + Some(&json!({"type":"sdk.heartbeat", "kind":"tool", "elapsedMs":cap + 1})), + ) + .await; + assert_eq!( + st.live_snapshot_overlay(&id).await["status"], + "stuck", + "an expired tool heartbeat is not renewed progress" + ); + let first: Vec = std::iter::from_fn(|| rx.try_recv().ok()) + .map(|raw| serde_json::from_str(&raw).unwrap()) + .collect(); + assert_eq!( + first + .iter() + .filter(|frame| frame["event"]["type"] == "freshAgent.turn.complete") + .count(), + 1 + ); + for kind in ["retry", "task"] { + st.note_claude_stdout( + &id, + &owner, + Some(&json!({"type":"sdk.heartbeat", "kind":kind, "elapsedMs":cap + 1})), + ) + .await; + assert_eq!(st.live_snapshot_overlay(&id).await["status"], "running"); + clock_forward(90).await; + assert_ne!(st.live_snapshot_overlay(&id).await["status"], "stuck"); + } + clock_forward(15).await; + assert_eq!(st.live_snapshot_overlay(&id).await["status"], "stuck"); + // Even a non-JSON stdout line is liveness, unlike an expired tool heartbeat. + st.note_claude_stdout(&id, &owner, None).await; + assert_eq!(st.live_snapshot_overlay(&id).await["status"], "running"); + tokio::time::resume(); + st.shutdown().await; + tokio::time::pause(); + } +} + +#[tokio::test(start_paused = true)] +async fn an_old_quiet_waiter_never_flags_a_replacement_runtime_with_the_same_session_id() { + let (st, mut rx) = state_with_bus(); + let st = st + .with_sidecar_environment([("FRESHELL_FRESHCLAUDE_QUIET_WINDOW_MS".into(), "100".into())]); + let id = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&st, &id).await; + let owner = { + let sessions = st.sessions.lock().await; + let session = &sessions[&id]; + session.ledger.lock().unwrap().arm( + "old-turn".into(), + None, + crate::claude_turns::SendKind::Turn, + ); + session.ownership_id.clone() + }; + st.note_claude_stdout(&id, &owner, None).await; + let removed = st.sessions.lock().await.remove(&id).unwrap(); + insert_fake_claude_session(&st, &id).await; + st.sessions.lock().await.get_mut(&id).unwrap().ownership_id = "replacement-runtime".into(); + clock_forward(150).await; + assert!( + removed.quiet.state.lock().unwrap().is_stuck(), + "the obsolete waiter's deadline actually fired" + ); + assert_eq!(st.live_snapshot_overlay(&id).await["status"], "idle"); + assert!( + rx.try_recv().is_err(), + "an old timeout published into the new runtime" + ); + tokio::time::resume(); + st.teardown_removed_session_logged(removed).await; + st.shutdown().await; +} + +#[tokio::test(start_paused = true)] +async fn an_idle_runtime_never_arms_a_quiet_timeout() { + let (st, mut rx) = state_with_bus(); + let st = st + .with_sidecar_environment([("FRESHELL_FRESHCLAUDE_QUIET_WINDOW_MS".into(), "100".into())]); + let id = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&st, &id).await; + let owner = st.sessions.lock().await[&id].ownership_id.clone(); + st.note_claude_stdout(&id, &owner, None).await; + clock_forward(1_000).await; + assert_eq!(st.live_snapshot_overlay(&id).await["status"], "idle"); + assert!(rx.try_recv().is_err()); + tokio::time::resume(); + st.shutdown().await; +} diff --git a/crates/freshell-freshagent/src/claude/turn_status.rs b/crates/freshell-freshagent/src/claude/turn_status.rs index c8852005d..22fd67bc0 100644 --- a/crates/freshell-freshagent/src/claude/turn_status.rs +++ b/crates/freshell-freshagent/src/claude/turn_status.rs @@ -78,16 +78,27 @@ impl FreshClaudeState { pub(super) fn take_back_unwritten_send( &self, ledger: &SharedLedger, + quiet: &super::deadman::SharedQuiet, session_id: &str, session_type: &str, uuid: &str, ) { + let mut quiet = quiet.lock().expect("quiet deadman lock"); let mut ledger = ledger.lock().expect("turn ledger lock"); // EOF may have cleared this command before its writer-failure receipt // reached the handler. Publish only for a command actually removed, // under this same guard so EOF cannot overtake its status update. if ledger.undo_arm(uuid).is_some() { - self.publish_locked_status(&mut ledger, session_id, session_type, false); + if !ledger.busy() { + quiet.disarm(); + } + self.publish_locked_status( + &mut ledger, + session_id, + session_type, + false, + quiet.is_stuck(), + ); } } @@ -99,20 +110,19 @@ impl FreshClaudeState { let Some(key) = self.resolve_session_key(any_id).await else { return; }; - let live = self.sessions.lock().await.get(&key).map(|session| { - let session_id = session - .broadcast_id - .lock() - .expect("broadcast id lock") - .clone(); - (session.ledger.clone(), session_id) - }); - let Some((ledger, session_id)) = live else { + let sessions = self.sessions.lock().await; + let Some(session) = sessions.get(&key) else { return; }; - let mut ledger = ledger.lock().expect("turn ledger lock"); + let session_id = session + .broadcast_id + .lock() + .expect("broadcast id lock") + .clone(); + let quiet = session.quiet.state.lock().expect("quiet deadman lock"); + let mut ledger = session.ledger.lock().expect("turn ledger lock"); let status = ledger - .take_status_change(false) + .take_status_change(quiet.is_stuck()) .unwrap_or_else(|| ledger.published_status()); tracing::debug!(target: TARGET, provider = PROVIDER, session_id = %session_id, session_type, status = status.as_str(), announced = true, "freshagent.claude.status"); @@ -232,22 +242,31 @@ impl FreshClaudeState { pub(super) fn publish_claude_status( &self, ledger: &SharedLedger, + quiet: &super::deadman::SharedQuiet, session_id: &str, session_type: &str, after_result: bool, ) { + let quiet = quiet.lock().expect("quiet deadman lock"); let mut ledger = ledger.lock().expect("turn ledger lock"); - self.publish_locked_status(&mut ledger, session_id, session_type, after_result); + self.publish_locked_status( + &mut ledger, + session_id, + session_type, + after_result, + quiet.is_stuck(), + ); } - fn publish_locked_status( + pub(super) fn publish_locked_status( &self, ledger: &mut crate::claude_turns::ClaudeTurnLedger, session_id: &str, session_type: &str, after_result: bool, + stuck: bool, ) { - let (status, changed) = match ledger.take_status_change(false) { + let (status, changed) = match ledger.take_status_change(stuck) { Some(status) => (status, true), None if after_result && ledger.published_status() != EffectiveStatus::Idle => { (ledger.published_status(), false) @@ -276,11 +295,12 @@ mod tests { let (tx, mut rx) = tokio::sync::broadcast::channel(16); let state = FreshClaudeState::new(Arc::new(tx)); let ledger = SharedLedger::default(); + let quiet = crate::quiet_deadman::QuietDeadman::new_shared(); ledger .lock() .unwrap() .arm("send-uuid".into(), Some("request".into()), SendKind::Turn); - state.publish_claude_status(&ledger, "session", "freshclaude", false); + state.publish_claude_status(&ledger, &quiet, "session", "freshclaude", false); assert!(ledger.lock().unwrap().eof()); state.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { provider: "claude".into(), @@ -290,7 +310,7 @@ mod tests { })); // A delayed writer-failure handler wakes after stdout EOF has already // cleared the command and announced the ended runtime. - state.take_back_unwritten_send(&ledger, "session", "freshclaude", "send-uuid"); + state.take_back_unwritten_send(&ledger, &quiet, "session", "freshclaude", "send-uuid"); let frames: Vec = std::iter::from_fn(|| rx.try_recv().ok()) .map(|raw| serde_json::from_str(&raw).unwrap()) .collect(); @@ -302,12 +322,13 @@ mod tests { let (tx, mut rx) = tokio::sync::broadcast::channel(16); let state = FreshClaudeState::new(Arc::new(tx)); let ledger = SharedLedger::default(); + let quiet = crate::quiet_deadman::QuietDeadman::new_shared(); ledger .lock() .unwrap() .arm("send-uuid".into(), None, SendKind::Turn); - state.publish_claude_status(&ledger, "session", "freshclaude", false); - state.take_back_unwritten_send(&ledger, "session", "freshclaude", "send-uuid"); + state.publish_claude_status(&ledger, &quiet, "session", "freshclaude", false); + state.take_back_unwritten_send(&ledger, &quiet, "session", "freshclaude", "send-uuid"); let frames: Vec = std::iter::from_fn(|| rx.try_recv().ok()) .map(|raw| serde_json::from_str(&raw).unwrap()) .collect(); diff --git a/crates/freshell-freshagent/src/claude_edges.rs b/crates/freshell-freshagent/src/claude_edges.rs index 666416c20..ff096bb08 100644 --- a/crates/freshell-freshagent/src/claude_edges.rs +++ b/crates/freshell-freshagent/src/claude_edges.rs @@ -106,6 +106,7 @@ pub(crate) enum EdgeSource { Death, /// Pending approvals or questions went from none to some. Pending, + QuietDeadman, } impl EdgeSource { @@ -114,6 +115,7 @@ impl EdgeSource { Self::Result => "result", Self::Death => "death", Self::Pending => "pending", + Self::QuietDeadman => "quiet_deadman", } } } @@ -248,6 +250,12 @@ impl ClaudeEdgeHub { at } + /// A busy runtime exhausted its quiet window. It still owes its turn; + /// this attention edge does not publish a settlement. + pub(crate) fn quiet_deadman(&self, record: &EdgeRecord<'_>) -> i64 { + self.emit(record, EdgeKind::TurnComplete, EdgeSource::QuietDeadman) + } + /// The sidecar ended unrequested: ring when a turn was owed, and publish the /// end either way. pub(crate) fn ended( diff --git a/crates/freshell-freshagent/src/quiet_deadman.rs b/crates/freshell-freshagent/src/quiet_deadman.rs index 1af478b4b..817fac925 100644 --- a/crates/freshell-freshagent/src/quiet_deadman.rs +++ b/crates/freshell-freshagent/src/quiet_deadman.rs @@ -113,6 +113,10 @@ impl QuietDeadman { self.stuck_since.is_some() } + pub(crate) fn is_stuck_generation(&self, generation: u64) -> bool { + self.generation == generation && self.is_stuck() + } + /// Fire iff NOTHING changed since the epoch was armed: the same /// generation and deadline, a turn still `in_flight`, not already fired. /// Returns whether it fired (and flagged the session stuck). diff --git a/crates/freshell-ws/tests/freshagent_claude_stuck.rs b/crates/freshell-ws/tests/freshagent_claude_stuck.rs new file mode 100644 index 000000000..c125c2973 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_stuck.rs @@ -0,0 +1,139 @@ +//! Quiet-window detection and process-only recovery use the real runtime path. +#![cfg(unix)] + +mod common; + +use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions}; +use serde_json::{json, Value}; +use std::time::Duration; + +fn statuses(conn: &RigConnection, status: &str) -> usize { + conn.frames + .iter() + .filter(|frame| { + frame["event"]["type"] == "freshAgent.status" && frame["event"]["status"] == status + }) + .count() +} + +async fn quiet_rig(program: Option) -> ClaudeRig { + let mut env = vec![("FRESHELL_FRESHCLAUDE_QUIET_WINDOW_MS".into(), "150".into())]; + if let Some(program) = program { + env.push(("FRESHELL_CLAUDE_SIM_PROGRAM".into(), program.to_string())); + } + ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + env, + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_quiet_busy_session_becomes_stuck_and_rings_once_per_epoch() { + let logs = common::log_capture::install(); + let rig = quiet_rig(None).await; + let mut conn = rig.connect().await; + let id = conn.create("quiet-hang").await; + conn.send_text(&id, "hanging-turn", "HANG").await; + conn.read_for(Duration::from_millis(700)).await; + assert_eq!(statuses(&conn, "stuck"), 1, "{:#?}", conn.frames); + assert_eq!(conn.events_seen("freshAgent.turn.complete"), 1); + assert_eq!( + conn.events_seen("freshAgent.result"), + 0, + "a stuck edge never settles a turn" + ); + assert_eq!( + rig.fresh_claude.live_snapshot_overlay(&id).await["status"], + "stuck" + ); + let edge = logs + .wait_for("the quiet deadman's edge", |event| { + event.message == "freshagent.turn.complete" && event.field("session_id") == Some(&id) + }) + .await; + assert_eq!(edge.field("source"), Some("quiet_deadman")); + conn.read_for(Duration::from_millis(450)).await; + assert_eq!( + conn.events_seen("freshAgent.turn.complete"), + 1, + "the same quiet epoch rings once" + ); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn later_stdout_activity_resolves_stuck_and_starts_a_new_quiet_epoch() { + let rig = quiet_rig(Some(json!({"rules":[{"on":"msg:prompt", "match":{"text":"late progress"}, + "emit":[{"kind":"text", "delayMs":1500, "data":{"text":"still working"}}, {"kind":"hang"}]}]}))).await; + let mut conn = rig.connect().await; + let id = conn.create("quiet-late-progress").await; + conn.send_text(&id, "late-progress-turn", "late progress") + .await; + conn.read_for(Duration::from_millis(700)).await; + assert_eq!(statuses(&conn, "stuck"), 1, "{:#?}", conn.frames); + let first_stuck = conn + .frames + .iter() + .position(|frame| frame["event"]["status"] == "stuck") + .unwrap(); + conn.wait_for("activity resolving the stuck state", |frame| { + frame["event"]["content"].as_array().is_some_and(|content| { + content.iter().any(|block| { + block["text"] + .as_str() + .is_some_and(|text| text.contains("still working")) + }) + }) + }) + .await; + assert!(conn + .frames + .iter() + .skip(first_stuck + 1) + .any(|frame| frame["event"]["status"] == "running")); + conn.read_for(Duration::from_millis(500)).await; + assert_eq!( + statuses(&conn, "stuck"), + 2, + "a new silent epoch follows real progress" + ); + assert_eq!(conn.events_seen("freshAgent.turn.complete"), 2); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_pending_approval_pauses_the_deadman_until_the_user_answers() { + let rig = quiet_rig(Some(json!({"rules":[{"on":"msg:prompt", "match":{"text":"ask then hang"}, + "emit":[{"kind":"approval", "data":{"tool":"Bash", "input":{"command":"ls"}}}, {"kind":"hang"}]}]}))).await; + let mut conn = rig.connect().await; + let created = conn + .request_create("quiet-approval", json!({"permissionMode":"default"})) + .await; + let id = created["sessionId"].as_str().unwrap(); + conn.send_text(id, "approval-turn", "ask then hang").await; + let approval = conn.wait_for_event("freshAgent.permission.request").await; + conn.wait_for_event("freshAgent.turn.waiting").await; + conn.read_for(Duration::from_millis(600)).await; + assert_eq!( + statuses(&conn, "stuck"), + 0, + "the user is blocking this turn" + ); + assert_eq!(conn.events_seen("freshAgent.turn.complete"), 0); + conn.send(json!({"type":"freshAgent.approval.respond", "provider":"claude", "sessionType":"freshclaude", + "sessionId":id, "requestId":approval["event"]["requestId"], + "decision":{"behavior":"allow", "updatedInput":{"command":"ls"}}})).await; + conn.read_for(Duration::from_millis(700)).await; + assert_eq!( + statuses(&conn, "stuck"), + 1, + "the resumed agent is now blocking the turn" + ); + assert_eq!(conn.events_seen("freshAgent.turn.complete"), 1); + rig.fresh_claude.shutdown().await; +} From f88efb0950722c2fc1c5f9bd8118e5310f7e92c9 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:36:45 -0700 Subject: [PATCH 09/19] feat(freshclaude): stop owned runtimes for recovery without closing the session --- crates/freshell-freshagent/src/claude.rs | 2 + .../freshell-freshagent/src/claude/deadman.rs | 10 +- .../src/claude/recovery_stop.rs | 460 ++++++++++++++++++ .../src/claude/tests/recovery_stop_tests.rs | 71 +++ .../freshell-protocol/src/client_messages.rs | 2 +- crates/freshell-ws/src/terminal.rs | 43 +- .../tests/freshagent_claude_recovery_stop.rs | 163 +++++++ shared/ws-protocol.ts | 8 +- src/lib/fresh-agent-ws.ts | 2 +- 9 files changed, 748 insertions(+), 13 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude/recovery_stop.rs create mode 100644 crates/freshell-freshagent/src/claude/tests/recovery_stop_tests.rs create mode 100644 crates/freshell-ws/tests/freshagent_claude_recovery_stop.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 0e6371abf..785e985f1 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -8203,6 +8203,7 @@ mod create_binding; mod create_outcome; mod deadman; mod recovery; +mod recovery_stop; pub use create_outcome::ClaudeCreateOutcome; mod send_outcome; @@ -8266,6 +8267,7 @@ pub(crate) mod tests { mod control_tests; mod create_binding_tests; mod deadman_tests; + mod recovery_stop_tests; mod recovery_tests; mod refusal_tests; diff --git a/crates/freshell-freshagent/src/claude/deadman.rs b/crates/freshell-freshagent/src/claude/deadman.rs index df8574a23..889bd4223 100644 --- a/crates/freshell-freshagent/src/claude/deadman.rs +++ b/crates/freshell-freshagent/src/claude/deadman.rs @@ -19,8 +19,8 @@ impl Default for QuietWatch { } } -impl Drop for QuietWatch { - fn drop(&mut self) { +impl QuietWatch { + pub(super) fn cancel(&mut self) { self.state.lock().expect("quiet deadman lock").disarm(); if let Some(waiter) = self.waiter.take() { waiter.abort(); @@ -28,6 +28,12 @@ impl Drop for QuietWatch { } } +impl Drop for QuietWatch { + fn drop(&mut self) { + self.cancel(); + } +} + fn positive_ms(environment: Option<&[(String, String)]>, name: &str) -> Option { let value = match environment { Some(environment) => environment diff --git a/crates/freshell-freshagent/src/claude/recovery_stop.rs b/crates/freshell-freshagent/src/claude/recovery_stop.rs new file mode 100644 index 000000000..34cce56a1 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/recovery_stop.rs @@ -0,0 +1,460 @@ +//! Stop the owned process for recovery, retaining the conversation's binding. + +use super::*; +use freshell_protocol::{FreshAgentRecoveryStop, FreshAgentRecoveryStopped}; + +type StopFailure = (&'static str, String); + +struct RecoveryOperation { + registry: Arc, + durable: String, + operation_id: String, + generation: u64, + _settlement: crate::ownership_lane::StopSettlementGuard, +} + +impl RecoveryOperation { + fn commit(&self, state: &FreshClaudeState) -> Result<(), StopFailure> { + let outcome = crate::ownership_lane::commit_fresh_agent_stop( + &self.registry, + PROVIDER, + &self.durable, + &self.operation_id, + self.generation, + ); + if !matches!(outcome, freshell_ownership::CommitOutcome::Committed) { + return Err(( + "STALE_CLAIM", + "Ownership changed while stopping Claude. Refresh and retry.".into(), + )); + } + if let Some(frame) = crate::ownership_lane::released_owner_frame( + &state.ownership, + PROVIDER, + &self.durable, + &self.operation_id, + self.registry.boot_epoch(), + self.generation, + ) { + state.broadcast(&frame); + } + Ok(()) + } +} + +struct RecoveryRuntime { + session: ClaudeSession, + map_key: String, + durable: String, + aliases: Vec, + operation: Option, +} + +impl FreshClaudeState { + pub async fn handle_recovery_stop(&self, msg: FreshAgentRecoveryStop) { + if msg.provider != freshell_protocol::AgentProvider::Claude + || !matches!( + msg.session_type, + SessionType::Freshclaude | SessionType::Kilroy + ) + { + self.reply_recovery_stop( + &msg, + Err(( + "UNSUPPORTED_CAPABILITY", + "Recovery stop is unavailable for this provider and session type.".into(), + )), + ); + return; + } + let fence = + match crate::ownership_lane::wire_fence(msg.observed_epoch, msg.observed_generation) { + Ok(fence) => fence, + Err(error) => { + self.reply_recovery_stop(&msg, Err((error.code(), error.message().into()))); + return; + } + }; + let runtime = match self.take_recovery_runtime(&msg, fence).await { + Ok(Some(runtime)) => runtime, + Ok(None) => { + self.reply_recovery_stop( + &msg, + self.recovery_already_stopped(&msg.session_id, fence), + ); + return; + } + Err(error) => { + self.reply_recovery_stop(&msg, Err(error)); + return; + } + }; + // Capture before the first post-removal await. Cancellation can never + // leave an unrecorded child tree for the ownership watchdog to probe. + if let Some(pid) = runtime.session.child.id() { + let recorded = crate::session_lease::record_condemned_runtime_identity( + pid, + CLAUDE_SIDECAR_OWNERSHIP_ENV, + &runtime.session.ownership_id, + ); + self.condemned_priors + .lock() + .expect("condemned priors lock") + .insert(runtime.durable.clone(), recorded); + } + // The process cleanup owns its operation, lease, and removed session. + // A canceled requesting handler detaches this task rather than canceling + // cleanup or leaving a completed lease permanently attached to no runtime. + let state = self.clone(); + let outcome = tokio::spawn(async move { state.finish_recovery_stop(runtime).await }).await; + let outcome = outcome.unwrap_or_else(|error| { + tracing::error!(provider = PROVIDER, request_id = %msg.request_id, + cause = %error, "freshagent.claude.recovery_stop_task_failed"); + Err(( + "TEARDOWN_FAILED", + "Claude cleanup did not finish. Try again after it settles.".into(), + )) + }); + self.reply_recovery_stop(&msg, outcome); + } + + async fn take_recovery_runtime( + &self, + msg: &FreshAgentRecoveryStop, + fence: Option, + ) -> Result, StopFailure> { + let Some(map_key) = self.resolve_session_key(&msg.session_id).await else { + return Ok(None); + }; + // Match the established alias->map lock order. No process I/O or await + // follows the claim until the removal, pending drain and end are visible. + let index = self.cli_index.lock().await; + let mut sessions = self.sessions.lock().await; + let Some(session) = sessions.get(&map_key) else { + return Err(( + "STALE_CLAIM", + "Claude changed before recovery stopped it. Try again.".into(), + )); + }; + let durable = session + .cli_session_id + .clone() + .unwrap_or_else(|| map_key.clone()); + let operation = self.claim_recovery_stop(session, &map_key, &durable, fence)?; + let mut aliases: Vec = index + .iter() + .filter(|(_, mapped)| **mapped == map_key) + .map(|(id, _)| id.clone()) + .collect(); + if !aliases.contains(&durable) { + aliases.push(durable.clone()); + } + let mut session = sessions + .remove(&map_key) + .expect("validated recovery runtime"); + session.consumer.abort(); + session.quiet.cancel(); + session.ledger.lock().expect("turn ledger lock").eof(); + let stamp = session + .broadcast_id + .lock() + .expect("broadcast id lock") + .clone(); + { + let mut pending = session.pending.lock().expect("pending lock"); + pending.permissions.clear(); + pending.questions.clear(); + } + self.edges.pending_drained(&stamp); + self.dead_sessions + .record(crate::claude_dead_sessions::Death { + map_key: &map_key, + stamp: &stamp, + session_type: &session.session_type, + durables: &aliases, + cli_session_id: session.cli_session_id.as_deref(), + settings: &session.configuration.settings, + ownership_id: &session.ownership_id, + launch_genesis: session.launch_genesis.as_ref(), + turn_lock: &session.turn_lock, + freshell_context: session.configuration.freshell_context.as_ref(), + commands: session.configuration.commands.as_ref(), + reason: "recovery_stop", + clock: &session.clock, + }); + self.edges.ended( + &crate::claude_edges::EdgeRecord { + session_key: &map_key, + stamp: &stamp, + sidecar_session_id: &session.sidecar_session_id, + session_type: &session.session_type, + clock: &session.clock, + }, + false, + "recovery_stop", + &session.ownership_id, + ); + self.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { + provider: PROVIDER.into(), + session_id: stamp.clone(), + session_type: session.session_type.clone(), + event: crate::claude_dead_sessions::exited_status_event(&stamp), + })); + Ok(Some(RecoveryRuntime { + session, + map_key, + durable, + aliases, + operation, + })) + } + + fn claim_recovery_stop( + &self, + session: &ClaudeSession, + map_key: &str, + durable: &str, + fence: Option, + ) -> Result, StopFailure> { + let Some(registry) = self.ownership.as_ref() else { + return Ok(None); + }; + let snapshot = registry.observe(PROVIDER, durable); + if fence.is_some_and(|fence| { + fence.epoch != snapshot.epoch || fence.generation != snapshot.generation + }) { + return Err(( + "STALE_CLAIM", + "Ownership changed before recovery stopped Claude. Refresh and retry.".into(), + )); + } + let owner = match snapshot.state { + freshell_ownership::OwnershipState::Live { owner, .. } + if owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent + && owner.live_session_key.as_deref() == Some(map_key) + && owner.pid == session.child.id() => + { + owner + } + freshell_ownership::OwnershipState::Vacant => return Ok(None), + freshell_ownership::OwnershipState::Live { .. } => { + return Err(( + "FOREIGN_OWNER", + "Another runtime owns this conversation. Refresh and retry.".into(), + )) + } + freshell_ownership::OwnershipState::Handoff { .. } => { + return Err(( + "HANDOFF_IN_FLIGHT", + "A handoff is moving this conversation. Try again after it settles.".into(), + )) + } + _ => { + return Err(( + "LIFECYCLE_IN_FLIGHT", + "Claude is already changing runtime. Try again after it settles.".into(), + )) + } + }; + let operation_id = format!("recovery-stop-{}", uuid::Uuid::new_v4()); + let claim = freshell_ownership::StopClaim { + expected_kind: freshell_ownership::RuntimeOwnerKind::FreshAgent, + expected_runtime: Some(owner), + observed: fence.unwrap_or(freshell_ownership::ObservedFence { + epoch: snapshot.epoch, + generation: snapshot.generation, + }), + }; + let generation = match crate::ownership_lane::begin_fresh_agent_stop( + registry, + PROVIDER, + durable, + &operation_id, + &claim, + "freshclaude/recovery-stop", + crate::session_lease::now_epoch_ms(), + ) { + freshell_ownership::StopOutcome::Granted { generation } => generation, + freshell_ownership::StopOutcome::NotLive { + state: freshell_ownership::OwnershipState::Vacant, + } => return Ok(None), + freshell_ownership::StopOutcome::BlockedHandoff { .. } => { + return Err(( + "HANDOFF_IN_FLIGHT", + "A handoff is moving this conversation. Try again after it settles.".into(), + )) + } + freshell_ownership::StopOutcome::StaleClaim { .. } => { + return Err(( + "STALE_CLAIM", + "Ownership changed before recovery stopped Claude. Refresh and retry.".into(), + )) + } + freshell_ownership::StopOutcome::NotLive { .. } => { + return Err(( + "LIFECYCLE_IN_FLIGHT", + "Claude is already changing runtime. Try again after it settles.".into(), + )) + } + }; + crate::ownership_lane::take_retained_stamp(&self.ownership_stamps, durable); + let settlement = crate::ownership_lane::register_stop_settlement_for_claim( + &self.ownership, + PROVIDER, + durable, + &operation_id, + generation, + ); + Ok(Some(RecoveryOperation { + registry: registry.clone(), + durable: durable.into(), + operation_id, + generation, + _settlement: settlement, + })) + } + + async fn finish_recovery_stop(&self, runtime: RecoveryRuntime) -> Result<(), StopFailure> { + let RecoveryRuntime { + session, + map_key, + durable, + aliases, + operation, + } = runtime; + let ownership_id = session.ownership_id.clone(); + self.create_dedup + .clear_for_session(|record| { + record.session_id == map_key || aliases.contains(&record.session_id) + }) + .await; + self.evict_cli_index_aliases(&map_key).await; + let teardown = self.teardown_removed_session_logged(session).await; + let teardown = self.release_leases_after_teardown( + teardown, + aliases, + map_key, + Some(ownership_id.clone()), + ); + match teardown { + LoggedTeardown::Confirmed => { + self.clear_recovery_prior(&durable, &ownership_id); + if let Some(operation) = operation { + operation.commit(self)?; + } + Ok(()) + } + LoggedTeardown::NotConfirmed { escalation } => { + let state = self.clone(); + tokio::spawn(async move { + if escalation.await.unwrap_or(false) { + state.clear_recovery_prior(&durable, &ownership_id); + if let Some(operation) = operation { + if let Err((code, message)) = operation.commit(&state) { + tracing::error!(provider = PROVIDER, session_id = %durable, code, message, + "freshagent.claude.recovery_stop_deferred_commit_failed"); + } + } + } + }); + Err(( + "TEARDOWN_NOT_CONFIRMED", + "Claude is stopping. Try again after its process tree is confirmed gone." + .into(), + )) + } + LoggedTeardown::PlatformLimited => { + if let Some(operation) = operation { + let _ = operation.registry.fence_unconfirmed_stop( + PROVIDER, + &operation.durable, + &operation.operation_id, + operation.generation, + freshell_ownership::FenceReason::PlatformLimited, + ); + } + Err(( + "TEARDOWN_PLATFORM_LIMITED", + "This platform could not confirm Claude's process tree stopped.".into(), + )) + } + } + } + + fn clear_recovery_prior(&self, durable: &str, ownership_id: &str) { + let mut priors = self.condemned_priors.lock().expect("condemned priors lock"); + if priors + .get(durable) + .is_some_and(|prior| prior.ownership_id == ownership_id) + { + priors.remove(durable); + } + } + + fn recovery_already_stopped( + &self, + id: &str, + fence: Option, + ) -> Result<(), StopFailure> { + let id = self.resolve_ownership_key(id); + if self.resuming.lock().expect("resuming lock").contains(&id) + || self + .condemned_priors + .lock() + .expect("condemned priors lock") + .contains_key(&id) + { + return Err(( + "LIFECYCLE_IN_FLIGHT", + "Claude is already changing runtime. Try again after it settles.".into(), + )); + } + let snapshot = self.ownership_snapshot(PROVIDER, &id); + if fence.is_some_and(|fence| { + fence.epoch != snapshot.epoch || fence.generation != snapshot.generation + }) { + return Err(( + "STALE_CLAIM", + "Ownership changed before recovery stopped Claude. Refresh and retry.".into(), + )); + } + if !matches!(snapshot.state, freshell_ownership::OwnershipState::Vacant) { + return Err(( + "LIFECYCLE_IN_FLIGHT", + "Another runtime operation owns this conversation. Refresh and retry.".into(), + )); + } + if self.dead_sessions.find(&id).is_some() + || self + .identity_sink() + .is_some_and(|sink| sink.row_is_bound(PROVIDER, &id)) + { + Ok(()) + } else { + Err(( + "INVALID_SESSION_ID", + "The Claude conversation for this session could not be found.".into(), + )) + } + } + + fn reply_recovery_stop(&self, msg: &FreshAgentRecoveryStop, outcome: Result<(), StopFailure>) { + let (code, message) = outcome + .err() + .map(|(code, message)| (Some(code.into()), Some(message))) + .unwrap_or_default(); + tracing::info!(provider = PROVIDER, session_id = %msg.session_id, request_id = %msg.request_id, + success = code.is_none(), code = ?code, "freshagent.claude.recovery_stop_settled"); + self.broadcast(&ServerMessage::FreshAgentRecoveryStopped( + FreshAgentRecoveryStopped { + request_id: msg.request_id.clone(), + provider: PROVIDER.into(), + session_id: msg.session_id.clone(), + session_type: session_type_str(msg.session_type).into(), + success: code.is_none(), + code, + message, + }, + )); + } +} diff --git a/crates/freshell-freshagent/src/claude/tests/recovery_stop_tests.rs b/crates/freshell-freshagent/src/claude/tests/recovery_stop_tests.rs new file mode 100644 index 000000000..ee855b454 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/tests/recovery_stop_tests.rs @@ -0,0 +1,71 @@ +use super::*; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn canceling_recovery_stop_keeps_cleanup_running_and_releases_the_unwired_lease() { + let (st, _rx) = state_with_bus(); + let id = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&st, &id).await; + let (pid, owner, turn_lock) = { + let sessions = st.sessions.lock().await; + let session = &sessions[&id]; + ( + session.child.id().unwrap(), + session.ownership_id.clone(), + session.turn_lock.clone(), + ) + }; + let LaunchLease::Acquired(mut lease) = st.claim_launch_lease(&id, "recovery-stop-create").await + else { + panic!("the test runtime must acquire its provider lease"); + }; + lease.set_kill_handle(pid, &owner); + assert!(lease.complete(&id)); + // A blocked configure/send may own this lock while its RPC never answers. + // Recovery must stop the process without waiting behind that control. + let turn = turn_lock.lock().await; + let request = { + let st = st.clone(); + let id = id.clone(); + tokio::spawn(async move { + st.handle_recovery_stop(freshell_protocol::FreshAgentRecoveryStop { + request_id: "cancelable-recovery-stop".into(), + provider: freshell_protocol::AgentProvider::Claude, + session_id: id, + session_type: SessionType::Freshclaude, + observed_epoch: None, + observed_generation: None, + }) + .await; + }) + }; + tokio::time::timeout(Duration::from_secs(5), async { + while st.resolve_session_key(&id).await.is_some() { + tokio::task::yield_now().await; + } + }) + .await + .expect("recovery waited behind an unresolved turn control"); + assert!( + !request.is_finished(), + "the sleeper keeps cleanup pending during graceful shutdown" + ); + request.abort(); + assert!(request.await.unwrap_err().is_cancelled()); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let LaunchLease::Acquired(mut next) = + st.claim_launch_lease(&id, "after-canceled-recovery").await + { + next.fail(); + break; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("canceling the requester abandoned cleanup and stranded its completed lease"); + assert!(st.dead_sessions.find(&id).is_some()); + assert!(!st.condemned_priors.lock().unwrap().contains_key(&id)); + drop(turn); + st.shutdown().await; +} diff --git a/crates/freshell-protocol/src/client_messages.rs b/crates/freshell-protocol/src/client_messages.rs index 8563ba088..d59a70105 100644 --- a/crates/freshell-protocol/src/client_messages.rs +++ b/crates/freshell-protocol/src/client_messages.rs @@ -1103,7 +1103,7 @@ pub struct FreshAgentKill { pub observed_generation: Option, } -/// Request-correlated, process-only Codex recovery. A regular +/// Request-correlated, process-only agent recovery. A regular /// `freshAgent.kill` permanently closes the durable session instead. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index f81a612bc..b924d63e8 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -2138,11 +2138,19 @@ async fn handle_client_text( true } ClientMessage::FreshAgentRecoveryStop(stop) => { - let fresh_codex = state.fresh_codex.clone(); - tokio::spawn( - async move { fresh_codex.handle_recovery_stop(stop).await } - .instrument(tracing::Span::current()), - ); + if stop.provider == freshell_protocol::AgentProvider::Claude { + let fresh_claude = state.fresh_claude.clone(); + tokio::spawn( + async move { fresh_claude.handle_recovery_stop(stop).await } + .instrument(tracing::Span::current()), + ); + } else { + let fresh_codex = state.fresh_codex.clone(); + tokio::spawn( + async move { fresh_codex.handle_recovery_stop(stop).await } + .instrument(tracing::Span::current()), + ); + } true } // freshAgent.approval.respond / question.respond / compact (approval-respond @@ -9516,6 +9524,31 @@ fn session_type_wire(session_type: SessionType) -> &'static str { /// refusal is a visible pane error (`fresh-agent-ws.ts` -- any code other than /// `INVALID_SESSION_ID` dispatches `sessionError`). pub(crate) fn fresh_agent_control_refusal(message: &ClientMessage) -> Option { + if let ClientMessage::FreshAgentRecoveryStop(stop) = message { + let supported = matches!( + (stop.provider, stop.session_type), + (AgentProvider::Codex, SessionType::Freshcodex) + | ( + AgentProvider::Claude, + SessionType::Freshclaude | SessionType::Kilroy + ) + ); + if !supported { + return Some(ServerMessage::FreshAgentRecoveryStopped( + freshell_protocol::FreshAgentRecoveryStopped { + request_id: stop.request_id.clone(), + provider: agent_provider_wire(stop.provider).into(), + session_id: stop.session_id.clone(), + session_type: session_type_wire(stop.session_type).into(), + success: false, + code: Some("UNSUPPORTED_CAPABILITY".into()), + message: Some( + "Recovery stop is unavailable for this provider and session type.".into(), + ), + }, + )); + } + } let (refused, wording, provider, session_id, session_type) = match message { ClientMessage::FreshAgentApprovalRespond(m) => ( !matches!(m.provider, AgentProvider::Claude | AgentProvider::Codex), diff --git a/crates/freshell-ws/tests/freshagent_claude_recovery_stop.rs b/crates/freshell-ws/tests/freshagent_claude_recovery_stop.rs new file mode 100644 index 000000000..2ea9f5337 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_recovery_stop.rs @@ -0,0 +1,163 @@ +//! Process-only Claude recovery preserves the durable conversation. +#![cfg(unix)] +mod common; +use common::claude_rig::{ClaudeRig, RigKind, RigOptions}; +use freshell_freshagent::{ClaudeSendOutcome, ClaudeTurnEvent}; +use freshell_ws::pane_ledger::RowState; +use serde_json::json; +use std::time::Duration; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn malformed_and_obsolete_recovery_fences_leave_the_runtime_and_binding_live() { + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let id = conn.create("fenced-recovery-stop").await; + let owner = rig.fresh_claude.ownership_snapshot("claude", &id); + assert!(owner.generation > 0); + for (request_id, fields, code) in [ + ( + "half-recovery-fence", + json!({"observedEpoch":owner.epoch}), + "INVALID_FENCE", + ), + ( + "stale-recovery-fence", + json!({"observedEpoch":owner.epoch, "observedGeneration":owner.generation - 1}), + "STALE_CLAIM", + ), + ] { + let mut request = json!({"type":"freshAgent.recovery.stop", "provider":"claude", "sessionType":"freshclaude", + "sessionId":id, "requestId":request_id}); + request + .as_object_mut() + .unwrap() + .extend(fields.as_object().unwrap().clone()); + conn.send(request).await; + let refused = conn + .wait_for("the fenced recovery refusal", |frame| { + frame["type"] == "freshAgent.recovery.stopped" && frame["requestId"] == request_id + }) + .await; + assert_eq!(refused["success"], false, "{refused}"); + assert_eq!(refused["code"], code, "{refused}"); + assert!(rig.fresh_claude.has_live_session(&id).await); + assert_eq!( + rig.pane_ledger + .as_ref() + .unwrap() + .lookup_by_session("claude", &id) + .unwrap() + .row + .state, + RowState::Bound + ); + } + assert_eq!(rig.ledger_rows("query").len(), 1); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unsupported_recovery_stop_cells_reply_to_the_original_request_and_provider() { + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + for (provider, session_type) in [ + ("opencode", "freshopencode"), + ("amplifier", "freshclaude"), + ("claude", "freshcodex"), + ] { + let request_id = format!("unsupported-{provider}-{session_type}"); + conn.send(json!({"type":"freshAgent.recovery.stop", "provider":provider, "sessionType":session_type, + "sessionId":"unsupported-recovery-session", "requestId":request_id})).await; + let reply = conn + .wait_for("the recovery capability refusal", |frame| { + frame["type"] == "freshAgent.recovery.stopped" && frame["requestId"] == request_id + }) + .await; + assert_eq!(reply["success"], false, "{reply}"); + assert_eq!(reply["code"], "UNSUPPORTED_CAPABILITY", "{reply}"); + assert_eq!(reply["provider"], provider); + assert_eq!(reply["sessionType"], session_type); + } + assert!(rig.ledger_rows("query").is_empty()); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn recovery_stop_keeps_the_binding_and_ends_only_the_owned_runtime_without_a_bell() { + for flavor in ["freshclaude", "kilroy"] { + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let created = conn + .request_create("process-only-recovery", json!({"sessionType":flavor})) + .await; + let id = created["sessionId"].as_str().unwrap(); + let mut events = rig.fresh_claude.subscribe_turn_events(); + let outcome = rig.fresh_claude.handle_send(serde_json::from_value(json!({ + "provider":"claude", "sessionType":flavor, "sessionId":id, "requestId":"recover-hang", "text":"HANG", + })).unwrap()).await; + let ClaudeSendOutcome::Accepted { ownership_id, .. } = outcome else { + panic!("the hanging turn must be accepted: {outcome:?}"); + }; + let before = conn.events_seen("freshAgent.turn.complete"); + conn.send( + json!({"type":"freshAgent.recovery.stop", "provider":"claude", "sessionType":flavor, + "sessionId":id, "requestId":"stop-for-recovery"}), + ) + .await; + let stopped = conn + .wait_for("the correlated recovery stop", |frame| { + frame["type"] == "freshAgent.recovery.stopped" + && frame["requestId"] == "stop-for-recovery" + }) + .await; + assert_eq!(stopped["success"], true, "{stopped}"); + assert_eq!(stopped["provider"], "claude"); + assert_eq!(stopped["sessionType"], flavor); + let ended = tokio::time::timeout(Duration::from_secs(1), events.recv()) + .await + .unwrap() + .unwrap(); + assert!( + matches!(ended, ClaudeTurnEvent::Ended { ownership_id: old, .. } if old == ownership_id) + ); + assert!(!rig.fresh_claude.has_live_session(id).await); + assert_eq!( + rig.pane_ledger + .as_ref() + .unwrap() + .lookup_by_session("claude", id) + .unwrap() + .row + .state, + RowState::Bound + ); + assert_eq!( + rig.fresh_claude.live_snapshot_overlay(id).await["status"], + "exited" + ); + conn.read_for(Duration::from_millis(250)).await; + assert_eq!( + conn.events_seen("freshAgent.turn.complete"), + before, + "a requested recovery stop never rings" + ); + conn.attach(id).await; + assert!(rig.fresh_claude.has_live_session(id).await); + assert_eq!(rig.ledger_rows("query").len(), 2); + rig.fresh_claude.shutdown().await; + } +} diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts index 558dd2226..9f0c0ccf0 100644 --- a/shared/ws-protocol.ts +++ b/shared/ws-protocol.ts @@ -1036,8 +1036,8 @@ export const FreshAgentRecoveryStopSchema = z.object({ type: z.literal('freshAgent.recovery.stop'), requestId: z.string().min(1), sessionId: z.string().min(1), - sessionType: z.literal('freshcodex'), - provider: z.literal('codex'), + sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), + provider: z.enum(['claude', 'codex', 'opencode', 'amplifier']), observedEpoch: z.number().int().nonnegative().optional(), observedGeneration: z.number().int().nonnegative().optional(), }) @@ -1047,8 +1047,8 @@ export const FreshAgentRecoveryStoppedSchema = z.object({ type: z.literal('freshAgent.recovery.stopped'), requestId: z.string().min(1), sessionId: z.string().min(1), - sessionType: z.literal('freshcodex'), - provider: z.literal('codex'), + sessionType: z.enum(['freshclaude', 'freshcodex', 'kilroy', 'freshopencode']), + provider: z.enum(['claude', 'codex', 'opencode', 'amplifier']), success: z.boolean(), code: z.string().optional(), message: z.string().optional(), diff --git a/src/lib/fresh-agent-ws.ts b/src/lib/fresh-agent-ws.ts index ade49f51d..9221c722b 100644 --- a/src/lib/fresh-agent-ws.ts +++ b/src/lib/fresh-agent-ws.ts @@ -273,7 +273,7 @@ export function handleFreshAgentMessage( } case 'freshAgent.recovery.stopped': { const stopped = msg as FreshAgentRecoveryStoppedMessage - if (stopped.success) { + if (stopped.success && stopped.provider !== 'amplifier') { // Stream events queued for the session land before it goes, so no // later flush re-creates a tail for a session that is gone. streamBatcherFor(dispatch).flush({ From cbf8bee393c10ac2e69e9bca8ea64060362b9ca8 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:49:47 -0700 Subject: [PATCH 10/19] feat(freshclaude): capture exact create cleanup authority --- crates/freshell-freshagent/src/claude.rs | 32 +++++++++ .../src/claude/create_outcome.rs | 4 ++ .../src/claude/establish.rs | 5 +- .../src/claude/tests/create_cleanup_tests.rs | 69 +++++++++++++++++++ 4 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 crates/freshell-freshagent/src/claude/tests/create_cleanup_tests.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 785e985f1..fc432606f 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -491,6 +491,8 @@ impl AliasTombstones { struct ClaudeCreateRecord { session_id: String, session_ref: Option, + cleanup_ownership_id: Option, + cleanup_destination: Option<(String, String)>, } /// The `sessionRef` of the claude conversation `session_id`. @@ -1982,6 +1984,11 @@ impl FreshClaudeState { ) -> ClaudeCreateOutcome { let request_id = msg.request_id.clone(); let session_type = session_type_str(msg.session_type); + let cleanup_destination = msg + .tab_id + .clone() + .zip(msg.pane_id.clone()) + .filter(|(tab, pane)| !tab.is_empty() && !pane.is_empty()); // Dedup by requestId (parity gap fix -- see [`crate::FreshAgentCreateDedup`]'s // doc and [`Self::create_dedup`]'s field doc). Held for the whole creation @@ -2008,6 +2015,13 @@ impl FreshClaudeState { return ClaudeCreateOutcome::Created { session_id: cached.session_id, session_ref: cached.session_ref, + cleanup_ownership_id: if cached.cleanup_destination.is_some() + && cached.cleanup_destination == cleanup_destination + { + cached.cleanup_ownership_id + } else { + None + }, }; } FreshAgentCreateOutcome::Proceed(guard) => guard, @@ -2387,6 +2401,7 @@ impl FreshClaudeState { } }; let created = established.map_key; + let cleanup_ownership_id = Some(established.ownership_id); let naming_projection = established.naming_projection; // Cache the completed create for requestId dedup BEFORE responding (mirrors @@ -2404,6 +2419,8 @@ impl FreshClaudeState { ClaudeCreateRecord { session_id: created.clone(), session_ref: session_ref.clone(), + cleanup_ownership_id: cleanup_ownership_id.clone(), + cleanup_destination, }, ) .await; @@ -2431,6 +2448,7 @@ impl FreshClaudeState { ClaudeCreateOutcome::Created { session_id: created, session_ref, + cleanup_ownership_id, } } @@ -2503,6 +2521,8 @@ impl FreshClaudeState { ClaudeCreateRecord { session_id: durable.to_string(), session_ref: Some(claude_session_ref(durable)), + cleanup_ownership_id: None, + cleanup_destination: None, }, ) .await; @@ -2534,6 +2554,7 @@ impl FreshClaudeState { ClaudeCreateOutcome::Created { session_id: durable.to_string(), session_ref: Some(claude_session_ref(durable)), + cleanup_ownership_id: None, } } @@ -8266,6 +8287,7 @@ pub(crate) mod tests { mod control_tests; mod create_binding_tests; + mod create_cleanup_tests; mod deadman_tests; mod recovery_stop_tests; mod recovery_tests; @@ -11200,6 +11222,11 @@ rl.on('line', (line) => { assert_eq!( fresh, ClaudeCreateOutcome::Created { + cleanup_ownership_id: Some( + st.sessions.lock().await[frame["sessionId"].as_str().unwrap()] + .ownership_id + .clone() + ), session_id: frame["sessionId"].as_str().unwrap().to_string(), session_ref: Some(claude_session_ref(frame["sessionId"].as_str().unwrap())), } @@ -11213,6 +11240,11 @@ rl.on('line', (line) => { assert_eq!( resumed, ClaudeCreateOutcome::Created { + cleanup_ownership_id: Some( + st.sessions.lock().await[frame["sessionId"].as_str().unwrap()] + .ownership_id + .clone() + ), session_id: frame["sessionId"].as_str().unwrap().to_string(), session_ref: Some(claude_session_ref(durable)), } diff --git a/crates/freshell-freshagent/src/claude/create_outcome.rs b/crates/freshell-freshagent/src/claude/create_outcome.rs index 8aa89064a..548c3d512 100644 --- a/crates/freshell-freshagent/src/claude/create_outcome.rs +++ b/crates/freshell-freshagent/src/claude/create_outcome.rs @@ -20,6 +20,9 @@ pub enum ClaudeCreateOutcome { session_id: String, /// The durable conversation, when known (a resume or an adopted session). session_ref: Option, + /// Authority to abandon only the runtime established by this create. + /// Live adoption supplies none. This receipt never appears on the wire. + cleanup_ownership_id: Option, }, /// No session runs for this create. Failed { @@ -109,6 +112,7 @@ mod tests { let created = ClaudeCreateOutcome::Created { session_id: "s".into(), session_ref: None, + cleanup_ownership_id: None, }; assert!(created.create_failed_frame("req-3").is_none()); } diff --git a/crates/freshell-freshagent/src/claude/establish.rs b/crates/freshell-freshagent/src/claude/establish.rs index 54e0e0a03..fc44446fb 100644 --- a/crates/freshell-freshagent/src/claude/establish.rs +++ b/crates/freshell-freshagent/src/claude/establish.rs @@ -163,6 +163,8 @@ pub(super) struct EstablishPlan<'a> { /// A runtime that is registered and committed. pub(super) struct Established { + /// Captured from this launch, never looked up after publication awaits. + pub(super) ownership_id: String, /// The sessions-map key (the minted id, or the attaching pane's id). pub(super) map_key: String, /// The create's naming projection (the pending handle's record). @@ -806,7 +808,7 @@ impl FreshClaudeState { stdin, child, tree_guard, - ownership_id, + ownership_id: ownership_id.clone(), consumer, sidecar_session_id: minted, cli_session_id: durable.clone(), @@ -998,6 +1000,7 @@ impl FreshClaudeState { } Ok(Established { + ownership_id, map_key, naming_projection, sidecar_pid, diff --git a/crates/freshell-freshagent/src/claude/tests/create_cleanup_tests.rs b/crates/freshell-freshagent/src/claude/tests/create_cleanup_tests.rs new file mode 100644 index 000000000..0f7751b9d --- /dev/null +++ b/crates/freshell-freshagent/src/claude/tests/create_cleanup_tests.rs @@ -0,0 +1,69 @@ +use super::*; + +#[tokio::test] +async fn create_cleanup_authority_belongs_to_the_established_runtime_and_is_retained_on_replay() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let _env = FakeClaudeSidecarEnv::install(); + let (st, mut rx) = state_with_bus(); + let mut original_request = dedup_create_msg("create-cleanup-authority"); + original_request.tab_id = Some("cleanup-tab".into()); + original_request.pane_id = Some("cleanup-pane".into()); + let first = st.handle_create(original_request.clone(), None).await; + let ClaudeCreateOutcome::Created { + session_id, + cleanup_ownership_id: Some(original_owner), + .. + } = first + else { + panic!("a newly established runtime must carry cleanup authority: {first:?}"); + }; + assert_eq!( + st.sessions.lock().await[&session_id].ownership_id, + original_owner + ); + let wire = await_claude_created(&mut rx, "create-cleanup-authority").await; + assert!( + wire.get("cleanupOwnershipId").is_none(), + "the cleanup receipt is Rust-only" + ); + + // A later runtime now occupies the same durable session id. Replaying the + // old request retains the old receipt; it cannot mint authority over this one. + let removed = st.sessions.lock().await.remove(&session_id).unwrap(); + st.teardown_removed_session_logged(removed).await; + insert_fake_claude_session(&st, &session_id).await; + assert_ne!( + st.sessions.lock().await[&session_id].ownership_id, + original_owner + ); + let replay = st.handle_create(original_request.clone(), None).await; + assert!( + matches!(replay, ClaudeCreateOutcome::Created { cleanup_ownership_id: Some(owner), .. } + if owner == original_owner) + ); + + for destination in [None, Some("another-pane")] { + let mut different = original_request.clone(); + different.pane_id = destination.map(str::to_string); + let replay = st.handle_create(different, None).await; + assert!(matches!(replay, ClaudeCreateOutcome::Created { cleanup_ownership_id: None, .. }), + "an absent or different destination cannot inherit earlier cleanup authority: {replay:?}"); + } + + let mut adopting = dedup_create_msg("adopt-without-cleanup-authority"); + adopting.session_ref = Some(claude_session_ref(&session_id)); + for _ in 0..2 { + let adopted = st.handle_create(adopting.clone(), None).await; + assert!( + matches!( + adopted, + ClaudeCreateOutcome::Created { + cleanup_ownership_id: None, + .. + } + ), + "adopting or replaying adoption must not authorize process cleanup: {adopted:?}" + ); + } + st.shutdown().await; +} From fd8ed3de064ff606cb44d957f470318f0e57fc92 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:52:32 -0700 Subject: [PATCH 11/19] test(freshclaude): prove native CLI crash and hung tool recovery --- .../tests/freshagent_claude_crash_cli.rs | 348 ++++++++++++++++++ .../providers/claude-sdk-with-hung-mcp.mjs | 16 + 2 files changed, 364 insertions(+) create mode 100644 crates/freshell-ws/tests/freshagent_claude_crash_cli.rs create mode 100644 test/integration/providers/claude-sdk-with-hung-mcp.mjs diff --git a/crates/freshell-ws/tests/freshagent_claude_crash_cli.rs b/crates/freshell-ws/tests/freshagent_claude_crash_cli.rs new file mode 100644 index 000000000..86736ca69 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_crash_cli.rs @@ -0,0 +1,348 @@ +//! Missing recovery contracts on the pinned CLI and loopback provider. +#![cfg(unix)] + +mod common; + +#[cfg(target_os = "linux")] +use common::claude_rig::repo_root; +use common::claude_rig::{ClaudeRig, RigKind, RigOptions, RIG_FRAME_BUDGET}; +#[cfg(target_os = "linux")] +use common::process_probe::ProcessProbe; +#[cfg(target_os = "linux")] +use freshell_freshagent::ClaudeSendOutcome; +use freshell_ws::pane_ledger::RowState; +use serde_json::{json, Value}; +use std::time::Duration; + +async fn snapshot_containing(rig: &ClaudeRig, id: &str, text: &str) -> Value { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + let (status, snapshot) = rig.snapshot(id).await; + if status == 200 && snapshot.to_string().contains(text) { + return snapshot; + } + assert!( + tokio::time::Instant::now() < deadline, + "the transcript never contained {text:?}: {snapshot}" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +#[cfg(target_os = "linux")] +fn children(pid: u32) -> Vec { + // The native CLI can spawn MCP from a worker thread. A thread's children + // file covers only that thread, so inspect every task in this process. + let mut children = std::collections::BTreeSet::new(); + if let Ok(tasks) = std::fs::read_dir(format!("/proc/{pid}/task")) { + for task in tasks.flatten() { + if let Ok(text) = std::fs::read_to_string(task.path().join("children")) { + children.extend( + text.split_whitespace() + .filter_map(|pid| pid.parse::().ok()), + ); + } + } + } + children.into_iter().collect() +} + +#[cfg(target_os = "linux")] +async fn native_cli(rig: &ClaudeRig, id: &str, ownership_id: &str) -> (u32, u32) { + let freshell_ownership::OwnershipState::Live { owner, .. } = + rig.fresh_claude.ownership_snapshot("claude", id).state + else { + panic!("the rig must own the live sidecar"); + }; + let sidecar = owner.pid.unwrap(); + let tag = format!( + "{}={ownership_id}", + freshell_freshagent::claude_spawn::CLAUDE_SIDECAR_OWNERSHIP_ENV + ); + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + for pid in children(sidecar) { + let args = std::fs::read(format!("/proc/{pid}/cmdline")).unwrap_or_default(); + let is_cli = args + .split(|byte| *byte == 0) + .any(|arg| arg == b"claude" || arg.ends_with(b"/claude")); + let environment = std::fs::read(format!("/proc/{pid}/environ")).unwrap_or_default(); + if is_cli + && environment + .split(|byte| *byte == 0) + .any(|entry| entry == tag.as_bytes()) + { + return (sidecar, pid); + } + } + assert!( + tokio::time::Instant::now() < deadline, + "no owned native CLI child of sidecar {sidecar}" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +#[cfg(target_os = "linux")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn killing_the_native_cli_mid_turn_rings_once_and_recovers_the_same_conversation() { + let rig = ClaudeRig::start_with_options( + RigKind::Cli, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut first = rig.connect().await; + let id = first.create("actual-mid-turn-crash").await; + let outcome = rig + .fresh_claude + .handle_send( + serde_json::from_value(json!({ + "provider":"claude", "sessionType":"freshclaude", "sessionId":id, + "requestId":"native-hanging-turn", "text":"HANG before the native CLI crash", + })) + .unwrap(), + ) + .await; + let ClaudeSendOutcome::Accepted { ownership_id, .. } = outcome else { + panic!("{outcome:?}"); + }; + snapshot_containing(&rig, &id, "HANG before the native CLI crash").await; + let (sidecar, cli) = native_cli(&rig, &id, &ownership_id).await; + assert_ne!(sidecar, cli); + let cli_probe = ProcessProbe::open(cli); + assert!(cli_probe.running()); + // SAFETY: this is the tagged native CLI child of this rig's registered sidecar. + assert_eq!(unsafe { libc::kill(cli as i32, libc::SIGKILL) }, 0); + first + .wait_for("the native CLI death report", |frame| { + frame["event"]["code"] == "SIDECAR_EXITED" + }) + .await; + first.wait_for_event("freshAgent.turn.complete").await; + first.read_for(Duration::from_millis(200)).await; + assert_eq!(first.events_seen("freshAgent.turn.complete"), 1); + assert!(cli_probe.exited()); + assert_eq!( + rig.fresh_claude.live_snapshot_overlay(&id).await["status"], + "exited" + ); + let mut second = rig.connect().await; + second + .send_text( + &id, + "continue-after-native-crash", + "continue the same conversation after the native CLI crash", + ) + .await; + second.wait_for_event("freshAgent.result").await; + second.wait_for_event("freshAgent.turn.complete").await; + assert_eq!(second.init_session_id(&id), id); + let snapshot = snapshot_containing( + &rig, + &id, + "continue the same conversation after the native CLI crash", + ) + .await; + assert!(snapshot + .to_string() + .contains("HANG before the native CLI crash")); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_actual_zero_turn_create_survives_a_server_restart_with_the_same_uuid() { + let mut rig = ClaudeRig::start_with_options( + RigKind::Cli, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut first = rig.connect().await; + let id = first.create("actual-zero-turn-restart").await; + assert!(uuid::Uuid::parse_str(&id).is_ok()); + assert_eq!( + rig.pane_ledger + .as_ref() + .unwrap() + .binding_genesis("claude", &id), + Some(json!({"kind":"fresh"})) + ); + rig.restart().await; + let mut second = rig.connect().await; + second.attach(&id).await; + second + .send_text( + &id, + "first-turn-after-restart", + "the first prompt after an actual zero-turn restart", + ) + .await; + second.wait_for_event("freshAgent.result").await; + assert_eq!(second.init_session_id(&id), id); + snapshot_containing( + &rig, + &id, + "the first prompt after an actual zero-turn restart", + ) + .await; + rig.wait_for_genesis_cleared(&id).await; + assert_eq!( + rig.pane_ledger + .as_ref() + .unwrap() + .lookup_by_session("claude", &id) + .unwrap() + .row + .state, + RowState::Bound + ); + rig.fresh_claude.shutdown().await; +} + +#[cfg(target_os = "linux")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_real_hung_stdio_mcp_tool_becomes_stuck_and_recovers_without_closing_its_binding() { + let module = repo_root().join("test/integration/providers/claude-sdk-with-hung-mcp.mjs"); + let rig = ClaudeRig::start_with_options( + RigKind::Cli, + RigOptions { + durable_ledger: true, + env: vec![ + ( + "FRESHELL_CLAUDE_SDK_QUERY_MODULE".into(), + module.to_string_lossy().into(), + ), + ("FRESHELL_FRESHCLAUDE_QUIET_WINDOW_MS".into(), "5000".into()), + ( + "FRESHELL_FRESHCLAUDE_TOOL_HEARTBEAT_CAP_MS".into(), + "1000".into(), + ), + ], + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let created = conn + .request_create( + "actual-hung-mcp", + json!({"permissionMode":"bypassPermissions"}), + ) + .await; + let id = created["sessionId"] + .as_str() + .expect("the pinned CLI booted with its MCP server"); + let outcome = rig + .fresh_claude + .handle_send( + serde_json::from_value(json!({ + "provider":"claude", "sessionType":"freshclaude", "sessionId":id, + "requestId":"native-hung-mcp-turn", "text":"MCP:hang", + })) + .unwrap(), + ) + .await; + let ClaudeSendOutcome::Accepted { ownership_id, .. } = outcome else { + panic!("{outcome:?}"); + }; + conn.wait_for("the real MCP tool call", |frame| { + frame["event"]["content"].as_array().is_some_and(|blocks| { + blocks + .iter() + .any(|block| block["type"] == "tool_use" && block["name"] == "mcp__hang__wait") + }) + }) + .await; + let (sidecar, cli) = native_cli(&rig, id, &ownership_id).await; + let mcp_script = repo_root() + .join("test/integration/providers/claude-hang-mcp-server.mjs") + .canonicalize() + .unwrap(); + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + let mcp = loop { + let mut descendants = children(cli); + let mut found = None; + while let Some(pid) = descendants.pop() { + let args = std::fs::read(format!("/proc/{pid}/cmdline")).unwrap_or_default(); + if args + .split(|byte| *byte == 0) + .any(|arg| arg == mcp_script.to_string_lossy().as_bytes()) + { + found = Some(pid); + break; + } + descendants.extend(children(pid)); + } + if let Some(pid) = found { + break pid; + } + assert!( + tokio::time::Instant::now() < deadline, + "the real hung stdio MCP process never became a child of the owned CLI" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + }; + let probes = [sidecar, cli, mcp].map(ProcessProbe::open); + assert!(probes.iter().all(ProcessProbe::running)); + conn.wait_for("the real hung tool's quiet timeout", |frame| { + frame["event"]["status"] == "stuck" + }) + .await; + let edge = conn.wait_for_event("freshAgent.turn.complete").await; + let first_at = edge["event"]["at"].as_i64().unwrap(); + conn.read_for(Duration::from_millis(250)).await; + assert_eq!(conn.events_seen("freshAgent.turn.complete"), 1); + assert_eq!(conn.events_seen("freshAgent.result"), 0); + conn.send( + json!({"type":"freshAgent.recovery.stop", "provider":"claude", "sessionType":"freshclaude", + "sessionId":id, "requestId":"recover-native-mcp"}), + ) + .await; + let stopped = conn + .wait_for("the native MCP recovery stop", |frame| { + frame["type"] == "freshAgent.recovery.stopped" + && frame["requestId"] == "recover-native-mcp" + }) + .await; + assert_eq!(stopped["success"], true, "{stopped}"); + assert!( + probes.iter().all(ProcessProbe::exited), + "confirmation covers the sidecar, native CLI, and hung MCP server" + ); + assert_eq!( + rig.pane_ledger + .as_ref() + .unwrap() + .lookup_by_session("claude", id) + .unwrap() + .row + .state, + RowState::Bound + ); + assert_eq!( + conn.events_seen("freshAgent.turn.complete"), + 1, + "the requested stop adds no bell" + ); + conn.frames.clear(); + conn.attach(id).await; + conn.send_text( + id, + "after-native-mcp-recovery", + "after the hung MCP process was stopped", + ) + .await; + conn.wait_for_event("freshAgent.result").await; + let completed = conn.wait_for_event("freshAgent.turn.complete").await; + assert!( + completed["event"]["at"].as_i64().unwrap() > first_at, + "recovery retains the attention clock" + ); + assert_eq!(conn.init_session_id(id), id); + rig.fresh_claude.shutdown().await; +} diff --git a/test/integration/providers/claude-sdk-with-hung-mcp.mjs b/test/integration/providers/claude-sdk-with-hung-mcp.mjs new file mode 100644 index 000000000..03a3218a6 --- /dev/null +++ b/test/integration/providers/claude-sdk-with-hung-mcp.mjs @@ -0,0 +1,16 @@ +// The pinned SDK and CLI, with the contract suite's real hung stdio MCP server. +import { query as sdkQuery } from '../../../crates/freshell-claude-sidecar/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs' +import { HANG_MCP_SERVER_SCRIPT } from './claude-local-provider.mjs' + +export function query(params) { + return sdkQuery({ + ...params, + options: { + ...params.options, + mcpServers: { + ...params.options?.mcpServers, + hang: { type: 'stdio', command: process.execPath, args: [HANG_MCP_SERVER_SCRIPT] }, + }, + }, + }) +} From bea2e0d6561db3c1150fdaabe2f528f1b359a765 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:54:30 -0700 Subject: [PATCH 12/19] feat(freshclaude): await process recovery before resuming stuck panes --- src/components/fresh-agent/FreshAgentView.tsx | 28 +++-- src/lib/kill-ack.ts | 9 +- .../freshclaude-stuck-recovery-rust.spec.ts | 101 ++++++++++++++++++ .../fresh-agent/FreshAgentView.test.tsx | 76 +++++++++---- test/unit/client/lib/kill-ack.test.ts | 13 +++ 5 files changed, 199 insertions(+), 28 deletions(-) create mode 100644 test/e2e-browser/specs/freshclaude-stuck-recovery-rust.spec.ts diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx index ff97be04f..28f01fde0 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -2372,8 +2372,7 @@ export function FreshAgentView({ })) }, [claudeSession, dispatch, paneId, tabId]) - // A recovery stop must finish before the pane attempts to resume. The - // ordinary kill closes the durable session; Codex uses a process-only stop. + // A process-only stop must finish before the pane attempts to resume. const recoveryStopPendingRef = useRef(false) const restartStuckSidecar = useCallback(() => { if (recoveryStopPendingRef.current) return @@ -2390,7 +2389,12 @@ export function FreshAgentView({ if (!killSessionId) return const cwd = getFreshOpenCodeRouteCwd(current, { sessionCwd: freshOpenCodeRouteCwdRef.current }) const fence = selectPaneOwnerFence(appStore.getState(), current) - if (current.provider !== 'codex' || current.sessionType !== 'freshcodex') { + const recoveryTarget = current.provider === 'codex' && current.sessionType === 'freshcodex' + ? { provider: 'codex' as const, sessionType: 'freshcodex' as const } + : current.provider === 'claude' && (current.sessionType === 'freshclaude' || current.sessionType === 'kilroy') + ? { provider: 'claude' as const, sessionType: current.sessionType } + : undefined + if (!recoveryTarget) { sendFreshAgentMessage({ type: 'freshAgent.kill', sessionId: killSessionId, @@ -2405,11 +2409,20 @@ export function FreshAgentView({ recoveryStopPendingRef.current = true void sendFreshAgentRecoveryStopAndAwait({ sessionId: killSessionId, - sessionType: 'freshcodex', - provider: 'codex', + ...recoveryTarget, ...(fence ? { observedEpoch: fence.epoch, observedGeneration: fence.generation } : {}), }, { send: (message) => sendFreshAgentMessage(message as Record) }) - .then((ack) => { + .then(async (stopAck) => { + let ack = stopAck + if (!ack.ok && ack.error === 'UNSUPPORTED_CAPABILITY' && current.provider === 'claude') { + if (!isMountedRef.current || paneContentRef.current.createRequestId !== current.createRequestId) return + ack = await sendFreshAgentKillAndAwait({ + sessionId: killSessionId, + ...recoveryTarget, + ...(cwd ? { cwd } : {}), + ...(fence ? { observedEpoch: fence.epoch, observedGeneration: fence.generation } : {}), + }, { send: (message) => sendFreshAgentMessage(message as Record) }) + } if (!isMountedRef.current || paneContentRef.current.createRequestId !== current.createRequestId) return if (ack.ok) { triggerRecovery() @@ -2417,8 +2430,7 @@ export function FreshAgentView({ } dispatch(sessionError({ sessionId: killSessionId, - sessionType: 'freshcodex', - provider: 'codex', + ...recoveryTarget, code: ack.error ?? 'RECOVERY_STOP_FAILED', message: ack.timedOut ? 'The sidecar stop was not confirmed in time. Try again.' diff --git a/src/lib/kill-ack.ts b/src/lib/kill-ack.ts index defb71e98..a49a76e14 100644 --- a/src/lib/kill-ack.ts +++ b/src/lib/kill-ack.ts @@ -619,11 +619,16 @@ export function sendFreshAgentKillAndAwait( }) } -/** Stop a wedged Codex writer while preserving its durable thread for resume. +/** Stop a wedged writer while preserving its durable conversation for resume. * Unlike a close, this requires the acknowledgement for this exact request: * another device closing the same session cannot satisfy a restart. */ +export type FreshAgentRecoveryStopRequest = Omit & ( + | { sessionType: 'freshcodex'; provider: 'codex' } + | { sessionType: 'freshclaude' | 'kilroy'; provider: 'claude' } +) + export function sendFreshAgentRecoveryStopAndAwait( - req: Omit & { sessionType: 'freshcodex'; provider: 'codex' }, + req: FreshAgentRecoveryStopRequest, opts?: { timeoutMs?: number; send?: (msg: unknown) => void }, ): Promise { const requestId = nanoid() diff --git a/test/e2e-browser/specs/freshclaude-stuck-recovery-rust.spec.ts b/test/e2e-browser/specs/freshclaude-stuck-recovery-rust.spec.ts new file mode 100644 index 000000000..063ff3be9 --- /dev/null +++ b/test/e2e-browser/specs/freshclaude-stuck-recovery-rust.spec.ts @@ -0,0 +1,101 @@ +// Production sidecar + CLI simulator + Rust + browser: a quiet turn rings +// once, recovery preserves the conversation, and an approval pauses the timer. +import { test, expect } from '../helpers/fixtures.js' +import { ClaudeSimLane, freshAgentLeaf, onPrompt } from '../helpers/claude-sim-lane.js' + +test.describe('freshclaude quiet timeout and process recovery', () => { + test.setTimeout(180_000) + + for (const flavour of ['freshclaude', 'kilroy'] as const) { + test(`${flavour}: stuck recovery keeps the session and approvals pause the timeout`, async ({ browser }) => { + const windowMs = 2_000 + const lane = await ClaudeSimLane.start(browser, { + flavour, + providerDefaults: { defaultPermissionMode: 'default' }, + env: { FRESHELL_FRESHCLAUDE_QUIET_WINDOW_MS: String(windowMs) }, + program: { rules: [ + onPrompt('hang until process recovery', [{ kind: 'hang' }]), + onPrompt('wait for my approval', [ + { kind: 'approval', data: { tool: 'Bash', input: { command: 'printf approved' } } }, + { kind: 'text', data: { text: 'approval completed' } }, + ]), + ] }, + }) + const frames: Array<{ direction: 'sent' | 'received'; message: Record }> = [] + lane.page.on('websocket', (socket) => { + for (const direction of ['sent', 'received'] as const) { + socket.on(direction === 'sent' ? 'framesent' : 'framereceived', (frame) => { + try { frames.push({ direction, message: JSON.parse(String(frame.payload)) }) } catch { /* non-JSON frames */ } + }) + } + }) + // This page's socket already exists. Reconnect before the first pane so + // the trace observes the same real transport the recovery button uses. + try { + await lane.harness.forceDisconnect() + await lane.harness.waitForConnection() + const pane = await lane.openPane() + const leaf = async () => freshAgentLeaf(await lane.harness.getPaneLayout(pane.tabId)) + const sequence = async () => (await lane.harness.getState())?.turnCompletion?.seq ?? 0 + await lane.send(pane, 'save a turn before recovery') + await lane.waitForIdle(pane) + const id = (await leaf()).content.sessionId + expect(id).toMatch(/^[0-9a-f-]{36}$/) + const before = await sequence() + await lane.send(pane, 'hang until process recovery') + const stuck = pane.paneRoot.getByRole('alert').filter({ hasText: /appears stuck/i }) + await expect(stuck).toBeVisible({ timeout: 30_000 }) + await expect.poll(sequence).toBe(before + 1) + const atBefore = (await lane.harness.getState())?.turnCompletion?.lastAtByTerminalId?.[`claude:${id}`] + expect(atBefore).toBeGreaterThan(0) + + await stuck.getByRole('button', { name: /restart sidecar and resume session/i }).click() + await lane.waitForIdle(pane) + await expect(stuck).toBeHidden() + expect((await leaf()).content).toMatchObject({ sessionId: id, sessionType: flavour, provider: 'claude' }) + expect(await sequence(), 'the requested process stop adds no completion').toBe(before + 1) + const stopAt = frames.findIndex(({ direction, message }) => direction === 'sent' + && message.type === 'freshAgent.recovery.stop' && message.sessionId === id) + expect(stopAt, 'the client used process recovery').toBeGreaterThanOrEqual(0) + const stop = frames[stopAt].message + expect(stop).toMatchObject({ provider: 'claude', sessionType: flavour }) + const ackAt = frames.findIndex(({ direction, message }) => direction === 'received' + && message.type === 'freshAgent.recovery.stopped' && message.requestId === stop.requestId && message.success) + const resumeAt = frames.findIndex(({ direction, message }, index) => index > stopAt && direction === 'sent' + && message.type === 'freshAgent.create') + expect(ackAt, 'the stop is confirmed before the replacement create').toBeGreaterThan(stopAt) + expect(resumeAt).toBeGreaterThan(ackAt) + expect(frames.slice(stopAt, resumeAt).some(({ message }) => message.type === 'freshAgent.kill')).toBe(false) + const queries = lane.ledger().filter((row) => row.kind === 'query') + expect(queries).toHaveLength(2) + expect(queries[1].options).toMatchObject({ resume: id }) + await expect(pane.paneRoot.getByRole('article', { name: /You transcript turn$/ }) + .filter({ hasText: 'save a turn before recovery' })).toHaveCount(1) + + await lane.send(pane, 'continue the recovered conversation') + await lane.waitForIdle(pane) + await expect.poll(sequence).toBe(before + 2) + expect((await lane.harness.getState())?.turnCompletion?.lastAtByTerminalId?.[`claude:${id}`]) + .toBeGreaterThan(atBefore) + + await lane.send(pane, 'wait for my approval') + const approval = pane.paneRoot.getByRole('alert', { name: 'Permission request for Bash' }) + await expect(approval).toBeVisible({ timeout: 30_000 }) + await expect.poll(sequence).toBe(before + 3) + const waitingSequence = await sequence() + // Deliberately outwait the quiet window while the real pending control + // stays unanswered; a timer that ignores pending controls fails here. + await lane.page.waitForTimeout(windowMs * 2) + await expect(approval).toBeVisible() + await expect(stuck).toBeHidden() + expect(await sequence()).toBe(waitingSequence) + await approval.getByRole('button', { name: 'Allow tool use' }).click() + await lane.waitForIdle(pane) + await expect(pane.paneRoot.getByText('approval completed', { exact: true })).toBeVisible() + } finally { + await lane.attachLogsOnFailure(test.info()) + await lane.stop() + } + }) + } +}) diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index 9a4f000e6..1982c8100 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -14363,7 +14363,7 @@ describe('b8ke ext F2: sessionRef-only panes kill the old runtime on replacement }) }) - it('restartStuckSidecar kills the sessionRef session before re-driving creation', async () => { + it.each([['freshcodex', 'codex'], ['freshclaude', 'claude'], ['kilroy', 'claude']] as const)('restartStuckSidecar waits for the %s sessionRef process stop before resuming', async (sessionType, provider) => { const handlers: Array<(msg: Record) => void> = [] wsMock.onMessage.mockReset() wsMock.onMessage.mockImplementation((listener: (msg: Record) => void) => { @@ -14380,10 +14380,10 @@ describe('b8ke ext F2: sessionRef-only panes kill the old runtime on replacement paneId="pane-1" paneContent={{ kind: 'fresh-agent', - sessionType: 'freshcodex', - provider: 'codex', + sessionType, + provider, createRequestId: 'req-ref-stuck', - sessionRef: { provider: 'codex', sessionId: 'thread-ref-stuck' }, + sessionRef: { provider, sessionId: '550e8400-e29b-41d4-a716-446655440001' }, status: 'stuck', }} /> @@ -14398,10 +14398,13 @@ describe('b8ke ext F2: sessionRef-only panes kill the old runtime on replacement // correlated confirmation before any new runtime starts. const sent = wsMock.send.mock.calls.find(([message]) => message.type === 'freshAgent.recovery.stop')?.[0] expect(sent).toMatchObject({ - sessionId: 'thread-ref-stuck', - sessionType: 'freshcodex', - provider: 'codex', + sessionId: '550e8400-e29b-41d4-a716-446655440001', + sessionType, + provider, }) + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.kill' })) + fireEvent.click(screen.getByRole('button', { name: /restart sidecar and resume session/i })) + expect(wsMock.send.mock.calls.filter(([message]) => message.type === 'freshAgent.recovery.stop')).toHaveLength(1) const remintsBeforeAck = dispatchSpy.mock.calls .map(([action]) => action) .filter((action: any) => action?.type === 'panes/updatePaneContent' @@ -14411,9 +14414,9 @@ describe('b8ke ext F2: sessionRef-only panes kill the old runtime on replacement handler({ type: 'freshAgent.recovery.stopped', requestId: sent.requestId, - sessionId: 'thread-ref-stuck', - sessionType: 'freshcodex', - provider: 'codex', + sessionId: '550e8400-e29b-41d4-a716-446655440001', + sessionType, + provider, success: true, }) } @@ -14423,10 +14426,11 @@ describe('b8ke ext F2: sessionRef-only panes kill the old runtime on replacement .filter((action: any) => action?.type === 'panes/updatePaneContent' && action.payload?.content?.status === 'creating') expect(remints).toHaveLength(1) + expect(remints[0].payload.content).toMatchObject({ sessionType, provider, resumeSessionId: '550e8400-e29b-41d4-a716-446655440001', sessionRef: { provider, sessionId: '550e8400-e29b-41d4-a716-446655440001' } }) }) }) - it('does not resume a wedged Codex session after a refused recovery stop', async () => { + it.each([['freshcodex', 'codex'], ['freshclaude', 'claude'], ['kilroy', 'claude']] as const)('does not resume a wedged %s session after a refused recovery stop', async (sessionType, provider) => { const handlers: Array<(msg: Record) => void> = [] wsMock.onMessage.mockReset() wsMock.onMessage.mockImplementation((listener: (msg: Record) => void) => { @@ -14442,10 +14446,10 @@ describe('b8ke ext F2: sessionRef-only panes kill the old runtime on replacement paneId="pane-1" paneContent={{ kind: 'fresh-agent', - sessionType: 'freshcodex', - provider: 'codex', + sessionType, + provider, createRequestId: 'req-ref-stuck-failed', - sessionRef: { provider: 'codex', sessionId: 'thread-ref-stuck-failed' }, + sessionRef: { provider, sessionId: '550e8400-e29b-41d4-a716-446655440002' }, status: 'stuck', }} /> @@ -14460,20 +14464,56 @@ describe('b8ke ext F2: sessionRef-only panes kill the old runtime on replacement handler({ type: 'freshAgent.recovery.stopped', requestId: sent.requestId, - sessionId: 'thread-ref-stuck-failed', - sessionType: 'freshcodex', - provider: 'codex', + sessionId: '550e8400-e29b-41d4-a716-446655440002', + sessionType, + provider, success: false, code: 'TEARDOWN_NOT_CONFIRMED', }) } - await waitFor(() => expect(store.getState().freshAgent.sessions['freshcodex:codex:thread-ref-stuck-failed']?.lastErrorCode).toBe('TEARDOWN_NOT_CONFIRMED')) + await waitFor(() => expect(store.getState().freshAgent.sessions[`${sessionType}:${provider}:550e8400-e29b-41d4-a716-446655440002`]?.lastErrorCode).toBe('TEARDOWN_NOT_CONFIRMED')) const remints = dispatchSpy.mock.calls .map(([action]) => action) .filter((action: any) => action?.type === 'panes/updatePaneContent' && action.payload?.content?.status === 'creating') expect(remints).toHaveLength(0) }) + + it.each(['freshclaude', 'kilroy'] as const)('hosted %s waits for ordinary kill after recovery stop is unsupported', async (sessionType) => { + const handlers = new Set<(msg: Record) => void>() + wsMock.onMessage.mockReset() + wsMock.onMessage.mockImplementation((listener: (msg: Record) => void) => { + handlers.add(listener) + return () => { handlers.delete(listener) } + }) + const emit = (message: Record) => act(() => { + for (const handler of [...handlers]) handler(message) + }) + const store = createStore() + const dispatchSpy = vi.spyOn(store, 'dispatch') + const durable = '550e8400-e29b-41d4-a716-446655440003' + render() + await screen.findByRole('alert') + wsMock.send.mockClear() + const creating = () => dispatchSpy.mock.calls.map(([action]) => action) + .filter((action: any) => action?.type === 'panes/updatePaneContent' && action.payload?.content?.status === 'creating') + fireEvent.click(screen.getByRole('button', { name: /restart sidecar and resume session/i })) + const stop = wsMock.send.mock.calls.find(([message]) => message.type === 'freshAgent.recovery.stop')?.[0] + expect(stop).toMatchObject({ sessionId: durable, sessionType, provider: 'claude' }) + emit({ type: 'freshAgent.recovery.stopped', requestId: stop.requestId, + sessionId: durable, sessionType, provider: 'claude', success: false, code: 'UNSUPPORTED_CAPABILITY' }) + await waitFor(() => expect(wsMock.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'freshAgent.kill', sessionId: durable, sessionType, provider: 'claude', + }))) + expect(creating()).toHaveLength(0) + emit({ type: 'freshAgent.killed', sessionId: durable, sessionType, provider: 'claude', success: true }) + await waitFor(() => expect(creating()).toHaveLength(1)) + expect(creating()[0].payload.content).toMatchObject({ sessionType, provider: 'claude', resumeSessionId: durable }) + }) + }) describe('b8ke ext F1: locatorMatchesPane accepts canonical-session events for an old-key pane', () => { diff --git a/test/unit/client/lib/kill-ack.test.ts b/test/unit/client/lib/kill-ack.test.ts index 6f2cba8af..f5b397fd0 100644 --- a/test/unit/client/lib/kill-ack.test.ts +++ b/test/unit/client/lib/kill-ack.test.ts @@ -340,6 +340,19 @@ describe('kill-ack', () => { await expect(pending).resolves.toEqual({ ok: true }) }) + it.each(['freshclaude', 'kilroy'] as const)('sends the %s recovery stop with the observed owner fence', async (sessionType) => { + const pending = sendFreshAgentRecoveryStopAndAwait({ + sessionId: 'claude-durable', sessionType, provider: 'claude', observedEpoch: 4, observedGeneration: 9, + }) + const sent = mockSend.mock.calls[0][0] + expect(sent).toMatchObject({ type: 'freshAgent.recovery.stop', sessionId: 'claude-durable', + sessionType, provider: 'claude', observedEpoch: 4, observedGeneration: 9 }) + emit({ type: 'freshAgent.recovery.stopped', requestId: sent.requestId, sessionId: 'claude-durable', provider: 'codex', success: true }) + expect(handlers.size).toBe(1) + emit({ type: 'freshAgent.recovery.stopped', requestId: sent.requestId, sessionId: 'claude-durable', sessionType, provider: 'claude', success: false, code: 'UNSUPPORTED_CAPABILITY' }) + await expect(pending).resolves.toEqual({ ok: false, error: 'UNSUPPORTED_CAPABILITY' }) + }) + it('keeps a refused recovery stop as a failure', async () => { const pending = sendFreshAgentRecoveryStopAndAwait({ sessionId: 'thread-2', From 2c35262fa6f7043fc24184925987ca4b607651af Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:00:39 -0700 Subject: [PATCH 13/19] chore(freshclaude): clear lint leftovers from UUID test migration --- crates/freshell-freshagent/src/claude.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index fc432606f..6615f6288 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -12940,8 +12940,6 @@ rl.on('line', (line) => { // ── b8ke focused episode-2 round-1: the adoption's atomicity tests ───── - /// The fake sidecar's durable id for a create with NO resume boot. - /// A minimal thread-local tracing capture (the session_handoff /// tracing_capture pattern, trimmed to the events the e2r1 tests need). mod init_capture { @@ -18369,7 +18367,7 @@ rl.on('line', (line) => { let fresh_create_durable_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; assert_eq!(created["type"], "freshAgent.created", "{created}"); - prepare_legacy_init(&st, &created["sessionId"].as_str().unwrap()).await; + prepare_legacy_init(&st, created["sessionId"].as_str().unwrap()).await; while rx.try_recv().is_ok() {} fake.set_fail_writes(true); st.adopt_session_init( From e5273c543125454e835b44b5acffdfe3de639244 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:19:50 -0700 Subject: [PATCH 14/19] test(freshclaude): preserve handoff coverage with native create UUIDs --- .../src/session_handoff/tests.rs | 89 +++++++++++-------- 1 file changed, 50 insertions(+), 39 deletions(-) diff --git a/crates/freshell-freshagent/src/session_handoff/tests.rs b/crates/freshell-freshagent/src/session_handoff/tests.rs index 76f44eef4..3357ee833 100644 --- a/crates/freshell-freshagent/src/session_handoff/tests.rs +++ b/crates/freshell-freshagent/src/session_handoff/tests.rs @@ -61,9 +61,9 @@ rl.on('line', (line) => { logReq(msg) if (msg.type === 'create') { counter += 1 - const sessionId = `fake-claude-session-${process.pid}-${counter}` + const sessionId = msg.sessionId || `fake-claude-session-${process.pid}-${counter}` process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') - const cliSessionId = msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const cliSessionId = msg.boot?.resume || sessionId process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') } else if (msg.type === 'shutdown') { @@ -6256,16 +6256,21 @@ async fn the_claude_handoff_target_binding_carries_the_handoff_generation() { std::env::set_var("CLAUDE_CONFIG_DIR", &store_dir); let mut rig = build_rig(None); - // The sink wired BEFORE the seed below; the establish's own - // all-blank adoption writes NOTHING (the V7 no-laundering gate), so - // the ONLY binding row is the handoff target's. + // Creation now records a binding even for default settings. Preserve + // that initial receipt and distinguish the later handoff generation. let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); rig.fresh_claude.set_identity_sink(fake.clone()); establish_fresh_claude_owner(&mut rig, &sid).await; - assert!( - fake.bindings.lock().unwrap().is_empty(), - "fixture: the all-blank establish wrote no binding row" - ); + let initial_generation = rig.ownership.observe("claude", &sid).generation; + { + let bindings = fake.bindings.lock().unwrap(); + let initial = bindings + .last() + .expect("creation records its native binding"); + assert_eq!(initial.session_id, sid); + assert_eq!(initial.observed_epoch, Some(rig.ownership.boot_epoch())); + assert_eq!(initial.observed_generation, Some(initial_generation)); + } // Seed the settings record so the TARGET resume's create carries // non-default settings — its session-init adoption's binding write is // recordable (the settings-bearing row). @@ -6293,6 +6298,7 @@ async fn the_claude_handoff_target_binding_carries_the_handoff_generation() { let committed_generation = { let snap = rig.ownership.observe("claude", &sid); assert!(matches!(snap.state, OwnershipState::Live { .. })); + assert!(snap.generation > initial_generation); snap.generation }; @@ -6948,10 +6954,6 @@ async fn an_abort_cleanup_broadcasts_the_restored_prior_owner() { ); } -/// The fake sidecar's durable id for a create with NO resume boot -/// (the fresh-create fallback in FAKE_CLAUDE_SIDECAR_SOURCE). -const FRESH_CREATE_DURABLE_ID: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - fn fresh_create_msg(session_type: SessionType) -> FreshAgentCreate { FreshAgentCreate { pane_id: None, @@ -6971,9 +6973,7 @@ fn fresh_create_msg(session_type: SessionType) -> FreshAgentCreate { resume_session_id: None, sandbox: None, // b8ke delta round-2 F1: the ORDINARY create — NO explicit - // sessionRef. The durable id materializes at sdk.session.init - // (the pre-fix tests all created with an explicit sessionRef, - // masking the no-adoption defect). + // sessionRef. Creation must establish its own durable UUID and owner. session_ref: None, tab_id: None, } @@ -7086,7 +7086,7 @@ async fn a_handoff_on_a_superseded_rekeyed_id_resolves_the_canonical_owner() { } /// 8a. b8ke delta round-2 F1: a NORMALLY created freshclaude session (no -/// explicit sessionRef) adopts the canonical key at `sdk.session.init` — +/// explicit sessionRef) establishes its native UUID before acknowledgement — /// the coordinator holds authoritative Live{FreshAgent} under the durable /// id, every device receives the owner broadcast, and a handoff to the CLI /// sees the prior owner, STOPS it (the sidecar is reaped), and transfers @@ -7094,7 +7094,7 @@ async fn a_handoff_on_a_superseded_rekeyed_id_resolves_the_canonical_owner() { /// was never stopped), the handoff "succeeded" over a live second writer, /// and no owner frame ever reached the bus. #[tokio::test] -async fn a_fresh_created_claude_session_adopts_the_canonical_key_and_hands_off_atomically() { +async fn a_fresh_created_claude_session_owns_its_uuid_and_hands_off_atomically() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; let env = FakeSidecarEnv::install(); @@ -7102,19 +7102,27 @@ async fn a_fresh_created_claude_session_adopts_the_canonical_key_and_hands_off_a let _ = drain_runtime_owner_frames(&mut rig.rx); // The ORDINARY create: no sessionRef, no resume id. - rig.fresh_claude + let created = rig + .fresh_claude .handle_create(fresh_create_msg(SessionType::Freshclaude), None) .await; + let crate::ClaudeCreateOutcome::Created { + session_id: durable, + .. + } = created + else { + panic!("fresh create failed: {created:?}"); + }; + assert!(uuid::Uuid::parse_str(&durable).is_ok()); - // THE F1 ADOPTION: the durable id minted at sdk.session.init becomes - // the authoritative Live{FreshAgent} owner. Pre-fix: the key stayed - // Vacant forever (this await is the red). + // A fresh create owns the UUID that its acknowledgement names. The + // handoff must discover and reap that exact prior before transferring it. await_cond( "the fresh-created session must adopt the canonical key", || { matches!( rig.ownership - .observe("claude", FRESH_CREATE_DURABLE_ID) + .observe("claude", &durable) .state, OwnershipState::Live { owner, .. } if owner.kind == RuntimeOwnerKind::FreshAgent @@ -7131,7 +7139,7 @@ async fn a_fresh_created_claude_session_adopts_the_canonical_key_and_hands_off_a .find(|f| { f["transition"] == "handoff-committed" && f["ownerKind"] == "fresh-agent" - && f["sessionId"] == json!(FRESH_CREATE_DURABLE_ID) + && f["sessionId"] == json!(&durable) }) .expect("the adoption's authoritative owner frame"); assert_eq!(committed["provider"], json!("claude")); @@ -7148,11 +7156,9 @@ async fn a_fresh_created_claude_session_adopts_the_canonical_key_and_hands_off_a // the runner sees the prior owner, stops the sidecar, and transfers // atomically. Pre-fix: prior=None (Vacant) — the handoff committed a // terminal over the LIVE sidecar (two writers). - let handle = rig.runner.spawn_handoff(handoff_req_terminal( - "claude", - FRESH_CREATE_DURABLE_ID, - "claude", - )); + let handle = rig + .runner + .spawn_handoff(handoff_req_terminal("claude", &durable, "claude")); let result = handle.completion.await.expect("handoff completed"); assert_eq!( result["ok"], @@ -7160,11 +7166,7 @@ async fn a_fresh_created_claude_session_adopts_the_canonical_key_and_hands_off_a "the from-fresh-create handoff must succeed: {result}" ); let terminal_id = result["owner"]["terminalId"].as_str().unwrap().to_string(); - match rig - .ownership - .observe("claude", FRESH_CREATE_DURABLE_ID) - .state - { + match rig.ownership.observe("claude", &durable).state { OwnershipState::Live { owner, .. } => { assert_eq!(owner.kind, RuntimeOwnerKind::Terminal); assert_eq!(owner.terminal_id.as_deref(), Some(terminal_id.as_str())); @@ -7177,27 +7179,36 @@ async fn a_fresh_created_claude_session_adopts_the_canonical_key_and_hands_off_a } /// 8b. b8ke delta round-2 F1 (kilroy): the ORDINARY create shares the -/// claude lane — a fresh-created KILROY session adopts the canonical key -/// at `sdk.session.init` the same way (the ownership machinery is +/// claude lane — a fresh-created KILROY session owns its acknowledged native UUID +/// the same way (the ownership machinery is /// flavor-independent). #[tokio::test] -async fn a_fresh_created_kilroy_session_adopts_the_canonical_key() { +async fn a_fresh_created_kilroy_session_owns_its_acknowledged_uuid() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; let _env = FakeSidecarEnv::install(); let mut rig = build_rig(None); let _ = drain_runtime_owner_frames(&mut rig.rx); - rig.fresh_claude + let created = rig + .fresh_claude .handle_create(fresh_create_msg(SessionType::Kilroy), None) .await; + let crate::ClaudeCreateOutcome::Created { + session_id: durable, + .. + } = created + else { + panic!("fresh Kilroy create failed: {created:?}"); + }; + assert!(uuid::Uuid::parse_str(&durable).is_ok()); await_cond( "the fresh-created kilroy session must adopt the canonical key", || { matches!( rig.ownership - .observe("claude", FRESH_CREATE_DURABLE_ID) + .observe("claude", &durable) .state, OwnershipState::Live { owner, .. } if owner.kind == RuntimeOwnerKind::FreshAgent From df9f0639b995f11e42b5b172e07c53fa9f388575 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:27:11 -0700 Subject: [PATCH 15/19] test(claude-sidecar): verify correlated failed Stop receipts --- .../claude_sidecar_interrupt_dispatch.rs | 35 +++++++++++-------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs b/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs index 042de35db..1683a6e78 100644 --- a/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs +++ b/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs @@ -17,10 +17,9 @@ //! `query()`; the stub throws if it is called). Only module RESOLUTION is redirected //! — every dispatched line of JS is the real source, read at test time. //! -//! Observable contract (fire-and-forget interrupt, per the comment above -//! `handleInterrupt`): an interrupt for an unknown session emits an -//! `sdk.error "session not found"` frame on stdout. Pre-fix behavior was a -//! stderr `unknown request type: interrupt` log and NO stdout frame. +//! An interrupt for an unknown session reports a failed `sdk.interrupt_settled` +//! receipt with the request's Stop id. A dispatch regression instead logs an +//! unknown request and emits no receipt. //! //! Requires `node` on PATH — the same requirement the crate's existing //! fake-sidecar tests already impose. @@ -168,22 +167,23 @@ fn real_sidecar_dispatches_interrupt_frames_to_handle_interrupt() { let mut stdin = sidecar.child.stdin.take().expect("piped stdin"); stdin - .write_all(b"{\"type\":\"interrupt\",\"sessionId\":\"nope\"}\n") + .write_all( + b"{\"type\":\"interrupt\",\"sessionId\":\"nope\",\"stopId\":\"dispatch-stop\"}\n", + ) .expect("write interrupt frame"); stdin.flush().expect("flush interrupt frame"); - // Post-fix observable: handleInterrupt emits the session-not-found sdk.error - // frame on stdout. Pre-fix: the frame is dropped by the `default:` arm and - // stderr logs `unknown request type: interrupt` instead. - let mut error_frame: Option = None; + // The real handler answers this exact Stop even when the session is gone. + // A dispatch regression drops it and produces no stdout receipt. + let mut receipt_frame: Option = None; let deadline = std::time::Instant::now() + Duration::from_secs(30); - while error_frame.is_none() { + while receipt_frame.is_none() { let Some(remaining) = deadline.checked_duration_since(std::time::Instant::now()) else { break; }; match rx.recv_timeout(remaining) { Ok(("stdout", line)) => { - error_frame = + receipt_frame = Some(serde_json::from_str(&line).expect("sidecar stdout is newline-JSON")); } Ok((_, line)) => stderr_lines.push(line), @@ -200,10 +200,17 @@ fn real_sidecar_dispatches_interrupt_frames_to_handle_interrupt() { "pre-fix behavior: the interrupt frame fell into the dispatch default arm and was \ dropped; stderr: {stderr_lines:?}" ); - let frame = error_frame.expect( + let frame = receipt_frame.expect( "no stdout frame arrived: the interrupt request was not dispatched to handleInterrupt", ); - assert_eq!(frame["type"], "sdk.error", "frame: {frame}"); + assert_eq!(frame["type"], "sdk.interrupt_settled", "frame: {frame}"); + assert_eq!(frame["stopId"], "dispatch-stop", "frame: {frame}"); + assert_eq!(frame["ok"], false, "frame: {frame}"); assert_eq!(frame["sessionId"], "nope", "frame: {frame}"); - assert_eq!(frame["message"], "session not found", "frame: {frame}"); + assert!( + frame["message"] + .as_str() + .is_some_and(|message| !message.is_empty()), + "a failed Stop explains why it was refused: {frame}" + ); } From da665d82e9e39586d9e642a828d7eda4bb58ff7e Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:43:21 -0700 Subject: [PATCH 16/19] test(freshclaude): fence checkpointed sends with create-time identity --- .../freshagent_claude_checkpointed_send.rs | 105 ++++++++++-------- 1 file changed, 61 insertions(+), 44 deletions(-) diff --git a/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs b/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs index 0cc848377..0df275ed1 100644 --- a/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs +++ b/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs @@ -7,11 +7,10 @@ //! (`FreshAgentView.tsx`'s `sendFreshAgentSendFrame`), and once a //! `session.runtimeOwner` frame named the pane's durable session id it also //! carries that record's observed `(epoch, generation)` pair. `handle_send` -//! fences such a send through the coordinator. The claude lane's pane id is the -//! sidecar's placeholder; the coordinator owns the session under the durable id -//! the CLI reports in `system/init` (`sdk.session.init`). The real CLI reports -//! it only once a prompt runs; the e2e fake reports it at create. Both orders -//! are covered here. +//! fences such a send through the coordinator. Creation establishes the native +//! UUID and its owner before acknowledgement. The CLI repeats that identity in +//! `system/init` (`sdk.session.init`), either after create or on the first prompt; +//! checkpointed sends must work with both reporting orders. //! //! The claude sidecar is a scripted fake (the production //! `FRESHELL_CLAUDE_SIDECAR`/`FRESHELL_CLAUDE_NODE` overrides): no SDK, no @@ -50,7 +49,6 @@ static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(( const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" import fs from 'node:fs' import readline from 'node:readline' -import { randomUUID } from 'node:crypto' const initAt = process.env.FRESHELL_TEST_CLAUDE_INIT_AT const sendLog = process.env.FRESHELL_TEST_CLAUDE_SEND_LOG @@ -71,8 +69,8 @@ rl.on('line', (line) => { try { msg = JSON.parse(trimmed) } catch { return } if (msg.type === 'create') { counter += 1 - const sessionId = `fake-claude-${process.pid}-${counter}` - const st = { cliSessionId: randomUUID() } + const sessionId = msg.sessionId || `fake-claude-${process.pid}-${counter}` + const st = { cliSessionId: msg.boot?.resume || sessionId } sessions.set(sessionId, st) emit({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) if (initAt === 'create') reportSession(sessionId, st) @@ -410,15 +408,24 @@ impl Conn { } } -/// The durable id the session's `freshAgent.session.init` reported. +/// The browser learns the native identity from create before the CLI reports it. fn durable_id(frames: &[Value], session_id: &str) -> Option { - frames.iter().find_map(|v| { - (v["type"] == "freshAgent.event" - && v["sessionId"] == session_id - && v["event"]["type"] == "freshAgent.session.init") - .then(|| v["event"]["cliSessionId"].as_str().map(str::to_string)) - .flatten() - }) + frames + .iter() + .find_map(|v| { + (v["type"] == "freshAgent.created" && v["sessionId"] == session_id) + .then(|| v["sessionRef"]["sessionId"].as_str().map(str::to_string)) + .flatten() + }) + .or_else(|| { + frames.iter().find_map(|v| { + (v["type"] == "freshAgent.event" + && v["sessionId"] == session_id + && v["event"]["type"] == "freshAgent.session.init") + .then(|| v["event"]["cliSessionId"].as_str().map(str::to_string)) + .flatten() + }) + }) } /// The owner pair the browser holds for the pane: the latest @@ -461,14 +468,16 @@ async fn a_new_panes_messages_each_run_once(init_at: InitAt) { let mut conn = Conn::connect(&url).await; let session_id = conn.create("create-1").await; - if matches!(init_at, InitAt::Create) { - // The session was reported at create: the pane learns its durable id and - // owner pair before the user types, so even the first message carries - // the pair. - conn.wait_until("the reported session's owner frame", |frames| { - observed_owner(frames, &session_id).is_some() - }) - .await; + conn.wait_until("the created session's owner frame", |frames| { + observed_owner(frames, &session_id).is_some() + }) + .await; + if matches!(init_at, InitAt::FirstPrompt) { + assert_eq!( + conn.events_of(&session_id, "freshAgent.session.init"), + 0, + "the first checkpointed send is fenced before any CLI init" + ); } let messages = ["first message", "second message", "third message"]; for (index, text) in messages.iter().enumerate() { @@ -511,8 +520,8 @@ async fn a_new_panes_messages_each_run_once(init_at: InitAt) { mod checkpointed_sends { use super::*; - /// The real CLI reports the session only once a prompt runs: nothing owns the - /// new pane's session in the coordinator when its first message is sent. + /// The first send carries the create-time UUID's owner pair even when the + /// CLI has not emitted its first init yet. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn run_when_the_session_is_reported_by_the_first_prompt() { a_new_panes_messages_each_run_once(InitAt::FirstPrompt).await; @@ -600,14 +609,11 @@ mod checkpointed_sends { assert_eq!(turn_completes(&conn.frames, &session_id).len(), 1); } - /// Before the CLI reports the session, the coordinator can still hold a - /// record under the pane's placeholder id: a lifecycle operation started from - /// a pane with no durable id addresses the session by that id. A message sent - /// while the operation is in flight is refused and never reaches the sidecar; - /// once the operation settles and nothing owns the placeholder, the same - /// message runs. + /// A lifecycle operation can own the newly created native UUID before the + /// first CLI init. Checkpointing must respect it; restoring the known-live + /// prior lets a freshly fenced retry run exactly once. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn wait_for_a_lifecycle_operation_on_the_placeholder_before_the_session_is_reported() { + async fn wait_for_a_lifecycle_operation_on_the_native_uuid_before_the_first_init() { let _env = CLAUDE_ENV_LOCK.lock().await; let sidecar = FakeSidecar::install(InitAt::FirstPrompt); let ownership = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); @@ -619,16 +625,21 @@ mod checkpointed_sends { "claude", &session_id, freshell_ownership::RuntimeOwnerKind::Terminal, - "test-placeholder-handoff", + "test-native-handoff", None, "test", freshell_ownership::now_epoch_ms(), ) else { - panic!("the handoff on the unreported session's placeholder must be granted") + panic!("the handoff on the created native session must be granted") }; - conn.send_like_the_browser(&session_id, "send-during", "message during the handoff") - .await; + conn.send_checkpointed( + &session_id, + "send-during", + "message during the handoff", + Some((ownership.boot_epoch(), generation)), + ) + .await; let refusal = conn.send_outcome("send-during").await; assert_eq!(refusal["type"], "error", "{refusal}"); let message = refusal["message"].as_str().unwrap_or_default(); @@ -636,7 +647,7 @@ mod checkpointed_sends { message.starts_with("SESSION_RESERVED") && message .contains(freshell_freshagent::ownership_lane::LIFECYCLE_OP_GUARD_MESSAGE), - "refused while the handoff owns the placeholder: {refusal}" + "refused while the handoff owns the native UUID: {refusal}" ); assert!( @@ -644,16 +655,22 @@ mod checkpointed_sends { ownership.fail( "claude", &session_id, - "test-placeholder-handoff", + "test-native-handoff", generation, - false + true ), - freshell_ownership::FailOutcome::Vacant { .. } + freshell_ownership::FailOutcome::RestoredPriorOwner ), - "fixture: the failed handoff leaves the placeholder vacant" + "fixture: the failed handoff restores the confirmed live prior" ); - conn.send_like_the_browser(&session_id, "send-after", "message after the handoff") - .await; + let restored = ownership.observe("claude", &session_id); + conn.send_checkpointed( + &session_id, + "send-after", + "message after the handoff", + Some((restored.epoch, restored.generation)), + ) + .await; conn.expect_send_runs(&session_id, "send-after", 1).await; conn.kill(&session_id).await; From 24dcc149570b053141576074765d0a5263afb478 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:56:53 -0700 Subject: [PATCH 17/19] test(freshclaude): observe create-time ownership before the first CLI prompt --- crates/freshell-ws/tests/common/claude_rig.rs | 31 +++++++++---------- .../tests/freshagent_claude_real_cli.rs | 14 ++++----- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index 14a093f28..5c7931dbb 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -777,23 +777,22 @@ impl RigConnection { }) } - /// The owner pair the client would hold for this pane's session: the latest - /// `session.runtimeOwner` frame for the durable id its `session.init` named. + /// The current init identifies an existing conversation; before the first + /// init, create already supplied the native UUID and its owner record. fn observed_owner(&self, session_id: &str) -> Option<(u64, u64)> { - let durable = self.frames.iter().rev().find_map(|v| { - (v["type"] == "freshAgent.event" - && v["event"]["type"] == "freshAgent.session.init" - && v["event"]["sessionId"] == session_id) - .then(|| v["event"]["cliSessionId"].as_str().map(str::to_string)) - .flatten() - })?; - self.frames.iter().rev().find_map(|v| { - (v["type"] == "session.runtimeOwner" - && v["provider"] == "claude" - && v["sessionId"] == durable.as_str()) - .then(|| Some((v["epoch"].as_u64()?, v["generation"].as_u64()?))) - .flatten() - }) + let durable = self + .frames + .iter() + .rev() + .find_map(|v| { + (v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.session.init" + && v["event"]["sessionId"] == session_id) + .then(|| v["event"]["cliSessionId"].as_str().map(str::to_string)) + .flatten() + }) + .unwrap_or_else(|| session_id.to_string()); + self.owner_pair(&durable) } /// Top-level `error` frames answering this request id, read so far. diff --git a/crates/freshell-ws/tests/freshagent_claude_real_cli.rs b/crates/freshell-ws/tests/freshagent_claude_real_cli.rs index 81f1d51a5..cb2d1bfc5 100644 --- a/crates/freshell-ws/tests/freshagent_claude_real_cli.rs +++ b/crates/freshell-ws/tests/freshagent_claude_real_cli.rs @@ -55,18 +55,18 @@ async fn create_send_result_rings_once(kind: RigKind) { } } -/// A new pane's messages are sent the way the browser sends every message: with a -/// checkpoint intent, which the coordinator fences. The CLI reports the session -/// only once a prompt runs (T1), so nothing owns the new pane's session in the -/// coordinator yet: the first send must still run, not be refused as -/// SESSION_RESERVED. Once the session is reported the coordinator owns it under -/// its durable id, and the next send, fenced with the owner pair the client -/// observed for that id, runs too. +/// Both first and later checkpointed sends carry the owner pair of the native +/// UUID acknowledged at create. The first one must run before the CLI's init. async fn first_checkpointed_send_of_a_new_pane_runs(kind: RigKind) { let rig = ClaudeRig::start(kind).await; let mut conn = rig.connect().await; let session_id = conn.create("create-1").await; + assert!( + conn.owner_pair(&session_id).is_some(), + "creation announces ownership before the first SDK init" + ); + conn.send_checkpointed_text(&session_id, "send-1", "hello") .await; let result = conn.wait_for_event("freshAgent.result").await; From 3019f8c38f3805a6cdd7e5a14d5569bb39a4cb6f Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:07:46 -0700 Subject: [PATCH 18/19] feat(freshclaude): acknowledge canonical attaches to the requesting page Adapted from f2ad58952352b2a54f51b39dfc186ac3386e88f3, preserving create-time native identity in the attach and lease fixtures. --- crates/freshell-freshagent/src/claude.rs | 457 ++++------------- .../freshell-freshagent/src/claude/attach.rs | 210 ++++++++ .../src/claude/recovery.rs | 9 +- crates/freshell-ws/src/terminal.rs | 3 +- .../tests/freshagent_claude_attach.rs | 64 ++- .../freshagent_claude_kill_after_restart.rs | 461 +++--------------- .../tests/freshagent_claude_observers.rs | 108 ++++ .../tests/freshagent_session_lease.rs | 4 +- 8 files changed, 519 insertions(+), 797 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude/attach.rs create mode 100644 crates/freshell-ws/tests/freshagent_claude_observers.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 6615f6288..ac5498b76 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -5271,7 +5271,12 @@ impl FreshClaudeState { /// Unified agent names: the frame carries the durable record's current /// projection — the pending→durable transfer commits in /// [`Self::adopt_session_init`] BEFORE this publishes the identity. - async fn broadcast_materialized(&self, old_id: &str, new_id: &str, session_type: &str) { + async fn materialized_message( + &self, + old_id: &str, + new_id: &str, + session_type: &str, + ) -> ServerMessage { let projection = if session_type == "kilroy" { None } else { @@ -5309,6 +5314,13 @@ impl FreshClaudeState { ); } } + message + } + + async fn broadcast_materialized(&self, old_id: &str, new_id: &str, session_type: &str) { + let message = self + .materialized_message(old_id, new_id, session_type) + .await; self.broadcast(&message); } @@ -5328,263 +5340,6 @@ impl FreshClaudeState { .then_some(mapped) } - // ── freshAgent.attach (restart parity: resume untracked sessions in place) ────────── - - /// Handle a `freshAgent.attach` for claude/kilroy. Decision table (restart parity): - /// - /// | State | Action | - /// |---|---| - /// | tracked under `msg.session_id` | no-op -- NO frame (wire-shape parity, unchanged). Safe against dead sidecars ONLY because the consumer-exit eviction removes dead entries (ledger A9) | - /// | untracked, no canonical durable id on the message | `lost_session_frame` (`INVALID_SESSION_ID`) -- unchanged fallback (also covers the verified A2 edge: a pane that never learned its UUID pre-kill attaches bare; lost -> client re-create is the designed, non-destructive outcome) | - /// | untracked, durable id already in `cli_index`, aliased session LIVE | REBIND + ACK (Task 10b): flip the live session's envelope stamp to the durable id and answer with the `freshAgent.session.snapshot` stamped with the durable and the session's TRACKED status (the consumer's turn-lifecycle + sdk.status fold, never a hardcoded idle) -- the attaching client must observe success, never silence. The map is never re-keyed (alias, don't move) | - /// | untracked, durable id in `cli_index` but aliased session GONE (stale row, eviction in flight) | fall through to the resume path below (the session is dead; resuming converges the client) | - /// | untracked, transcript EXISTS (in ANY candidate root) | spawn sidecar and resume with the session's ORIGINAL cwd from `transcript_cwd` (ledger A15: the CLI's resume lookup is cwd-slug-scoped); if that cwd no longer exists, resume by the transcript's `.jsonl` PATH (verified cli.js escape hatch that bypasses slug scoping) with the attach cwd. Register under the CLIENT's `msg.session_id`, emit a `freshAgent.session.snapshot` whose `timelineSessionId` is the durable UUID -- NEVER a nanoid (the frozen client persists it unvalidated, ledger A14/N3) -- carrying the session's tracked status ("idle" for a fresh resume, by construction) | - /// | untracked, transcript ABSENT in EVERY candidate root (every root fully readable), binding row records a genesis (core design §4.2/§4.3: a zero-turn session, a pending fork) | boot it from the genesis under the durable id (`Fresh`, or the lazy fork of its parent), register and acknowledge exactly like the resume row above | - /// | untracked, transcript ABSENT in EVERY candidate root (every root fully readable), no genesis | `lost_session_frame` -- positive denial: the store is the authority (honest even under the 30-day GC, ledger A4) | - /// | untracked, store UNREADABLE (no store root, or an IO error other than NotFound/NotADirectory while scanning) | top-level `error` `CLAUDE_ATTACH_RESUME_FAILED` -- cannot check, so NEVER the lost frame (G28) | - /// | untracked, spawn/pipe/created failure | top-level `error` `CLAUDE_ATTACH_RESUME_FAILED` -- NEVER the lost frame | - pub async fn handle_attach(&self, msg: FreshAgentAttach) { - // b8ke delta review F7: a half-sent observed pair (exactly one of - // epoch/generation) is the typed invalid-fence refusal — before any - // state interaction. The attach never proceeds as a silently - // downgraded legacy request. - let attach_fence = - match crate::ownership_lane::wire_fence(msg.observed_epoch, msg.observed_generation) { - Ok(fence) => fence, - Err(err) => { - tracing::warn!(target: "freshell_freshagent::claude", - session_id = %msg.session_id, code = err.code(), - "fresh_agent_attach_refused: the observed fence is half-sent (invalid)"); - self.emit_fresh_agent_error( - &msg.session_id, - session_type_str(msg.session_type), - err.code(), - err.message(), - ); - return; - } - }; - // b8ke delta round-3 F2: BOTH existing-session early exits consult - // the coordinator — a delayed attach during a Handoff (the map - // entry exists until the stop lands) must be BLOCKED or typed, - // never a silent parity no-op or rebind against a runtime the - // handoff owns. - let coordinator_in_flight = |state: &freshell_ownership::OwnershipState| { - matches!( - state, - freshell_ownership::OwnershipState::Handoff { .. } - | freshell_ownership::OwnershipState::Starting { .. } - | freshell_ownership::OwnershipState::Stopping { .. } - | freshell_ownership::OwnershipState::Fenced { .. } - ) - }; - if self.sessions.lock().await.contains_key(&msg.session_id) { - // The gate observes the session's CANONICAL durable id (the - // registry keys the coordinator record there — the map key is - // the lane's own). - let durable = { - let sessions = self.sessions.lock().await; - sessions - .get(&msg.session_id) - .and_then(|s| s.cli_session_id.clone()) - }; - let observed_id = durable.as_deref().unwrap_or(&msg.session_id); - let snap = self.ownership_snapshot(PROVIDER, observed_id); - if coordinator_in_flight(&snap.state) { - tracing::warn!(target: "freshell_freshagent::claude", - session_id = %msg.session_id, state = ?snap.state, - "fresh_agent_attach_refused: a lifecycle transition owns this \ - session — the tracked-and-alive fast path is blocked (typed)"); - self.emit_fresh_agent_error( - &msg.session_id, - session_type_str(msg.session_type), - "SESSION_RESERVED", - "A lifecycle operation owns this session; retry after it settles", - ); - return; - } - return; // tracked-and-alive: no frame (wire-shape parity with codex) - } - let Some(durable) = attach_durable_id(&msg) else { - // No durable identity to resume from: the pre-parity fallback (PR #529). - self.broadcast(&lost_session_frame(&msg.session_id, msg.session_type)); - return; - }; - // A lifecycle transition blocks either a live rebind or a cold - // resume. The coordinator is the authority for both paths. - let rebind_key = self.resolve_ownership_key(&durable); - let rebind_snap = self.ownership_snapshot(PROVIDER, &rebind_key); - if coordinator_in_flight(&rebind_snap.state) { - tracing::warn!(target: "freshell_freshagent::claude", - session_id = %durable, state = ?rebind_snap.state, - "fresh_agent_attach_refused: a lifecycle transition owns this \ - session — the rebind fast path is blocked (typed)"); - self.emit_fresh_agent_error( - &msg.session_id, - session_type_str(msg.session_type), - "SESSION_RESERVED", - "A lifecycle operation owns this session; retry after it settles", - ); - return; - } - // A fresh server has no live alias or coordinator record to guard. - // Only the observer rebind needs an attach guard; a cold resume claims - // the vacant key through begin_lane_claim_at below. Check both maps: - // a stale cli_index row whose session was evicted is cold too. - let mapped = { self.cli_index.lock().await.get(&durable).cloned() }; - let live_rebind_candidate = match mapped { - Some(map_key) => self.sessions.lock().await.contains_key(&map_key), - None => false, - }; - if live_rebind_candidate { - // b8ke ext r12 F2: the rebind's REAL claim — the guard arms under - // the coordinator lock and is held ACROSS the rebind, so the - // coordinator covers the attach through completion: a handoff or - // stop beginning inside the window answers the typed Blocked - // outcome and can never commit around the rebind (pre-r12 the - // point-in-time snapshot closed no window). - // b8ke ext r38 F1: `try_rebind_to_live` is an OBSERVER bind (the - // broadcast alias flip + the status ack; it never spawns or - // restarts — the fall-through RESUME claims independently through - // `begin_lane_claim_at` with the attach's observed fence, the - // Task-3 round-2 discipline). So the rebind ADOPTS SPECIFICALLY - // when the request carries a pair (a superseded-session bind — - // ownership advanced or a foreign owner — refuses typed, the - // laundering class closed), and keeps the pre-r38 owner-agnostic - // window when the pair is ABSENT (the observer boundary: a pane - // bind misdirected by a mid-window handoff self-heals through - // the dead-session/reconcile flow; no writer hazard). - let expected_fresh_owner = freshell_ownership::OwnerIdentity { - kind: freshell_ownership::RuntimeOwnerKind::FreshAgent, - terminal_id: None, - live_session_key: None, - pid: None, - ownership_id: None, - }; - let rebind_guard = match attach_fence { - Some(adopt_fence) => { - match crate::ownership_lane::arm_adopt_guard( - &self.ownership, - PROVIDER, - &rebind_key, - &format!("attach-rebind-{}", uuid::Uuid::new_v4()), - &expected_fresh_owner, - adopt_fence, - "freshclaude/attach-rebind", - ) { - crate::ownership_lane::LaneAttachGuard::Armed(guard) => Some(guard), - crate::ownership_lane::LaneAttachGuard::Unwired => None, - crate::ownership_lane::LaneAttachGuard::Refused => { - self.emit_fresh_agent_error( - &msg.session_id, - session_type_str(msg.session_type), - "SESSION_RESERVED", - "A lifecycle operation owns this session; retry after it settles", - ); - return; - } - } - } - None => match crate::ownership_lane::arm_attach_guard( - &self.ownership, - PROVIDER, - &rebind_key, - &format!("attach-rebind-{}", uuid::Uuid::new_v4()), - None, - "freshclaude/attach-rebind", - ) { - crate::ownership_lane::LaneAttachGuard::Armed(guard) => Some(guard), - crate::ownership_lane::LaneAttachGuard::Unwired => None, - crate::ownership_lane::LaneAttachGuard::Refused => { - self.emit_fresh_agent_error( - &msg.session_id, - session_type_str(msg.session_type), - "SESSION_RESERVED", - "A lifecycle operation owns this session; retry after it settles", - ); - return; - } - }, - }; - let rebound = self - .try_rebind_to_live(&durable, session_type_str(msg.session_type)) - .await; - drop(rebind_guard); - if rebound { - // Task 10b: a live alias answers with a rebind ACK. If its - // session dies inside the guarded window, the failed rebind - // falls through to the cold claim below. - return; - } - } - let Some(_flight) = self.try_resume_flight(&durable) else { - return; - }; - match self - .resume_untracked(&msg, &durable, attach_fence, true) - .await - { - Ok(()) => {} - Err(recovery::RuntimeResumeError::Lost | recovery::RuntimeResumeError::Closed) => { - self.broadcast(&lost_session_frame(&msg.session_id, msg.session_type)); - } - Err(recovery::RuntimeResumeError::Unavailable { code, message, .. }) => { - if code == "SESSION_RESERVED" { - self.emit_fresh_agent_error( - &msg.session_id, - session_type_str(msg.session_type), - code, - &message, - ); - } else { - self.send_error(&None, code, &message); - } - } - } - } - - /// Task 10b's REBIND + ACK: if `durable` is in `cli_index` and its aliased session - /// is LIVE, flip the session's envelope stamp to the durable id and broadcast the - /// snapshot ack (stamped with the durable) announcing the session's REAL tracked - /// status — never a hardcoded "idle" (which flipped panes idle mid-turn and left - /// dead-window completions stale-busy forever). Returns `false` when the index has - /// no row or the row is stale (aliased session gone). The sessions map is never - /// re-keyed (alias, don't move -- in-flight consumers hold the placeholder key; - /// sends and kills resolve through `cli_index`). - async fn try_rebind_to_live(&self, durable: &str, session_type: &str) -> bool { - let Some(map_key) = self.cli_index.lock().await.get(durable).cloned() else { - return false; - }; - let rebound_status = { - let guard = self.sessions.lock().await; - match guard.get(&map_key) { - Some(session) => { - // Alias, don't move: flip only the envelope stamp. The status the - // ack speaks is the consumer's tracked-status fold — truth at - // ack time (a completed turn settles it; a live compaction or - // in-flight turn announces truthfully). - let previous = std::mem::replace( - &mut *session.broadcast_id.lock().expect("broadcast id lock"), - durable.to_string(), - ); - self.edges.rekeyed(&previous, durable); - Some(session.current_status()) - } - None => None, - } - }; - let Some(status) = rebound_status else { - return false; - }; - self.broadcast(&status_snapshot_frame( - durable, - durable, - &status, - session_type, - )); - true - } - /// The not-tracked resume (codex `ensure_session_resumable` analog, file-store /// flavored): boot decision (a found transcript resumes; a session with no /// transcript yet boots from its binding row's genesis, design §4.2) -> spawn @@ -5754,27 +5509,6 @@ impl FreshClaudeState { ) .await .map_err(|error| ResumeClaudeError::Transient(error.resume_detail()))?; - let session_type = session_type.to_string(); - - // Read the tracked status through the same `current_status()` helper the - // rebind arm uses (the session was registered above; the lease-revocation - // teardown already returned). Identical to the hardcoded "idle" it replaces - // for a fresh resume — truthful by construction. The None fallback covers a - // sidecar that died before this read (already EOF-evicted by its consumer): - // nothing was ever announced, so "idle" stays the truthful default. - let last_announced = self - .sessions - .lock() - .await - .get(&msg.session_id) - .map(ClaudeSession::current_status) - .unwrap_or_else(|| "idle".to_string()); - self.broadcast(&status_snapshot_frame( - &msg.session_id, - durable, - &last_announced, - &session_type, - )); Ok(()) } @@ -7480,8 +7214,10 @@ fn attach_durable_id(msg: &FreshAgentAttach) -> Option { let candidate = msg .session_ref .as_ref() + .filter(|reference| reference.provider == PROVIDER) .map(|r| r.session_id.clone()) - .or_else(|| msg.resume_session_id.clone())?; + .or_else(|| msg.resume_session_id.clone()) + .or_else(|| is_canonical_claude_uuid(&msg.session_id).then(|| msg.session_id.clone()))?; is_canonical_claude_uuid(&candidate).then_some(candidate) } @@ -8230,6 +7966,7 @@ pub use create_outcome::ClaudeCreateOutcome; mod send_outcome; pub use send_outcome::ClaudeSendOutcome; +mod attach; mod establish; mod fork; mod genesis; @@ -8513,7 +8250,7 @@ pub(crate) mod tests { async fn handle_attach_untracked_session_emits_lost_session_frame() { let (st, mut rx) = state_with_bus(); - st.handle_attach(attach_msg("does-not-exist")).await; + st.attach_for_test(attach_msg("does-not-exist")).await; let raw = rx.try_recv().expect("a lost-session frame was broadcast"); let frame: Value = serde_json::from_str(&raw).unwrap(); @@ -8538,7 +8275,7 @@ pub(crate) mod tests { msg.observed_epoch = epoch; msg.observed_generation = generation; - st.handle_attach(msg).await; + st.attach_for_test(msg).await; let raw = rx.try_recv().expect("the typed refusal frame"); let frame: Value = serde_json::from_str(&raw).unwrap(); @@ -8568,7 +8305,7 @@ pub(crate) mod tests { let mut msg = attach_msg("kilroy-gone"); msg.session_type = SessionType::Kilroy; - st.handle_attach(msg).await; + st.attach_for_test(msg).await; let frame: Value = serde_json::from_str(&rx.try_recv().unwrap()).unwrap(); assert_eq!(frame["sessionType"], "kilroy"); @@ -8576,22 +8313,20 @@ pub(crate) mod tests { assert_eq!(frame["event"]["code"], "INVALID_SESSION_ID"); } - /// Wire-shape parity with codex's tracked-and-alive row (codex.rs decision table / - /// `handle_attach_known_alive_session_emits_no_frame_regardless_of_turn_state`): - /// attaching to a session this process DOES track must broadcast nothing -- above all - /// it must never declare a live session lost (which would make the client kill and - /// re-create a healthy pane). + /// A page observing a live UUID receives its current status without + /// creating or replacing a runtime. #[tokio::test] - async fn handle_attach_tracked_session_broadcasts_nothing() { + async fn handle_attach_tracked_session_acknowledges_its_status() { let (st, mut rx) = state_with_bus(); - insert_fake_claude_session(&st, "still-alive").await; + let id = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&st, &id).await; - st.handle_attach(attach_msg("still-alive")).await; + st.attach_for_test(attach_msg(&id)).await; - assert!( - rx.try_recv().is_err(), - "tracked attach must not broadcast any frame (wire-shape parity)" - ); + let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; + assert_eq!(frame["sessionId"], id); + assert_eq!(frame["event"]["status"], "idle"); + assert_eq!(st.sessions.lock().await.len(), 1); } // ── freshAgent.attach: resume-on-attach (restart parity, Task 6) ────────────── @@ -8737,15 +8472,15 @@ pub(crate) mod tests { std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); let (st, mut rx) = state_with_bus(); - st.handle_attach(attach_msg_with_resume("client-nanoid-1", durable)) + st.attach_for_test(attach_msg_with_resume("client-nanoid-1", durable)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); - // Registered under the CLIENT's id (envelope tagging + send routing depend on it). - assert!(st.sessions.lock().await.contains_key("client-nanoid-1")); + // A legacy pane now observes the canonical runtime. + assert!(st.sessions.lock().await.contains_key(durable)); assert_eq!( st.cli_index.lock().await.get(durable), - Some(&"client-nanoid-1".to_string()) + Some(&durable.to_string()) ); // The fake received the resume boot (spawn log now records the create request). let log = std::fs::read_to_string(env.spawn_log_path()).unwrap(); @@ -8755,7 +8490,7 @@ pub(crate) mod tests { ); // Idle snapshot frame, tagged with the client's id + the durable timeline id. let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; - assert_eq!(frame["sessionId"], "client-nanoid-1"); + assert_eq!(frame["sessionId"], durable); assert_eq!(frame["event"]["status"], "idle"); assert_eq!(frame["event"]["timelineSessionId"], durable); drop(env); @@ -8768,7 +8503,7 @@ pub(crate) mod tests { std::fs::create_dir_all(home.path().join("projects")).unwrap(); std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); let (st, mut rx) = state_with_bus(); - st.handle_attach(attach_msg_with_resume( + st.attach_for_test(attach_msg_with_resume( "client-nanoid-2", "88888888-8888-4888-8888-888888888888", )) @@ -8787,14 +8522,13 @@ pub(crate) mod tests { std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); std::env::set_var("FRESHELL_CLAUDE_NODE", "/nonexistent-node-binary"); let (st, mut rx) = state_with_bus(); - st.handle_attach(attach_msg_with_resume("client-nanoid-3", durable)) + st.attach_for_test(attach_msg_with_resume("client-nanoid-3", durable)) .await; std::env::remove_var("FRESHELL_CLAUDE_NODE"); std::env::remove_var("CLAUDE_CONFIG_DIR"); - // Mirrors codex: transient => top-level error with the provider code, - // explicitly NOT INVALID_SESSION_ID. - let err = await_top_level_error(&mut rx).await; - assert!(err.contains("CLAUDE_ATTACH_RESUME_FAILED")); + let err = await_frame_of_inner_type(&mut rx, "freshAgent.error").await; + assert_eq!(err["event"]["code"], "CLAUDE_ATTACH_RESUME_FAILED"); + assert_eq!(err["sessionId"], "client-nanoid-3"); assert!(st.sessions.lock().await.is_empty()); } @@ -8802,7 +8536,7 @@ pub(crate) mod tests { async fn attach_untracked_without_any_durable_id_still_emits_lost_frame() { // The pre-parity fallback (PR #529) is preserved verbatim. let (st, mut rx) = state_with_bus(); - st.handle_attach(attach_msg("no-resume-anywhere")).await; + st.attach_for_test(attach_msg("no-resume-anywhere")).await; let frame = await_frame_of_inner_type(&mut rx, "freshAgent.error").await; assert_eq!(frame["event"]["code"], "INVALID_SESSION_ID"); } @@ -8820,41 +8554,33 @@ pub(crate) mod tests { let b = st.clone(); let m1 = attach_msg_with_resume("nano-a", durable); let m2 = attach_msg_with_resume("nano-b", durable); - tokio::join!(a.handle_attach(m1), b.handle_attach(m2)); + tokio::join!(a.attach_for_test(m1), b.attach_for_test(m2)); std::env::remove_var("CLAUDE_CONFIG_DIR"); assert_eq!(env.spawn_count(), 1, "single-flight per durable id"); drop(env); } - /// Decision-table row 3 (Task 10b pins the NEW contract): a durable id already in - /// `cli_index` whose aliased session is LIVE is REBOUND -- the envelope stamp flips - /// to the durable id and the idle snapshot ack (stamped with the durable) answers - /// the attach. The sessions map is never re-keyed (alias, don't move). + /// A legacy page observes the same canonical runtime and receives its + /// correction before its status acknowledgement. #[tokio::test] - async fn attach_with_durable_id_already_indexed_rebinds_and_acks() { + async fn attach_to_a_live_uuid_keeps_its_envelope_and_acknowledges_the_legacy_page() { let (st, mut rx) = state_with_bus(); let durable = "56565656-5656-4656-8656-565656565656"; - insert_fake_claude_session(&st, "earlier-winner").await; - st.cli_index - .lock() - .await - .insert(durable.to_string(), "earlier-winner".to_string()); - st.handle_attach(attach_msg_with_resume("late-attacher", durable)) + insert_fake_claude_session(&st, durable).await; + st.attach_for_test(attach_msg_with_resume("late-attacher", durable)) .await; let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; assert_eq!(frame["sessionId"], durable); assert_eq!(frame["event"]["status"], "idle"); assert_eq!(frame["event"]["timelineSessionId"], durable); - // Alias, don't move: the map still holds the placeholder key... let sessions = st.sessions.lock().await; let session = sessions - .get("earlier-winner") - .expect("map key never re-keyed"); - // ...and the live record's envelope stamp now reads the durable id. + .get(durable) + .expect("the canonical runtime stays registered"); assert_eq!( session.broadcast_id.lock().unwrap().as_str(), durable, - "rebind must flip the envelope stamp to the durable id" + "attaching a page preserves the runtime's canonical envelope" ); } @@ -8896,7 +8622,7 @@ pub(crate) mod tests { await_status_on_wire(&mut rx, "compacting").await; // The reconnect rescue: attach addressing the durable id → the REBIND arm. - st.handle_attach(attach_msg_with_resume("late-attacher", durable)) + st.attach_for_test(attach_msg_with_resume("late-attacher", durable)) .await; let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; @@ -8951,7 +8677,7 @@ pub(crate) mod tests { // The reconnect rescue AFTER the turn settled: the ack must speak "idle", // never the pre-completion "compacting". - st.handle_attach(attach_msg_with_resume("late-attacher-settled", durable)) + st.attach_for_test(attach_msg_with_resume("late-attacher-settled", durable)) .await; let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; @@ -8996,7 +8722,7 @@ pub(crate) mod tests { let _ = await_frame_of_inner_type(&mut rx, "freshAgent.assistant").await; // The reconnect rescue mid-turn: the ack must speak "running". - st.handle_attach(attach_msg_with_resume("late-attacher-midturn", durable)) + st.attach_for_test(attach_msg_with_resume("late-attacher-midturn", durable)) .await; let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; @@ -9023,12 +8749,12 @@ pub(crate) mod tests { std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); let (st, mut rx) = state_with_bus(); - st.handle_attach(attach_msg_with_resume("client-fresh-resume", durable)) + st.attach_for_test(attach_msg_with_resume("client-fresh-resume", durable)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; - assert_eq!(frame["sessionId"], "client-fresh-resume"); + assert_eq!(frame["sessionId"], durable); assert_eq!( frame["event"]["status"], "idle", "a freshly resumed session's ack must announce idle (the tracked default): {frame}" @@ -9056,7 +8782,7 @@ pub(crate) mod tests { .unwrap(); std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); let (st, mut rx) = state_with_bus(); - st.handle_attach(attach_msg_with_resume("client-nanoid-4", durable)) + st.attach_for_test(attach_msg_with_resume("client-nanoid-4", durable)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); let log = std::fs::read_to_string(env.spawn_log_path()).unwrap(); @@ -12223,7 +11949,7 @@ rl.on('line', (line) => { // A delayed ATTACH: the tracked-and-alive fast path is BLOCKED // with the typed error (pre-d3: the silent parity return). - st.handle_attach(freshell_protocol::FreshAgentAttach { + st.attach_for_test(freshell_protocol::FreshAgentAttach { tab_id: None, pane_id: None, provider: freshell_protocol::AgentProvider::Claude, @@ -15684,7 +15410,7 @@ rl.on('line', (line) => { // The attach (conn-less): resume succeeds; the row must be Bound // again even though no settings-bearing adoption write ever runs. state - .handle_attach(attach_msg_with_resume("client-lineage-attach", DURABLE)) + .attach_for_test(attach_msg_with_resume("client-lineage-attach", DURABLE)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); @@ -15750,7 +15476,7 @@ rl.on('line', (line) => { // resume parks inside its provider await. let st2 = state.clone(); let attach = tokio::spawn(async move { - st2.handle_attach(attach_msg_with_resume("client-kill-mid-resume", DURABLE)) + st2.attach_for_test(attach_msg_with_resume("client-kill-mid-resume", DURABLE)) .await; }); // Determinism: wait until the resume's create request has REACHED the @@ -15810,11 +15536,8 @@ rl.on('line', (line) => { "the newer kill's fence stands — never cleared by the stale claim" ); assert!( - fake.claim_refusals - .lock() - .unwrap() - .contains(&("claude".to_string(), DURABLE.to_string())), - "the refusal is positively logged" + fake.claim_commits.lock().unwrap().is_empty(), + "the closed pane never commits a launch claim" ); assert!( !fake @@ -16179,7 +15902,7 @@ rl.on('line', (line) => { let stall = fake.arm_post_commit_stall("claude", DURABLE); let st2 = state.clone(); let attach = tokio::spawn(async move { - st2.handle_attach(attach_msg_with_resume("client-killed-post-commit", DURABLE)) + st2.attach_for_test(attach_msg_with_resume("client-killed-post-commit", DURABLE)) .await; }); stall @@ -16767,13 +16490,13 @@ rl.on('line', (line) => { // The reconnect's late attach RIDES the fenced seat to the durable. state - .handle_attach(attach_msg_with_resume("seat-closed", DURABLE)) + .attach_for_test(attach_msg_with_resume("seat-closed", DURABLE)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); let mut saw_resume_failed = false; while let Ok(raw) = rx.try_recv() { - if raw.contains("CLAUDE_ATTACH_RESUME_FAILED") { + if raw.contains("SESSION_CLOSED") { saw_resume_failed = true; } } @@ -16787,11 +16510,8 @@ rl.on('line', (line) => { "no registration alias survives" ); assert!( - fake.claim_refusals - .lock() - .unwrap() - .contains(&("claude".to_string(), DURABLE.to_string())), - "the refusal is positively logged" + fake.claim_commits.lock().unwrap().is_empty(), + "the closed pane never commits a launch claim" ); assert!( fake.kill_tombstone_at_ms("claude", DURABLE).is_none(), @@ -16884,13 +16604,13 @@ rl.on('line', (line) => { // The NEW pane (a fresh seat) genuinely reopens the session. state - .handle_attach(attach_msg_with_resume("seat-new", DURABLE)) + .attach_for_test(attach_msg_with_resume("seat-new", DURABLE)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); assert!( - state.sessions.lock().await.contains_key("seat-new"), - "the clean-seat attach registers" + state.sessions.lock().await.contains_key(DURABLE), + "the clean-seat attach registers under the durable id" ); assert!( fake.kill_tombstone_at_ms("claude", DURABLE).is_none(), @@ -16907,9 +16627,8 @@ rl.on('line', (line) => { ); assert!( fake.alias_tombstone_records("claude", "seat-new") - .iter() - .any(|(d, _)| d == DURABLE), - "the attach registration persisted its alias record" + .is_empty(), + "the canonical runtime needs no new placeholder alias record" ); assert!( fake.alias_tombstone_records("claude", "seat-old") @@ -16952,7 +16671,7 @@ rl.on('line', (line) => { // The commit's io failure knob (Finding 5's shape). fake.set_fail_writes(true); state - .handle_attach(attach_msg_with_resume("client-commit-error", DURABLE)) + .attach_for_test(attach_msg_with_resume("client-commit-error", DURABLE)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); @@ -18503,7 +18222,7 @@ rl.on('line', (line) => { // Drive the donor resume flow (attach to DURABLE with a transcript on disk). state - .handle_attach(attach_msg_with_resume("client-nanoid-settings", DURABLE)) + .attach_for_test(attach_msg_with_resume("client-nanoid-settings", DURABLE)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); @@ -18534,19 +18253,19 @@ rl.on('line', (line) => { let (state, _rx) = state_with_bus(); let mut msg = attach_msg_with_resume("client-nanoid-logs", DURABLE); msg.session_type = SessionType::Kilroy; - state.handle_attach(msg).await; + state.attach_for_test(msg).await; std::env::remove_var("CLAUDE_CONFIG_DIR"); let ownership_id = state .sessions .lock() .await - .get("client-nanoid-logs") + .get(DURABLE) .map(|session| session.ownership_id.clone()) - .expect("the attach resumed a session under the client's id"); + .expect("the attach resumed the canonical session"); let bound = with_message(&events, "freshagent.claude.sidecar_bound"); assert_eq!(bound.len(), 1, "{bound:?}"); - assert_eq!(bound[0].field("session_id"), Some("client-nanoid-logs")); + assert_eq!(bound[0].field("session_id"), Some(DURABLE)); assert_eq!(bound[0].field("session_type"), Some("kilroy")); assert_eq!(bound[0].field("ownership_id"), Some(ownership_id.as_str())); drop(env); @@ -18580,7 +18299,7 @@ rl.on('line', (line) => { state.set_identity_sink(settings); state - .handle_attach(attach_msg_with_resume("client-after-restart", DURABLE)) + .attach_for_test(attach_msg_with_resume("client-after-restart", DURABLE)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); @@ -18599,7 +18318,7 @@ rl.on('line', (line) => { assert_eq!(create_req["model"], "opus-x"); assert_eq!(create_req["permissionMode"], "plan"); let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; - assert_eq!(frame["sessionId"], "client-after-restart"); + assert_eq!(frame["sessionId"], DURABLE); assert!(matches!( ownership.observe("claude", DURABLE).state, freshell_ownership::OwnershipState::Live { .. } @@ -18636,7 +18355,7 @@ rl.on('line', (line) => { )); state - .handle_attach(attach_msg_with_resume( + .attach_for_test(attach_msg_with_resume( "client-nanoid-empty-resume", DURABLE, )) @@ -18683,7 +18402,7 @@ rl.on('line', (line) => { state.set_identity_sink(fake.clone()); state - .handle_attach(attach_msg_with_resume("client-nanoid-norec", DURABLE)) + .attach_for_test(attach_msg_with_resume("client-nanoid-norec", DURABLE)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); @@ -18753,7 +18472,7 @@ rl.on('line', (line) => { state.set_identity_sink(fake); state - .handle_attach(attach_msg_with_resume("client-nanoid-alarm", DURABLE)) + .attach_for_test(attach_msg_with_resume("client-nanoid-alarm", DURABLE)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); @@ -19077,7 +18796,7 @@ rl.on('line', (line) => { let home = tempfile::tempdir().unwrap(); write_fake_transcript(home.path(), durable); std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); - st.handle_attach(attach_msg_with_resume("pane-after-reload", durable)) + st.attach_for_test(attach_msg_with_resume("pane-after-reload", durable)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); assert!(st.has_live_session(durable).await, "the attach resumed it"); @@ -19124,7 +18843,7 @@ rl.on('line', (line) => { let home = tempfile::tempdir().unwrap(); write_fake_transcript(home.path(), durable); std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); - st.handle_attach(attach_msg_with_resume("pane-after-reload", durable)) + st.attach_for_test(attach_msg_with_resume("pane-after-reload", durable)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); assert!(st.has_live_session(durable).await, "the attach resumed it"); @@ -19154,12 +18873,12 @@ rl.on('line', (line) => { let _guard = CLAUDE_ENV_LOCK.lock().await; let _env = FakeClaudeSidecarEnv::install(); // The fake sidecar's session reports this durable id. - const DURABLE: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; let (st, mut rx) = state_with_bus(); st.handle_create(dedup_create_msg("req-reap-resume"), None) .await; let created = await_claude_created(&mut rx, "req-reap-resume").await; let pane_id = created["sessionId"].as_str().unwrap().to_string(); + let durable = pane_id.as_str(); // The fake never answers a plain send: the turn stays in flight. st.handle_send(send_msg(&pane_id, "a turn the sidecar dies in")) .await; @@ -19179,12 +18898,12 @@ rl.on('line', (line) => { .expect("the end-of-output path reached the exit report"); let home = tempfile::tempdir().unwrap(); - write_fake_transcript(home.path(), DURABLE); + write_fake_transcript(home.path(), durable); std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); - st.handle_attach(attach_msg_with_resume(&pane_id, DURABLE)) + st.attach_for_test(attach_msg_with_resume(&pane_id, durable)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); - assert!(st.has_live_session(DURABLE).await, "the attach resumed it"); + assert!(st.has_live_session(durable).await, "the attach resumed it"); while rx.try_recv().is_ok() {} hold.release(); diff --git a/crates/freshell-freshagent/src/claude/attach.rs b/crates/freshell-freshagent/src/claude/attach.rs new file mode 100644 index 000000000..b0e55abde --- /dev/null +++ b/crates/freshell-freshagent/src/claude/attach.rs @@ -0,0 +1,210 @@ +//! A page attaches to a durable conversation, without changing its runtime. + +use super::*; +use freshell_terminal::FrameSink; + +impl FreshClaudeState { + /// Reply to this page only. Status, content, and attention keep their + /// ordinary broadcast path and canonical session identity. + pub async fn handle_attach(&self, mut msg: FreshAgentAttach, reply: FrameSink) { + let session_type = session_type_str(msg.session_type); + let fence = + match crate::ownership_lane::wire_fence(msg.observed_epoch, msg.observed_generation) { + Ok(fence) => fence, + Err(error) => { + attach_error(&reply, &msg, &msg.session_id, error.code(), error.message()); + return; + } + }; + let Some(candidate) = attach_durable_id(&msg) else { + reply(lost_session_frame(&msg.session_id, msg.session_type)); + return; + }; + if !is_canonical_claude_uuid(&msg.session_id) + && self.identity_sink().is_some_and(|sink| { + sink.kill_tombstone_at_ms(PROVIDER, &msg.session_id) + .is_some() + }) + { + attach_error( + &reply, + &msg, + &msg.session_id, + "SESSION_CLOSED", + "This pane was closed. Open the conversation again to continue.", + ); + return; + } + let durable = self + .identity_sink() + .and_then(|sink| sink.chain_terminus(PROVIDER, &candidate)) + .unwrap_or_else(|| candidate.clone()); + let mut previous = msg.session_id.clone(); + if durable != candidate && previous != durable { + // A durable ledger correction remains useful even when the + // subsequent owner fence refuses this delayed attach. + reply( + self.materialized_message(&previous, &durable, session_type) + .await, + ); + previous = durable.clone(); + } + msg.session_id = durable.clone(); + msg.session_ref = Some(claude_session_ref(&durable)); + msg.resume_session_id = None; + + match self.reply_live_attach(&msg, &previous, fence, &reply).await { + Ok(true) | Err(()) => return, + Ok(false) => {} + } + // A competing attach may already be booting this conversation. + // Wait for it, then inspect the runtime again before launching. + let _flight = self.wait_resume_flight(&durable).await; + match self.reply_live_attach(&msg, &previous, fence, &reply).await { + Ok(true) | Err(()) => return, + Ok(false) => {} + } + match self.resume_untracked(&msg, &durable, fence, true).await { + Ok(()) => { + // This operation advanced its own claim. The incoming + // pre-launch fence no longer names its committed owner. + match self.reply_live_attach(&msg, &previous, None, &reply).await { + Ok(true) | Err(()) => {} + Ok(false) => attach_error( + &reply, + &msg, + &previous, + "CLAUDE_ATTACH_RESUME_FAILED", + "Claude stopped while attaching. Try again.", + ), + } + } + Err(recovery::RuntimeResumeError::Lost) => { + reply(lost_session_frame(&previous, msg.session_type)); + } + Err(recovery::RuntimeResumeError::Closed) => attach_error( + &reply, + &msg, + &previous, + "SESSION_CLOSED", + "This conversation was closed. Open it again to continue.", + ), + Err(recovery::RuntimeResumeError::Unavailable { code, message, .. }) => { + attach_error(&reply, &msg, &previous, code, &message); + } + } + tracing::debug!(target: "freshell_freshagent::claude", provider = PROVIDER, + session_id = %durable, session_type, "freshagent.claude.attach_settled"); + } + + /// Hold the coordinator's observation window through the snapshot and + /// correction. A handoff cannot commit around this acknowledgement. + async fn reply_live_attach( + &self, + msg: &FreshAgentAttach, + previous: &str, + fence: Option, + reply: &FrameSink, + ) -> Result { + let Some(key) = self.resolve_session_key(&msg.session_id).await else { + return Ok(false); + }; + let operation = format!("claude-attach-{}", uuid::Uuid::new_v4()); + let guard = match fence { + Some(fence) => crate::ownership_lane::arm_adopt_guard( + &self.ownership, + PROVIDER, + &msg.session_id, + &operation, + &freshell_ownership::OwnerIdentity { + kind: freshell_ownership::RuntimeOwnerKind::FreshAgent, + terminal_id: None, + live_session_key: Some(key.clone()), + pid: None, + ownership_id: None, + }, + fence, + "freshclaude/attach", + ), + None => crate::ownership_lane::arm_attach_guard( + &self.ownership, + PROVIDER, + &msg.session_id, + &operation, + None, + "freshclaude/attach", + ), + }; + if matches!(guard, crate::ownership_lane::LaneAttachGuard::Refused) { + attach_error( + reply, + msg, + previous, + "SESSION_RESERVED", + "A lifecycle operation owns this session; retry after it settles", + ); + return Err(()); + } + let status = self + .sessions + .lock() + .await + .get(&key) + .map(ClaudeSession::current_status); + let Some(status) = status else { + return Ok(false); + }; + if previous != msg.session_id { + reply( + self.materialized_message( + previous, + &msg.session_id, + session_type_str(msg.session_type), + ) + .await, + ); + } + reply(status_snapshot_frame( + &msg.session_id, + &msg.session_id, + &status, + session_type_str(msg.session_type), + )); + drop(guard); + Ok(true) + } +} + +#[cfg(test)] +impl FreshClaudeState { + /// In-crate tests observe replies through their existing capture bus. + pub(super) async fn attach_for_test(&self, msg: FreshAgentAttach) { + let bus = self.broadcast_tx.clone(); + self.handle_attach( + msg, + Arc::new(move |frame| { + if let Ok(frame) = serde_json::to_string(&frame) { + let _ = bus.send(frame); + } + }), + ) + .await; + } +} + +fn attach_error( + reply: &FrameSink, + msg: &FreshAgentAttach, + session_id: &str, + code: &str, + message: &str, +) { + tracing::warn!(target: "freshell_freshagent::claude", provider = PROVIDER, + session_id, code, "freshagent.claude.attach_refused"); + reply(ServerMessage::FreshAgentEvent(FreshAgentEvent { + provider: PROVIDER.into(), + session_id: session_id.into(), + session_type: session_type_str(msg.session_type).into(), + event: json!({"type":"freshAgent.error", "sessionId":session_id, "code":code, "message":message}), + })); +} diff --git a/crates/freshell-freshagent/src/claude/recovery.rs b/crates/freshell-freshagent/src/claude/recovery.rs index 264d734d6..e4693ec43 100644 --- a/crates/freshell-freshagent/src/claude/recovery.rs +++ b/crates/freshell-freshagent/src/claude/recovery.rs @@ -95,10 +95,7 @@ impl FreshClaudeState { crate::ownership_lane::LaneClaim::Granted(ticket) => Some(ticket), crate::ownership_lane::LaneClaim::Unwired => None, crate::ownership_lane::LaneClaim::Adopt => { - return if self - .try_rebind_to_live(durable, session_type_str(msg.session_type)) - .await - { + return if self.resolve_session_key(durable).await.is_some() { Ok(()) } else { Err(RuntimeResumeError::reserved(None)) @@ -121,9 +118,7 @@ impl FreshClaudeState { LaunchLease::Acquired(lease) => Some(lease), refused => { return if matches!(refused, LaunchLease::BoundLive { .. }) - && self - .try_rebind_to_live(durable, session_type_str(msg.session_type)) - .await + && self.resolve_session_key(durable).await.is_some() { Ok(()) } else { diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index b924d63e8..f959013ee 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -1974,8 +1974,9 @@ async fn handle_client_text( } freshell_protocol::AgentProvider::Claude => { let fresh_claude = state.fresh_claude.clone(); + let reply = conn_sink.clone(); tokio::spawn( - async move { fresh_claude.handle_attach(attach).await } + async move { fresh_claude.handle_attach(attach, reply).await } .instrument(tracing::Span::current()), ); } diff --git a/crates/freshell-ws/tests/freshagent_claude_attach.rs b/crates/freshell-ws/tests/freshagent_claude_attach.rs index 5c7f1ca61..4cebff344 100644 --- a/crates/freshell-ws/tests/freshagent_claude_attach.rs +++ b/crates/freshell-ws/tests/freshagent_claude_attach.rs @@ -59,9 +59,9 @@ rl.on('line', (line) => { logReq(msg) if (msg.type === 'create') { counter += 1 - const sessionId = `fake-claude-session-${process.pid}-${counter}` + const sessionId = msg.sessionId || `fake-claude-session-${process.pid}-${counter}` process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') - const cliSessionId = msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const cliSessionId = msg.boot?.resume || sessionId process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') } else if (msg.type === 'send') { @@ -375,7 +375,7 @@ async fn claude_attach_with_resumable_transcript_resumes_and_emits_snapshot_over }) .await; - assert_eq!(frame["sessionId"], "gone-after-restart"); + assert_eq!(frame["sessionId"], durable); assert_eq!(frame["sessionType"], "freshclaude"); assert_eq!(frame["event"]["status"], "idle"); assert_eq!(frame["event"]["timelineSessionId"], durable); @@ -439,21 +439,19 @@ async fn claude_attach_with_an_unreadable_store_is_transient_not_lost() { ) .await; - // The FIRST answer is decisive: either the lost frame for this pane or the - // transient top-level error. + // A transient failure stays scoped to this pane and is distinct from + // the permanent INVALID_SESSION_ID lost-session response. let frame = await_frame(&mut ws, Duration::from_secs(15), |v| { (v["type"] == "freshAgent.event" && v["sessionId"] == "pane-with-unreadable-store") || v["type"] == "error" }) .await; assert_eq!( - frame["type"], "error", + frame["event"]["type"], "freshAgent.error", "an unreadable store must not mark the session lost: {frame}" ); assert!( - frame["message"] - .as_str() - .is_some_and(|m| m.starts_with("CLAUDE_ATTACH_RESUME_FAILED")), + frame["event"]["code"] == "CLAUDE_ATTACH_RESUME_FAILED", "the attach answers with the transient resume failure: {frame}" ); @@ -504,12 +502,10 @@ async fn kilroy_attach_for_untracked_session_emits_lost_session_frame_over_ws() // a silent no-op and `send{durable}` misses the sessions map (SESSION_NOT_FOUND) while // events keep broadcasting under the placeholder — stranding the pane the fold rebound. -/// The fake sidecar's baked-in durable `cliSessionId` for a create WITHOUT resume. -const DURABLE_FALLBACK: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +/// An unrelated saved conversation; fresh creates still choose their own UUID. +const SEEDED_TRANSCRIPT_ID: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; -/// Drive a claude `freshAgent.create` (no resume) through the fake sidecar; wait for -/// `freshAgent.session.init` so `cli_index[DURABLE_FALLBACK] = placeholder` is recorded. -/// Returns the placeholder session id. +/// Create a native session and verify the sidecar repeats its acknowledged UUID. async fn create_live_claude_session(ws: &mut TestWs, request_id: &str) -> String { send_json( ws, @@ -530,10 +526,11 @@ async fn create_live_claude_session(ws: &mut TestWs, request_id: &str) -> String .as_str() .expect("created carries sessionId") .to_string(); - await_frame(ws, Duration::from_secs(15), |v| { + let init = await_frame(ws, Duration::from_secs(15), |v| { v["type"] == "freshAgent.event" && v["event"]["type"] == "freshAgent.session.init" }) .await; + assert_eq!(init["event"]["cliSessionId"], placeholder); placeholder } @@ -543,11 +540,11 @@ async fn create_live_claude_session(ws: &mut TestWs, request_id: &str) -> String #[tokio::test] async fn attach_by_durable_id_on_a_live_session_rebinds_and_acks() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeResumeEnv::install(DURABLE_FALLBACK); + let env = FakeClaudeResumeEnv::install(SEEDED_TRANSCRIPT_ID); let url = spawn_server().await; let mut ws = connect_and_complete_handshake(&url).await; - let _placeholder = create_live_claude_session(&mut ws, "req-10b-attach").await; + let durable = create_live_claude_session(&mut ws, "req-10b-attach").await; let mut ws2 = connect_and_complete_handshake(&url).await; send_json( @@ -555,15 +552,15 @@ async fn attach_by_durable_id_on_a_live_session_rebinds_and_acks() { &serde_json::json!({ "type": "freshAgent.attach", "provider": "claude", - "sessionId": DURABLE_FALLBACK, + "sessionId": durable, "sessionType": "freshclaude", - "sessionRef": { "provider": "claude", "sessionId": DURABLE_FALLBACK }, + "sessionRef": { "provider": "claude", "sessionId": durable }, }), ) .await; let frame = await_frame(&mut ws2, Duration::from_secs(10), |v| { - v["sessionId"] == DURABLE_FALLBACK + v["sessionId"] == durable }) .await; assert_eq!(frame["type"], "freshAgent.event"); @@ -584,18 +581,18 @@ async fn attach_by_durable_id_on_a_live_session_rebinds_and_acks() { #[tokio::test] async fn send_by_durable_id_routes_to_the_live_session() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeResumeEnv::install(DURABLE_FALLBACK); + let env = FakeClaudeResumeEnv::install(SEEDED_TRANSCRIPT_ID); let url = spawn_server().await; let mut ws = connect_and_complete_handshake(&url).await; - let _placeholder = create_live_claude_session(&mut ws, "req-10b-send").await; + let durable = create_live_claude_session(&mut ws, "req-10b-send").await; send_json( &mut ws, &serde_json::json!({ "type": "freshAgent.send", "provider": "claude", - "sessionId": DURABLE_FALLBACK, + "sessionId": durable, "sessionType": "freshclaude", "text": "ping", "requestId": "send-10b", @@ -607,7 +604,7 @@ async fn send_by_durable_id_routes_to_the_live_session() { v["type"] == "freshAgent.send.accepted" && v["requestId"] == "send-10b" }) .await; - assert_eq!(accepted["sessionId"], DURABLE_FALLBACK); + assert_eq!(accepted["sessionId"], durable); // The sidecar answers every `send` with an assistant frame -- awaiting it proves // the sidecar PROCESSED the routed send (the accepted broadcast races the log append). @@ -625,16 +622,15 @@ async fn send_by_durable_id_routes_to_the_live_session() { assert_eq!(sends, 1, "the live sidecar must receive the routed send"); } -/// After the durable rebind, event envelopes must be stamped with the DURABLE id (the -/// broadcast stamp flips), or the pane keyed on the durable never receives events. +/// A second page's attach keeps subsequent events on the same native UUID. #[tokio::test] async fn events_after_durable_rebind_are_stamped_with_the_durable_id() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let _env = FakeClaudeResumeEnv::install(DURABLE_FALLBACK); + let _env = FakeClaudeResumeEnv::install(SEEDED_TRANSCRIPT_ID); let url = spawn_server().await; let mut ws = connect_and_complete_handshake(&url).await; - let _placeholder = create_live_claude_session(&mut ws, "req-10b-events").await; + let durable = create_live_claude_session(&mut ws, "req-10b-events").await; let mut ws2 = connect_and_complete_handshake(&url).await; send_json( @@ -642,15 +638,15 @@ async fn events_after_durable_rebind_are_stamped_with_the_durable_id() { &serde_json::json!({ "type": "freshAgent.attach", "provider": "claude", - "sessionId": DURABLE_FALLBACK, + "sessionId": durable, "sessionType": "freshclaude", - "sessionRef": { "provider": "claude", "sessionId": DURABLE_FALLBACK }, + "sessionRef": { "provider": "claude", "sessionId": durable }, }), ) .await; // The rebind ack gates the ordering (attach handled before the send below). await_frame(&mut ws2, Duration::from_secs(10), |v| { - v["sessionId"] == DURABLE_FALLBACK + v["sessionId"] == durable }) .await; @@ -660,7 +656,7 @@ async fn events_after_durable_rebind_are_stamped_with_the_durable_id() { &serde_json::json!({ "type": "freshAgent.send", "provider": "claude", - "sessionId": DURABLE_FALLBACK, + "sessionId": durable, "sessionType": "freshclaude", "text": "poke", "requestId": "send-10b-events", @@ -673,8 +669,8 @@ async fn events_after_durable_rebind_are_stamped_with_the_durable_id() { }) .await; assert_eq!( - event["sessionId"], DURABLE_FALLBACK, - "post-rebind envelopes must be stamped with the durable id" + event["sessionId"], durable, + "attaching preserves the native envelope UUID" ); } diff --git a/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs b/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs index ecd0fdf86..5e15503e6 100644 --- a/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs +++ b/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs @@ -1,403 +1,96 @@ -//! Retire-on-kill round 6 (focused-ep5-r5 Finding 2) — the restart-boundary -//! integration pins. In-memory claude placeholder→durable alias tombstones -//! used to die with the server process, so the finding's exact interleaving -//! was: panes reconnect after a restart and resume their durable session -//! (slow); the user closes the pane before the resume re-registers the alias; -//! the close names only the bare placeholder and fences/retires nothing -//! durable; the resume then commits the durable row Bound and registers a -//! session for a pane that no longer exists — and the next recovery offers -//! it. -//! -//! The repair has two halves, pinned here across a REAL ledger reload (a -//! fresh `PaneLedger` over the same root + a fresh `FreshClaudeState` IS the -//! restart — every process-local map is empty, only the durable store -//! survives): -//! -//! 1. Alias tombstones are durable (the ledger's `alias-tombstones/` -//! subtree, minted at registration/adoption and demoted at eviction), so a -//! post-restart kill naming the bare placeholder still resolves, fences, -//! and retires the durable row. (Test 1 — the finding's own order.) -//! 2. The claim commit consults the placeholder's fence: a close recorded -//! under the one-shot seat blocks the commit exactly like one recorded -//! under the durable id — the brief's literal pin: a restart boundary -//! BETWEEN the fence write and the claim commit leaves the commit blocked -//! and the row Retired. (Test 2.) -//! -//! Harness note: these tests drive `FreshClaudeState` DIRECTLY (no socket) — -//! the create/kill/attach handlers are public; the production -//! `spawn_sidecar()` is swapped by the SAME `FRESHELL_CLAUDE_SIDECAR` / -//! `FRESHELL_CLAUDE_NODE` env overrides `freshagent_claude_kill_interrupt.rs` -//! uses — so the alias-mint, kill, and attach lanes all run their real code -//! against a REAL on-disk ledger, through the shared `common::ledger_sink` -//! sink, which mirrors `freshell-server/src/identity_sink.rs`'s adapter (the ws -//! test crate cannot depend on freshell-server). +//! Read compatibility for placeholder aliases persisted by older servers. +//! New runtimes use UUIDs; seed the old on-disk records, then exercise the +//! production sidecar and the actual kill/attach paths across ledger reloads. mod common; -use std::sync::Arc; - -use serde_json::Value; - -use common::ledger_sink::LedgerSink; -use freshell_protocol::{ - AgentProvider, FreshAgentAttach, FreshAgentCreate, FreshAgentKill, SessionLocator, SessionType, -}; -use freshell_ws::pane_ledger::{PaneLedger, RetiredReason, RowState}; - -/// Serializes every test in this file (process-global env vars mutate). -static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - -/// A minimal scripted fake claude sidecar (production wire protocol only): -/// `create` answers `created` with a fresh placeholder and then emits -/// `sdk.session.init` echoing the resume boot's id as the durable cli id (the -/// resume-continuity shape) so the ADOPTION path runs its real fold — -/// cli_index insert, binding write, and (round 6) the alias-tombstone mint -/// write. `shutdown` exits (the kill's graceful teardown drives it). -const FAKE_SIDECAR: &str = r#" -import fs from 'node:fs' -import readline from 'node:readline' - -let counter = 0 -const rl = readline.createInterface({ input: process.stdin, terminal: false }) -rl.on('line', (line) => { - const trimmed = line.trim() - if (!trimmed) return - let msg - try { - msg = JSON.parse(trimmed) - } catch { - return - } - if (msg.type === 'create') { - counter += 1 - const sessionId = `fake-claude-kar-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', sessionId, protocol: 2 }) + '\n') - const cliSessionId = msg.boot?.resume || '00000000-0000-4000-8000-0000000000ff' - console.log(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] })) - console.log(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' })) - } else if (msg.type === 'shutdown') { - process.exit(0) - } -}) -"#; - -/// One test's temp root: the claude store (fake transcript), the fake -/// sidecar script, and the pane-ledger root (the durable store that survives -/// the scripted restart). -struct Rig { - dir: tempfile::TempDir, -} -impl Rig { - fn install(&self) { - let script = self.dir.path().join("fake-sidecar.mjs"); - std::fs::write(&script, FAKE_SIDECAR).expect("write fake sidecar"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("CLAUDE_CONFIG_DIR", self.dir.path().join("claude-store")); - } - fn write_transcript(&self, durable: &str) { - let dir = self - .dir - .path() - .join("claude-store") - .join("projects") - .join("-t"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write( - dir.join(format!("{durable}.jsonl")), - r#"{"type":"user","cwd":"/tmp","message":{"role":"user","content":[{"type":"text","text":"hi"}]}}"#, - ) +use common::claude_rig::{ClaudeRig, RigKind, RigOptions}; +use freshell_ws::pane_ledger::{RetiredReason, RowState}; +use serde_json::json; + +async fn legacy_close_survives_restart(close_before_restart: bool) { + let mut rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut old_server = rig.connect().await; + let durable = old_server.create("legacy-record-source").await; + rig.converse(&mut old_server, &durable, &["saved before the upgrade"]) + .await; + let placeholder = "persisted-old-pane-placeholder"; + rig.pane_ledger + .as_ref() + .unwrap() + .record_alias_tombstone("claude", placeholder, &durable, 1_000) .unwrap(); + // The old runtime is gone. The alias is now exclusively on disk, as + // it is when a server upgrade first reads an older pane's saved state. + rig.restart().await; + assert!(rig + .pane_ledger + .as_ref() + .unwrap() + .alias_tombstone_records("claude", placeholder) + .iter() + .any(|(id, _)| id == &durable)); + if close_before_restart { + rig.connect().await.kill(placeholder).await; + rig.restart().await; } - fn ledger_root(&self) -> std::path::PathBuf { - self.dir.path().join("pane-ledger") - } -} -impl Drop for Rig { - fn drop(&mut self) { - std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); - std::env::remove_var("FRESHELL_CLAUDE_NODE"); - std::env::remove_var("CLAUDE_CONFIG_DIR"); - } -} - -/// One process generation: a fresh `FreshClaudeState` over a REAL ledger -/// rooted at `root` (a second generation over the same root IS the restart). -fn new_generation( - root: &std::path::Path, -) -> ( - freshell_freshagent::FreshClaudeState, - Arc, - tokio::sync::broadcast::Receiver, -) { - let (tx, rx) = tokio::sync::broadcast::channel::(64); - let st = freshell_freshagent::FreshClaudeState::new(Arc::new(tx)); - let ledger = Arc::new(PaneLedger::new(Some(root.to_path_buf()))); - st.set_identity_sink(Arc::new(LedgerSink::new(ledger.clone()))); - (st, ledger, rx) -} - -fn create_msg_resume(request_id: &str, durable: &str) -> FreshAgentCreate { - FreshAgentCreate { - pane_id: None, - naming_handle: None, - observed_epoch: None, - observed_generation: None, - request_id: request_id.to_string(), - session_type: SessionType::Freshclaude, - provider: Some(AgentProvider::Claude), - cwd: Some("/t".to_string()), // settings-bearing: the adoption writes the row - legacy_restore_context: None, - resume_session_id: None, - session_ref: Some(SessionLocator { - provider: "claude".to_string(), - session_id: durable.to_string(), - }), - model: None, - model_selection: None, - permission_mode: None, - sandbox: None, - effort: None, - plugins: None, - tab_id: None, - } -} - -fn attach_msg(seat: &str, durable: &str) -> FreshAgentAttach { - FreshAgentAttach { - tab_id: None, - pane_id: None, - observed_epoch: None, - observed_generation: None, - provider: AgentProvider::Claude, - session_id: seat.to_string(), - session_type: SessionType::Freshclaude, - cwd: None, - resume_session_id: None, - session_ref: Some(SessionLocator { - provider: "claude".to_string(), - session_id: durable.to_string(), - }), - } -} - -fn kill_msg(session_id: &str) -> FreshAgentKill { - FreshAgentKill { - observed_epoch: None, - observed_generation: None, - provider: AgentProvider::Claude, - session_id: session_id.to_string(), - session_type: SessionType::Freshclaude, - cwd: None, + let mut client = rig.connect().await; + if !close_before_restart { + client.kill(placeholder).await; } -} - -/// Bounded drain of the broadcast receiver until a frame containing `needle` -/// arrives (the lane answers always broadcast through this bus). -async fn await_frame_containing( - rx: &mut tokio::sync::broadcast::Receiver, - needle: &str, -) -> Value { - tokio::time::timeout(std::time::Duration::from_secs(15), async { - loop { - let raw = rx.recv().await.expect("bus frame"); - if raw.contains(needle) { - return serde_json::from_str(&raw).expect("frame is JSON"); - } - } - }) - .await - .unwrap_or_else(|_| panic!("a frame containing {needle} within budget")) -} - -/// Bounded wait for a condition (the consumer's adoption fold runs on its -/// own task; the deadline poll is the suite convention). -async fn await_condition(what: &str, cond: impl Fn() -> bool) { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - if cond() { - return; - } + let ledger = rig.pane_ledger.as_ref().unwrap(); + let row = ledger.load_binding("claude", &durable).unwrap(); + assert_eq!(row.state, RowState::Retired); + assert_eq!(row.retired_reason, Some(RetiredReason::Closed)); + for id in [placeholder, durable.as_str()] { assert!( - tokio::time::Instant::now() < deadline, - "{what} (never happened)" + ledger.kill_tombstone_at("claude", id).is_some(), + "missing close fence for {id}" ); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; } -} -/// Test 1 — the finding's own order across the restart: create-with-resume -/// in generation 1 (the row lands Bound, the alias mapping is persisted at -/// registration/adoption); "restart" (generation 2 over the same durable -/// root — every process-local map is empty); the user THEN closes the pane -/// by its bare placeholder. The kill must resolve the PERSISTED alias, so -/// the durable row ends Retired(Closed) with its fence standing. And the -/// reconnect's late attach (riding the closed seat) must be refused — -/// nothing registers, the row never revives. -#[tokio::test(flavor = "multi_thread")] -async fn a_kill_naming_the_bare_placeholder_after_a_restart_retires_the_durable_row_and_blocks_the_late_attach( -) { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let rig = Rig { - dir: tempfile::tempdir().expect("temp dir"), - }; - rig.install(); - let durable = "a1a1a1a1-a1a1-41a1-81a1-a1a1a1a1a1a1".to_string(); - rig.write_transcript(&durable); - let root = rig.ledger_root(); - - // Generation 1: the session lives; the durable alias record is minted. - let placeholder = { - let (st1, ledger1, mut rx1) = new_generation(&root); - st1.handle_create(create_msg_resume("req-kar-1", &durable), None) - .await; - let created = await_frame_containing(&mut rx1, "\"freshAgent.created\"").await; - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - let ledger1b = ledger1.clone(); - let dur = durable.clone(); - let ph = placeholder.clone(); - await_condition("the adoption row + alias record", move || { - ledger1b - .load_binding("claude", &dur) - .is_some_and(|r| r.state == RowState::Bound) - && !ledger1b.alias_tombstone_records("claude", &ph).is_empty() + client + .send(json!({ + "type":"freshAgent.attach", "provider":"claude", "sessionType":"freshclaude", + "sessionId":placeholder, "sessionRef":{"provider":"claude", "sessionId":durable}, + })) + .await; + let error = client + .wait_for("a closed legacy pane refusal", |frame| { + frame["event"]["type"] == "freshAgent.error" + && frame["event"]["code"] == "SESSION_CLOSED" }) .await; - placeholder - // generation 1 drops here — the "restart" - }; - - // Generation 2: no process-local mapping anywhere. - let (st2, ledger2, mut rx2) = new_generation(&root); - assert!( - ledger2 - .alias_tombstone_records("claude", &placeholder) - .iter() - .any(|(d, _)| d == &durable), - "the persisted alias record survived the scripted restart" - ); - - // The user closes the pane by its bare placeholder NOW (the finding's - // window — before any resume re-registers the alias in memory). - st2.handle_kill(kill_msg(&placeholder)).await; - - let row = ledger2 - .load_binding("claude", &durable) - .expect("the durable row"); + assert_eq!(error["sessionId"], placeholder); + assert!(!rig.fresh_claude.has_live_session(&durable).await); assert_eq!( - row.state, - RowState::Retired, - "the close retired the durable row" - ); - assert_eq!(row.retired_reason, Some(RetiredReason::Closed)); - assert!( - ledger2.kill_tombstone_at("claude", &durable).is_some(), - "the durable close fence landed" - ); - assert!( - ledger2.kill_tombstone_at("claude", &placeholder).is_some(), - "the placeholder's own fence landed too" + ledger.load_binding("claude", &durable).unwrap().state, + RowState::Retired ); + assert!(ledger.kill_tombstone_at("claude", &durable).is_some()); - // The reconnect's late attach (riding the closed seat) must be refused: - // no session registers, the row never revives. - st2.handle_attach(attach_msg(&placeholder, &durable)).await; - let failed = await_frame_containing(&mut rx2, "CLAUDE_ATTACH_RESUME_FAILED").await; - assert_eq!(failed["type"], "error"); - let row = ledger2 - .load_binding("claude", &durable) - .expect("the durable row"); - assert_eq!( - row.state, - RowState::Retired, - "the row stays Retired — the phantom claim never revived it" - ); - assert!( - ledger2.kill_tombstone_at("claude", &durable).is_some(), - "the fence still stands" - ); - assert!( - !st2.has_live_session(&durable).await, - "nothing live registered for the closed pane" - ); -} - -/// Test 2 — the brief's literal pin: the restart boundary sits BETWEEN the -/// fence write and the claim commit. Generation 1 closes the pane (the kill -/// fences placeholder AND durable and retires the row — the live generation -/// resolves its own aliases); the restart then wipes every process-local -/// alias; the late attach's commit must be blocked (a fence recorded under -/// the one-shot seat blocks exactly like one under the durable), and the row -/// stays Retired. -#[tokio::test(flavor = "multi_thread")] -async fn a_close_fenced_before_the_restart_blocks_the_late_claim_and_the_row_stays_retired() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let rig = Rig { - dir: tempfile::tempdir().expect("temp dir"), - }; - rig.install(); - let durable = "a2a2a2a2-a2a2-42a2-82a2-a2a2a2a2a2a2".to_string(); - rig.write_transcript(&durable); - let root = rig.ledger_root(); - - // Generation 1: create-with-resume lives, then the close lands. - let placeholder = { - let (st1, ledger1, mut rx1) = new_generation(&root); - st1.handle_create(create_msg_resume("req-kar-2", &durable), None) - .await; - let created = await_frame_containing(&mut rx1, "\"freshAgent.created\"").await; - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - let ledger1b = ledger1.clone(); - let dur = durable.clone(); - let ph = placeholder.clone(); - await_condition("the adoption row + alias record", move || { - ledger1b - .load_binding("claude", &dur) - .is_some_and(|r| r.state == RowState::Bound) - && !ledger1b.alias_tombstone_records("claude", &ph).is_empty() - }) + // An explicit new open of the durable conversation is still allowed. + // A closed legacy pane and a new open are distinct user actions. + let mut reopened = rig.connect().await; + reopened.attach(&durable).await; + rig.converse(&mut reopened, &durable, &["explicitly reopened"]) .await; + rig.fresh_claude.shutdown().await; +} - // THE FENCE WRITE (pre-restart): the user closes the pane. - st1.handle_kill(kill_msg(&placeholder)).await; - let row = ledger1 - .load_binding("claude", &durable) - .expect("the durable row"); - assert_eq!( - row.state, - RowState::Retired, - "pre-restart: the close retired the row" - ); - assert!(ledger1.kill_tombstone_at("claude", &durable).is_some()); - placeholder - }; - - // ── the restart boundary: every in-memory alias/fence-holder is gone ── - let (st2, ledger2, mut rx2) = new_generation(&root); - assert!( - ledger2.kill_tombstone_at("claude", &placeholder).is_some(), - "the seat's fence is durable across the restart" - ); +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_kill_by_legacy_placeholder_after_restart_closes_its_durable_row() { + legacy_close_survives_restart(false).await; +} - // The claim commit lands now — it must be BLOCKED, the row stays Retired. - st2.handle_attach(attach_msg(&placeholder, &durable)).await; - let failed = await_frame_containing(&mut rx2, "CLAUDE_ATTACH_RESUME_FAILED").await; - assert_eq!(failed["type"], "error"); - let row = ledger2 - .load_binding("claude", &durable) - .expect("the durable row"); - assert_eq!( - row.state, - RowState::Retired, - "the row stays Retired across the restart boundary" - ); - assert_eq!(row.retired_reason, Some(RetiredReason::Closed)); - assert!( - ledger2.kill_tombstone_at("claude", &durable).is_some(), - "the durable fence is never cleared by the blocked commit" - ); - assert!( - !st2.has_live_session(&durable).await, - "nothing live registered for the closed pane" - ); +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_legacy_close_written_before_restart_still_blocks_the_late_attach() { + legacy_close_survives_restart(true).await; } diff --git a/crates/freshell-ws/tests/freshagent_claude_observers.rs b/crates/freshell-ws/tests/freshagent_claude_observers.rs new file mode 100644 index 000000000..bc0da3de2 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_observers.rs @@ -0,0 +1,108 @@ +//! Attaching another page observes one canonical runtime without resetting peers. + +mod common; + +use common::claude_rig::{ClaudeRig, RigKind, RigOptions}; +use serde_json::json; +use std::time::Duration; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn attaching_mid_turn_acknowledges_running_only_to_the_requester() { + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut first = rig.connect().await; + let session = first.create("create-observers").await; + let mut second = rig.connect().await; + first.send_text(&session, "hang", "HANG").await; + first.wait_for_event("freshAgent.stream").await; + second + .send(json!({ + "type": "freshAgent.attach", "provider": "claude", "sessionType": "freshclaude", + "sessionId": session, "sessionRef": {"provider":"claude", "sessionId":session}, + })) + .await; + let ack = second.wait_for_event("freshAgent.session.snapshot").await; + assert_eq!(ack["sessionId"], session); + assert_eq!(ack["event"]["status"], "running"); + first.read_for(Duration::from_millis(100)).await; + assert_eq!( + first.events_seen("freshAgent.session.snapshot"), + 0, + "another page's attach must not reset this page's live stream" + ); + + // Finish the held turn, then prove both observers receive the next + // stream, status, result and completion under the same durable UUID. + first.send(json!({ + "type":"freshAgent.interrupt", "provider":"claude", "sessionType":"freshclaude", "sessionId":session, + })).await; + first.wait_for_event("freshAgent.result").await; + first.read_for(Duration::from_millis(100)).await; + second.read_for(Duration::from_millis(100)).await; + first.frames.clear(); + second.frames.clear(); + first + .send_text(&session, "next", "both pages receive this turn") + .await; + for page in [&mut first, &mut second] { + page.wait_for_event("freshAgent.turn.complete").await; + for event in [ + "freshAgent.stream", + "freshAgent.status", + "freshAgent.result", + ] { + assert!( + page.frames.iter().any(|frame| { + frame["sessionId"] == session && frame["event"]["type"] == event + }), + "the observer missed {event}: {:?}", + page.frames + ); + } + assert_eq!(page.events_seen("freshAgent.turn.complete"), 1); + } + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_legacy_placeholder_attach_materializes_and_resumes_the_durable_uuid() { + let mut rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut first = rig.connect().await; + let session = first.create("create-legacy-observer").await; + rig.converse(&mut first, &session, &["before the restart"]) + .await; + rig.restart().await; + let mut legacy = rig.connect().await; + legacy.send(json!({ + "type": "freshAgent.attach", "provider":"claude", "sessionType":"freshclaude", + "sessionId":"legacy-placeholder", "sessionRef":{"provider":"claude", "sessionId":session}, + })).await; + legacy.wait_for_event("freshAgent.session.snapshot").await; + let materialized = legacy + .frames + .iter() + .position(|frame| { + frame["type"] == "freshAgent.session.materialized" + && frame["previousSessionId"] == "legacy-placeholder" + && frame["sessionId"] == session + }) + .expect("an open legacy pane must learn the canonical UUID"); + let ack = legacy + .frames + .iter() + .position(|frame| { + frame["sessionId"] == session && frame["event"]["type"] == "freshAgent.session.snapshot" + }) + .expect("the migrated pane must receive its attach acknowledgement"); + assert!(materialized < ack); + assert!(rig.fresh_claude.has_live_session(&session).await); + rig.converse(&mut legacy, &session, &["after legacy migration"]) + .await; + rig.fresh_claude.shutdown().await; +} diff --git a/crates/freshell-ws/tests/freshagent_session_lease.rs b/crates/freshell-ws/tests/freshagent_session_lease.rs index 1e9360727..336a9f992 100644 --- a/crates/freshell-ws/tests/freshagent_session_lease.rs +++ b/crates/freshell-ws/tests/freshagent_session_lease.rs @@ -158,10 +158,10 @@ rl.on('line', (line) => { gc.unref() } counter += 1 - const sessionId = `fake-claude-session-${process.pid}-${counter}` + const sessionId = msg.sessionId || `fake-claude-session-${process.pid}-${counter}` const reply = () => { process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') - const cliSessionId = msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' + const cliSessionId = msg.boot?.resume || sessionId process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') } From 58959d1480235bf8f5bc606b0f3e822cf37260fe Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:36:05 -0700 Subject: [PATCH 19/19] test(freshclaude): retain Kilroy handoff owner notifications while awaiting session frames --- .../src/session_handoff/tests.rs | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/crates/freshell-freshagent/src/session_handoff/tests.rs b/crates/freshell-freshagent/src/session_handoff/tests.rs index 3357ee833..7199833fd 100644 --- a/crates/freshell-freshagent/src/session_handoff/tests.rs +++ b/crates/freshell-freshagent/src/session_handoff/tests.rs @@ -8471,20 +8471,38 @@ async fn handoff_to_a_kilroy_target_resumes_as_kilroy_on_the_claude_lane() { // The prior terminal is dead. assert!(rig.registry.terminal_is_dead(&prior_terminal)); - // The created/resumed session's frames carry sessionType "kilroy". + // Retain both facts regardless of their relative delivery order. The + // consumer can publish its first Kilroy frame before or after the runner's + // owner commit; waiting for one must never discard the other. let deadline = tokio::time::Instant::now() + Duration::from_secs(10); let mut saw_kilroy_frame = false; + let mut frames = Vec::new(); while tokio::time::Instant::now() < deadline { - match tokio::time::timeout(Duration::from_millis(250), rig.rx.recv()).await { + match tokio::time::timeout_at(deadline, rig.rx.recv()).await { Ok(Ok(raw)) => { - if raw.contains("\"sessionType\":\"kilroy\"") && raw.contains(&sid) { + let frame: Value = serde_json::from_str(&raw).expect("JSON bus frame"); + if frame["type"] == "session.runtimeOwner" + && frame["sessionId"] == sid + && frame["ownerKind"] == "fresh-agent" + { + frames.push(frame.clone()); + } + if frame["type"] == "freshAgent.event" + && frame["sessionType"] == "kilroy" + && frame["sessionId"] == sid + { saw_kilroy_frame = true; + } + if saw_kilroy_frame + && frames + .iter() + .any(|frame| frame["transition"] == "handoff-committed") + { break; } } Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue, - Ok(Err(_)) => break, - Err(_) => {} + Ok(Err(_)) | Err(_) => break, } } assert!( @@ -8501,7 +8519,6 @@ async fn handoff_to_a_kilroy_target_resumes_as_kilroy_on_the_claude_lane() { } // The broadcast frames carry epoch + ownerKind fresh-agent. - let frames = drain_runtime_owner_frames(&mut rig.rx); let committed = runtime_owner_frame(&frames, "handoff-committed"); assert!(committed["epoch"].as_u64().unwrap_or(0) >= 1); assert_eq!(committed["ownerKind"], json!("fresh-agent"));