From f263ed8b076ee54aace607dec181c4d8f7f4cdc4 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:13:11 -0700 Subject: [PATCH 01/24] refactor(freshclaude): one launch spec and one establish path (G15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every claude sidecar launch now hands the CLI the pane's plugins and, in a hosted lane, the hosted Freshell MCP. Before this, only a create sent them: an attach that resumed an untracked session (a reload after a crash, or a restart), a handoff resume and an undo/redo respawn dropped both. - claude_launch.rs: `ClaudeLaunchSpec` and `create_request()`, the only builder of a sidecar `create` line (it replaces `create_line`). Every line carries the same keys (`plugins` and `hostedMcp` included; unset values are null); `modelSelection` rides only a create that has one. - `FreshAgentSettings.plugins` and `BindingRow.plugins` (serde-optional under LEDGER_VERSION 1): the create records its plugins with its binding row at `sdk.session.init`, and attach-resume, handoff resume and the rollback respawn read them back. An empty plugin list is recorded as none, so a pane without plugins keeps the lineage-only row. `fresh_agent_settings_recorded` counts plugins, matching `load_settings`. - claude/establish.rs: `boot_sidecar` (spawn, lease kill handle, start cancellation, the create line, `created`) for every launch, and `establish_runtime` (§4.9): naming admission, kill-fence claim commit, the fresh create's provisional-key rekey, registration, alias record, post-registration dead-state recheck, lease completion and coordinator commit, moved unchanged out of handle_create and resume_for_attach (the handoff resume is one). It logs `freshagent.claude.runtime_established` (info) and `freshagent.claude.runtime_not_established` (warn). - `decide_boot` (§4.2, I8) matches on `TranscriptLocation`: found resumes (by id from the recorded directory, else by path), absent is lost, unreadable is transient. The attach-resume uses it; `resume_target` is shared with the rollback's fork source. - The lane locates transcripts in the store its sidecars write: this process's environment, or the sidecar environment a rig configures (`with_sidecar_environment`), so RealSidecarSim tests can resume. Tests: - New `freshagent_claude_launch_parity.rs` (RealSidecarSim, hosted lane, real pane ledger, handoff route): the create, attach-resume, handoff resume and rollback respawn `query()` options (simulator ledger) carry the plugins and the `freshell` MCP server. Red: with the old resume and rollback lines, the three resume tests fail (`plugins` null, no `mcpServers`); create was already green. - Ledger, sink and create round trips of the plugins (pane_ledger_tests, freshell-server identity_sink, an in-crate create test), each red with its write or read reverted. - claude_launch unit tests: the spec's line on every boot, and each `decide_boot` row. - The existing kill/claim/commit race tests run unchanged against `establish_runtime` (freshagent lib 1440/1440; ws claude binaries green). - The rig gains `RigOptions` (durable ledger, hosted claude lane, handoff route) and `post_json`; `common/ledger_sink.rs` mirrors the production LedgerIdentitySink. Deviations from the design: - The rollback respawn boots through `boot_sidecar` and the launch spec, but its registration still waits for the fork's first `sdk.session.init` (G01). C13 rewrites the rollback onto `establish_runtime`. - `decide_boot` has no genesis input yet: the binding row's genesis lands in C12. A create that resumes still lets the CLI answer for the transcript (typed CONVERSATION_NOT_FOUND), as before. - `ClaudeCreateOutcome` (I1) follows in its own commit. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 1020 ++++------------- .../src/claude/establish.rs | 669 +++++++++++ .../freshell-freshagent/src/claude_launch.rs | 369 +++++- crates/freshell-freshagent/src/codex.rs | 4 + .../freshell-freshagent/src/identity_sink.rs | 15 + crates/freshell-freshagent/src/lib.rs | 2 + crates/freshell-freshagent/src/opencode_ws.rs | 14 + .../src/pane_ops_tab_tests.rs | 2 + .../src/session_handoff/tests.rs | 1 + crates/freshell-server/src/identity_sink.rs | 57 + .../src/recovery_inventory_tests.rs | 4 + crates/freshell-ws/src/codex_association.rs | 1 + crates/freshell-ws/src/invariants.rs | 1 + .../freshell-ws/src/opencode_association.rs | 1 + crates/freshell-ws/src/pane_ledger.rs | 16 +- crates/freshell-ws/src/pane_ledger_tests.rs | 82 ++ crates/freshell-ws/tests/common/claude_rig.rs | 168 ++- .../freshell-ws/tests/common/ledger_sink.rs | 335 ++++++ crates/freshell-ws/tests/common/mod.rs | 1 + .../freshell-ws/tests/cross_kind_liveness.rs | 3 + .../freshagent_claude_kill_after_restart.rs | 2 + .../tests/freshagent_claude_launch_parity.rs | 242 ++++ .../tests/freshagent_claude_rollback.rs | 1 + .../tests/pane_reconcile_freshagent.rs | 2 + 24 files changed, 2131 insertions(+), 881 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude/establish.rs create mode 100644 crates/freshell-ws/tests/common/ledger_sink.rs create mode 100644 crates/freshell-ws/tests/freshagent_claude_launch_parity.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 4c34c32f9..5f88fde6d 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -76,7 +76,7 @@ use freshell_protocol::{ }; use crate::claude_handshake::{read_created, BootContext}; -use crate::claude_launch::{create_line, ClaudeBoot}; +use crate::claude_launch::{BootDecision, ClaudeBoot, ClaudeLaunchSpec}; use crate::{FreshAgentCreateDedup, FreshAgentCreateOutcome, SharedPaneIdentitySink}; /// The runtime provider (`AGENT_SESSION_TYPES.claude.provider`). @@ -2416,431 +2416,67 @@ impl FreshClaudeState { .as_deref() .and_then(|sid| self.claim_dead_state_snapshot(sid)); - let SpawnedSidecar { - mut child, - mut stdin, - stdout, - ownership_id, - log_labels, - tree_guard, - } = match self.spawn_sidecar_process(session_type).await { - Ok(spawned) => spawned, - Err(err) => { - if let Some(mut g) = lease_guard.take() { - g.fail(); - } - self.fail_create(&request_id, "CLAUDE_SIDECAR_START_FAILED", &err); - return; - } - }; - // Arm the TTL tree-kill path now that the child + its ownership tag exist. - if let Some(g) = lease_guard.as_mut() { - if let Some(pid) = child.id() { - g.set_kill_handle(pid, &ownership_id); - } - } - // kata b8ke Task 3: the spawn's partial runtime (the watchdog's - // reap target) + the pid for the commit's owner identity (captured - // before `child` moves into the session entry). - let sidecar_pid = child.id(); - // b8ke delta round-2 F2: arm the registered start cancellation — - // from here the watchdog's cancel SIGTERMs this child. - crate::ownership_lane::arm_sidecar_pid_slot(&start_pid_slot, sidecar_pid); - if let Some(sid) = resume_sid.as_deref() { - crate::ownership_lane::register_partial_fresh_runtime( - &self.ownership, - PROVIDER, - sid, - &own_ticket, - sid, - sidecar_pid, - ); - } - // b8ke ext r22 F1: the fresh path registers its spawn's partial - // runtime under the pane-scoped claim key (the ticket's key — the - // watchdog's reap target through the window; the `created` mint - // rekey below carries it onto the client-visible key's record). - if let Some(claim_key) = fresh_claim_key.as_deref() { - crate::ownership_lane::register_partial_fresh_runtime( - &self.ownership, - PROVIDER, - claim_key, - &own_ticket, - claim_key, - sidecar_pid, - ); - } - - // P1.13: FULL settings snapshot for the binding row the consumer writes at - // `sdk.session.init` (claude has no sandbox concept — always `None`). Built - // from the SAME values the create request below actually sends. + // P1.13: the FULL settings snapshot the session records at + // `sdk.session.init` (claude has no sandbox concept: always `None`), and + // the launch is built from the same values (G15: the plugins too). let settings = crate::identity_sink::FreshAgentSettings { model: msg.model.clone(), sandbox: None, permission_mode: msg.permission_mode.clone(), effort: msg.effort.clone(), cwd: msg.cwd.clone(), - }; - - // Send the create request (faithful to createClaudeSdkOptions inputs). - let boot = ClaudeBoot::resume_or_fresh(resume_sid.as_deref()); - let create_req = create_line( - &request_id, - session_type, - &boot, - json!({ - "cwd": msg.cwd, - "model": msg.model, - "permissionMode": msg.permission_mode, - "effort": msg.effort, - "plugins": msg.plugins, - "hostedMcp": self.hosted_mcp, - "modelSelection": msg.model_selection, - }), - ); - if let Err(err) = write_line(&mut stdin, &create_req).await { - let _ = child.start_kill(); - reap_owned_claude_sidecars(&ownership_id); - // Own tree torn down above -- releasing the lease is safe (no orphan writer). - if let Some(mut g) = lease_guard.take() { - g.fail(); - } - self.fail_create(&request_id, "CLAUDE_SIDECAR_WRITE_FAILED", &err); - return; + plugins: None, } - - // Read stdout until `created` / `create.failed` (bounded): the sidecar answers - // `created` only once the CLI has booted the conversation, and a refused boot - // (a resume of a conversation the CLI does not have, a CLAUDE_CMD that is not - // the CLI) is a typed `create.failed`, never a session that is already dead. - // Keep the reader to hand to the consumer so no post-created event line is lost. - let mut reader = BufReader::new(stdout).lines(); - let boot_context = BootContext { - request_id: &request_id, + .with_plugins(msg.plugins.clone()); + let spec = ClaudeLaunchSpec::new( + request_id.clone(), session_type, - boot: &boot, - }; - let created = match read_created(&mut reader, SIDECAR_CREATE_BUDGET, &boot_context).await { - Ok(created) => created.session_id, - Err(failure) => { - let _ = child.start_kill(); - reap_owned_claude_sidecars(&ownership_id); - // Own tree torn down above -- release so the loser can acquire. - if let Some(mut g) = lease_guard.take() { - g.fail(); - } - self.fail_create(&request_id, failure.code(), failure.message()); - return; - } - }; - - // Retire-on-kill round 3 (focused-ep5-r2 Findings 4+5): the genuine - // claim COMMITS here — the replacement session is ESTABLISHED - // (sidecar created + `created` answered), so the durable kill fence - // the close recorded is cleared, the stale placeholder alias - // tombstones pointing at this durable are consumed, and a - // kill-closed ledger row returns to Bound. Every failure arm ABOVE - // precedes the commit, so a failed spawn/write/create keeps the - // fence by never touching it (Finding 5); the lease-revoke arm below - // is the ONE post-commit failure and rolls the claim back - // (`rollback_session_claim` re-raises the fence and re-retires the - // row). The commit runs BEFORE `spawn_consumer` by construction, so - // the consumer's `sdk.session.init` adoption write can never consult - // a fence whose clear it raced — the ordering is structural, not - // scheduled. - // - // Unified agent names (Task 2): the pre-durable handle is admitted - // with the placeholder session id — BEFORE any failure arm that - // would otherwise strand a handle the pane content already carries - // (creation/recovery retries re-ensure the same handle idempotently). - let naming_projection = self.admit_create_handle(&msg, session_type, &created).await; - // - // Round 4 (focused-ep5-r3 Finding 1): the commit is CONDITIONAL — - // if a kill landed while this create awaited `created`, the durable - // dead-state has advanced past the claim-start snapshot and the - // commit refuses with NO side effects: the just-built session is an - // orphan of the pane the user already closed, so it is torn down - // here (own tree killed, lease failed open) and the row the kill - // retired STAYS Retired. Nothing was registered yet (the - // consumer/`cli_index`/sessions inserts all follow the commit), so - // a plain process teardown is the entire arm. - if let Some(sid) = resume_sid.as_deref() { - // Round 6 (Finding 2): the commit's placeholder-fence consult — - // the claim's identity registers under the just-minted `created` - // seat; a close fence under it blocks the commit exactly like - // one under the durable. - if !self - .commit_session_claim(sid, claim_dead_state, std::slice::from_ref(&created)) - .await - { - let _ = child.start_kill(); - reap_owned_claude_sidecars(&ownership_id); - if let Some(mut g) = lease_guard.take() { - g.fail(); - } - self.fail_create( - &request_id, - "FRESH_AGENT_CREATE_FAILED", - "session closed while the resume was in flight; torn down", - ); - return; - } - } - - // b8ke ext r22 F1: the FRESH path's mint-time rekey — the SAME - // ticket's in-flight record moves from the pane-scoped provisional - // key to the just-minted client-visible session id in ONE atomic - // step (`rekey_starting`: the provisional key becomes - // `Aliased{to: }` — the rekey family's resolution record, - // so an idempotent re-drive of this createRequestId resolves to the - // session and adopts — and the `created` key holds the SAME - // operation's `Starting` through this handler's tail; the ticket - // survives via `rekey_session_id`, so the RAII typed drop at - // handler end settles the window record). A record already under - // the `created` key (a competitor claimed the freshly discoverable - // id inside the window) REFUSES typed — the minted runtime is torn - // down below (never two writers, never an orphan). - if fresh_claim_key.is_some() { - if let Some(ticket) = own_ticket.as_mut() { - let Some(registry) = self.ownership.as_ref() else { - unreachable!("a live ticket implies a wired coordinator"); - }; - match registry.rekey_starting( - PROVIDER, - ticket.session_id(), - &created, - ticket.operation_id(), - ticket.generation(), - ) { - freshell_ownership::CommitOutcome::Committed => { - ticket.rekey_session_id(&created); - } - outcome => { - tracing::error!(target: "invariant", - provider = PROVIDER, session_id = %created, request_id = %request_id, - outcome = ?outcome, - "freshagent.claude.create_rekey_refused: the minted session id \ - was claimed by another owner during the create window — the \ - minted runtime is torn down (kata b8ke ext r22 F1)" - ); - let _ = child.start_kill(); - reap_owned_claude_sidecars(&ownership_id); - self.fail_create( - &request_id, - "FRESH_AGENT_CREATE_FAILED", - "the minted session id was claimed by another owner during the create; torn down", - ); - return; - } - } - } - } - - // V5 interleaving 2 (Task 12): on the create-resume path, insert - // `cli_index[durable] = map_key` SYNCHRONOUSLY at session registration - // (mirroring the attach path) -- NOT lazily at `sdk.session.init`, which lands - // hundreds of ms later and leaves `has_live_session` blind exactly when the 1s - // loser retry arrives. The `sdk.session.init` write stays as a corrector. - if let Some(sid) = resume_sid.as_deref() { - self.cli_index - .lock() - .await - .insert(sid.to_string(), created.clone()); - } - - // Start the stdout consumer (the completion edge normalization + the Task 2 - // pending-set fold live here) and register the session, under the sessions - // map lock (review PRB-C10): the consumer reaches its session only through - // that lock, so no line it reads -- the boot's command catalog, a CLI death - // right after the boot -- can find the session missing. `Some(settings)` => - // the consumer records a binding row at `sdk.session.init`. - let broadcast_id = Arc::new(std::sync::Mutex::new(created.clone())); - log_labels.bind_session(&broadcast_id); - let pending = Arc::new(std::sync::Mutex::new(ClaudePending::default())); - let in_turn = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let turn_tracker = Arc::new(std::sync::Mutex::new(TurnTracker::default())); - // Task 5: the fresh session's death-mint clock (see the field doc). - let last_synthesized_complete_at = Arc::new(std::sync::Mutex::new(None)); - let turn_lock = Arc::new(TokioMutex::new(())); - // Fresh session: nothing announced yet; the consumer's status fold owns it. - let last_status = Arc::new(std::sync::Mutex::new("idle".to_string())); - { - let mut sessions = self.sessions.lock().await; - let consumer = self.spawn_consumer( - reader, - created.clone(), - session_type.to_string(), - created.clone(), - Some(settings.clone()), - Arc::clone(&broadcast_id), - Arc::clone(&pending), - Arc::clone(&last_status), - Arc::clone(&in_turn), - Arc::clone(&turn_tracker), - Arc::clone(&last_synthesized_complete_at), - None, - provenance, - ); - self.test_holds.registration().await; - sessions.insert( - created.clone(), - ClaudeSession { - configuration: ClaudeConfiguration { - settings, - ..ClaudeConfiguration::default() - }, - stdin, - child, - tree_guard, - ownership_id: ownership_id.clone(), - consumer, - sidecar_session_id: created.clone(), - cli_session_id: resume_sid.clone(), - // b8ke focused episode-2 round-2 F2: the owning lifecycle op - // — the create-resume claim's op id, so the session-init - // adoption's Blocked arm can correlate its OWN in-flight - // claim (a foreign blocker tears down instead). - owning_operation: own_ticket.as_ref().map(|t| t.operation_id().to_string()), + ClaudeBoot::resume_or_fresh(resume_sid.as_deref()), + msg.cwd.clone(), + &settings, + self.hosted_mcp, + ) + .with_model_selection(serde_json::to_value(&msg.model_selection).ok()); + // b8ke focused episode-2 round-2 F2: the create's claim owns this + // runtime's coordinator transition (the session-init adoption's Blocked + // arm correlates its own in-flight claim through it). + let owning_operation = own_ticket.as_ref().map(|t| t.operation_id().to_string()); + let established = match self + .establish_runtime( + EstablishPlan { + lane: EstablishLane::Create, + spec, + register_under: RegisterUnder::Minted, + claim: resume_sid.clone().map(|durable| DurableClaim { + durable, + dead_state: claim_dead_state, + }), + rekey_provisional_claim: fresh_claim_key.is_some(), + recorded_settings: Some(settings), + owning_operation, owning_generation: None, - broadcast_id, - pending, - in_turn, - turn_tracker, - last_synthesized_complete_at, - turn_lock, - rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), - last_status, + provenance, + naming: Some(&msg), + partial_runtime_key: resume_sid.clone().or_else(|| fresh_claim_key.clone()), + start_pid_slot: &start_pid_slot, }, - ); - // C09: a create that resumes an ended session makes it live again. - // Forgotten before the lock is released, so a death of this new - // incarnation is recorded after it, never forgotten by it. - if let Some(sid) = resume_sid.as_deref() { - self.dead_sessions.forget(&[sid], "resumed"); - } - } - // Round 6 (Finding 2): the alias mapping is durable at registration (the - // adopt path's corrector re-stamps it later). - if let Some(sid) = resume_sid.as_deref() { - if let Some(sink) = self.identity_sink() { - if let Err(e) = sink - .record_alias_tombstone( - PROVIDER, - &created, - sid, - crate::session_lease::now_epoch_ms() as i64, - ) - .await - { - tracing::warn!(error = %e, session = %sid, placeholder = %created, - "freshagent.claude.alias_tombstone_write_failed"); - } - } - } - - // Round 5 (focused-ep5-r4 Finding 1): the claim's POST-REGISTRATION - // dead-state re-check. The commit above and a kill's retire serialize - // on the ledger guard, so a kill QUEUED behind a successful commit - // (its decision pre-dated the kill) lands while this lane is between - // the commit and the registration above. Now that the session is - // registered (kill-visible), re-validate against the claim-start - // snapshot: an advanced fence means the row was retired after our - // commit — the registered session is a late orphan of the pane the - // user closed. Tear it down (the kill's retire already left the row - // Retired; the kill's post-close sweep covers the mirror order) and - // answer failed. Kill always wins vs an in-flight claim. - if let Some(sid) = resume_sid.as_deref() { - if self.claim_dead_state_advanced(sid, claim_dead_state) { - tracing::info!(target: "freshell_freshagent::claude", - durable = %sid, - "freshagent.claude.claim_late_orphan_torn_down: a close landed between the \ - claim's commit and its registration; the registered orphan is torn down" - ); - if let Some(session) = self.sessions.lock().await.remove(&created) { - // R2-2: the typed outcome is propagated; this unwind path holds no - // coordinator fence, so the helper's typed log + its detached - // escalation are the whole discipline here. - let _teardown: LoggedTeardown = - self.teardown_removed_session_logged(session).await; - } - // Demote (never drop) the torn-down registration: a later - // kill naming this placeholder still resolves the durable. - self.evict_cli_index_aliases(&created).await; - if let Some(mut g) = lease_guard.take() { - g.fail(); - } - self.fail_create( - &request_id, - "FRESH_AGENT_CREATE_FAILED", - "session closed while the resume was in flight; torn down", - ); - return; - } - } - - // Task 12: bind the durable id to this live session + release the lease in ONE - // lock scope. A revoked lease (expired handle-less holder) means we must NOT - // keep the session -- tear down our own tree and answer failed. - if let Some(mut g) = lease_guard.take() { - if !g.complete(&created) { - if let Some(session) = self.sessions.lock().await.remove(&created) { - session.consumer.abort(); - let mut child = session.child; - let _ = child.start_kill(); - reap_owned_claude_sidecars(&session.ownership_id); - } - if let Some(sid) = resume_sid.as_deref() { - // Finding 5's re-raise: the ONE post-commit failure arm — - // the session is torn back down, so the close the commit - // undid is durable again (fence re-raised, the revived - // row re-retired) before any orphan write can land. - self.rollback_session_claim(sid).await; - // Finding 1: demote (not drop) the torn-down alias. - self.evict_cli_index_aliases(&created).await; - let _ = sid; - } - g.fail(); // own tree torn down -- reopen the key - self.fail_create( - &request_id, - "FRESH_AGENT_CREATE_FAILED", - "session lease revoked during create; torn down", - ); - return; - } - } - - // kata b8ke Task 3: the create's registration survived every teardown - // gate — commit `Live{FreshAgent}` under the claimed durable id (the - // sessions-map key is the `created` placeholder; the stamp lands in - // the lane's retained map under the CANONICAL durable). The FRESH - // create lane (no resume id) never claimed — nothing to commit (the - // canonical durable id only materializes at `sdk.session.init`; the - // residual is covered by the D7 probe backstop). - if let Some(sid) = resume_sid.as_deref() { - if let Err(outcome) = self.commit_lane_claim_at(&mut own_ticket, &created, sidecar_pid) - { - tracing::error!(target: "invariant", - provider = PROVIDER, session_id = %sid, request_id = %request_id, - outcome = ?outcome, - "freshagent.claude.create_commit_stale: the coordinator moved on while \ - the create registered; the uncommitted session is torn down" - ); - if let Some(session) = self.sessions.lock().await.remove(&created) { - // R2-2: the typed outcome is propagated; this unwind path holds no - // coordinator fence, so the helper's typed log + its detached - // escalation are the whole discipline here. - let _teardown: LoggedTeardown = - self.teardown_removed_session_logged(session).await; - } - self.evict_cli_index_aliases(&created).await; + &mut lease_guard, + &mut own_ticket, + ) + .await + { + Ok(established) => established, + Err(error) => { self.fail_create( &request_id, - "FRESH_AGENT_CREATE_FAILED", - "session ownership changed during create; torn down", + error.create_code(), + &error.message(EstablishLane::Create), ); return; } - } + }; + let created = established.map_key; + let naming_projection = established.naming_projection; // Cache the completed create for requestId dedup BEFORE responding (mirrors // codex/opencode: a duplicate `create` arriving right after this point must see @@ -6032,7 +5668,7 @@ impl FreshClaudeState { guard_uuid, ) = match op.direction { RollbackDirection::Undo => { - let path = match snap::locate_transcript_state(&durable_id) { + let path = match self.locate_transcript(&durable_id) { snap::TranscriptLocation::Found(path) => path, snap::TranscriptLocation::Absent => { reply_sink(rollback_error_frame( @@ -6154,7 +5790,7 @@ impl FreshClaudeState { )); return; }; - let original_path = match snap::locate_transcript_state(&original) { + let original_path = match self.locate_transcript(&original) { snap::TranscriptLocation::Found(path) => path, snap::TranscriptLocation::Absent => { reply_sink(rollback_error_frame( @@ -6530,19 +6166,17 @@ impl FreshClaudeState { let recovered = self .identity_sink() .and_then(|s| s.load_settings(PROVIDER, &durable_id)); - let original_cwd = - snap::transcript_cwd(&source_path).filter(|c| std::path::Path::new(c).is_dir()); // resume value (the fork leg): the durable id under its ORIGINAL cwd's // slug scope when that cwd survives; else the transcript PATH (the // verified cli.js escape hatch bypassing slug scoping). - let resume_value = match &original_cwd { - Some(_) => resume_from.clone(), - None => source_path.to_string_lossy().to_string(), - }; + let (resume_value, original_cwd) = + crate::claude_launch::resume_target(&resume_from, &source_path); let spawn_cwd = original_cwd - .clone() .or_else(|| op.cwd.clone()) .or_else(|| recovered.as_ref().and_then(|r| r.cwd.clone())); + // G15: the respawn runs with the session's recorded settings, plugins + // included, and the lane's hosted MCP selection. + let launch_settings = recovered.clone().unwrap_or_default(); // Tear down the old sidecar (handle_kill discipline): remove the record // FIRST so its consumer's exit arm stays silent (evicted=false), then the // shared kill-family teardown (G31: the shutdown request, escalation only @@ -6570,21 +6204,6 @@ impl FreshClaudeState { // the raw guard text is never surfaced). let mut guard_retried = guard_uuid.is_none(); // no guard => nothing to retry let spawned = loop { - let mut create_req = Map::new(); - if let Some(cwd) = &spawn_cwd { - create_req.insert("cwd".to_string(), json!(cwd)); - } - if let Some(rec) = &recovered { - if let Some(model) = &rec.model { - create_req.insert("model".to_string(), json!(model)); - } - if let Some(permission_mode) = &rec.permission_mode { - create_req.insert("permissionMode".to_string(), json!(permission_mode)); - } - if let Some(effort) = &rec.effort { - create_req.insert("effort".to_string(), json!(effort)); - } - } let boot = match &resume_at_uuid { Some(resume_at) => ClaudeBoot::Fork { resume: resume_value.clone(), @@ -6594,14 +6213,16 @@ impl FreshClaudeState { }, None => ClaudeBoot::Fresh, }; - let create_req = create_line( - &format!("rollback-{}", uuid::Uuid::new_v4()), + let spec = ClaudeLaunchSpec::new( + format!("rollback-{}", uuid::Uuid::new_v4()), session_type, - &boot, - Value::Object(create_req), + boot, + spawn_cwd.clone(), + &launch_settings, + self.hosted_mcp, ); match self - .rollback_spawn_create(&create_req, &boot, session_type, lease_guard.as_mut()) + .rollback_spawn_create(&spec, lease_guard.as_mut()) .await { Ok(ok) => break Ok(ok), @@ -7340,24 +6961,29 @@ impl FreshClaudeState { own_ticket, crate::ownership_lane::pid_slot_cancellation(&start_pid_slot), ); - // Positive denial only on a CLEAN miss in EVERY candidate root - // (CLAUDE_CONFIG_DIR > CLAUDE_HOME > platform home -- ledger A3). An - // unreadable store (or no root at all) cannot CHECK, so it must not DENY. - let transcript = match crate::claude_snapshot::locate_transcript_state(durable) { - crate::claude_snapshot::TranscriptLocation::Found(path) => path, - crate::claude_snapshot::TranscriptLocation::Absent => { - return Err(ResumeClaudeError::NotFound); - } - crate::claude_snapshot::TranscriptLocation::Unreadable(error) => { - crate::claude_snapshot::log_transcript_unreadable("attach_resume", durable, &error); - return Err(ResumeClaudeError::Transient(error.to_string())); - } - }; + // Which conversation boots (design §4.2, I8): a found transcript resumes; + // a positive denial only on a CLEAN miss in every candidate root + // (CLAUDE_CONFIG_DIR > CLAUDE_HOME > platform home, ledger A3); an + // unreadable store (or no root at all) cannot CHECK, so it must not deny. + let (boot, transcript_cwd) = + match crate::claude_launch::decide_boot(durable, self.locate_transcript(durable)) { + BootDecision::Boot { boot, cwd } => (boot, cwd), + BootDecision::Lost => return Err(ResumeClaudeError::NotFound), + BootDecision::Transient(error) => { + crate::claude_snapshot::log_transcript_unreadable( + "attach_resume", + durable, + &error, + ); + return Err(ResumeClaudeError::Transient(error.to_string())); + } + }; // 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. V7/A10 - // alarm gate: alarm ONLY when the ledger PROVES prior recording yet no snapshot - // is recoverable. Never-recorded transcripts (the entire pre-existing - // ~/.claude/projects population — resume_for_attach exists FOR them) stay silent. + // resume-in-place does not silently revert model/permissionMode/effort (or, + // G15, drop its plugins). V7/A10 alarm gate: alarm ONLY when the ledger + // PROVES prior recording yet no snapshot is recoverable. Never-recorded + // transcripts (the entire pre-existing ~/.claude/projects population — + // resume_for_attach exists FOR them) stay silent. let sink = self.identity_sink(); let recovered = sink .as_ref() @@ -7377,336 +7003,64 @@ impl FreshClaudeState { ); } let rec = recovered.clone().unwrap_or_default(); - // The CLI's resume lookup is scoped to the ORIGINAL cwd's project slug - // (ledger A15) -- resume with the cwd recorded in the transcript itself. - // If that directory is gone, fall back to path-based resume - // (`--resume .jsonl` bypasses slug scoping -- verified cli.js 2.1.220), - // keeping the attach cwd for the process itself. The path form is - // reserved for a transcript WITH records: a zero-turn transcript - // (touched at create, no records) has no cwd to resolve and NOTHING - // to path-resume — and the create line's resume boot names a - // SESSION ID (the real sidecar forwards it verbatim to the SDK's - // `resume` option), so a path there would make the recovered session - // write under a path-flattened phantom file. An empty transcript - // resumes by id. - let original_cwd = crate::claude_snapshot::transcript_cwd(&transcript) - .filter(|c| std::path::Path::new(c).is_dir()); - let transcript_has_records = std::fs::metadata(&transcript) - .map(|meta| meta.len() > 0) - .unwrap_or(false); - let (resume_value, resume_cwd) = match original_cwd { - Some(cwd) => (durable.to_string(), json!(cwd)), - None if transcript_has_records => ( - transcript.to_string_lossy().to_string(), - json!(msg.cwd.clone().or_else(|| rec.cwd.clone())), - ), - // Attach cwd stays primary; the ledger record's cwd is a FINAL - // fallback only when both existing sources are absent (Task 10). - None => ( - durable.to_string(), - json!(msg.cwd.clone().or_else(|| rec.cwd.clone())), - ), - }; - let boot = ClaudeBoot::Resume { - resume: resume_value, - }; - - // Round 4 (focused-ep5-r3 Finding 1): the claim's dead-state - // SNAPSHOT — taken at claim start, before the sidecar spawn and the - // `created` await — so a kill landing while this resume is in flight - // advances the durable tombstone past it and the commit below - // REFUSES instead of undoing the newer close. + // The CLI runs where the conversation was recorded when that directory + // still exists; otherwise the attach cwd stays primary and the ledger + // record's cwd is a FINAL fallback only when both are absent (Task 10). + let resume_cwd = transcript_cwd + .or_else(|| msg.cwd.clone()) + .or_else(|| rec.cwd.clone()); + + // Round 4 (focused-ep5-r3 Finding 1): the claim's dead-state SNAPSHOT — + // taken at claim start, before the sidecar spawn and the `created` await — + // 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 SpawnedSidecar { - mut child, - mut stdin, - stdout, - ownership_id, - log_labels, - tree_guard, - } = self - .spawn_sidecar_process(session_type_str(msg.session_type)) - .await - .map_err(ResumeClaudeError::Transient)?; - // Task 13: arm the lease's TTL tree-kill path now that the child + tag exist. - if let Some(g) = lease_guard.as_mut() { - if let Some(pid) = child.id() { - g.set_kill_handle(pid, &ownership_id); - } - } - // kata b8ke Task 3: the spawn's partial runtime (the watchdog's reap - // target) + the pid for the commit's owner identity (captured before - // `child` moves into the session entry). - let sidecar_pid = child.id(); - // b8ke delta round-2 F2: arm the registered start cancellation. - crate::ownership_lane::arm_sidecar_pid_slot(&start_pid_slot, sidecar_pid); - crate::ownership_lane::register_partial_fresh_runtime( - &self.ownership, - PROVIDER, - durable, - own_ticket, - durable, - sidecar_pid, - ); - let request_id = format!("attach-resume-{}", uuid::Uuid::new_v4()); let session_type = session_type_str(msg.session_type); - let create_req = create_line( - &request_id, + let spec = ClaudeLaunchSpec::new( + format!("attach-resume-{}", uuid::Uuid::new_v4()), session_type, - &boot, - json!({ - "cwd": resume_cwd, - // Recovered from the ledger record (Task 10); `json!` serializes - // `None` as `null`, preserving today's fallback wire shape exactly - // on a miss. - "model": rec.model, - "permissionMode": rec.permission_mode, - "effort": rec.effort, - }), + boot, + resume_cwd, + &rec, + self.hosted_mcp, ); - if let Err(err) = write_line(&mut stdin, &create_req).await { - let _ = child.start_kill(); - reap_owned_claude_sidecars(&ownership_id); - return Err(ResumeClaudeError::Transient(err)); - } - let mut reader = BufReader::new(stdout).lines(); - let boot_context = BootContext { - request_id: &request_id, - session_type, - boot: &boot, - }; - // A refusal stays transient here: the transcript was found just above, so a - // CLI that cannot resume it is not proof the conversation is gone. - let sidecar_session_id = - match read_created(&mut reader, SIDECAR_CREATE_BUDGET, &boot_context).await { - Ok(created) => created.session_id, - Err(failure) => { - let _ = child.start_kill(); - reap_owned_claude_sidecars(&ownership_id); - return Err(ResumeClaudeError::Transient(format!( - "{}: {}", - failure.code(), - failure.message() - ))); - } - }; - - // Retire-on-kill round 2/3 (focused-ep5-r1 Finding 2, -r2 Findings - // 4+5): this attach is a NEW pane GENUINELY CLAIMING `durable` — the - // claim COMMITS here: the replacement session is established (sidecar - // created + `created` answered), every error return above precedes - // the commit (no spawn-sidecar/no-created failure disturbs a fence it - // never claimed), the commit still strictly precedes the consumer - // below (so its sdk.session.init adoption write is never suppressed - // as the killed session's stale orphan), and the commit now also - // consumes the stale placeholder alias tombstones pointing at - // `durable` (Finding 2) and returns a kill-closed row to Bound - // (Finding 4 — unconditional, independent of the settings snapshot). - // - // Round 4 (focused-ep5-r3 Finding 1): the commit is CONDITIONAL on - // the claim-start dead-state snapshot. A kill that landed while this - // resume awaited `created` advanced the durable tombstone; the commit - // refuses with NO side effects, so the just-spawned session is an - // orphan of a pane the user closed mid-attach — tear it down here - // (nothing registered yet: consumer, sessions insert and cli_index - // all follow the commit) and leave the row Retired. - // Round 6 (focused-ep5-r5 Finding 2): the commit's placeholder-fence - // consult rides the ATTACHING pane's seat — `msg.session_id` — because - // that is exactly the identity a phantom post-restart close fenced: a - // pane the user closed while this slow resume was in flight fenced - // its (one-shot) placeholder, and a fence there blocks the commit - // exactly like one recorded under the durable id. - if !self - .commit_session_claim( - durable, - claim_dead_state, - std::slice::from_ref(&msg.session_id), - ) - .await - { - let _ = child.start_kill(); - reap_owned_claude_sidecars(&ownership_id); - if let Some(mut g) = lease_guard.take() { - g.fail(); - } - return Err(ResumeClaudeError::Transient( - "session closed while the resume was in flight; torn down".to_string(), - )); - } - - let session_type = session_type_str(msg.session_type).to_string(); // Register under the CLIENT's id: the consumer stamps the map key on every // envelope and the frozen client routes by envelope sessionId - // (fresh-agent-ws.ts:180-183) -- a fresh key would strand the pane. - // The sidecar's created id is the eviction identity guard for this consumer. - // `settings` (P1.13, Task 10): `Some(rec)` re-records the row under any new - // cliSessionId (the old durable's row keeps serving `load_settings`, so later - // attaches with the old id still resolve — no repeat-fire, V7 §4); `None` - // records nothing (no laundered blank row under the new id, V7/A10). - let broadcast_id = Arc::new(std::sync::Mutex::new(msg.session_id.clone())); - log_labels.bind_session(&broadcast_id); - let pending = Arc::new(std::sync::Mutex::new(ClaudePending::default())); - let in_turn = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let turn_tracker = Arc::new(std::sync::Mutex::new(TurnTracker::default())); - // Task 5: the resumed session's death-mint clock (see the field doc). - let last_synthesized_complete_at = Arc::new(std::sync::Mutex::new(None)); - let turn_lock = Arc::new(TokioMutex::new(())); - // Freshly resumed session: the tracked status starts "idle" (truthful — no - // turn can be in flight before the client sends). - let last_status = Arc::new(std::sync::Mutex::new("idle".to_string())); - // Spawned and registered under the sessions map lock (review PRB-C10): no - // line the consumer reads can find the resumed session missing. - { - let mut sessions = self.sessions.lock().await; - let consumer = self.spawn_consumer( - reader, - msg.session_id.clone(), - session_type.clone(), - sidecar_session_id.clone(), - recovered.clone(), - Arc::clone(&broadcast_id), - Arc::clone(&pending), - Arc::clone(&last_status), - Arc::clone(&in_turn), - Arc::clone(&turn_tracker), - Arc::clone(&last_synthesized_complete_at), - None, + // (fresh-agent-ws.ts:180-183) -- a fresh key would strand the pane. The + // sidecar's created id is the eviction identity guard for this consumer. + // `recorded_settings` (P1.13, Task 10): `Some(rec)` re-records the row under + // any new cliSessionId (the old durable's row keeps serving `load_settings`, + // so later attaches with the old id still resolve — no repeat-fire, V7 §4); + // `None` records nothing (no laundered blank row under the new id, V7/A10). + // A refusal stays transient: the transcript was found just above, so a CLI + // that cannot resume it is not proof the conversation is gone. + self.establish_runtime( + EstablishPlan { + lane: EstablishLane::AttachResume, + spec, + register_under: RegisterUnder::Pane(msg.session_id.clone()), + claim: Some(DurableClaim { + durable: durable.to_string(), + dead_state: claim_dead_state, + }), + rekey_provisional_claim: false, + recorded_settings: recovered, + owning_operation, + owning_generation: handoff_generation, // Conn-less lane (D8): attach-resume is not a create; the ledger // merge keeps the row's existing stamps. - None, - ); - self.test_holds.registration().await; - sessions.insert( - msg.session_id.clone(), - ClaudeSession { - configuration: ClaudeConfiguration { - settings: recovered.unwrap_or_default(), - ..ClaudeConfiguration::default() - }, - stdin, - child, - tree_guard, - ownership_id, - consumer, - sidecar_session_id, - cli_session_id: Some(durable.to_string()), - owning_operation, - owning_generation: handoff_generation, - broadcast_id, - pending, - in_turn, - turn_tracker, - last_synthesized_complete_at, - turn_lock, - rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), - last_status, - }, - ); - // C09: the session is live again, so it is no longer an ended one. - // Forgotten before the lock is released, so a death of this new - // incarnation is recorded after it, never forgotten by it. - self.dead_sessions - .forget(&[msg.session_id.as_str(), durable], "resumed"); - } - self.cli_index - .lock() - .await - .insert(durable.to_string(), msg.session_id.clone()); - // Test seam (Task 6 round-3 review I-1): park with the target - // session REGISTERED (map + cli_index) but the resume unreturned — - // the fresh-arm target-spawn-window abort test's deterministic - // hold. None in production and every other test. - if let Some(pause) = self.handoff_resume_pause.as_ref() { - let _ = pause.notified().await; - } - // Round 6 (Finding 2): the alias mapping is durable at registration. - if let Some(sink) = self.identity_sink() { - if let Err(e) = sink - .record_alias_tombstone( - PROVIDER, - &msg.session_id, - durable, - crate::session_lease::now_epoch_ms() as i64, - ) - .await - { - tracing::warn!(error = %e, session = %durable, placeholder = %msg.session_id, - "freshagent.claude.alias_tombstone_write_failed"); - } - } - - // Round 5 (focused-ep5-r4 Finding 1): the claim's POST-REGISTRATION - // dead-state re-check (the create lane's twin lives above - // `finish_create`). A kill QUEUED behind the successful commit lands - // between the commit and the registration; now that the session is - // registered (kill-visible), an advanced fence means the row was - // retired after our commit — the registered session is a late - // orphan: tear it down and fail the resume (the caller broadcasts - // `CLAUDE_ATTACH_RESUME_FAILED`), leaving the kill's row Retired. - if self.claim_dead_state_advanced(durable, claim_dead_state) { - tracing::info!(target: "freshell_freshagent::claude", - durable = %durable, - "freshagent.claude.claim_late_orphan_torn_down: a close landed between the \ - claim's commit and its registration; the registered orphan is torn down" - ); - if let Some(session) = self.sessions.lock().await.remove(&msg.session_id) { - // R2-2: the typed outcome is propagated; this unwind path holds no - // coordinator fence, so the helper's typed log + its detached - // escalation are the whole discipline here. - let _teardown: LoggedTeardown = self.teardown_removed_session_logged(session).await; - } - self.evict_cli_index_aliases(&msg.session_id).await; // demote, never drop - if let Some(mut g) = lease_guard.take() { - g.fail(); - } - return Err(ResumeClaudeError::Transient( - "session closed while the resume was in flight; torn down".to_string(), - )); - } - - // Task 13: bind the durable id to this live session + release the lease. A - // revoked lease means we must NOT keep the session -- tear down and fail. - if let Some(mut g) = lease_guard.take() { - if !g.complete(&msg.session_id) { - if let Some(session) = self.sessions.lock().await.remove(&msg.session_id) { - session.consumer.abort(); - let mut child = session.child; - let _ = child.start_kill(); - reap_owned_claude_sidecars(&session.ownership_id); - } - // Finding 5's re-raise: the ONE post-commit failure arm — the - // session is torn back down, so the close the commit undid is - // durable again (fence re-raised, the revived row re-retired). - self.rollback_session_claim(durable).await; - self.evict_cli_index_aliases(&msg.session_id).await; // Finding 1: demote, never drop - g.fail(); // own tree torn down -- reopen the key - return Err(ResumeClaudeError::Transient( - "session lease revoked during attach-resume; torn down".to_string(), - )); - } - } - - // kata b8ke Task 3: the attach-resume's registration survived every - // teardown gate — commit `Live{FreshAgent}` under the durable id - // (the sessions-map key is the CLIENT's msg.session_id). - if let Err(outcome) = self.commit_lane_claim_at(own_ticket, &msg.session_id, sidecar_pid) { - tracing::error!(target: "invariant", - provider = PROVIDER, session_id = %durable, - outcome = ?outcome, - "freshagent.claude.attach_commit_stale: the coordinator moved on while the \ - resume registered; the uncommitted session is torn down" - ); - if let Some(session) = self.sessions.lock().await.remove(&msg.session_id) { - // R2-2: the typed outcome is propagated; this unwind path holds no - // coordinator fence, so the helper's typed log + its detached - // escalation are the whole discipline here. - let _teardown: LoggedTeardown = self.teardown_removed_session_logged(session).await; - } - self.evict_cli_index_aliases(&msg.session_id).await; - return Err(ResumeClaudeError::Transient( - "session ownership changed during attach-resume; torn down".to_string(), - )); - } + provenance: None, + naming: None, + partial_runtime_key: Some(durable.to_string()), + start_pid_slot: &start_pid_slot, + }, + lease_guard, + own_ticket, + ) + .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 @@ -9976,65 +9330,37 @@ impl FreshClaudeState { self.evict_cli_index_aliases(map_key).await; } - /// One rollback-respawn attempt (kata 1wxv Task 4): spawn the sidecar, arm - /// the lease kill handle, write the create request, and PREREAD its output - /// until the `sdk.session.init` line — the `--resume-drops-turn` refusal + /// One rollback-respawn attempt (kata 1wxv Task 4): boot a sidecar for + /// `spec` ([`Self::boot_sidecar`]: spawn, arm the lease kill handle, write + /// the create line, read `created`), then PREREAD its output until the + /// `sdk.session.init` line — the `--resume-drops-turn` refusal /// watch lives in this pre-read, BEFORE any durable state moves (the refusal /// is retried by the caller with the guard omitted; the pre-read ordering is /// how a refused fork can never adopt). async fn rollback_spawn_create( &self, - create_req: &Value, - boot: &ClaudeBoot, - session_type: &str, + spec: &ClaudeLaunchSpec, lease_guard: Option<&mut crate::FreshSessionLeaseGuard>, ) -> Result { - let SpawnedSidecar { + let BootedSidecar { mut child, - mut stdin, - stdout, + stdin, + mut reader, ownership_id, log_labels, tree_guard, + created, } = self - .spawn_sidecar_process(session_type) + .boot_sidecar(spec, lease_guard, None) .await - .map_err(RollbackSpawnError::Other)?; - if let Some(g) = lease_guard { - if let Some(pid) = child.id() { - g.set_kill_handle(pid, &ownership_id); - } - } - if let Err(err) = write_line(&mut stdin, create_req).await { - let _ = child.start_kill(); - reap_owned_claude_sidecars(&ownership_id); - return Err(RollbackSpawnError::Other(err)); - } - let mut reader = BufReader::new(stdout).lines(); - let request_id = create_req - .get("requestId") - .and_then(Value::as_str) - .unwrap_or_default(); - let boot_context = BootContext { - request_id, - session_type, - boot, - }; - let sidecar_session_id = - match read_created(&mut reader, SIDECAR_CREATE_BUDGET, &boot_context).await { - Ok(created) => created.session_id, - Err(failure) => { - let _ = child.start_kill(); - reap_owned_claude_sidecars(&ownership_id); - // The CLI refuses a fork whose drops-turn guard does not hold at - // boot (T5): the same refusal the init pre-read below watches for. - return Err(if failure.is_refusal(FORK_GUARD_REFUSED_CODE) { - RollbackSpawnError::GuardRefusal - } else { - RollbackSpawnError::Other(failure.message().to_string()) - }); + .map_err(|error| match error { + // The CLI refuses a fork whose drops-turn guard does not hold at + // boot (T5): the same refusal the init pre-read below watches for. + EstablishError::Boot(failure) if failure.is_refusal(FORK_GUARD_REFUSED_CODE) => { + RollbackSpawnError::GuardRefusal } - }; + other => RollbackSpawnError::Other(other.message(EstablishLane::Create)), + })?; match read_session_init(&mut reader, SIDECAR_CREATE_BUDGET).await { Ok(init) => { let cli_id = init @@ -10049,7 +9375,7 @@ impl FreshClaudeState { reader, ownership_id, log_labels, - sidecar_session_id, + sidecar_session_id: created.session_id, preseeded_init: init, cli_id, }) @@ -10700,6 +10026,11 @@ fn log_claude_teardown( } } +mod establish; +use establish::{ + BootedSidecar, DurableClaim, EstablishError, EstablishLane, EstablishPlan, RegisterUnder, +}; + #[cfg(test)] mod teardown_tests; @@ -20580,6 +19911,44 @@ rl.on('line', (line) => { assert!(b.settings.cwd.is_some()); } + /// G15: the create's plugins are part of the settings the session records + /// at `sdk.session.init`, so every later launch of the session can restore + /// them. An empty plugin list is no plugins: it records nothing, so a + /// create that names none keeps the lineage-only row shape. + #[tokio::test(flavor = "multi_thread")] + async fn session_init_records_the_create_plugins_and_an_empty_list_as_none() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + let (state, mut rx) = state_with_bus(); + let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(fake.clone()); + + 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; + assert_eq!( + row.settings.plugins, + Some(vec!["/workspace/.claude/plugins/local".to_string()]) + ); + + let (state, mut rx) = state_with_bus(); + let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(fake.clone()); + 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; + assert_eq!( + row.settings, + crate::identity_sink::FreshAgentSettings::default(), + "an empty plugin list records nothing" + ); + drop(env); + } + /// b8ke ext r27 F4 reshape (was /// `session_init_with_all_blank_settings_records_no_binding`): an /// all-blank settings snapshot now records a LINEAGE-ONLY row — the @@ -20862,6 +20231,7 @@ rl.on('line', (line) => { permission_mode: Some("plan".into()), effort: Some("high".into()), cwd: None, + plugins: None, }, ); state.set_identity_sink(fake); diff --git a/crates/freshell-freshagent/src/claude/establish.rs b/crates/freshell-freshagent/src/claude/establish.rs new file mode 100644 index 000000000..0f6a89669 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/establish.rs @@ -0,0 +1,669 @@ +//! One spawn-and-commit path for claude sidecars (core-runtime design §4.9, C11). +//! +//! Every launch boots its sidecar through [`FreshClaudeState::boot_sidecar`]: spawn +//! the sidecar, arm the lease's kill handle and the start's cancellation, write the +//! launch's one `create` line ([`ClaudeLaunchSpec::create_request`]) and read the +//! sidecar's answer. A create and an attach-resume (the handoff resume is one) then +//! commit the booted sidecar through [`FreshClaudeState::establish_runtime`]: +//! +//! ```text +//! boot → naming admission (create) → kill-fence claim commit (resumes) +//! → provisional-key rekey (fresh create) → register the session +//! → alias records (resumes) → post-registration dead-state recheck +//! → lease completion → coordinator commit (resumes) +//! ``` +//! +//! The guards are the ones the create and attach handlers used to run inline, +//! moved here unchanged, so the kill/claim/commit race tests pin this one path. The +//! rollback respawn boots through `boot_sidecar` too; its registration still waits +//! for the fork's first turn until C13 moves it onto `establish_runtime`. + +use super::*; + +use crate::claude_handshake::{CreateFailure, SidecarCreated}; +use crate::claude_launch::ClaudeLaunchSpec; + +/// The launch establishing a runtime: it names the failures and the log events. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum EstablishLane { + Create, + AttachResume, +} + +impl EstablishLane { + fn name(self) -> &'static str { + match self { + Self::Create => "create", + Self::AttachResume => "attach-resume", + } + } +} + +/// 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), +} + +/// The durable session a launch claims, with the identity's dead state as it +/// stood when the claim started (before the spawn), so a close that lands while +/// the sidecar boots refuses the commit instead of being undone by it. +pub(super) struct DurableClaim { + pub(super) durable: String, + pub(super) dead_state: Option, +} + +/// One runtime to establish. +pub(super) struct EstablishPlan<'a> { + pub(super) lane: EstablishLane, + pub(super) spec: ClaudeLaunchSpec, + pub(super) register_under: RegisterUnder, + /// The durable session this launch claims (every resume). `None` for a fresh + /// create: its durable id appears only with its first turn. + pub(super) claim: Option, + /// A fresh create's coordinator claim is held under its pane-scoped + /// provisional key; the minted session id takes it over once `created` + /// names it (b8ke ext r22 F1). + pub(super) rekey_provisional_claim: bool, + /// The settings the session records at `sdk.session.init` (`None`: record + /// nothing, the resume of a session that never recorded any, V7/A10). The + /// session runs with them, or with the defaults. + pub(super) recorded_settings: Option, + /// The lifecycle operation that owns this runtime's coordinator transition. + pub(super) owning_operation: Option, + pub(super) owning_generation: Option, + pub(super) provenance: Option, + /// A create's request: its pending naming handle is admitted under the + /// minted id once the sidecar answered. + pub(super) naming: Option<&'a FreshAgentCreate>, + /// The coordinator key the spawn registers as the start's partial runtime + /// (the watchdog's reap target), when the start holds a claim. + pub(super) partial_runtime_key: Option, + /// The start's cancellation slot: the watchdog signals the spawned sidecar. + pub(super) start_pid_slot: &'a crate::ownership_lane::SidecarPidSlot, +} + +/// A runtime that is registered and committed. +pub(super) struct Established { + /// 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). + pub(super) naming_projection: Option<( + freshell_protocol::session_names::SessionNameRef, + freshell_protocol::session_names::SessionNameRecord, + )>, +} + +/// Why a runtime was not established. Everything the launch spawned is torn down. +#[derive(Debug)] +pub(super) enum EstablishError { + /// The sidecar process did not start. + Spawn(String), + /// The `create` line could not be written. + Write(String), + /// The sidecar did not answer `created` (a typed refusal, a protocol + /// mismatch, or no answer). + Boot(CreateFailure), + /// A close of the session landed while the launch was in flight. + ClosedInFlight, + /// The launch's lease was revoked while it was in flight. + LeaseRevoked, + /// The ownership coordinator moved on while the launch registered. + OwnershipChanged, + /// Another owner claimed the minted session id during a fresh create. + MintedIdClaimed, +} + +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::Boot(failure) => failure.message().to_string(), + Self::ClosedInFlight => { + "session closed while the resume was in flight; torn down".to_string() + } + Self::LeaseRevoked => { + format!("session lease revoked during {}; torn down", lane.name()) + } + Self::OwnershipChanged => format!( + "session ownership changed during {}; torn down", + lane.name() + ), + Self::MintedIdClaimed => { + "the minted session id was claimed by another owner during the create; torn down" + .to_string() + } + } + } + + /// The `freshAgent.create.failed` code of a create that failed this way. + pub(super) fn create_code(&self) -> &str { + match self { + Self::Spawn(_) => "CLAUDE_SIDECAR_START_FAILED", + Self::Write(_) => "CLAUDE_SIDECAR_WRITE_FAILED", + Self::Boot(failure) => failure.code(), + Self::ClosedInFlight + | Self::LeaseRevoked + | Self::OwnershipChanged + | Self::MintedIdClaimed => "FRESH_AGENT_CREATE_FAILED", + } + } + + /// The transient failure a resume reports for this. + pub(super) fn resume_detail(&self) -> String { + match self { + Self::Boot(failure) => format!("{}: {}", failure.code(), failure.message()), + other => other.message(EstablishLane::AttachResume), + } + } + + fn kind(&self) -> &'static str { + match self { + Self::Spawn(_) => "spawn", + Self::Write(_) => "write", + Self::Boot(_) => "boot", + Self::ClosedInFlight => "closed_in_flight", + Self::LeaseRevoked => "lease_revoked", + Self::OwnershipChanged => "ownership_changed", + Self::MintedIdClaimed => "minted_id_claimed", + } + } +} + +/// A sidecar that answered `created`: the CLI booted the launch's conversation. +pub(super) struct BootedSidecar { + pub(super) child: Child, + pub(super) stdin: ChildStdin, + /// The sidecar's stdout, positioned on the line after `created`. + pub(super) reader: tokio::io::Lines>, + pub(super) ownership_id: String, + pub(super) log_labels: crate::claude_sidecar_io::SidecarLogLabels, + pub(super) tree_guard: crate::claude_spawn::SidecarTreeGuard, + pub(super) created: SidecarCreated, +} + +/// The start's cancellation, armed with the spawned sidecar. +pub(super) struct StartWatch<'a> { + pid_slot: &'a crate::ownership_lane::SidecarPidSlot, + partial_runtime_key: Option<&'a str>, + ticket: &'a Option, +} + +impl FreshClaudeState { + /// The transcript store this lane's sidecars write: the roots their CLI + /// resolves. That is this process's environment, unless the lane gives its + /// sidecars their own (the `test-support` seam a rig uses), whose + /// `CLAUDE_CONFIG_DIR`, `CLAUDE_HOME` and `HOME` then decide. + pub(super) fn transcript_roots(&self) -> Vec { + match self.sidecar_environment.as_deref() { + Some(environment) => { + let lookup = |key: &str| { + environment + .iter() + .find(|(name, _)| name == key) + .map(|(_, value)| value.clone()) + }; + crate::claude_snapshot::claude_home_candidates_from( + lookup, + lookup("HOME").map(PathBuf::from), + ) + } + None => crate::claude_snapshot::claude_home_candidates(), + } + } + + /// Where the transcript of `session_id` is in this lane's store. + pub(super) fn locate_transcript( + &self, + session_id: &str, + ) -> crate::claude_snapshot::TranscriptLocation { + crate::claude_snapshot::locate_transcript_state_in(&self.transcript_roots(), session_id) + } + + /// Spawn a sidecar for `spec`, arm the lease's kill handle and (when given) + /// the start's cancellation, write the launch's `create` line and read the + /// sidecar's answer, bounded by [`SIDECAR_CREATE_BUDGET`]. A failure kills + /// what was spawned; the lease is the caller's to release. + pub(super) async fn boot_sidecar( + &self, + spec: &ClaudeLaunchSpec, + lease_guard: Option<&mut crate::FreshSessionLeaseGuard>, + start: Option>, + ) -> Result { + let SpawnedSidecar { + mut child, + mut stdin, + stdout, + ownership_id, + log_labels, + tree_guard, + } = self + .spawn_sidecar_process(&spec.session_type) + .await + .map_err(EstablishError::Spawn)?; + // Arm the lease's TTL tree-kill path now that the child and its tag exist. + if let Some(guard) = lease_guard { + if let Some(pid) = child.id() { + guard.set_kill_handle(pid, &ownership_id); + } + } + if let Some(start) = start { + let sidecar_pid = child.id(); + // From here the watchdog's start cancellation signals this child, and + // the spawn is the start's partial runtime (its reap target). + crate::ownership_lane::arm_sidecar_pid_slot(start.pid_slot, sidecar_pid); + if let Some(key) = start.partial_runtime_key { + crate::ownership_lane::register_partial_fresh_runtime( + &self.ownership, + PROVIDER, + key, + start.ticket, + key, + sidecar_pid, + ); + } + } + if let Err(error) = write_line(&mut stdin, &spec.create_request()).await { + let _ = child.start_kill(); + reap_owned_claude_sidecars(&ownership_id); + return Err(EstablishError::Write(error)); + } + // The sidecar answers `created` only once the CLI has booted the + // conversation, and a refused boot is a typed `create.failed`, never a + // session that is already dead. The reader is kept for the consumer, so + // no line after `created` is lost. + let mut reader = BufReader::new(stdout).lines(); + let context = BootContext { + request_id: &spec.request_id, + session_type: &spec.session_type, + boot: &spec.boot, + }; + match read_created(&mut reader, SIDECAR_CREATE_BUDGET, &context).await { + Ok(created) => Ok(BootedSidecar { + child, + stdin, + reader, + ownership_id, + log_labels, + tree_guard, + created, + }), + Err(failure) => { + let _ = child.start_kill(); + reap_owned_claude_sidecars(&ownership_id); + Err(EstablishError::Boot(failure)) + } + } + } + + /// Boot a sidecar for `plan` and commit it as the session's live runtime + /// (design §4.9). Every failure tears down what was spawned and releases the + /// lease; `own_ticket` is committed on success and otherwise left for the + /// caller's drop (the RAII typed fail). + pub(super) async fn establish_runtime( + &self, + plan: EstablishPlan<'_>, + lease_guard: &mut Option, + own_ticket: &mut Option, + ) -> Result { + let lane = plan.lane; + let request_id = plan.spec.request_id.clone(); + let boot_kind = plan.spec.boot.kind(); + let durable = plan.claim.as_ref().map(|claim| claim.durable.clone()); + let outcome = self.establish(plan, lease_guard, own_ticket).await; + match &outcome { + Ok(established) => tracing::info!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.runtime_established", + lane = lane.name(), + request_id = %request_id, + boot = boot_kind, + session_id = %established.map_key, + durable = durable.as_deref(), + "a claude runtime is established" + ), + Err(error) => tracing::warn!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.runtime_not_established", + lane = lane.name(), + request_id = %request_id, + boot = boot_kind, + durable = durable.as_deref(), + failure = error.kind(), + message = %error.message(lane), + "a claude runtime was not established; everything it spawned is torn down" + ), + } + outcome + } + + async fn establish( + &self, + plan: EstablishPlan<'_>, + lease_guard: &mut Option, + own_ticket: &mut Option, + ) -> Result { + let EstablishPlan { + lane, + spec, + register_under, + claim, + rekey_provisional_claim, + recorded_settings, + owning_operation, + owning_generation, + provenance, + naming, + partial_runtime_key, + start_pid_slot, + } = plan; + let start = StartWatch { + pid_slot: start_pid_slot, + partial_runtime_key: partial_runtime_key.as_deref(), + ticket: own_ticket, + }; + let booted = match self + .boot_sidecar(&spec, lease_guard.as_mut(), Some(start)) + .await + { + Ok(booted) => booted, + Err(error) => { + // The spawned tree is torn down: releasing the lease is safe. + if let Some(mut guard) = lease_guard.take() { + guard.fail(); + } + return Err(error); + } + }; + let BootedSidecar { + mut child, + stdin, + reader, + ownership_id, + log_labels, + tree_guard, + created, + } = booted; + // The pid for the commit's owner identity (captured before `child` moves + // into the session record). + let sidecar_pid = child.id(); + let minted = created.session_id; + let map_key = match register_under { + RegisterUnder::Minted => minted.clone(), + RegisterUnder::Pane(pane) => pane, + }; + + // 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 { + Some(msg) => { + self.admit_create_handle(msg, &spec.session_type, &minted) + .await + } + None => None, + }; + + // Retire-on-kill rounds 2-6: a resume GENUINELY CLAIMS the durable id, and + // the claim commits here, once the replacement is established (`created` + // answered) and strictly before the consumer starts, so its + // `sdk.session.init` adoption write never consults a fence whose clear it + // raced. The commit is conditional on the claim-start dead state: a close + // that landed while the sidecar booted refuses it with no side effects, + // and the just-spawned sidecar is an orphan of a pane the user closed. A + // close fence under the registration seat (the minted id, or the + // attaching pane's id) blocks the commit exactly like one under the + // durable id (round 6). + if let Some(claim) = claim.as_ref() { + if !self + .commit_session_claim( + &claim.durable, + claim.dead_state, + std::slice::from_ref(&map_key), + ) + .await + { + let _ = child.start_kill(); + reap_owned_claude_sidecars(&ownership_id); + if let Some(mut guard) = lease_guard.take() { + guard.fail(); + } + return Err(EstablishError::ClosedInFlight); + } + } + + // b8ke ext r22 F1: a fresh create's in-flight coordinator record moves + // from its pane-scoped provisional key to the minted id in one atomic + // step (`rekey_starting`; the provisional key becomes the resolution + // alias an idempotent re-drive adopts through). A record already under + // the minted id (a competitor claimed it inside the window) refuses + // typed, and the minted runtime is torn down: never two writers. + if rekey_provisional_claim { + if let Some(ticket) = own_ticket.as_mut() { + let Some(registry) = self.ownership.as_ref() else { + unreachable!("a live ticket implies a wired coordinator"); + }; + match registry.rekey_starting( + PROVIDER, + ticket.session_id(), + &minted, + ticket.operation_id(), + ticket.generation(), + ) { + freshell_ownership::CommitOutcome::Committed => { + ticket.rekey_session_id(&minted); + } + outcome => { + tracing::error!(target: "invariant", + provider = PROVIDER, session_id = %minted, + request_id = %spec.request_id, outcome = ?outcome, + "freshagent.claude.create_rekey_refused: the minted session id \ + was claimed by another owner during the create window — the \ + minted runtime is torn down (kata b8ke ext r22 F1)" + ); + let _ = child.start_kill(); + reap_owned_claude_sidecars(&ownership_id); + return Err(EstablishError::MintedIdClaimed); + } + } + } + } + + // Start the stdout consumer and register the session under the sessions + // map lock (review PRB-C10): the consumer reaches its session only + // through that lock, so no line it reads (the boot's command catalog, a + // CLI death right after the boot) can find the session missing. + let durable = claim.as_ref().map(|claim| claim.durable.clone()); + let configuration_settings = recorded_settings.clone().unwrap_or_default(); + let broadcast_id = Arc::new(std::sync::Mutex::new(map_key.clone())); + log_labels.bind_session(&broadcast_id); + let pending = Arc::new(std::sync::Mutex::new(ClaudePending::default())); + let in_turn = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let turn_tracker = Arc::new(std::sync::Mutex::new(TurnTracker::default())); + // Task 5: the session's death-mint clock (see the field doc). + let last_synthesized_complete_at = Arc::new(std::sync::Mutex::new(None)); + let turn_lock = Arc::new(TokioMutex::new(())); + // A just-booted session has announced nothing: no turn can be in flight + // before the client sends. + let last_status = Arc::new(std::sync::Mutex::new("idle".to_string())); + { + let mut sessions = self.sessions.lock().await; + let consumer = self.spawn_consumer( + reader, + map_key.clone(), + spec.session_type.clone(), + minted.clone(), + recorded_settings, + Arc::clone(&broadcast_id), + Arc::clone(&pending), + Arc::clone(&last_status), + Arc::clone(&in_turn), + Arc::clone(&turn_tracker), + Arc::clone(&last_synthesized_complete_at), + None, + provenance, + ); + self.test_holds.registration().await; + sessions.insert( + map_key.clone(), + ClaudeSession { + configuration: ClaudeConfiguration { + settings: configuration_settings, + ..ClaudeConfiguration::default() + }, + stdin, + child, + tree_guard, + ownership_id, + consumer, + sidecar_session_id: minted, + cli_session_id: durable.clone(), + // b8ke focused episode-2 round-2 F2: the lifecycle op that owns + // this runtime's coordinator transition, so the session-init + // adoption's Blocked arm can correlate its own in-flight claim. + owning_operation, + // b8ke ext r27 F2: the handoff's generation (the under-ticket + // target resume) stamps the adoption's binding write. + owning_generation, + broadcast_id, + pending, + in_turn, + turn_tracker, + last_synthesized_complete_at, + turn_lock, + rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), + last_status, + }, + ); + // C09: a resumed session is live again, so it is no longer an ended + // one. Forgotten before the lock is released, so a death of this new + // incarnation is recorded after it, never forgotten by it. + if let Some(durable) = durable.as_deref() { + self.dead_sessions + .forget(&[map_key.as_str(), durable], "resumed"); + } + } + // V5 interleaving 2 (Task 12): the durable id resolves to the session at + // registration, not lazily at `sdk.session.init` (hundreds of ms later, + // exactly when a 1 s loser retry arrives). + if let Some(durable) = durable.as_deref() { + self.cli_index + .lock() + .await + .insert(durable.to_string(), map_key.clone()); + } + // Test seam (Task 6 round-3 review I-1): park with the target session + // registered but the resume unreturned (the handoff abort-window tests). + if lane == EstablishLane::AttachResume { + if let Some(pause) = self.handoff_resume_pause.as_ref() { + let _ = pause.notified().await; + } + } + // Round 6 (Finding 2): the seat→durable mapping is durable at registration. + if let Some(durable) = durable.as_deref() { + if let Some(sink) = self.identity_sink() { + if let Err(e) = sink + .record_alias_tombstone( + PROVIDER, + &map_key, + durable, + crate::session_lease::now_epoch_ms() as i64, + ) + .await + { + tracing::warn!(error = %e, session = %durable, placeholder = %map_key, + "freshagent.claude.alias_tombstone_write_failed"); + } + } + } + + // Round 5 (focused-ep5-r4 Finding 1): the claim's POST-REGISTRATION + // dead-state recheck. The commit and a kill's retire serialize on the + // ledger guard, so a kill queued behind the commit lands between the + // commit and the registration. Now that the session is kill-visible, an + // advanced fence means the row was retired after the commit: the + // registered session is a late orphan of the pane the user closed. Kill + // always wins against an in-flight claim. + if let Some(claim) = claim.as_ref() { + if self.claim_dead_state_advanced(&claim.durable, claim.dead_state) { + tracing::info!(target: "freshell_freshagent::claude", + durable = %claim.durable, + "freshagent.claude.claim_late_orphan_torn_down: a close landed between the \ + claim's commit and its registration; the registered orphan is torn down" + ); + if let Some(session) = self.sessions.lock().await.remove(&map_key) { + // R2-2: this unwind holds no coordinator fence, so the + // helper's typed log and detached escalation are the whole + // discipline here. + let _teardown: LoggedTeardown = + self.teardown_removed_session_logged(session).await; + } + // Demote, never drop: a later kill naming the seat still + // resolves the durable. + self.evict_cli_index_aliases(&map_key).await; + if let Some(mut guard) = lease_guard.take() { + guard.fail(); + } + return Err(EstablishError::ClosedInFlight); + } + } + + // 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. + if let Some(mut guard) = lease_guard.take() { + if !guard.complete(&map_key) { + if let Some(session) = self.sessions.lock().await.remove(&map_key) { + session.consumer.abort(); + let mut child = session.child; + let _ = child.start_kill(); + reap_owned_claude_sidecars(&session.ownership_id); + } + if let Some(claim) = claim.as_ref() { + // Finding 5's re-raise: the one post-commit failure arm. The + // session is torn back down, so the close the commit undid is + // durable again (fence re-raised, the revived row re-retired). + self.rollback_session_claim(&claim.durable).await; + // Finding 1: demote, never drop. + self.evict_cli_index_aliases(&map_key).await; + } + guard.fail(); // own tree torn down: reopen the key + return Err(EstablishError::LeaseRevoked); + } + } + + // kata b8ke Task 3: the registration survived every teardown gate; commit + // `Live{FreshAgent}` under the claimed durable id. A fresh create claims + // no durable id: its canonical commit is the consumer's + // `sdk.session.init` adoption. + if claim.is_some() { + if let Err(outcome) = self.commit_lane_claim_at(own_ticket, &map_key, sidecar_pid) { + let event = match lane { + EstablishLane::Create => "freshagent.claude.create_commit_stale", + EstablishLane::AttachResume => "freshagent.claude.attach_commit_stale", + }; + tracing::error!(target: "invariant", + event, provider = PROVIDER, session_id = ?durable, + request_id = %spec.request_id, outcome = ?outcome, + "{event}: the coordinator moved on while the session registered; the \ + uncommitted session is torn down" + ); + 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; + return Err(EstablishError::OwnershipChanged); + } + } + + Ok(Established { + map_key, + naming_projection, + }) + } +} diff --git a/crates/freshell-freshagent/src/claude_launch.rs b/crates/freshell-freshagent/src/claude_launch.rs index ea2a85e4e..f8185fdc5 100644 --- a/crates/freshell-freshagent/src/claude_launch.rs +++ b/crates/freshell-freshagent/src/claude_launch.rs @@ -1,14 +1,27 @@ -//! Which conversation a claude sidecar boots (core-runtime design §4.2 and §5.1, +//! How a claude sidecar is launched (core-runtime design §3.2, §4.2 and §5.1, //! protocol v2). //! -//! Every `create` line Rust writes to a claude sidecar names its stdio protocol -//! ([`SIDECAR_PROTOCOL`]) and a [`ClaudeBoot`]: a fresh conversation, a resume, or a -//! fork of a parent conversation at a point. The sidecar maps the boot onto the SDK's -//! `query()` options (`crates/freshell-claude-sidecar/boot.mjs`) and answers -//! `created` only once the CLI has booted it (`claude_handshake`). +//! Every launch (a create, an attach that resumes an untracked session, a +//! handoff resume, a rollback respawn) describes itself as one +//! [`ClaudeLaunchSpec`], and [`ClaudeLaunchSpec::create_request`] is the only +//! builder of the sidecar's `create` line. So no launch can drop a field another +//! carries: the pane's plugins and the hosted Freshell MCP reach the CLI on every +//! launch (G15). +//! +//! The line names its stdio protocol ([`SIDECAR_PROTOCOL`]) and a [`ClaudeBoot`]: +//! a fresh conversation, a resume, or a fork of a parent conversation at a point. +//! The sidecar maps the boot onto the SDK's `query()` options +//! (`crates/freshell-claude-sidecar/boot.mjs`) and answers `created` only once +//! the CLI has booted it (`claude_handshake`). [`decide_boot`] picks the boot of +//! a session that has a durable id from where its transcript is. + +use std::path::Path; use serde_json::{json, Map, Value}; +use crate::claude_snapshot::{LocateError, TranscriptLocation}; +use crate::identity_sink::FreshAgentSettings; + /// The claude sidecar stdio protocol this server speaks: `create.protocol` and /// `created.protocol` (`crates/freshell-claude-sidecar/boot.mjs`). pub(crate) const SIDECAR_PROTOCOL: u64 = 2; @@ -76,28 +89,144 @@ impl ClaudeBoot { } } -/// A sidecar `create` line: `type`, the stdio `protocol`, `requestId`, -/// `sessionType` and the `boot` (the fields every launch's line carries), plus -/// that launch's own `fields` (an object: cwd, model, permission mode and so on). -/// A launch field never replaces one of the line's own. The one builder of the -/// common fields until `ClaudeLaunchSpec::create_request()` builds the whole line -/// (design §3.2, C11). -pub(crate) fn create_line( - request_id: &str, - session_type: &str, - boot: &ClaudeBoot, - fields: Value, -) -> Value { - let mut line = match fields { - Value::Object(fields) => fields, - _ => Map::new(), +/// Everything one sidecar launch hands the sidecar: the one input of every +/// `create` line (design §4.9). Built by each launch from the session's settings, +/// so the plugins and the hosted MCP selection travel with every launch. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct ClaudeLaunchSpec { + /// The create request's id (the sidecar answers `created` with it). + pub(crate) request_id: String, + /// `freshclaude` or `kilroy`. + pub(crate) session_type: String, + pub(crate) boot: ClaudeBoot, + /// Where the CLI runs. + pub(crate) cwd: Option, + pub(crate) model: Option, + pub(crate) permission_mode: Option, + pub(crate) effort: Option, + /// The pane's local plugin directories. + pub(crate) plugins: Option>, + /// The lane selects the hosted Freshell MCP (a hosted session host's lane). + pub(crate) hosted_mcp: bool, + /// The client's `modelSelection` on a create, passed through as sent. + pub(crate) model_selection: Option, +} + +impl ClaudeLaunchSpec { + /// A launch of `boot` for a `session_type` session running in `cwd`, with + /// the session's `settings` (model, permission mode, effort, plugins) and + /// the lane's hosted MCP selection. The settings' own `cwd` is the session's + /// recorded one; the launch says where this CLI runs. + pub(crate) fn new( + request_id: impl Into, + session_type: &str, + boot: ClaudeBoot, + cwd: Option, + settings: &FreshAgentSettings, + hosted_mcp: bool, + ) -> Self { + Self { + request_id: request_id.into(), + session_type: session_type.to_string(), + boot, + cwd, + model: settings.model.clone(), + permission_mode: settings.permission_mode.clone(), + effort: settings.effort.clone(), + plugins: settings.plugins.clone(), + hosted_mcp, + model_selection: None, + } + } + + /// This launch carrying the client's `modelSelection` (a create's). + pub(crate) fn with_model_selection(mut self, model_selection: Option) -> Self { + self.model_selection = model_selection.filter(|selection| !selection.is_null()); + self + } + + /// The sidecar `create` line. Every line carries the same keys, whatever + /// the launch: `type`, `protocol`, `requestId`, `sessionType`, `boot`, and + /// `cwd`, `model`, `permissionMode`, `effort`, `plugins` (each `null` when + /// unset) and `hostedMcp`; `modelSelection` only when the launch has one. + pub(crate) fn create_request(&self) -> Value { + let mut line = Map::new(); + line.insert("type".into(), json!("create")); + line.insert("protocol".into(), json!(SIDECAR_PROTOCOL)); + line.insert("requestId".into(), json!(self.request_id)); + line.insert("sessionType".into(), json!(self.session_type)); + line.insert("boot".into(), self.boot.wire()); + line.insert("cwd".into(), json!(self.cwd)); + line.insert("model".into(), json!(self.model)); + line.insert("permissionMode".into(), json!(self.permission_mode)); + line.insert("effort".into(), json!(self.effort)); + line.insert("plugins".into(), json!(self.plugins)); + line.insert("hostedMcp".into(), json!(self.hosted_mcp)); + if let Some(selection) = &self.model_selection { + line.insert("modelSelection".into(), selection.clone()); + } + Value::Object(line) + } +} + +/// What [`decide_boot`] found for a session. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum BootDecision { + /// Boot this. `cwd` is the directory the conversation was recorded in, when + /// it still exists: the CLI finds a conversation by id only from the + /// directory it ran in (ledger A15), so a resume runs there. + Boot { + boot: ClaudeBoot, + cwd: Option, + }, + /// The store holds no transcript for the session: it is gone. + Lost, + /// The store could not be checked. Never lost: the caller answers a + /// retryable failure. + Transient(LocateError), +} + +/// Which conversation a session boots, from where its transcript is (design +/// §4.2): a found transcript resumes, an absent one is lost, and a store that +/// could not be checked is transient. The genesis rows of §4.2 (a session with +/// no transcript yet that may still boot) arrive with the binding row's genesis +/// (C12). +pub(crate) fn decide_boot(session_id: &str, location: TranscriptLocation) -> BootDecision { + match location { + TranscriptLocation::Found(path) => { + let (resume, cwd) = resume_target(session_id, &path); + BootDecision::Boot { + boot: ClaudeBoot::Resume { resume }, + cwd, + } + } + TranscriptLocation::Absent => BootDecision::Lost, + TranscriptLocation::Unreadable(error) => BootDecision::Transient(error), + } +} + +/// How to name the conversation `session_id`, whose transcript is at `path`, to +/// a resume (or a fork of it), and the directory to run that CLI in: the id and +/// the recorded directory while that directory exists. Once it is gone, the +/// transcript's path (the CLI resumes a path without the directory scoping, +/// verified cli.js 2.1.220), unless the transcript is empty: an empty one has +/// nothing to resume by path, and a path there would make the CLI write a +/// second, path-named conversation. +pub(crate) fn resume_target(session_id: &str, path: &Path) -> (String, Option) { + let recorded_cwd = + crate::claude_snapshot::transcript_cwd(path).filter(|cwd| Path::new(cwd).is_dir()); + if recorded_cwd.is_some() { + return (session_id.to_string(), recorded_cwd); + } + let has_records = std::fs::metadata(path) + .map(|meta| meta.len() > 0) + .unwrap_or(false); + let resume = if has_records { + path.to_string_lossy().to_string() + } else { + session_id.to_string() }; - line.insert("type".into(), json!("create")); - line.insert("protocol".into(), json!(SIDECAR_PROTOCOL)); - line.insert("requestId".into(), json!(request_id)); - line.insert("sessionType".into(), json!(session_type)); - line.insert("boot".into(), boot.wire()); - Value::Object(line) + (resume, None) } #[cfg(test)] @@ -132,15 +261,28 @@ mod tests { ); } + fn settings_with_plugins() -> FreshAgentSettings { + FreshAgentSettings { + model: Some("opus".into()), + permission_mode: Some("plan".into()), + effort: Some("high".into()), + cwd: Some("/recorded".into()), + ..FreshAgentSettings::default() + } + .with_plugins(Some(vec!["/w/.claude/plugins/review".into()])) + } + #[test] - fn a_create_line_carries_the_protocol_session_type_and_boot_with_the_launch_fields() { - let boot = ClaudeBoot::Resume { resume: "P".into() }; - let line = create_line( + fn a_create_line_carries_the_protocol_session_type_boot_and_the_launch() { + let line = ClaudeLaunchSpec::new( "req-1", "kilroy", - &boot, - json!({ "cwd": "/w", "model": null, "type": "not-create", "protocol": 1 }), - ); + ClaudeBoot::Resume { resume: "P".into() }, + Some("/w".into()), + &settings_with_plugins(), + true, + ) + .create_request(); assert_eq!( line, json!({ @@ -150,9 +292,164 @@ mod tests { "sessionType": "kilroy", "boot": { "kind": "resume", "resume": "P" }, "cwd": "/w", - "model": null, - }), - "a launch field never overrides the line's own fields" + "model": "opus", + "permissionMode": "plan", + "effort": "high", + "plugins": ["/w/.claude/plugins/review"], + "hostedMcp": true, + }) + ); + } + + /// G15: whatever the boot, the line hands the sidecar the session's plugins + /// and the lane's hosted MCP selection, and names both keys when unset. + #[test] + fn every_boot_carries_the_plugins_and_the_hosted_mcp_selection() { + let boots = [ + ClaudeBoot::Fresh, + ClaudeBoot::Resume { resume: "P".into() }, + ClaudeBoot::Fork { + resume: "P".into(), + at: Some("a1".into()), + drops_turn: Some("u2".into()), + }, + ]; + for boot in boots { + let kind = boot.kind(); + let line = ClaudeLaunchSpec::new( + "req", + "freshclaude", + boot.clone(), + None, + &settings_with_plugins(), + true, + ) + .create_request(); + assert_eq!( + line["plugins"], + json!(["/w/.claude/plugins/review"]), + "{kind}" + ); + assert_eq!(line["hostedMcp"], json!(true), "{kind}"); + assert_eq!(line["boot"], boot.wire(), "{kind}"); + + let bare = ClaudeLaunchSpec::new( + "req", + "freshclaude", + boot, + None, + &FreshAgentSettings::default(), + false, + ) + .create_request(); + assert_eq!(bare.get("plugins"), Some(&Value::Null), "{kind}"); + assert_eq!(bare.get("hostedMcp"), Some(&json!(false)), "{kind}"); + } + } + + #[test] + fn a_model_selection_rides_the_line_only_when_the_launch_has_one() { + let spec = ClaudeLaunchSpec::new( + "req", + "freshclaude", + ClaudeBoot::Fresh, + None, + &FreshAgentSettings::default(), + false, + ); + assert!(spec.create_request().get("modelSelection").is_none()); + let selection = json!({ "kind": "exact", "modelId": "opus" }); + assert_eq!( + spec.clone() + .with_model_selection(Some(selection.clone())) + .create_request()["modelSelection"], + selection + ); + assert!(spec + .with_model_selection(Some(Value::Null)) + .create_request() + .get("modelSelection") + .is_none()); + } + + fn transcript(dir: &Path, name: &str, body: &str) -> std::path::PathBuf { + let path = dir.join(format!("{name}.jsonl")); + std::fs::write(&path, body).unwrap(); + path + } + + #[test] + fn a_found_transcript_resumes_by_id_from_its_recorded_directory() { + let store = tempfile::tempdir().unwrap(); + let recorded = tempfile::tempdir().unwrap(); + let cwd = recorded.path().to_str().unwrap().to_string(); + let path = transcript( + store.path(), + "S", + &format!("{{\"type\":\"user\",\"cwd\":{}}}\n", json!(cwd)), + ); + assert_eq!( + decide_boot("S", TranscriptLocation::Found(path)), + BootDecision::Boot { + boot: ClaudeBoot::Resume { resume: "S".into() }, + cwd: Some(cwd), + } + ); + } + + #[test] + fn a_found_transcript_whose_directory_is_gone_resumes_by_its_path() { + let store = tempfile::tempdir().unwrap(); + let path = transcript( + store.path(), + "S", + "{\"type\":\"user\",\"cwd\":\"/no/such/directory/anymore\"}\n", + ); + assert_eq!( + decide_boot("S", TranscriptLocation::Found(path.clone())), + BootDecision::Boot { + boot: ClaudeBoot::Resume { + resume: path.to_string_lossy().to_string() + }, + cwd: None, + } + ); + } + + #[test] + fn an_empty_found_transcript_resumes_by_id() { + let store = tempfile::tempdir().unwrap(); + let path = transcript(store.path(), "S", ""); + assert_eq!( + decide_boot("S", TranscriptLocation::Found(path)), + BootDecision::Boot { + boot: ClaudeBoot::Resume { resume: "S".into() }, + cwd: None, + } + ); + } + + #[test] + fn an_absent_transcript_is_lost_and_an_unreadable_store_is_transient() { + assert_eq!( + decide_boot("S", TranscriptLocation::Absent), + BootDecision::Lost + ); + let error = LocateError::Io { + root: "/store".into(), + kind: std::io::ErrorKind::PermissionDenied, + message: "denied".into(), + }; + assert_eq!( + decide_boot("S", TranscriptLocation::Unreadable(error.clone())), + BootDecision::Transient(error) + ); + assert_eq!( + decide_boot( + "S", + TranscriptLocation::Unreadable(LocateError::NoStoreRoot) + ), + BootDecision::Transient(LocateError::NoStoreRoot) ); } diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index e23d3802d..45d1e8c76 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -1726,6 +1726,7 @@ impl FreshCodexState { permission_mode: permission_mode.map(Into::into), effort: effort.map(Into::into), cwd: cwd.map(Into::into), + plugins: None, }; // No-laundering guard (V7/A10): never persist an all-blank snapshot -- // it would mask a genuine record miss forever. Real creates always carry @@ -18551,6 +18552,7 @@ pub(crate) mod tests { permission_mode: None, effort: None, cwd: Some(std::env::temp_dir().to_string_lossy().to_string()), + plugins: None, }, }) .await @@ -18654,6 +18656,7 @@ pub(crate) mod tests { permission_mode: None, effort: None, cwd: Some("/w".into()), + plugins: None, }, }) .await @@ -22616,6 +22619,7 @@ pub(crate) mod tests { permission_mode: Some("on-request".into()), effort: Some("high".into()), cwd: Some("/w".into()), + plugins: None, }, ); state.set_identity_sink(fake.clone()); diff --git a/crates/freshell-freshagent/src/identity_sink.rs b/crates/freshell-freshagent/src/identity_sink.rs index eff17d08c..67401f36d 100644 --- a/crates/freshell-freshagent/src/identity_sink.rs +++ b/crates/freshell-freshagent/src/identity_sink.rs @@ -23,6 +23,20 @@ pub struct FreshAgentSettings { pub permission_mode: Option, pub effort: Option, pub cwd: Option, + /// The claude pane's local plugin directories (G15): every launch of the + /// session hands them to the sidecar. `None` (never an empty list) when + /// the pane has none — see [`Self::with_plugins`]. + pub plugins: Option>, +} + +impl FreshAgentSettings { + /// These settings with `plugins` as the pane's plugin directories. An + /// empty list is no plugins and is kept as `None`, so a pane that names + /// none records nothing (its row stays lineage-only). + pub fn with_plugins(mut self, plugins: Option>) -> Self { + self.plugins = plugins.filter(|plugins| !plugins.is_empty()); + self + } } /// D8 (restore-open-sessions-only) bind-lane provenance: WHICH browser client @@ -2014,6 +2028,7 @@ mod tests { permission_mode: None, effort: Some("low".into()), cwd: Some("/w".into()), + plugins: None, }, }) .await diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index 8496c98ed..447b0463a 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -5540,6 +5540,7 @@ async fn resume_session_ref_tab( permission_mode: None, effort: effort.clone(), cwd: cwd.clone(), + plugins: None, }, }) .await @@ -6608,6 +6609,7 @@ async fn send_keys( permission_mode: None, effort: pane.effort.clone(), cwd: pane.cwd.clone(), + plugins: None, }, }) .await diff --git a/crates/freshell-freshagent/src/opencode_ws.rs b/crates/freshell-freshagent/src/opencode_ws.rs index 7d5069822..9debab3ef 100644 --- a/crates/freshell-freshagent/src/opencode_ws.rs +++ b/crates/freshell-freshagent/src/opencode_ws.rs @@ -1797,6 +1797,7 @@ impl FreshOpencodeState { permission_mode: None, effort: session.effort.clone(), cwd: session.cwd.clone(), + plugins: None, }, ) }; @@ -1960,6 +1961,7 @@ impl FreshOpencodeState { permission_mode: None, effort, cwd, + plugins: None, }, }) .await; @@ -2576,6 +2578,7 @@ impl FreshOpencodeState { permission_mode: None, effort: session.effort.clone(), cwd: session.cwd.clone(), + plugins: None, }, }) .await @@ -2706,6 +2709,7 @@ impl FreshOpencodeState { permission_mode: None, effort: session.effort.clone(), cwd: session.cwd.clone(), + plugins: None, }, }) .await; @@ -3180,6 +3184,7 @@ impl FreshOpencodeState { permission_mode: None, effort: effort.clone(), cwd, + plugins: None, }, }) .await; @@ -6018,6 +6023,7 @@ impl FreshOpencodeState { permission_mode: None, effort, cwd: child_cwd.clone(), + plugins: None, }, }) .await @@ -7588,6 +7594,7 @@ impl FreshOpencodeState { permission_mode: None, effort: rec.effort.clone(), cwd, + plugins: None, } } else { crate::identity_sink::FreshAgentSettings::default() @@ -13900,6 +13907,7 @@ mod tests { permission_mode: None, effort: Some("high".into()), cwd: Some("/real/project".into()), + plugins: None, }, ); state.set_identity_sink(fake); @@ -13942,6 +13950,7 @@ mod tests { permission_mode: None, effort: Some("high".into()), cwd: Some("/real/project".into()), + plugins: None, }, ); state.set_identity_sink(fake.clone()); @@ -14404,6 +14413,7 @@ mod tests { permission_mode: None, effort: Some("high".into()), cwd: Some("/real/project".into()), + plugins: None, }, ); state.set_identity_sink(fake); @@ -14478,6 +14488,7 @@ mod tests { permission_mode: None, effort: Some("high".into()), cwd: Some("/real/project".into()), + plugins: None, }, ); state.set_identity_sink(fake.clone()); @@ -15005,6 +15016,7 @@ mod tests { permission_mode: None, effort: Some("high".into()), cwd: Some("/real/project".into()), + plugins: None, }, ); state.set_identity_sink(fake.clone()); @@ -15211,6 +15223,7 @@ mod tests { permission_mode: None, effort: Some("high".into()), cwd: Some("/real/project".into()), + plugins: None, }, }) .await @@ -15312,6 +15325,7 @@ mod tests { permission_mode: None, effort: Some("high".into()), cwd: Some("/real/project".into()), + plugins: None, }, }) .await diff --git a/crates/freshell-freshagent/src/pane_ops_tab_tests.rs b/crates/freshell-freshagent/src/pane_ops_tab_tests.rs index 0b38b8e74..39279e0e2 100644 --- a/crates/freshell-freshagent/src/pane_ops_tab_tests.rs +++ b/crates/freshell-freshagent/src/pane_ops_tab_tests.rs @@ -1135,6 +1135,7 @@ async fn rest_resume_durable_ses_is_born_durable_with_ledger_settings_and_route( permission_mode: None, effort: Some("high".to_string()), cwd: Some("/real/project".to_string()), + plugins: None, }, ); sink.recorded @@ -1682,6 +1683,7 @@ async fn rest_resume_body_model_and_effort_beat_the_ledger_record() { permission_mode: None, effort: Some("high".to_string()), cwd: None, + plugins: None, }, ); sink.recorded diff --git a/crates/freshell-freshagent/src/session_handoff/tests.rs b/crates/freshell-freshagent/src/session_handoff/tests.rs index d558f0ef8..ca479bf08 100644 --- a/crates/freshell-freshagent/src/session_handoff/tests.rs +++ b/crates/freshell-freshagent/src/session_handoff/tests.rs @@ -6276,6 +6276,7 @@ async fn the_claude_handoff_target_binding_carries_the_handoff_generation() { permission_mode: Some("plan".into()), effort: Some("high".into()), cwd: None, + plugins: None, }, ); diff --git a/crates/freshell-server/src/identity_sink.rs b/crates/freshell-server/src/identity_sink.rs index c0361602e..dda252137 100644 --- a/crates/freshell-server/src/identity_sink.rs +++ b/crates/freshell-server/src/identity_sink.rs @@ -340,6 +340,7 @@ impl PaneIdentitySink for LedgerIdentitySink { sandbox: upsert.settings.sandbox.as_deref(), permission_mode: upsert.settings.permission_mode.as_deref(), effort: upsert.settings.effort.as_deref(), + plugins: upsert.settings.plugins.as_deref(), supersedes: upsert.supersedes.as_deref(), provenance, // b8ke ext r22 F2: the upsert's observed (epoch, @@ -417,6 +418,7 @@ impl PaneIdentitySink for LedgerIdentitySink { permission_mode: row.permission_mode, effort: row.effort, cwd: row.cwd, + plugins: row.plugins, }; // A fully blank snapshot (a lineage-only row, Task 3) is "nothing // recoverable". Under the Task 3 `was_recorded` rekeying such a row @@ -779,6 +781,7 @@ mod tests { permission_mode: Some("on-request".into()), effort: None, cwd: Some("/w".into()), + plugins: None, }, }) .await @@ -795,6 +798,59 @@ mod tests { assert_eq!(row.pane_kind.as_deref(), Some("fresh-agent")); } + /// G15: the claude pane's plugins are part of the resume record — written + /// with the binding and served back by `load_settings`, also after a + /// restart, so a resume can hand them to the sidecar again. + #[tokio::test(flavor = "multi_thread")] + async fn plugins_write_through_and_read_back_after_a_restart() { + let tmp = tempfile::tempdir().unwrap(); + let ledger = Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + tmp.path().to_path_buf(), + ))); + let plugins = vec!["/workspace/.claude/plugins/local".to_string()]; + let sink = LedgerIdentitySink::new(ledger, None); + sink.record_binding(FreshAgentBindingUpsert { + name_transition: None, + provider: "claude".into(), + session_id: "s1".into(), + mode: "freshclaude".into(), + create_request_id: None, + resolves_pending: None, + supersedes: None, + provenance: freshell_freshagent::ProvenanceUpdate::Inherit, + observed_epoch: None, + observed_generation: None, + authoritative: false, + settings: FreshAgentSettings { + plugins: Some(plugins.clone()), + ..FreshAgentSettings::default() + }, + }) + .await + .expect("awaited write succeeds"); + assert_eq!( + sink.load_settings("claude", "s1") + .expect("a plugins-only row is a settings record") + .plugins, + Some(plugins.clone()) + ); + assert!(sink.was_recorded("claude", "s1")); + + let restarted = LedgerIdentitySink::new( + Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + tmp.path().to_path_buf(), + ))), + None, + ); + assert_eq!( + restarted + .load_settings("claude", "s1") + .expect("the row survives the restart") + .plugins, + Some(plugins) + ); + } + #[tokio::test(flavor = "multi_thread")] async fn rollback_record_writes_through_and_reads_back() { let tmp = tempfile::tempdir().unwrap(); @@ -908,6 +964,7 @@ mod tests { permission_mode: None, effort: None, cwd: Some("/w".into()), + plugins: None, }; sink.record_binding(FreshAgentBindingUpsert { name_transition: None, diff --git a/crates/freshell-server/src/recovery_inventory_tests.rs b/crates/freshell-server/src/recovery_inventory_tests.rs index 803fa3f22..ad3fd3616 100644 --- a/crates/freshell-server/src/recovery_inventory_tests.rs +++ b/crates/freshell-server/src/recovery_inventory_tests.rs @@ -134,6 +134,7 @@ fn binding_row_at( // Delta-r4 Finding 1: unattributed fixture rows are legacy-shaped // (no attribution-time key); `with_attribution` stamps one. last_attributed_at: None, + plugins: None, } } @@ -3133,6 +3134,7 @@ fn fresh_agent_create_completed_after_the_pane_closed_is_never_offered() { observed_generation: None, authoritative: false, + plugins: None, }) .expect("late landing binding write"); let row = ledger.load_binding("opencode", "S1").expect("row written"); @@ -3196,6 +3198,7 @@ fn fresh_agent_create_asserted_inside_the_kill_window_stays_offered_despite_a_la observed_generation: None, authoritative: false, + plugins: None, }) .expect("late landing binding write"); let row = ledger.load_binding("opencode", "S1").expect("row written"); @@ -3327,6 +3330,7 @@ fn supersession_after_the_freeze_judges_on_the_parents_assertion_time() { observed_generation: None, authoritative: false, + plugins: None, }) .expect("fork child binding write"); let child = ledger.load_binding("codex", "child-id").expect("child row"); diff --git a/crates/freshell-ws/src/codex_association.rs b/crates/freshell-ws/src/codex_association.rs index c9b47f0d2..6b4c77a4b 100644 --- a/crates/freshell-ws/src/codex_association.rs +++ b/crates/freshell-ws/src/codex_association.rs @@ -796,6 +796,7 @@ mod tests { authoritative: false, now_ms: now_ms(), + plugins: None, }) .expect("seed fresh-agent ledger row"); diff --git a/crates/freshell-ws/src/invariants.rs b/crates/freshell-ws/src/invariants.rs index 7fb15471d..d63801010 100644 --- a/crates/freshell-ws/src/invariants.rs +++ b/crates/freshell-ws/src/invariants.rs @@ -1449,6 +1449,7 @@ mod tests { observed_generation: None, authoritative: false, + plugins: None, }) .expect("seed fresh-agent ledger row"); diff --git a/crates/freshell-ws/src/opencode_association.rs b/crates/freshell-ws/src/opencode_association.rs index c81e9b628..946f29c2f 100644 --- a/crates/freshell-ws/src/opencode_association.rs +++ b/crates/freshell-ws/src/opencode_association.rs @@ -1335,6 +1335,7 @@ mod tests { authoritative: false, now_ms: now_ms(), + plugins: None, }) .expect("seed fresh-agent ledger row"); diff --git a/crates/freshell-ws/src/pane_ledger.rs b/crates/freshell-ws/src/pane_ledger.rs index e392c1d05..643962e9b 100644 --- a/crates/freshell-ws/src/pane_ledger.rs +++ b/crates/freshell-ws/src/pane_ledger.rs @@ -868,6 +868,12 @@ pub struct BindingRow { pub permission_mode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub effort: Option, + /// G15: the claude pane's local plugin directories, which every launch of + /// the session hands to the sidecar again. Serde-optional under + /// LEDGER_VERSION 1: older rows parse to `None` (the resume then has + /// only the request's plugins). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option>, /// D8 (restore-open-sessions-only) provenance: the browser client + tab /// this binding was created from. What a write DOES with them is the /// tri-state [`ProvenancePolicy`] on the write structs (delta-r2 Finding @@ -1291,6 +1297,9 @@ pub struct FreshAgentBindingWrite<'a> { pub sandbox: Option<&'a str>, pub permission_mode: Option<&'a str>, pub effort: Option<&'a str>, + /// G15: the claude pane's plugin directories (part of the settings + /// snapshot this write records). + pub plugins: Option<&'a [String]>, /// G3 supersession (V8/A14): the OLD session id this binding replaces /// (codex crash-respawn). When `Some`, the old `(provider, supersedes)` /// row is retired and linked AFTER the new row persists. @@ -2236,6 +2245,7 @@ impl PaneLedger { sandbox: None, permission_mode: None, effort: None, + plugins: None, client_instance_id, device_id, tab_key, @@ -2758,6 +2768,7 @@ impl PaneLedger { sandbox: w.sandbox.map(str::to_string), permission_mode: w.permission_mode.map(str::to_string), effort: w.effort.map(str::to_string), + plugins: w.plugins.map(<[String]>::to_vec), client_instance_id, device_id, tab_key, @@ -4529,7 +4540,7 @@ impl PaneLedger { /// Fresh-agent Task 3 (`was_recorded` rekeying): true iff a FRESH-AGENT /// binding row carrying a SETTINGS-BEARING snapshot exists for this key — - /// at least one of model/sandbox/permission_mode/effort/cwd set (the exact + /// at least one of model/sandbox/permission_mode/effort/cwd/plugins set (the exact /// complement of the identity sink's `load_settings` blank guard, so the /// two predicates can never disagree). A lineage-only row (every settings /// column blank) answers false: unconditional lineage recording can never @@ -4545,7 +4556,8 @@ impl PaneLedger { || r.sandbox.is_some() || r.permission_mode.is_some() || r.effort.is_some() - || r.cwd.is_some()) + || r.cwd.is_some() + || r.plugins.is_some()) }) .unwrap_or(false) } diff --git a/crates/freshell-ws/src/pane_ledger_tests.rs b/crates/freshell-ws/src/pane_ledger_tests.rs index 9972346d3..67bc9adf2 100644 --- a/crates/freshell-ws/src/pane_ledger_tests.rs +++ b/crates/freshell-ws/src/pane_ledger_tests.rs @@ -299,6 +299,7 @@ fn fa_write<'a>(provider: &'a str, session_id: &'a str, now_ms: i64) -> FreshAge authoritative: false, now_ms, + plugins: None, } } @@ -327,6 +328,7 @@ fn fa_write_fenced<'a>( authoritative: false, now_ms, + plugins: None, } } @@ -357,6 +359,7 @@ fn fa_write_target<'a>( observed_generation: Some(generation), authoritative: true, now_ms, + plugins: None, } } @@ -2970,6 +2973,7 @@ fn disabled_ledger_refuses_the_rollback_row_write_with_a_loud_error() { authoritative: false, now_ms: 1, + plugins: None, }) .expect("binding writes keep their silent-no-op policy on a disabled ledger"); ledger @@ -4092,6 +4096,76 @@ fn old_rows_and_markers_without_the_lineage_fields_still_deserialize() { std::fs::remove_dir_all(&root).ok(); } +#[test] +fn a_fresh_agent_binding_keeps_the_panes_plugins_across_a_restart() { + // G15: a claude pane's plugins are part of its resume record, so every + // later launch of the session (attach-resume, handoff resume, rollback) + // can hand them to the sidecar again. + let root = temp_root("fa-plugins-round-trip"); + let plugins = vec![ + "/workspace/.claude/plugins/local".to_string(), + "/opt/plugins/review".to_string(), + ]; + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_fresh_agent_binding(&FreshAgentBindingWrite { + plugins: Some(&plugins), + ..fa_write("claude", "s-plugins", 1_000) + }) + .unwrap(); + assert_eq!( + ledger.load_binding("claude", "s-plugins").unwrap().plugins, + Some(plugins.clone()) + ); + + // A new ledger over the same root (a server restart) reads them back. + let reloaded = PaneLedger::new(Some(root.clone())); + assert_eq!( + reloaded + .load_binding("claude", "s-plugins") + .unwrap() + .plugins, + Some(plugins.clone()) + ); + + // Plugins alone are a settings record: the resume has something to restore. + reloaded + .record_fresh_agent_binding(&FreshAgentBindingWrite { + cwd: None, + plugins: Some(&plugins), + ..fa_write("claude", "s-plugins-only", 2_000) + }) + .unwrap(); + assert!(reloaded.fresh_agent_settings_recorded("claude", "s-plugins-only")); + + // A row written before plugins were recorded parses with none. + let ledger_dir = root.join("bindings").join("claude"); + std::fs::write( + ledger_dir.join("s-legacy.json"), + serde_json::json!({ + "ledgerVersion": LEDGER_VERSION, + "provider": "claude", + "sessionId": "s-legacy", + "mode": "freshclaude", + "cwd": "/w", + "createdAt": 1_000i64, + "updatedAt": 1_000i64, + "lastObservedAt": 1_000i64, + "state": "bound", + "paneKind": "fresh-agent", + "model": "opus", + }) + .to_string(), + ) + .unwrap(); + let legacy = PaneLedger::new(Some(root.clone())) + .load_binding("claude", "s-legacy") + .expect("a legacy row parses"); + assert_eq!(legacy.plugins, None); + assert_eq!(legacy.model.as_deref(), Some("opus")); + std::fs::remove_dir_all(&root).ok(); +} + #[test] fn stamped_marker_retention_keys_on_creation_time_never_assertion_time() { // The stamps are payload, not lifetime: retention reads only @@ -4556,6 +4630,7 @@ fn crash_mid_supersession_two_bound_rows_repaired_by_updated_at_tiebreak() { device_id: None, tab_key: None, last_attributed_at: None, + plugins: None, }; write_row_atomic( &root @@ -4675,6 +4750,7 @@ fn fresh_agent_binding_roundtrips_settings_and_pane_kind() { authoritative: false, now_ms: 1_000, + plugins: None, }) .unwrap(); let row = ledger.load_binding("codex", "thread-1").expect("row"); @@ -4709,6 +4785,7 @@ fn fresh_agent_binding_upsert_preserves_created_at_and_refreshes_settings() { authoritative: false, now_ms: 1_000, + plugins: None, }; ledger.record_fresh_agent_binding(&base).unwrap(); ledger @@ -4758,6 +4835,7 @@ fn supersedes_retires_the_old_row_and_links_the_chain() { authoritative: false, now_ms: 1_000, + plugins: None, }; ledger.record_fresh_agent_binding(&base).unwrap(); ledger @@ -4823,6 +4901,7 @@ fn fresh_agent_upsert_preserves_advisory_create_request_id_when_absent() { authoritative: false, now_ms: 1_000, + plugins: None, }; ledger.record_fresh_agent_binding(&base).unwrap(); ledger @@ -4871,6 +4950,7 @@ fn fresh_agent_settings_recorded_keys_off_settings_bearing_rows() { authoritative: false, now_ms: 1_000, + plugins: None, }; // A cwd-only snapshot counts as settings-bearing (real creates always // carry at least cwd). @@ -4921,6 +5001,7 @@ fn supersedes_of_a_missing_old_row_is_a_silent_noop() { authoritative: false, now_ms: 1_000, + plugins: None, }) .expect("missing old row is a silent no-op, not an error"); assert!(ledger.load_binding("codex", "never-existed").is_none()); @@ -8201,6 +8282,7 @@ fn close_record_covers_row_is_the_shared_recovery_predicate() { device_id: None, tab_key: None, last_attributed_at: None, + plugins: None, } } let crid = |id: &str| id == "req-closed"; diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index 1102a1a39..87ff6db10 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -57,10 +57,35 @@ pub enum RigKind { Cli, } +/// What a rig's server is wired with beyond the default (no durable ledger, a +/// local claude lane, no handoff route). +#[derive(Debug, Clone, Default)] +pub struct RigOptions { + /// Extra sidecar environment (for example simulator knobs such as + /// `FRESHELL_CLAUDE_SIM_PROGRAM`); a key here replaces the rig's own. + pub env: Vec<(String, String)>, + /// A real pane ledger under the rig's temp root, shared by the WS state and the + /// claude lane's identity sink (`common::ledger_sink`), the way main.rs wires + /// it: sessions leave durable binding rows a later resume reads back. + pub durable_ledger: bool, + /// The claude lane runs the way the hosted session host runs it: the lane + /// selects the hosted Freshell MCP (`with_hosted_mcp`) and its sidecars see + /// `FRESHELL_HOSTED_FRESH_AGENT=claude`, so the sidecar offers the fixed MCP + /// recipe to `query()`. + pub hosted_claude: bool, + /// The session handoff route (`POST /api/sessions/handoff`) on the rig's + /// server, driven by a runner over the same lanes (main.rs's wiring). + pub handoff: bool, +} + pub struct ClaudeRig { pub kind: RigKind, /// `ws://127.0.0.1:/ws` of the rig's server (an ephemeral loopback port). pub url: String, + /// `http://127.0.0.1:` of the same server. + pub http_url: String, + /// The real pane ledger, when the rig has one ([`RigOptions::durable_ledger`]). + pub pane_ledger: Option>, /// The isolated workspace every session of this rig runs in. pub workspace: PathBuf, /// CLAUDE_CONFIG_DIR of the CLI (simulated or real): its transcripts land here. @@ -83,6 +108,20 @@ impl ClaudeRig { /// Start a rig whose sidecars also get `extra` (for example simulator knobs such /// as `FRESHELL_CLAUDE_SIM_PROGRAM` or `FRESHELL_CLAUDE_SIM_TIME_SCALE`). pub async fn start_with_env(kind: RigKind, extra: &[(&str, &str)]) -> Self { + Self::start_with_options( + kind, + RigOptions { + env: extra + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect(), + ..RigOptions::default() + }, + ) + .await + } + + pub async fn start_with_options(kind: RigKind, options: RigOptions) -> Self { let node_version = require_node(); let root = tempfile::Builder::new() .prefix("freshell-claude-rig-") @@ -157,12 +196,24 @@ impl ClaudeRig { ]); } } - for (key, value) in extra { + if options.hosted_claude { + environment.push(("FRESHELL_HOSTED_FRESH_AGENT".into(), "claude".into())); + } + for (key, value) in &options.env { environment.retain(|(existing, _)| existing != key); - environment.push(((*key).into(), (*value).into())); + environment.push((key.clone(), value.clone())); } + let pane_ledger = options.durable_ledger.then(|| { + Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + root.path().join("pane-ledger"), + ))) + }); - let (url, fresh_claude) = spawn_rig_server(environment).await; + let RigServer { + url, + http_url, + fresh_claude, + } = spawn_rig_server(environment, &options, pane_ledger.clone()).await; eprintln!( "{}", json!({ @@ -173,6 +224,8 @@ impl ClaudeRig { Self { kind, url, + http_url, + pane_ledger, workspace, claude_config_dir, ledger, @@ -206,6 +259,46 @@ impl ClaudeRig { connection } + /// `POST ` on the rig's server with the auth token and a JSON body; the + /// status and the JSON answer (`Null` when the body is not JSON). + pub async fn post_json(&self, path: &str, body: &Value) -> (u16, Value) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let host = self + .http_url + .strip_prefix("http://") + .expect("the rig serves http"); + let payload = body.to_string(); + let request = format!( + "POST {path} HTTP/1.1\r\nHost: {host}\r\nx-auth-token: {AUTH_TOKEN}\r\n\ + content-type: application/json\r\ncontent-length: {}\r\nConnection: close\r\n\r\n\ + {payload}", + payload.len(), + ); + let mut stream = tokio::net::TcpStream::connect(host) + .await + .expect("connect to the rig's server"); + stream + .write_all(request.as_bytes()) + .await + .expect("write the request"); + let mut raw = Vec::new(); + tokio::time::timeout(RIG_FRAME_BUDGET, stream.read_to_end(&mut raw)) + .await + .expect("the rig's server answers in time") + .expect("read the answer"); + let text = String::from_utf8(raw).expect("utf-8 answer"); + let (head, answer) = text.split_once("\r\n\r\n").expect("an http answer"); + let status = head + .split_whitespace() + .nth(1) + .and_then(|code| code.parse().ok()) + .expect("an http status"); + ( + status, + serde_json::from_str(answer.trim()).unwrap_or(Value::Null), + ) + } + /// The transcripts (`*.jsonl`) under this rig's CLAUDE_CONFIG_DIR/projects. pub fn transcripts(&self) -> Vec { let mut found = Vec::new(); @@ -536,11 +629,20 @@ async fn start_local_provider() -> (tokio::process::Child, String) { (child, url) } +struct RigServer { + url: String, + http_url: String, + fresh_claude: freshell_freshagent::FreshClaudeState, +} + /// A freshell-ws server on an ephemeral loopback port (never 3001/3002) with fresh -/// agents enabled and a claude state whose sidecars get exactly `environment`. +/// agents enabled and a claude state whose sidecars get exactly `environment`, +/// wired as `options` asks. async fn spawn_rig_server( environment: Vec<(String, String)>, -) -> (String, freshell_freshagent::FreshClaudeState) { + options: &RigOptions, + pane_ledger: Option>, +) -> RigServer { let mut settings_value = test_settings_value(); settings_value["freshAgent"]["enabled"] = json!(true); let auth_token = Arc::new(AUTH_TOKEN.to_string()); @@ -555,14 +657,28 @@ async fn spawn_rig_server( // the way main.rs wires it. let ownership = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); let mut fresh_claude = freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)) - .with_sidecar_environment(environment); + .with_sidecar_environment(environment) + .with_hosted_mcp(options.hosted_claude); fresh_claude.set_ownership(Arc::clone(&ownership)); + if let Some(ledger) = pane_ledger.as_ref() { + fresh_claude.set_identity_sink(Arc::new(super::ledger_sink::LedgerSink::new(Arc::clone( + ledger, + )))); + } let lane = fresh_claude.clone(); + let fresh_agent_state = freshell_freshagent::FreshAgentState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + ); + let registry = freshell_terminal::TerminalRegistry::new(); + let cli_commands: Arc> = Arc::new(Vec::new()); let state = WsState { layout: Default::default(), terminal_meta: Default::default(), - pane_ledger: Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), + pane_ledger: pane_ledger + .clone() + .unwrap_or_else(|| Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled())), identity: freshell_ws::identity::TerminalIdentityRegistry::new(), auth_token: Arc::clone(&auth_token), server_instance_id: Arc::new("srv-claude-rig".to_string()), @@ -578,13 +694,8 @@ async fn spawn_rig_server( json!({ "freshAgent": { "enabled": true } }), ), fresh_claude, - fresh_opencode: freshell_freshagent::FreshOpencodeState::new( - freshell_freshagent::FreshAgentState::new( - Arc::clone(&auth_token), - Arc::clone(&broadcast_tx), - ), - ), - registry: freshell_terminal::TerminalRegistry::new(), + fresh_opencode: freshell_freshagent::FreshOpencodeState::new(fresh_agent_state.clone()), + registry: registry.clone(), tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), @@ -592,7 +703,7 @@ async fn spawn_rig_server( checkpoint_send: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), - cli_commands: Arc::new(Vec::new()), + cli_commands: Arc::clone(&cli_commands), shutdown: Arc::new(tokio::sync::Notify::new()), ping_interval_ms: 30_000, hello_timeout_ms: 5_000, @@ -610,10 +721,27 @@ async fn spawn_rig_server( session_existence: Arc::new(freshell_ws::existence::NoIndexProbe::default()), reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, fresh_agent_respawn_counts: Default::default(), - ownership: Some(ownership), + ownership: Some(Arc::clone(&ownership)), }; - let router = freshell_ws::router(state); + let mut router = freshell_ws::router(state.clone()); + if options.handoff { + let runner = Arc::new(freshell_freshagent::SessionHandoffRunner::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + Arc::clone(&ownership), + registry.clone(), + state.fresh_codex.clone(), + state.fresh_claude.clone(), + state.fresh_opencode.clone(), + fresh_agent_state + .with_ownership(Arc::clone(&ownership)) + .with_terminal_registry(registry) + .with_cli_commands(Arc::clone(&cli_commands)), + cli_commands, + )); + router = router.merge(freshell_freshagent::session_handoff::handoff_router(runner)); + } let listener = TcpListener::bind("127.0.0.1:0") .await .expect("bind an ephemeral loopback port"); @@ -621,5 +749,9 @@ async fn spawn_rig_server( tokio::spawn(async move { let _ = axum::serve(listener, router).await; }); - (format!("ws://{addr}/ws"), lane) + RigServer { + url: format!("ws://{addr}/ws"), + http_url: format!("http://{addr}"), + fresh_claude: lane, + } } diff --git a/crates/freshell-ws/tests/common/ledger_sink.rs b/crates/freshell-ws/tests/common/ledger_sink.rs new file mode 100644 index 000000000..1880e2686 --- /dev/null +++ b/crates/freshell-ws/tests/common/ledger_sink.rs @@ -0,0 +1,335 @@ +//! A fresh-agent identity sink over a REAL pane ledger, for the integration tests +//! whose sessions must leave and read back durable binding rows. +//! +//! It mirrors `crates/freshell-server/src/identity_sink.rs`'s `LedgerIdentitySink` +//! (the production bridge; the ws-test crate cannot depend on freshell-server, the +//! dependency edge runs the other way): awaited `spawn_blocking` writes, inline +//! memory reads, the same settings gates, the tri-state provenance policy and the +//! claude rollback-row re-key that rides the binding batch. The one omission is the +//! authoritative-write ownership consult (only the handoff runner's own target +//! bindings set `authoritative`, and the production consult's refusal is pinned in +//! freshell-server's own tests). + +use std::sync::Arc; + +use freshell_freshagent::identity_sink::SinkCloseError; +use freshell_freshagent::{ + BindProvenance, ClaimCommit, FreshAgentBindingUpsert, FreshAgentSettings, PaneIdentitySink, + ProvenanceUpdate, RollbackRecord, SinkAliasClearWrite, SinkCloseWrite, SinkCommitWrite, + SinkWrite, +}; +use freshell_ws::pane_ledger::{ + ClaimCommitOutcome, CloseEnvelopeError, FreshAgentBindingWrite, PaneLedger, ProvenancePolicy, + ProvenanceStamps, +}; + +pub struct LedgerSink { + pub ledger: Arc, +} + +impl LedgerSink { + pub fn new(ledger: Arc) -> Self { + Self { ledger } + } +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as i64) + .unwrap_or(0) +} + +fn close_error(error: CloseEnvelopeError) -> SinkCloseError { + match error { + CloseEnvelopeError::Clean(e) => SinkCloseError::Clean(e), + CloseEnvelopeError::Persisted(e) => SinkCloseError::Persisted(e), + } +} + +/// Run a blocking ledger write on the blocking pool and await it. +fn blocking(write: F) -> SinkWrite +where + F: FnOnce() -> std::io::Result<()> + Send + 'static, +{ + Box::pin(async move { + tokio::task::spawn_blocking(write) + .await + .map_err(std::io::Error::other)? + }) +} + +impl PaneIdentitySink for LedgerSink { + fn record_pending(&self, placeholder_id: &str, mode: &str, cwd: Option<&str>) -> SinkWrite { + let ledger = self.ledger.clone(); + let (p, m, c) = ( + placeholder_id.to_string(), + mode.to_string(), + cwd.map(str::to_string), + ); + let now = now_ms(); + blocking(move || { + ledger.record_pending(&p, &m, c.as_deref(), None, ProvenanceStamps::default(), now) + }) + } + + fn record_binding(&self, upsert: FreshAgentBindingUpsert) -> SinkWrite { + let ledger = self.ledger.clone(); + let now = now_ms(); + blocking(move || { + let provenance = match &upsert.provenance { + ProvenanceUpdate::Replace(stamps) => ProvenancePolicy::Replace(ProvenanceStamps { + client_instance_id: stamps.client_instance_id.as_deref(), + device_id: stamps.device_id.as_deref(), + tab_key: stamps.tab_key.as_deref(), + asserted_at: stamps.asserted_at, + }), + ProvenanceUpdate::Inherit => ProvenancePolicy::Inherit, + ProvenanceUpdate::Clear => ProvenancePolicy::Clear, + }; + ledger.record_fresh_agent_binding(&FreshAgentBindingWrite { + provider: &upsert.provider, + session_id: &upsert.session_id, + mode: &upsert.mode, + cwd: upsert.settings.cwd.as_deref(), + create_request_id: upsert.create_request_id.as_deref(), + model: upsert.settings.model.as_deref(), + sandbox: upsert.settings.sandbox.as_deref(), + permission_mode: upsert.settings.permission_mode.as_deref(), + effort: upsert.settings.effort.as_deref(), + plugins: upsert.settings.plugins.as_deref(), + supersedes: upsert.supersedes.as_deref(), + provenance, + observed_epoch: upsert.observed_epoch, + observed_generation: upsert.observed_generation, + authoritative: upsert.authoritative, + now_ms: now, + })?; + // The claude rollback row follows the pane to the superseding id in the + // same awaited batch as the binding write. + if upsert.provider == "claude" { + if let Some(old_id) = upsert.supersedes.as_deref() { + if old_id != upsert.session_id { + if let Some(payload) = ledger.load_rollback_row(&upsert.provider, old_id) { + ledger.record_rollback_row( + &upsert.provider, + &upsert.session_id, + &payload, + now, + )?; + let _ = ledger.delete_rollback_row(&upsert.provider, old_id); + } + } + } + } + if let Some(p) = upsert.resolves_pending.as_deref() { + let _ = ledger.delete_pending(p); + } + Ok(()) + }) + } + + fn load_settings(&self, provider: &str, session_id: &str) -> Option { + let row = self.ledger.load_binding(provider, session_id)?; + if row.pane_kind.as_deref() != Some("fresh-agent") { + return None; + } + let settings = FreshAgentSettings { + model: row.model, + sandbox: row.sandbox, + permission_mode: row.permission_mode, + effort: row.effort, + cwd: row.cwd, + plugins: row.plugins, + }; + (settings != FreshAgentSettings::default()).then_some(settings) + } + + fn was_recorded(&self, provider: &str, session_id: &str) -> bool { + self.ledger + .fresh_agent_settings_recorded(provider, session_id) + } + + fn load_provenance(&self, provider: &str, session_id: &str) -> Option { + let row = self.ledger.load_binding(provider, session_id)?; + if row.pane_kind.as_deref() != Some("fresh-agent") { + return None; + } + let provenance = BindProvenance { + client_instance_id: row.client_instance_id, + device_id: row.device_id, + tab_key: row.tab_key, + asserted_at: row.last_attributed_at.unwrap_or(row.created_at), + }; + (provenance.client_instance_id.is_some() + || provenance.device_id.is_some() + || provenance.tab_key.is_some()) + .then_some(provenance) + } + + fn record_rollback( + &self, + provider: &str, + session_id: &str, + record: RollbackRecord, + ) -> SinkWrite { + let ledger = self.ledger.clone(); + let (p, s) = (provider.to_string(), session_id.to_string()); + let now = now_ms(); + blocking(move || { + let payload = serde_json::to_value(&record).map_err(std::io::Error::other)?; + ledger.record_rollback_row(&p, &s, &payload, now) + }) + } + + fn load_rollback(&self, provider: &str, session_id: &str) -> Option { + let payload = self.ledger.load_rollback_row(provider, session_id)?; + RollbackRecord::from_stored_payload(payload) + } + + fn delete_rollback(&self, provider: &str, session_id: &str) -> SinkWrite { + let ledger = self.ledger.clone(); + let (p, s) = (provider.to_string(), session_id.to_string()); + blocking(move || ledger.delete_rollback_row(&p, &s)) + } + + fn repair_failed_transition( + &self, + provider: &str, + session_id: &str, + epoch: u64, + generation: u64, + ) -> SinkWrite { + let ledger = self.ledger.clone(); + let (p, s) = (provider.to_string(), session_id.to_string()); + let now = now_ms(); + blocking(move || ledger.repair_failed_transition_binding(&p, &s, epoch, generation, now)) + } + + fn lookup_by_create_request_id( + &self, + provider: &str, + create_request_id: &str, + ) -> Option { + self.ledger + .lookup_by_create_request_id(provider, create_request_id) + .map(|row| row.session_id) + } + + fn retire_closed(&self, provider: &str, session_id: &str) -> SinkCloseWrite { + let ledger = self.ledger.clone(); + let (p, s) = (provider.to_string(), session_id.to_string()); + let now = now_ms(); + Box::pin(async move { + tokio::task::spawn_blocking(move || ledger.retire_closed(&p, &s, now)) + .await + .map_err(|join| SinkCloseError::Clean(std::io::Error::other(join)))? + .map_err(close_error) + }) + } + + fn retire_closed_batch( + &self, + provider: &str, + session_ids: &[String], + pending_ids: &[String], + ) -> SinkCloseWrite { + let ledger = self.ledger.clone(); + let p = provider.to_string(); + let (ids, pendings) = (session_ids.to_vec(), pending_ids.to_vec()); + let now = now_ms(); + Box::pin(async move { + tokio::task::spawn_blocking(move || ledger.close_identities(&p, &ids, &pendings, now)) + .await + .map_err(|join| SinkCloseError::Clean(std::io::Error::other(join)))? + .map_err(close_error) + }) + } + + fn delete_pending(&self, placeholder_id: &str) -> SinkWrite { + let ledger = self.ledger.clone(); + let p = placeholder_id.to_string(); + blocking(move || ledger.delete_pending(&p)) + } + + fn clear_kill_tombstone(&self, provider: &str, session_id: &str) -> SinkWrite { + let ledger = self.ledger.clone(); + let (p, s) = (provider.to_string(), session_id.to_string()); + blocking(move || ledger.clear_kill_tombstone(&p, &s)) + } + + fn kill_tombstone_at_ms(&self, provider: &str, session_id: &str) -> Option { + self.ledger.kill_tombstone_at(provider, session_id) + } + + fn row_is_bound(&self, provider: &str, session_id: &str) -> bool { + self.ledger.row_is_bound(provider, session_id) + } + + fn commit_claim( + &self, + provider: &str, + session_id: &str, + expect_killed_at_ms: Option, + ) -> SinkCommitWrite { + self.commit_claim_aliased(provider, session_id, expect_killed_at_ms, &[]) + } + + fn commit_claim_aliased( + &self, + provider: &str, + session_id: &str, + expect_killed_at_ms: Option, + fence_checked_aliases: &[String], + ) -> SinkCommitWrite { + let ledger = self.ledger.clone(); + let (p, s) = (provider.to_string(), session_id.to_string()); + let aliases = fence_checked_aliases.to_vec(); + let now = now_ms(); + Box::pin(async move { + let outcome = tokio::task::spawn_blocking(move || { + ledger.commit_claim_aliased(&p, &s, expect_killed_at_ms, &aliases, now) + }) + .await + .map_err(std::io::Error::other)??; + Ok(match outcome { + ClaimCommitOutcome::Committed => ClaimCommit::Committed, + ClaimCommitOutcome::RefusedStale => ClaimCommit::RefusedStale, + }) + }) + } + + fn record_alias_tombstone( + &self, + provider: &str, + placeholder: &str, + durable: &str, + at_ms: i64, + ) -> SinkWrite { + let ledger = self.ledger.clone(); + let (p, ph, d) = ( + provider.to_string(), + placeholder.to_string(), + durable.to_string(), + ); + blocking(move || ledger.record_alias_tombstone(&p, &ph, &d, at_ms)) + } + + fn alias_tombstone_records(&self, provider: &str, placeholder: &str) -> Vec<(String, i64)> { + self.ledger.alias_tombstone_records(provider, placeholder) + } + + fn clear_alias_tombstones_for_durable( + &self, + provider: &str, + durable: &str, + ) -> SinkAliasClearWrite { + let ledger = self.ledger.clone(); + let (p, d) = (provider.to_string(), durable.to_string()); + Box::pin(async move { + tokio::task::spawn_blocking(move || ledger.clear_alias_tombstones_for_durable(&p, &d)) + .await + .map_err(std::io::Error::other)? + }) + } +} diff --git a/crates/freshell-ws/tests/common/mod.rs b/crates/freshell-ws/tests/common/mod.rs index c0de0181d..d388e9aee 100644 --- a/crates/freshell-ws/tests/common/mod.rs +++ b/crates/freshell-ws/tests/common/mod.rs @@ -18,6 +18,7 @@ use tokio_tungstenite::tungstenite::Message as WsMessage; use freshell_ws::WsState; pub mod claude_rig; +pub mod ledger_sink; pub mod log_capture; pub mod opencode_home; diff --git a/crates/freshell-ws/tests/cross_kind_liveness.rs b/crates/freshell-ws/tests/cross_kind_liveness.rs index 734cece52..249288ca1 100644 --- a/crates/freshell-ws/tests/cross_kind_liveness.rs +++ b/crates/freshell-ws/tests/cross_kind_liveness.rs @@ -832,6 +832,7 @@ impl PaneIdentitySink for TestLedgerSink { observed_generation: upsert.observed_generation, authoritative: upsert.authoritative, now_ms: now, + plugins: upsert.settings.plugins.as_deref(), }; ledger.record_fresh_agent_binding(&w)?; // kata 1wxv Task 4 (claude adoption): the rollback row re-keys @@ -882,6 +883,7 @@ impl PaneIdentitySink for TestLedgerSink { permission_mode: row.permission_mode, effort: row.effort, cwd: row.cwd, + plugins: row.plugins, }; if s == FreshAgentSettings::default() { return None; @@ -4005,6 +4007,7 @@ async fn an_ordinary_codex_cold_resume_with_recovered_settings_lands_end_to_end( observed_generation: None, authoritative: false, now_ms: 1_000, + plugins: None, }) .expect("the recovered row seeds"); let row_before = pane_ledger 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 f16d50abc..d2d574a1d 100644 --- a/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs +++ b/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs @@ -175,6 +175,7 @@ impl PaneIdentitySink for TestLedgerSink { observed_generation: None, authoritative: upsert.authoritative, now_ms: now, + plugins: upsert.settings.plugins.as_deref(), }; ledger.record_fresh_agent_binding(&w)?; if let Some(p) = upsert.resolves_pending.as_deref() { @@ -194,6 +195,7 @@ impl PaneIdentitySink for TestLedgerSink { permission_mode: row.permission_mode, effort: row.effort, cwd: row.cwd, + plugins: row.plugins, }) } fn was_recorded(&self, provider: &str, session_id: &str) -> bool { diff --git a/crates/freshell-ws/tests/freshagent_claude_launch_parity.rs b/crates/freshell-ws/tests/freshagent_claude_launch_parity.rs new file mode 100644 index 000000000..8030f02ae --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_launch_parity.rs @@ -0,0 +1,242 @@ +//! G15 (core-runtime design §4.9, C11): every claude sidecar launch hands the CLI +//! the pane's plugins and, in a hosted lane, the hosted Freshell MCP. +//! +//! Four launches start a sidecar for a session: the create, an attach that resumes +//! an untracked session (a reload after the sidecar died, or after a restart), a +//! handoff that resumes it as a fresh agent, and a rollback (undo/redo) respawn. +//! Each test runs one of them on the real sidecar over the CLI simulator +//! (RealSidecarSim), whose ledger records the options each `query()` call +//! received, and checks those options. +//! +//! The rig runs the claude lane the hosted way (the lane selects the hosted MCP and +//! the sidecar runs as hosted claude, so `hostedMcp: true` becomes the fixed +//! `freshell` MCP server) over a real pane ledger, so the create's plugins land in +//! the session's binding row and the resumes read them back from there. + +mod common; + +use serde_json::{json, Value}; + +use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions, RIG_FRAME_BUDGET}; + +async fn hosted_rig() -> (ClaudeRig, String) { + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + hosted_claude: true, + handoff: true, + ..RigOptions::default() + }, + ) + .await; + let plugin = rig.workspace.join(".claude/plugins/review"); + std::fs::create_dir_all(&plugin).expect("create the plugin directory"); + let plugin = plugin.to_str().expect("utf-8 path").to_string(); + (rig, plugin) +} + +/// Create a session with `plugin`; returns the session id `created` named. +async fn create_with_plugin(conn: &mut RigConnection, plugin: &str) -> String { + let created = conn + .request_create("create-1", json!({ "plugins": [plugin] })) + .await; + assert_eq!(created["type"], "freshAgent.created", "{created}"); + created["sessionId"] + .as_str() + .expect("a session id") + .to_string() +} + +/// One finished turn per prompt (each `freshAgent.result`), so the conversation is +/// on disk; returns the durable id the first turn reported. +async fn converse( + rig: &ClaudeRig, + conn: &mut RigConnection, + session_id: &str, + prompts: &[&str], +) -> String { + for (n, prompt) in prompts.iter().enumerate() { + let results_before = conn.events_seen("freshAgent.result"); + conn.send_text(session_id, &format!("send-{n}"), prompt) + .await; + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while conn.events_seen("freshAgent.result") == results_before { + assert!( + tokio::time::Instant::now() < deadline, + "turn {n} never finished" + ); + conn.read_for(std::time::Duration::from_millis(50)).await; + } + } + rig.wait_for_transcripts().await; + conn.frames + .iter() + .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() + }) + .expect("the first turn reported the durable id") +} + +/// The session's sidecar dies on its own (SIGKILL of the Node process): the session +/// is untracked, the state a reload or a handoff resumes from. +async fn kill_the_sidecar(rig: &ClaudeRig, conn: &mut RigConnection) { + let pid = rig.ledger_rows("boot")[0]["pid"] + .as_i64() + .expect("the boot row names the sidecar's pid") as i32; + // SAFETY: a plain signal to the sidecar this rig spawned. + assert_eq!(unsafe { libc::kill(pid, libc::SIGKILL) }, 0); + conn.wait_for("the sidecar's exit", |v| { + v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.error" + && v["event"]["code"] == "SIDECAR_EXITED" + }) + .await; +} + +/// The options of the `query()` calls the simulator recorded, in order. +fn query_options(rig: &ClaudeRig) -> Vec { + rig.ledger_rows("query") + .into_iter() + .map(|row| row["options"].clone()) + .collect() +} + +/// The first recorded `query()` options matching `predicate`, once recorded. +async fn wait_for_query(rig: &ClaudeRig, what: &str, predicate: impl Fn(&Value) -> bool) -> Value { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + if let Some(options) = query_options(rig).into_iter().find(|o| predicate(o)) { + return options; + } + assert!( + tokio::time::Instant::now() < deadline, + "no query() call for {what}; recorded: {:?}", + query_options(rig) + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } +} + +fn assert_carries_plugin_and_hosted_mcp(options: &Value, plugin: &str, launch: &str) { + assert_eq!( + options["plugins"], + json!([{ "type": "local", "path": plugin }]), + "the {launch} hands the CLI the pane's plugins: {options}" + ); + assert!( + options["mcpServers"]["freshell"].is_object(), + "the {launch} hands the CLI the hosted Freshell MCP: {options}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_create_carries_the_panes_plugins_and_the_hosted_mcp() { + let (rig, plugin) = hosted_rig().await; + let mut conn = rig.connect().await; + create_with_plugin(&mut conn, &plugin).await; + + let options = query_options(&rig); + assert_eq!(options.len(), 1, "one sidecar booted: {options:?}"); + assert_carries_plugin_and_hosted_mcp(&options[0], &plugin, "create"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_attach_resume_carries_the_bound_plugins_and_the_hosted_mcp() { + let (rig, plugin) = hosted_rig().await; + let mut conn = rig.connect().await; + let session_id = create_with_plugin(&mut conn, &plugin).await; + let durable = converse(&rig, &mut conn, &session_id, &["first prompt"]).await; + kill_the_sidecar(&rig, &mut conn).await; + + // The pane attaches again (a reload): the lane resumes the session. + conn.send(json!({ + "type": "freshAgent.attach", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": session_id, + "sessionRef": { "provider": "claude", "sessionId": durable }, + })) + .await; + conn.wait_for("the attach-resume acknowledgement", |v| { + v["type"] == "freshAgent.event" && v["event"]["type"] == "freshAgent.session.snapshot" + }) + .await; + + let resumed = wait_for_query(&rig, "the attach-resume", |o| o["resume"] == durable).await; + assert_carries_plugin_and_hosted_mcp(&resumed, &plugin, "attach-resume"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_handoff_resume_carries_the_bound_plugins_and_the_hosted_mcp() { + let (rig, plugin) = hosted_rig().await; + let mut conn = rig.connect().await; + let session_id = create_with_plugin(&mut conn, &plugin).await; + let durable = converse(&rig, &mut conn, &session_id, &["first prompt"]).await; + kill_the_sidecar(&rig, &mut conn).await; + + let (status, answer) = rig + .post_json( + "/api/sessions/handoff", + &json!({ + "provider": "claude", + "sessionId": durable, + "targetKind": "fresh-agent", + "sessionType": "freshclaude", + "cwd": rig.workspace.to_str().expect("utf-8 path"), + }), + ) + .await; + assert_eq!(status, 200, "the handoff resumes the session: {answer}"); + + let resumed = wait_for_query(&rig, "the handoff resume", |o| o["resume"] == durable).await; + assert_carries_plugin_and_hosted_mcp(&resumed, &plugin, "handoff resume"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_rollback_respawn_carries_the_bound_plugins_and_the_hosted_mcp() { + let (rig, plugin) = hosted_rig().await; + let mut conn = rig.connect().await; + let session_id = create_with_plugin(&mut conn, &plugin).await; + let durable = converse( + &rig, + &mut conn, + &session_id, + &["first prompt", "second prompt"], + ) + .await; + + // The client sends the owner pair of the latest `session.runtimeOwner` frame + // for the durable id with every rollback. + let owner = conn + .frames + .iter() + .rev() + .find(|v| v["type"] == "session.runtimeOwner" && v["sessionId"] == durable.as_str()) + .cloned() + .expect("the session's owner record was broadcast"); + conn.send(json!({ + "type": "freshAgent.undo", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": session_id, + "requestId": "undo-1", + "mode": "step", + "observedEpoch": owner["epoch"], + "observedGeneration": owner["generation"], + })) + .await; + + // The undo boots a fork of the conversation through its first turn. (Until C13 + // the rollback then waits for a turn that never comes, G01; the fork's launch + // is what this test pins, and the rig's teardown stops the fork.) + let fork = wait_for_query(&rig, "the undo's fork", |o| { + o["forkSession"] == true && o["resume"] == durable + }) + .await; + assert_carries_plugin_and_hosted_mcp(&fork, &plugin, "rollback respawn"); +} diff --git a/crates/freshell-ws/tests/freshagent_claude_rollback.rs b/crates/freshell-ws/tests/freshagent_claude_rollback.rs index b682ddeb0..9da75a4d7 100644 --- a/crates/freshell-ws/tests/freshagent_claude_rollback.rs +++ b/crates/freshell-ws/tests/freshagent_claude_rollback.rs @@ -257,6 +257,7 @@ impl PaneIdentitySink for TestLedgerSink { observed_generation: None, authoritative: upsert.authoritative, now_ms: now, + plugins: upsert.settings.plugins.as_deref(), }; ledger.record_fresh_agent_binding(&w)?; // kata 1wxv Task 4 (claude adoption): the rollback row re-keys diff --git a/crates/freshell-ws/tests/pane_reconcile_freshagent.rs b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs index 30c157610..a503841f4 100644 --- a/crates/freshell-ws/tests/pane_reconcile_freshagent.rs +++ b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs @@ -730,6 +730,7 @@ async fn old_thread_claim_after_crash_respawn_answers_the_new_terminus() { authoritative: false, now_ms: now, + plugins: None, }) .unwrap(); server @@ -751,6 +752,7 @@ async fn old_thread_claim_after_crash_respawn_answers_the_new_terminus() { authoritative: false, now_ms: now + 1, + plugins: None, }) .unwrap(); let (mut ws, _ready) = connect(&server.url, true, true).await; From 202dc3101aae58cb1cb0c52bcf22d7cab7805390 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:32:54 -0700 Subject: [PATCH 02/24] refactor(freshclaude): handle_create returns ClaudeCreateOutcome (I1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `FreshClaudeState::handle_create` now returns what it answered, for the API plan's in-process callers (design §9.3, I1): enum ClaudeCreateOutcome { Created { session_id, session_ref: Option }, Failed { code, message, retryable: Option, owner: Option }, } The WS frames stay byte-identical: a failed create's `freshAgent.create.failed` frame is now built from its outcome (`claude/create_outcome.rs`), and the dispatchers ignore the value. Typing every answer exposed a fall-through: when the coordinator refused a fresh create's pane-scoped pre-spawn claim (another operation holds `pending-create-`), or answered Adopt for it, the handler answered and then went on to spawn a sidecar and answer `created` too. Both arms now return their answer. Tests: - `a_refused_fresh_create_claim_answers_only_the_refusal_and_spawns_nothing` (red before: `create.failed` then `freshAgent.created`, and a spawn), which also checks the returned outcome equals the frame. - `a_create_returns_the_session_its_created_frame_names` (fresh and resume creates). - `a_failed_outcome_is_its_create_failed_frame` (wire shape with and without the retry hint and owner fields). Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 231 ++++++++++++------ .../src/claude/create_outcome.rs | 115 +++++++++ crates/freshell-freshagent/src/lib.rs | 2 +- .../src/providers/fresh_agent.rs | 5 +- 4 files changed, 278 insertions(+), 75 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude/create_outcome.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 5f88fde6d..9b92ceee7 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -69,10 +69,10 @@ use tokio::sync::Mutex as TokioMutex; use freshell_codex::next_monotonic_turn_complete_at; use freshell_protocol::{ ErrorCode, ErrorMsg, FreshAgentApprovalRespond, FreshAgentAttach, FreshAgentCompact, - FreshAgentConfigure, FreshAgentCreate, FreshAgentCreateFailed, FreshAgentCreated, - FreshAgentEvent, FreshAgentInterrupt, FreshAgentKill, FreshAgentKilled, - FreshAgentQuestionRespond, FreshAgentSend, FreshAgentSendAccepted, - FreshAgentSessionMaterialized, ServerMessage, SessionRuntimeOwner, SessionType, + FreshAgentConfigure, FreshAgentCreate, FreshAgentCreated, FreshAgentEvent, FreshAgentInterrupt, + FreshAgentKill, FreshAgentKilled, FreshAgentQuestionRespond, FreshAgentSend, + FreshAgentSendAccepted, FreshAgentSessionMaterialized, ServerMessage, SessionRuntimeOwner, + SessionType, }; use crate::claude_handshake::{read_created, BootContext}; @@ -2023,7 +2023,7 @@ impl FreshClaudeState { &self, msg: FreshAgentCreate, provenance: Option, - ) { + ) -> ClaudeCreateOutcome { let request_id = msg.request_id.clone(); let session_type = session_type_str(msg.session_type); @@ -2043,13 +2043,16 @@ impl FreshClaudeState { provider: PROVIDER.to_string(), request_id, runtime_provider: PROVIDER.to_string(), - session_id: cached.session_id, + session_id: cached.session_id.clone(), session_type: session_type.to_string(), - session_ref: cached.session_ref, + session_ref: cached.session_ref.clone(), name_ref, session_name, })); - return; + return ClaudeCreateOutcome::Created { + session_id: cached.session_id, + session_ref: cached.session_ref, + }; } FreshAgentCreateOutcome::Proceed(guard) => guard, }; @@ -2114,8 +2117,8 @@ impl FreshClaudeState { let owner_fields = crate::ownership_lane::terminal_owner_fields_from_snapshot( &self.ownership_snapshot(PROVIDER, sid), ); - self.fail_create_session_reserved_with_owner(&request_id, owner_fields.as_ref()); - return; + return self + .fail_create_session_reserved_with_owner(&request_id, owner_fields.as_ref()); } // b8ke delta round-3 F2: the existing-session ADOPT is // COORDINATED — the coordinator claim decides BEFORE any @@ -2140,8 +2143,7 @@ impl FreshClaudeState { tracing::warn!(target: "freshell_freshagent::claude", session_id = %sid, request_id = %request_id, code = err.code(), "fresh_agent_create_refused: the observed fence is half-sent (invalid)"); - self.fail_create(&request_id, err.code(), err.message()); - return; + return self.fail_create(&request_id, err.code(), err.message()); } }; match self.begin_lane_claim_at( @@ -2212,16 +2214,14 @@ impl FreshClaudeState { moved on while the live-runtime adopt committed — the \ create is refused (never a live writer left unowned)" ); - self.fail_create( + return self.fail_create( &request_id, "STALE_CLAIM", "ownership moved on while adopting the live session; refresh and retry", ); - return; } } - self.adopt_live_create(&request_id, sid, session_type).await; - return; + return self.adopt_live_create(&request_id, sid, session_type).await; } own_ticket = Some(ticket); // b8ke delta round-2 F2: register the start's @@ -2242,16 +2242,14 @@ impl FreshClaudeState { // live session adopts, spawn nothing. With no // coordinator there is no fence to consult. if self.has_live_session(sid).await { - self.adopt_live_create(&request_id, sid, session_type).await; - return; + return self.adopt_live_create(&request_id, sid, session_type).await; } } crate::ownership_lane::LaneClaim::Adopt => { // A same-kind live runtime the coordinator names — // adopt it, spawn nothing (the COORDINATED map-hit: // the claim already verified the key's state). - self.adopt_live_create(&request_id, sid, session_type).await; - return; + return self.adopt_live_create(&request_id, sid, session_type).await; } crate::ownership_lane::LaneClaim::Refused(outcome) => { tracing::warn!(target: "freshell_freshagent::claude", @@ -2267,11 +2265,10 @@ impl FreshClaudeState { &self.ownership, &outcome, ); - self.fail_create_session_reserved_with_owner( + return self.fail_create_session_reserved_with_owner( &request_id, owner_fields.as_ref(), ); - return; } } for round in 0..2u8 { @@ -2293,12 +2290,10 @@ impl FreshClaudeState { crate::session_lease::FreshSessionClaim::BoundLive { .. } => { // Under-lock ADOPT: the winner completed between our pre-check // and the claim (the V5 TOCTOU window). - self.adopt_live_create(&request_id, sid, session_type).await; - return; + return self.adopt_live_create(&request_id, sid, session_type).await; } crate::session_lease::FreshSessionClaim::Held { .. } => { - self.fail_create_session_reserved(&request_id); - return; + return self.fail_create_session_reserved(&request_id); } crate::session_lease::FreshSessionClaim::ExpiredNeedsKill { pid, @@ -2318,8 +2313,7 @@ impl FreshClaudeState { } tracing::error!(target: "invariant", pid, session_id = sid, "fresh_agent_lease_expired_kill_unconfirmed: holding closed"); - self.fail_create_session_reserved(&request_id); - return; + return self.fail_create_session_reserved(&request_id); } } } @@ -2368,9 +2362,9 @@ impl FreshClaudeState { // (the key is Aliased{to: the live session} after its // mint-time rekey); the idempotent re-drive ADOPTS the // live session, spawning nothing. - self.adopt_live_create(&request_id, &claim_key, session_type) + return self + .adopt_live_create(&request_id, &claim_key, session_type) .await; - None } crate::ownership_lane::LaneClaim::Refused(outcome) => { // A duplicate create for this pane is still in flight @@ -2386,11 +2380,10 @@ impl FreshClaudeState { &self.ownership, &outcome, ); - self.fail_create_session_reserved_with_owner( + return self.fail_create_session_reserved_with_owner( &request_id, owner_fields.as_ref(), ); - None } } } else { @@ -2467,12 +2460,11 @@ impl FreshClaudeState { { Ok(established) => established, Err(error) => { - self.fail_create( + return self.fail_create( &request_id, error.create_code(), &error.message(EstablishLane::Create), ); - return; } }; let created = established.map_key; @@ -2508,43 +2500,48 @@ impl FreshClaudeState { provider: PROVIDER.to_string(), request_id, runtime_provider: PROVIDER.to_string(), - session_id: created, + session_id: created.clone(), session_type: session_type.to_string(), - session_ref, + session_ref: session_ref.clone(), name_ref, session_name, })); + ClaudeCreateOutcome::Created { + session_id: created, + session_ref, + } + } + + /// Answer the create `request_id` with the failed `outcome` on the bus, and + /// return the outcome (I1: the frame is built from it). + fn answer_create_failure( + &self, + request_id: &str, + outcome: ClaudeCreateOutcome, + ) -> ClaudeCreateOutcome { + if let Some(frame) = outcome.create_failed_frame(request_id) { + self.broadcast(&frame); + } + outcome } - fn fail_create(&self, request_id: &str, code: &str, message: &str) { - self.broadcast(&ServerMessage::FreshAgentCreateFailed( - FreshAgentCreateFailed { - owner_kind: None, - owner_generation: None, - owner_epoch: None, + fn fail_create(&self, request_id: &str, code: &str, message: &str) -> ClaudeCreateOutcome { + self.answer_create_failure( + request_id, + ClaudeCreateOutcome::Failed { code: code.to_string(), message: message.to_string(), - request_id: request_id.to_string(), retryable: None, + owner: None, }, - )); + ) } /// The D8 loser answer (Task 12): reuses the existing create-failed frame with the /// fixed reservation code — NO new protocol fields (deliberate: avoids a C3 wire /// change; the client re-drive uses a fixed floor). - fn fail_create_session_reserved(&self, request_id: &str) { - self.broadcast(&ServerMessage::FreshAgentCreateFailed( - FreshAgentCreateFailed { - owner_kind: None, - owner_generation: None, - owner_epoch: None, - code: "SESSION_RESERVED".to_string(), - message: "Another resume for this session is in flight".to_string(), - request_id: request_id.to_string(), - retryable: Some(true), - }, - )); + fn fail_create_session_reserved(&self, request_id: &str) -> ClaudeCreateOutcome { + self.fail_create_session_reserved_with_owner(request_id, None) } /// [`Self::fail_create_session_reserved`] + the coordinator's additive @@ -2556,33 +2553,28 @@ impl FreshClaudeState { &self, request_id: &str, owner: Option<&crate::ownership_lane::TerminalOwnerFields>, - ) { - let (owner_kind, owner_generation, owner_epoch) = match owner { - Some(fields) => ( - Some(fields.owner_kind.to_string()), - Some(fields.owner_generation), - Some(fields.owner_epoch), - ), - None => (None, None, None), - }; - self.broadcast(&ServerMessage::FreshAgentCreateFailed( - FreshAgentCreateFailed { - owner_kind, - owner_generation, - owner_epoch, + ) -> ClaudeCreateOutcome { + self.answer_create_failure( + request_id, + ClaudeCreateOutcome::Failed { code: "SESSION_RESERVED".to_string(), message: "Another resume for this session is in flight".to_string(), - request_id: request_id.to_string(), retryable: Some(true), + owner: owner.cloned(), }, - )); + ) } /// The HAS-LIVE→ADOPT arm (Task 12, V1: new server behavior): answer a loser's /// create-with-resume with `freshAgent.created` naming the adopted DURABLE session /// (send/attach route to it via Task 10b's `cli_index` resolution) under the /// loser's own `requestId` — no spawn, no second writer. - async fn adopt_live_create(&self, request_id: &str, durable: &str, session_type: &str) { + async fn adopt_live_create( + &self, + request_id: &str, + durable: &str, + session_type: &str, + ) -> ClaudeCreateOutcome { self.create_dedup .record_success( request_id, @@ -2616,6 +2608,10 @@ impl FreshClaudeState { name_ref, session_name, })); + ClaudeCreateOutcome::Created { + session_id: durable.to_string(), + session_ref: Some(claude_session_ref(durable)), + } } // ── freshAgent.kill (WS) ───────────────────────────────────────────────── @@ -10026,6 +10022,9 @@ fn log_claude_teardown( } } +mod create_outcome; +pub use create_outcome::ClaudeCreateOutcome; + mod establish; use establish::{ BootedSidecar, DurableClaim, EstablishError, EstablishLane, EstablishPlan, RegisterUnder, @@ -13103,6 +13102,92 @@ rl.on('line', (line) => { ); } + /// A fresh create whose pane-scoped claim the coordinator refuses (another + /// operation holds the key) answers that refusal and nothing else: no + /// sidecar is spawned and no `created` follows the `create.failed`. + #[tokio::test(flavor = "multi_thread")] + async fn a_refused_fresh_create_claim_answers_only_the_refusal_and_spawns_nothing() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + 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 outcome = st + .handle_create(dedup_create_msg("req-refused-claim"), None) + .await; + + let mut answers = Vec::new(); + while let Ok(frame) = rx.try_recv() { + let frame: Value = serde_json::from_str(&frame).expect("json frame"); + if frame["requestId"] == json!("req-refused-claim") { + answers.push(frame); + } + } + assert_eq!(answers.len(), 1, "one answer: {answers:?}"); + assert_eq!(answers[0]["type"], "freshAgent.create.failed"); + assert_eq!(answers[0]["code"], "SESSION_RESERVED"); + assert_eq!(env.spawn_count(), 0, "no sidecar was spawned"); + // I1: the returned outcome is the answer the frame carries. + assert!(matches!( + &outcome, + ClaudeCreateOutcome::Failed { code, retryable: Some(true), .. } if code == "SESSION_RESERVED" + )); + assert_eq!( + serde_json::to_value(outcome.create_failed_frame("req-refused-claim").unwrap()) + .unwrap(), + answers[0] + ); + } + + /// I1: a create returns the session its `freshAgent.created` frame names, with + /// the durable conversation when it resumed one. + #[tokio::test] + async fn a_create_returns_the_session_its_created_frame_names() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + let (st, mut rx) = state_with_bus(); + + let fresh = st + .handle_create(dedup_create_msg("req-outcome-fresh"), None) + .await; + let frame = await_claude_created(&mut rx, "req-outcome-fresh").await; + assert_eq!( + fresh, + ClaudeCreateOutcome::Created { + session_id: frame["sessionId"].as_str().unwrap().to_string(), + session_ref: None, + } + ); + + let durable = "77777777-7777-4777-8777-777777777777"; + let mut resume = dedup_create_msg("req-outcome-resume"); + resume.resume_session_id = Some(durable.to_string()); + let resumed = st.handle_create(resume, None).await; + let frame = await_claude_created(&mut rx, "req-outcome-resume").await; + assert_eq!( + resumed, + ClaudeCreateOutcome::Created { + session_id: frame["sessionId"].as_str().unwrap().to_string(), + session_ref: Some(claude_session_ref(durable)), + } + ); + assert_eq!(frame["sessionRef"]["sessionId"], durable); + drop(env); + } + /// b8ke ext r22 F1 (b): a spawn-time provider rejection during the /// fresh-create window settles the provisional record typed through the /// ticket — the record is never left wedged in Starting and the create diff --git a/crates/freshell-freshagent/src/claude/create_outcome.rs b/crates/freshell-freshagent/src/claude/create_outcome.rs new file mode 100644 index 000000000..8aa89064a --- /dev/null +++ b/crates/freshell-freshagent/src/claude/create_outcome.rs @@ -0,0 +1,115 @@ +//! What a claude create answered (core-runtime design §9.3, I1). +//! +//! [`FreshClaudeState::handle_create`] answers every create on the WS bus +//! (`freshAgent.created` or `freshAgent.create.failed`) and also returns the +//! answer as a [`ClaudeCreateOutcome`], so an in-process caller (the REST/MCP agent +//! API) can act on it without reading the bus. A failed create's frame is built +//! from its outcome, so the two can never disagree; the WS dispatcher ignores the +//! value. + +use freshell_protocol::{FreshAgentCreateFailed, ServerMessage, SessionLocator}; + +use crate::ownership_lane::TerminalOwnerFields; + +/// The answer to one claude `freshAgent.create`. +#[derive(Debug, Clone, PartialEq)] +pub enum ClaudeCreateOutcome { + /// A session runs: the one this create started, or the live one it adopted. + Created { + /// The id the pane addresses the session by (`freshAgent.created.sessionId`). + session_id: String, + /// The durable conversation, when known (a resume or an adopted session). + session_ref: Option, + }, + /// No session runs for this create. + Failed { + code: String, + message: String, + /// `Some(true)` when the client should retry the same create. + retryable: Option, + /// The runtime that owns the session instead, when the refusal names one. + owner: Option, + }, +} + +impl ClaudeCreateOutcome { + /// The `freshAgent.create.failed` frame answering `request_id` for a failed + /// create; `None` for a created one. + pub(super) fn create_failed_frame(&self, request_id: &str) -> Option { + let Self::Failed { + code, + message, + retryable, + owner, + } = self + else { + return None; + }; + Some(ServerMessage::FreshAgentCreateFailed( + FreshAgentCreateFailed { + owner_kind: owner.as_ref().map(|o| o.owner_kind.to_string()), + owner_generation: owner.as_ref().map(|o| o.owner_generation), + owner_epoch: owner.as_ref().map(|o| o.owner_epoch), + code: code.clone(), + message: message.clone(), + request_id: request_id.to_string(), + retryable: *retryable, + }, + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_failed_outcome_is_its_create_failed_frame() { + let refused = ClaudeCreateOutcome::Failed { + code: "SESSION_RESERVED".into(), + message: "Another resume for this session is in flight".into(), + retryable: Some(true), + owner: Some(TerminalOwnerFields { + owner_kind: "terminal", + owner_generation: 4, + owner_epoch: 9, + }), + }; + assert_eq!( + serde_json::to_value(refused.create_failed_frame("req-1").unwrap()).unwrap(), + serde_json::json!({ + "type": "freshAgent.create.failed", + "code": "SESSION_RESERVED", + "message": "Another resume for this session is in flight", + "requestId": "req-1", + "retryable": true, + "ownerKind": "terminal", + "ownerGeneration": 4, + "ownerEpoch": 9, + }) + ); + + // No retry hint and no owner: neither key is on the wire. + let failed = ClaudeCreateOutcome::Failed { + code: "CLAUDE_CREATE_FAILED".into(), + message: "boom".into(), + retryable: None, + owner: None, + }; + assert_eq!( + serde_json::to_value(failed.create_failed_frame("req-2").unwrap()).unwrap(), + serde_json::json!({ + "type": "freshAgent.create.failed", + "code": "CLAUDE_CREATE_FAILED", + "message": "boom", + "requestId": "req-2", + }) + ); + + let created = ClaudeCreateOutcome::Created { + session_id: "s".into(), + session_ref: None, + }; + assert!(created.create_failed_frame("req-3").is_none()); + } +} diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index 447b0463a..ea155eda9 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -71,7 +71,7 @@ pub mod terminal_tabs; #[cfg(test)] mod test_environment; -pub use claude::FreshClaudeState; +pub use claude::{ClaudeCreateOutcome, FreshClaudeState}; // Kata 09v1 + SYNC-06 + G28: the claude_snapshot items visible outside this // crate — the ONE shared claude store-root rule (`claude_home_candidates`, // also read by freshell-ws's PTY truth probe), the three-state transcript diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index 53e208cd0..281fc968c 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -457,7 +457,10 @@ impl FreshAgentTransport for HostedTransport { *self.profile.lock().expect("profile lock") = Some(profile.clone()); let create = create_request_for_profile(profile); match &self.state { - ProviderState::Claude(state) => state.handle_create(create, None).await, + // The answer also rides the bus (`wait_created` below reads it). + ProviderState::Claude(state) => { + state.handle_create(create, None).await; + } ProviderState::Codex(state) => state.handle_create(create, None).await, ProviderState::Opencode { runtime, .. } => runtime.handle_create(create, None).await, } From d526a303b02fbc0f841d657695b67a838d694907 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:56:42 -0700 Subject: [PATCH 03/24] test(freshclaude): the default-model e2e spec reads the v2 boot in the create log C10 moved a create's resume into the protocol-v2 `boot` object, and its follow-up (e639d5eea) updated seven e2e specs that read the fake sidecar's create log, but not `freshagent-default-model-rust.spec.ts`. That spec still waited for a create with `resumeSessionId`, which no create line carries any more, so its resume leg timed out for both freshclaude and kilroy on the cloud backend ("timed out waiting for the resumed ... pane's sidecar create"). It now finds the new pane's create by `boot.kind === 'fresh'` and the resumed pane's by `boot.resume`. Co-Authored-By: Claude Opus 5.5 --- test/e2e-browser/specs/freshagent-default-model-rust.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/e2e-browser/specs/freshagent-default-model-rust.spec.ts b/test/e2e-browser/specs/freshagent-default-model-rust.spec.ts index 750476c75..89f422517 100644 --- a/test/e2e-browser/specs/freshagent-default-model-rust.spec.ts +++ b/test/e2e-browser/specs/freshagent-default-model-rust.spec.ts @@ -277,7 +277,7 @@ async function runDefaultModelJourney(page: Page, flavour: Flavour): Promise frame?.type === 'create' && !frame?.resumeSessionId, + (frame) => frame?.type === 'create' && frame?.boot?.kind === 'fresh', `the new ${label} pane's sidecar create`, ) expect(freshCreate.model, 'a new pane starts on the saved default model').toBe('sonnet') @@ -343,7 +343,7 @@ async function runDefaultModelJourney(page: Page, flavour: Flavour): Promise frame?.type === 'create' && frame?.resumeSessionId === durableId, + (frame) => frame?.type === 'create' && frame?.boot?.resume === durableId, `the resumed ${label} pane's sidecar create`, 30_000, ) From 95b02b8dd9d2174ac574b3fca31c7a956a931feb Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:07:34 -0700 Subject: [PATCH 04/24] docs(freshclaude): the create's claim comments name establish_runtime The create-resume claim's commit moved into `establish_runtime` (C11); the comments above the claim's dead-state snapshot in `handle_create` still said the commit happens "further down" and "below". Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 9b92ceee7..4774a611a 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -2392,7 +2392,7 @@ impl FreshClaudeState { // 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 further down, once the + // identity — but the claim COMMITS only in `establish_runtime`, once the // replacement session is established (sidecar created + `created` // answered). Clearing the fence before the spawn (round 2's // placement) left a failed create's identity UNFENCED — an older @@ -2403,8 +2403,8 @@ impl FreshClaudeState { // Round 4 (focused-ep5-r3 Finding 1): the claim's dead-state // SNAPSHOT is taken here — at claim start, after the lease and // before the spawn — so a kill that lands while the resume awaits - // `created` advances the durable tombstone past it and the commit - // below REFUSES instead of undoing the newer close. + // `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)); From 2e3f43372e94feee21360c0d9e802bfece423fc9 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:52:53 -0700 Subject: [PATCH 05/24] feat(freshclaude): genesis in the binding row; genesis-aware snapshot and reconcile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A claude session that has no transcript yet (a zero-turn session, a pending fork after an undo) records how it came to be in its binding row, so the snapshot shows what it holds and a restart brings it back instead of declaring it dead (core-runtime design §4.1, §4.3, §4.4). Inert until C13 writes a genesis: no lane sets one yet. - `ClaudeGenesis` (claude_launch.rs): `Fresh` or `Fork { parent, at, dropsTurn }`, stored as `{"kind":"fresh"}` / `{"kind":"fork",...}`; a kind this server does not know reads as none. - `BindingRow.genesis` (pane ledger, opaque like the rollback payload, serde-optional under LEDGER_VERSION 1: old rows parse to none). A binding write carries `GenesisWrite::{Keep, Set, Clear}`; every write that is not about the genesis keeps it (a settings change before the first send, T7), and a fork-chain child never inherits its parent's. `PaneLedger::binding_genesis` (memory read) and `clear_binding_genesis` (the lazy clear: drops only the genesis, durably; no-op without one). - Identity sink: `FreshAgentBindingUpsert.genesis` (`GenesisUpdate`), `load_genesis` (with the row's last write time) and `clear_genesis`, in the production `LedgerIdentitySink`, the fake and the ws test sinks. - Snapshot (§4.5): the effective transcript. A found transcript wins; a clean miss with a `Fresh` genesis is an empty snapshot; with a `Fork` genesis it is the parent's lines on the raw parentUuid chain through `at` (the whole parent when none), projected with the parent's subagents, so the turn ids are the ones the fork keeps (T6). A parent that is gone or no longer holds the fork point reads as lost; an unreadable store is Io, genesis or not. A pending snapshot's revision is the row's last write, floored by the rollback record. - REST and the session host read claude snapshots through the lane (`FreshClaudeState::disk_snapshot`, new `claude/genesis.rs`), from the store its sidecars write: a bound pending id answers 200, not the lost 404. - Reconcile (§4.4): a claude disk miss for an id whose binding row has a genesis and that was never seen on disk reads as present (`respawn`, under the respawn cap); seen-then-gone stays `dead_session`. The cold index already answered `respawn` for ledger-bound ids, so warm and cold now agree. Logged as `freshagent.claude.reconcile_pending_genesis` (debug); an unservable pending fork as `freshagent.claude.pending_fork_unservable` (warn); an unknown genesis shape as `freshagent.claude.genesis_unreadable` (warn). Tests (red first, then green): - pane_ledger_tests: the genesis round-trips across a restart; a row written before the field parses with none; only an explicit clear drops it (settings writes keep it, the clear is durable, a second clear writes nothing); a superseding binding carries only its own. Red: the write dropped it. - freshell-server identity_sink: write-through, read back after a restart, kept by a settings write, cleared. Red: nothing recorded. - claude_snapshot: Fresh gives an empty 200-shaped snapshot (and no genesis gives NotFound); Fork gives the parent's prefix through `at` with the turn ids the materialized fork keeps; the prefix follows the chain (an abandoned branch and a sidechain line stay out); gone parent or fork point gives NotFound; unreadable store gives Io; a found transcript wins; the rollback record floors the revision. Red: four failed with the genesis arm reverted to NotFound. - snapshot.rs (REST): a bound zero-turn id and a bound pending fork answer 200; an unbound id stays 404. Red: 404. - pane_reconcile_freshagent: Absent + genesis + never on disk gives `respawn` (Fresh and Fork); no genesis or once-observed gives `dead_session`; the respawn cap still converges. Red: `dead_session` and `fresh`. - claude_launch: the stored genesis reads back; an unknown kind reads as none. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 45 +- .../freshell-freshagent/src/claude/genesis.rs | 60 +++ .../freshell-freshagent/src/claude_launch.rs | 103 ++++ .../src/claude_snapshot.rs | 450 ++++++++++++++++-- crates/freshell-freshagent/src/codex.rs | 6 + .../freshell-freshagent/src/identity_sink.rs | 159 +++++++ crates/freshell-freshagent/src/lib.rs | 9 +- crates/freshell-freshagent/src/opencode_ws.rs | 14 + .../src/pane_ops_tab_tests.rs | 1 + crates/freshell-freshagent/src/snapshot.rs | 108 ++++- crates/freshell-server/src/identity_sink.rs | 141 +++++- .../src/recovery_inventory_tests.rs | 4 + crates/freshell-ws/src/codex_association.rs | 1 + crates/freshell-ws/src/invariants.rs | 1 + .../freshell-ws/src/opencode_association.rs | 1 + crates/freshell-ws/src/pane_ledger.rs | 92 ++++ crates/freshell-ws/src/pane_ledger_tests.rs | 203 ++++++++ .../freshell-ws/src/reconcile_freshagent.rs | 44 +- .../freshell-ws/tests/common/ledger_sink.rs | 35 ++ .../freshell-ws/tests/cross_kind_liveness.rs | 38 ++ .../freshagent_claude_kill_after_restart.rs | 35 ++ .../tests/freshagent_claude_rollback.rs | 35 ++ .../tests/pane_reconcile_freshagent.rs | 123 +++++ 23 files changed, 1629 insertions(+), 79 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude/genesis.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 4774a611a..124c2cfd6 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -4945,21 +4945,17 @@ impl FreshClaudeState { session_type: SessionType, thread_id: &str, ) -> Result { - let rollback = self.load_rollback_record(thread_id).await; - let mut snapshot = crate::claude_snapshot::get_claude_snapshot( - session_type_str(session_type), - thread_id, - rollback.as_ref(), - ) - .await - .map_err(|error| match error { - crate::claude_snapshot::ClaudeSnapshotError::NotFound => { - "claude session not found".to_string() - } - crate::claude_snapshot::ClaudeSnapshotError::Io(_) => { - "claude snapshot unavailable".to_string() - } - })?; + let mut snapshot = self + .disk_snapshot(session_type_str(session_type), thread_id) + .await + .map_err(|error| match error { + crate::claude_snapshot::ClaudeSnapshotError::NotFound => { + "claude session not found".to_string() + } + crate::claude_snapshot::ClaudeSnapshotError::Io(_) => { + "claude snapshot unavailable".to_string() + } + })?; let (approvals, questions) = self.snapshot_pending_overlay(thread_id).await; crate::claude_snapshot::apply_pending_overlay(&mut snapshot, approvals, questions); self.apply_snapshot_metadata(thread_id, &mut snapshot).await; @@ -4967,9 +4963,8 @@ impl FreshClaudeState { } /// Test seam (`test-support`, C09 rigs): the live-state overlay the snapshot - /// route applies ([`Self::apply_snapshot_metadata`]), over an empty snapshot. A - /// rig cannot read a transcript through its server (the store root comes from - /// this process's environment), so it reads the overlay directly. + /// route applies ([`Self::apply_snapshot_metadata`]), over an empty snapshot, + /// for a test that checks the overlay alone (no transcript needed). #[cfg(any(test, feature = "test-support"))] pub async fn live_snapshot_overlay(&self, any_id: &str) -> Value { let mut snapshot = json!({}); @@ -5039,16 +5034,7 @@ impl FreshClaudeState { &self, any_id: &str, ) -> Option { - let durable = match self.resolve_session_key(any_id).await { - Some(map_key) => self - .sessions - .lock() - .await - .get(&map_key) - .and_then(|s| s.cli_session_id.clone()) - .unwrap_or(map_key), - None => any_id.to_string(), - }; + let durable = self.durable_session_id(any_id).await; self.identity_sink() .and_then(|s| s.load_rollback(PROVIDER, &durable)) } @@ -7706,6 +7692,7 @@ impl FreshClaudeState { if let Some(sink) = &identity_sink { if let Err(e) = sink .record_binding(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, provider: PROVIDER.into(), session_id: cli_id.to_string(), mode: session_type.to_string(), @@ -10026,6 +10013,7 @@ mod create_outcome; pub use create_outcome::ClaudeCreateOutcome; mod establish; +mod genesis; use establish::{ BootedSidecar, DurableClaim, EstablishError, EstablishLane, EstablishPlan, RegisterUnder, }; @@ -17709,6 +17697,7 @@ rl.on('line', (line) => { // The lineage-only seed: a binding row exists, with an all-blank // settings snapshot (load_settings answers None). fake.record_binding(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "claude".into(), session_id: DURABLE.into(), diff --git a/crates/freshell-freshagent/src/claude/genesis.rs b/crates/freshell-freshagent/src/claude/genesis.rs new file mode 100644 index 000000000..953e97e22 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/genesis.rs @@ -0,0 +1,60 @@ +//! The claude lane's reads of a session's genesis (core-runtime design §4.1, +//! §4.3 and §4.5). +//! +//! A session that has no transcript yet (a fresh session before its first +//! prompt, or a pending fork) records how it came to be in its binding row +//! ([`ClaudeGenesis`]) until the transcript is observed on disk. The lane reads +//! it to serve the session's snapshot (`Fresh` holds nothing, a pending `Fork` +//! its parent's prefix), always from the transcript store its sidecars write. + +use super::*; + +use crate::claude_snapshot::ClaudeSnapshotError; +use crate::identity_sink::RecordedGenesis; + +impl FreshClaudeState { + /// The durable session id `any_id` names: a live session's adopted claude + /// id (or its map key until it has one), resolved like every handler here + /// (map key or durable UUID through `cli_index`); an untracked id (a + /// disk-only read after a restart) is itself. + pub(crate) async fn durable_session_id(&self, any_id: &str) -> String { + match self.resolve_session_key(any_id).await { + Some(map_key) => self + .sessions + .lock() + .await + .get(&map_key) + .and_then(|session| session.cli_session_id.clone()) + .unwrap_or(map_key), + None => any_id.to_string(), + } + } + + /// The genesis the binding row of the session `any_id` names records, if + /// any (memory-only). + pub(crate) async fn load_genesis(&self, any_id: &str) -> Option { + let durable = self.durable_session_id(any_id).await; + self.identity_sink() + .and_then(|sink| sink.load_genesis(PROVIDER, &durable)) + } + + /// The session's snapshot from disk, read from this lane's transcript store: + /// its transcript, or, before it has one, its genesis; with the durable + /// rollback record's markers and revision floor. No overlay of live state. + pub(crate) async fn disk_snapshot( + &self, + session_type: &str, + thread_id: &str, + ) -> Result { + let rollback = self.load_rollback_record(thread_id).await; + let genesis = self.load_genesis(thread_id).await; + crate::claude_snapshot::get_claude_snapshot_in( + &self.transcript_roots(), + session_type, + thread_id, + genesis.as_ref(), + rollback.as_ref(), + ) + .await + } +} diff --git a/crates/freshell-freshagent/src/claude_launch.rs b/crates/freshell-freshagent/src/claude_launch.rs index f8185fdc5..0df00629f 100644 --- a/crates/freshell-freshagent/src/claude_launch.rs +++ b/crates/freshell-freshagent/src/claude_launch.rs @@ -17,6 +17,7 @@ use std::path::Path; +use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; use crate::claude_snapshot::{LocateError, TranscriptLocation}; @@ -89,6 +90,58 @@ impl ClaudeBoot { } } +/// How a session that has no transcript yet came to be (design §4.1). The +/// session's binding row records it (`BindingRow.genesis`, opaque to the +/// ledger) until the transcript has been observed on disk, so a launch before +/// then boots the same conversation again, a snapshot shows what the session +/// holds, and reconcile restarts the session instead of declaring it dead. +/// +/// Wire shape: `{"kind":"fresh"}`, or +/// `{"kind":"fork","parent":P,"at":U?,"dropsTurn":D?}`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "kind", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum ClaudeGenesis { + /// A new conversation, booted under the session's own id (T3). + Fresh, + /// A lazy fork of `parent` holding its conversation through `at` (all of + /// it when `None`), booted under the session's own id (T6). `drops_turn` + /// arms the SDK's guard that the discarded part starts with that turn's + /// prompt. + Fork { + parent: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + drops_turn: Option, + }, +} + +impl ClaudeGenesis { + /// The genesis as the binding row stores it. + pub fn to_value(&self) -> Value { + serde_json::to_value(self).expect("a claude genesis always serializes") + } + + /// The genesis a binding row stores, or `None` for a shape this server does + /// not know (a newer server's kind): the caller then treats the session as + /// having no genesis, which is what an older server does. + pub fn from_value(value: &Value) -> Option { + serde_json::from_value(value.clone()).ok() + } + + /// The genesis's kind as the logs name it. + pub fn kind(&self) -> &'static str { + match self { + Self::Fresh => "fresh", + Self::Fork { .. } => "fork", + } + } +} + /// Everything one sidecar launch hands the sidecar: the one input of every /// `create` line (design §4.9). Built by each launch from the session's settings, /// so the plugins and the hosted MCP selection travel with every launch. @@ -261,6 +314,56 @@ mod tests { ); } + /// The stored genesis is the one the binding row keeps: it reads back as + /// written, and the ledger rows other crates seed (`{"kind":"fresh"}`, + /// `{"kind":"fork",...}`) read as the genesis they name. + #[test] + fn a_genesis_reads_back_from_the_shape_the_binding_row_stores() { + let fork = ClaudeGenesis::Fork { + parent: "P".into(), + at: Some("a1".into()), + drops_turn: Some("u2".into()), + }; + for genesis in [ + ClaudeGenesis::Fresh, + fork.clone(), + ClaudeGenesis::Fork { + parent: "P".into(), + at: None, + drops_turn: None, + }, + ] { + assert_eq!( + ClaudeGenesis::from_value(&genesis.to_value()), + Some(genesis) + ); + } + assert_eq!( + ClaudeGenesis::from_value(&json!({ "kind": "fresh" })), + Some(ClaudeGenesis::Fresh) + ); + assert_eq!( + ClaudeGenesis::from_value( + &json!({ "kind": "fork", "parent": "P", "at": "a1", "dropsTurn": "u2" }) + ), + Some(fork) + ); + // A kind this server does not know reads as no genesis. + assert_eq!( + ClaudeGenesis::from_value(&json!({ "kind": "clone", "parent": "P" })), + None + ); + assert_eq!( + crate::identity_sink::RecordedGenesis::from_row( + "claude", + "S", + Some(&json!({ "kind": "clone" })), + 1 + ), + None + ); + } + fn settings_with_plugins() -> FreshAgentSettings { FreshAgentSettings { model: Some("opus".into()), diff --git a/crates/freshell-freshagent/src/claude_snapshot.rs b/crates/freshell-freshagent/src/claude_snapshot.rs index f06d5e20a..b901dbd49 100644 --- a/crates/freshell-freshagent/src/claude_snapshot.rs +++ b/crates/freshell-freshagent/src/claude_snapshot.rs @@ -1254,9 +1254,16 @@ fn parse_chain_candidates(transcript: &str) -> Vec { /// never enter it. fn raw_chain(transcript: &str) -> Vec { let candidates = parse_chain_candidates(transcript); - let Some(mut cur) = candidates.last().map(|l| l.uuid.clone()) else { + let Some(tip) = candidates.last().map(|l| l.uuid.clone()) else { return Vec::new(); }; + chain_ending_at(&candidates, &tip) +} + +/// The raw parentUuid chain root→`end` over parsed `candidates` (the walk +/// [`raw_chain`] runs from the tip): empty when `end` is not a candidate. +fn chain_ending_at(candidates: &[ChainLine], end: &str) -> Vec { + let mut cur = end.to_string(); let by_uuid: std::collections::HashMap<&str, &ChainLine> = candidates.iter().map(|l| (l.uuid.as_str(), l)).collect(); let mut reversed = Vec::new(); @@ -1275,6 +1282,38 @@ fn raw_chain(transcript: &str) -> Vec { reversed } +/// The conversation a lazy fork of `parent` holds through `at` (inclusive; all +/// of it when `None`), before the fork writes its own transcript (design §4.5, +/// T6): `parent`'s lines on the raw parentUuid chain ending at `at`, verbatim +/// and in file order. A fork keeps those lines' uuids, so the turns projected +/// from this text keep their ids when the fork's transcript appears. `None` +/// when `at` is not a line of `parent` (the CLI refuses that fork: +/// `RESUME_POINT_NOT_FOUND`). +pub(crate) fn fork_prefix_transcript(parent: &str, at: Option<&str>) -> Option { + let chain = match at { + Some(at) => { + let candidates = parse_chain_candidates(parent); + if !candidates.iter().any(|line| line.uuid == at) { + return None; + } + chain_ending_at(&candidates, at) + } + None => raw_chain(parent), + }; + let members: std::collections::HashSet<&str> = + chain.iter().map(|line| line.uuid.as_str()).collect(); + let kept: Vec<&str> = parent + .lines() + .filter(|line| { + serde_json::from_str::(line.trim()) + .ok() + .and_then(|obj| obj.get("uuid").and_then(Value::as_str).map(str::to_string)) + .is_some_and(|uuid| members.contains(uuid.as_str())) + }) + .collect(); + Some(kept.join("\n")) +} + /// The last uuid of the raw parentUuid chain (the "chain tip") — the redo /// contract's recorded anchor (`original_tip_uuid`). `None` when the transcript /// carries no chain lines at all (e.g. the fresh conversation after a first-turn @@ -1742,29 +1781,56 @@ pub(crate) fn build_claude_snapshot_from_projection( snapshot } -/// Locate + read + build. `revision` = transcript mtime in ms (monotonic as the file -/// grows -- `mergeSnapshotForDisplay` DROPS revision regressions), fallback turn count; -/// kata 1wxv Task 5 floors it at the rollback record's `lastOpAtMs`. +/// Locate + read + build over this process's store roots, for a session with +/// no genesis. The served snapshot goes through the claude lane +/// (`FreshClaudeState::disk_snapshot`), which reads its own store and the +/// session's genesis; tests use this one. +#[cfg(test)] pub(crate) async fn get_claude_snapshot( session_type: &str, thread_id: &str, rollback: Option<&crate::rollback_record::RollbackRecord>, ) -> Result { - get_claude_snapshot_in(&claude_home_candidates(), session_type, thread_id, rollback).await + get_claude_snapshot_in( + &claude_home_candidates(), + session_type, + thread_id, + None, + rollback, + ) + .await } -/// [`get_claude_snapshot`] over explicit store roots (the production caller -/// passes [`claude_home_candidates`]; tests pass temp roots, no env mutation). -async fn get_claude_snapshot_in( +/// A session's snapshot, from the conversation it holds (design §4.5's +/// "effective transcript"): its own transcript when it has one; otherwise its +/// genesis's (nothing for `Fresh`, the parent's prefix for a pending `Fork`); a +/// clean miss with no genesis is gone (404 lost session), and a store that +/// could not be checked is never gone (500). +/// +/// `revision` = the transcript's mtime in ms (monotonic as the file grows -- +/// `mergeSnapshotForDisplay` DROPS revision regressions), fallback turn count; +/// a pending session's is its binding row's last write. Kata 1wxv Task 5 floors +/// either at the rollback record's `lastOpAtMs`. +pub(crate) async fn get_claude_snapshot_in( roots: &[PathBuf], session_type: &str, thread_id: &str, + genesis: Option<&crate::identity_sink::RecordedGenesis>, rollback: Option<&crate::rollback_record::RollbackRecord>, ) -> Result { let path = match locate_transcript_state_in(roots, thread_id) { TranscriptLocation::Found(path) => path, - // Clean miss in EVERY candidate root => 404 (positive denial; ledger A3/A4). - TranscriptLocation::Absent => return Err(ClaudeSnapshotError::NotFound), + // Clean miss in EVERY candidate root: the genesis, when the session has + // one, says what it holds before its transcript exists; without one the + // store positively does not know it => 404 (ledger A3/A4). + TranscriptLocation::Absent => { + return match genesis { + Some(recorded) => { + pending_snapshot(roots, session_type, thread_id, recorded, rollback).await + } + None => Err(ClaudeSnapshotError::NotFound), + }; + } // Cannot check => must not deny (the attach arm in claude.rs treats this // exact state as Transient): no store root, or a store read error, cannot // assert the session is gone, so this is Io (-> 500), never NotFound @@ -1774,38 +1840,129 @@ async fn get_claude_snapshot_in( return Err(ClaudeSnapshotError::Io(error.to_string())); } }; - // Reading the transcript and its subagent files, and projecting them, is - // blocking work: keep it off the async runtime. + let (mtime_ms, projection) = project_transcript_file(path, thread_id, None).await?; + let mut snapshot = + build_claude_snapshot_from_projection(session_type, thread_id, projection, 0, rollback); + let turn_count = snapshot["turns"] + .as_array() + .map(|a| a.len() as i64) + .unwrap_or(0); + let basis = mtime_ms.unwrap_or(turn_count).max(0); + snapshot["revision"] = json!(rollback_floor(basis, rollback)); + Ok(snapshot) +} + +/// The snapshot of a session whose transcript does not exist yet (design §4.3, +/// §4.5): a `Fresh` one holds nothing; a pending `Fork` holds its parent's +/// conversation through the fork point, with the parent's turn ids (the fork +/// keeps them, T6). A fork whose parent is gone, or no longer holds the fork +/// point, cannot boot (the CLI refuses it), so its session is gone too. +async fn pending_snapshot( + roots: &[PathBuf], + session_type: &str, + thread_id: &str, + recorded: &crate::identity_sink::RecordedGenesis, + rollback: Option<&crate::rollback_record::RollbackRecord>, +) -> Result { + use crate::claude_launch::ClaudeGenesis; + let projection = match &recorded.genesis { + ClaudeGenesis::Fresh => { + crate::claude_projection::ClaudeTranscriptProjector::project(thread_id, "") + } + ClaudeGenesis::Fork { parent, at, .. } => { + let parent_path = match locate_transcript_state_in(roots, parent) { + TranscriptLocation::Found(path) => path, + TranscriptLocation::Absent => { + log_pending_fork_unservable(thread_id, parent, at.as_deref(), "parent_absent"); + return Err(ClaudeSnapshotError::NotFound); + } + TranscriptLocation::Unreadable(error) => { + log_transcript_unreadable("pending_fork_snapshot", parent, &error); + return Err(ClaudeSnapshotError::Io(error.to_string())); + } + }; + match project_transcript_file(parent_path, thread_id, Some(at.clone())).await { + Ok((_, projection)) => projection, + Err(ClaudeSnapshotError::NotFound) => { + log_pending_fork_unservable( + thread_id, + parent, + at.as_deref(), + "fork_point_absent", + ); + return Err(ClaudeSnapshotError::NotFound); + } + Err(error) => return Err(error), + } + } + }; + tracing::debug!( + component = "freshagent.claude", + event = "freshagent.claude.pending_snapshot", + session_id = %thread_id, + genesis = recorded.genesis.kind(), + turns = projection.turns.len(), + "served the snapshot of a session whose transcript does not exist yet from its genesis" + ); + let mut snapshot = + build_claude_snapshot_from_projection(session_type, thread_id, projection, 0, rollback); + snapshot["revision"] = json!(rollback_floor(recorded.updated_at_ms.max(0), rollback)); + Ok(snapshot) +} + +/// Read and project the transcript at `path` as `thread_id`'s, with its +/// subagent activity, off the async runtime. `fork_at` projects only the +/// conversation a lazy fork of it holds ([`fork_prefix_transcript`]); a fork +/// point the transcript does not hold answers `NotFound`. +async fn project_transcript_file( + path: PathBuf, + thread_id: &str, + fork_at: Option>, +) -> Result<(Option, crate::claude_projection::ClaudeProjection), ClaudeSnapshotError> { let thread = thread_id.to_string(); - let (mtime_ms, projection) = tokio::task::spawn_blocking(move || { + tokio::task::spawn_blocking(move || { let mtime_ms = std::fs::metadata(&path) .ok() .and_then(|m| m.modified().ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_millis() as i64); - let content = std::fs::read_to_string(&path)?; + let content = std::fs::read_to_string(&path) + .map_err(|error| ClaudeSnapshotError::Io(error.to_string()))?; + let content = match fork_at { + None => content, + Some(at) => fork_prefix_transcript(&content, at.as_deref()) + .ok_or(ClaudeSnapshotError::NotFound)?, + }; let subagents = crate::claude_projection::FsSubagents::for_transcript(&path); let projection = crate::claude_projection::ClaudeTranscriptProjector::project_with( &thread, &content, &subagents, ); - Ok::<_, std::io::Error>((mtime_ms, projection)) + Ok((mtime_ms, projection)) }) .await .map_err(|e| ClaudeSnapshotError::Io(e.to_string()))? - .map_err(|e| ClaudeSnapshotError::Io(e.to_string()))?; - let mut snapshot = - build_claude_snapshot_from_projection(session_type, thread_id, projection, 0, rollback); - let turn_count = snapshot["turns"] - .as_array() - .map(|a| a.len() as i64) - .unwrap_or(0); - let basis = mtime_ms.unwrap_or(turn_count).max(0); - let floored = match rollback { +} + +/// `basis` floored at the rollback record's last operation (kata 1wxv Task 5). +fn rollback_floor(basis: i64, rollback: Option<&crate::rollback_record::RollbackRecord>) -> i64 { + match rollback { Some(record) => basis.max(record.last_op_at_ms), None => basis, - }; - snapshot["revision"] = json!(floored); - Ok(snapshot) + } +} + +/// One warn when a pending fork's snapshot cannot be served because the +/// conversation it forks is not there to fork. +fn log_pending_fork_unservable(thread_id: &str, parent: &str, at: Option<&str>, reason: &str) { + tracing::warn!( + component = "freshagent.claude", + event = "freshagent.claude.pending_fork_unservable", + session_id = %thread_id, + parent = %parent, + at = ?at, + reason, + "a pending fork's parent conversation does not hold the fork point; the session reads as gone" + ); } /// Task 3 (reload-while-pending): overlay a session's LIVE pending approvals/questions @@ -2692,6 +2849,7 @@ pub(crate) mod tests { "freshclaude", "55555555-5555-4555-8555-555555555555", None, + None, ) .await; assert!( @@ -2717,6 +2875,7 @@ pub(crate) mod tests { "freshclaude", "55555555-5555-4555-8555-555555555555", None, + None, ) .await; assert!( @@ -3166,6 +3325,243 @@ pub(crate) mod tests { record } + // ── Pending sessions: the snapshot of a session with no transcript yet + // comes from its genesis (core design §4.3, §4.5). ── + + const PENDING_ID: &str = "c0c0c0c0-c0c0-4c0c-8c0c-c0c0c0c0c0c0"; + const PARENT_ID: &str = "b0b0b0b0-b0b0-4b0b-8b0b-b0b0b0b0b0b0"; + + /// A store root with an empty `projects/` dir, plus `transcripts` written + /// under `projects/-p/.jsonl`. + fn pending_store(transcripts: &[(&str, &str)]) -> tempfile::TempDir { + let home = temp_home(); + let dir = home.path().join("projects").join("-p"); + std::fs::create_dir_all(&dir).unwrap(); + for (id, text) in transcripts { + std::fs::write(dir.join(format!("{id}.jsonl")), text).unwrap(); + } + home + } + + fn recorded( + genesis: crate::claude_launch::ClaudeGenesis, + updated_at_ms: i64, + ) -> crate::identity_sink::RecordedGenesis { + crate::identity_sink::RecordedGenesis { + genesis, + updated_at_ms, + } + } + + fn fork_of(parent: &str, at: Option<&str>) -> crate::claude_launch::ClaudeGenesis { + crate::claude_launch::ClaudeGenesis::Fork { + parent: parent.to_string(), + at: at.map(str::to_string), + drops_turn: None, + } + } + + fn turn_ids(snapshot: &Value) -> Vec { + snapshot["turns"] + .as_array() + .expect("turns") + .iter() + .filter_map(|t| t["turnId"].as_str().map(str::to_string)) + .collect() + } + + async fn pending( + home: &tempfile::TempDir, + id: &str, + genesis: Option<&crate::identity_sink::RecordedGenesis>, + rollback: Option<&crate::rollback_record::RollbackRecord>, + ) -> Result { + get_claude_snapshot_in( + &[home.path().to_path_buf()], + "freshclaude", + id, + genesis, + rollback, + ) + .await + } + + /// §4.3 "create, no send": the snapshot is 200 with no turns, and its + /// revision is the binding row's last write. Without a genesis the same miss + /// is a lost session. + #[tokio::test] + async fn a_fresh_pending_session_serves_an_empty_snapshot() { + let home = pending_store(&[]); + let fresh = recorded(crate::claude_launch::ClaudeGenesis::Fresh, 5_000); + let snapshot = pending(&home, PENDING_ID, Some(&fresh), None) + .await + .expect("a fresh pending session has a snapshot"); + assert!(turn_ids(&snapshot).is_empty()); + assert_eq!(snapshot["threadId"], PENDING_ID); + assert_eq!(snapshot["sessionId"], PENDING_ID); + assert_eq!(snapshot["latestTurnId"], Value::Null); + assert_eq!(snapshot["revision"], json!(5_000)); + + assert!(matches!( + pending(&home, PENDING_ID, None, None).await, + Err(ClaudeSnapshotError::NotFound) + )); + } + + /// §4.5: a pending fork shows its parent's conversation through the fork + /// point, and the turn ids are the parent's line uuids, which the fork keeps + /// (T6): the snapshot does not change when the fork's transcript appears. + #[tokio::test] + async fn a_pending_fork_serves_its_parents_prefix_with_the_turn_ids_the_fork_keeps() { + let parent = uuid_transcript(); + let home = pending_store(&[(PARENT_ID, &parent)]); + let fork = recorded(fork_of(PARENT_ID, Some("a1")), 6_000); + let pending_snapshot = pending(&home, PENDING_ID, Some(&fork), None) + .await + .expect("a pending fork has a snapshot"); + assert_eq!(turn_ids(&pending_snapshot), vec!["u1", "a1"]); + assert_eq!(pending_snapshot["threadId"], PENDING_ID); + assert_eq!(pending_snapshot["sessionId"], PENDING_ID); + assert_eq!( + pending_snapshot["turns"][1]["items"][0]["text"], + "answer one" + ); + assert_eq!(pending_snapshot["revision"], json!(6_000)); + + // The fork's first prompt writes its transcript: the kept prefix with the + // parent's uuids (session id rewritten) between the CLI's bookend lines. + let kept: Vec = parent + .lines() + .take(2) + .map(|line| { + let mut obj: Value = serde_json::from_str(line).unwrap(); + obj["sessionId"] = json!(PENDING_ID); + obj.to_string() + }) + .collect(); + let materialized = [ + json!({"type":"mode","mode":"default","sessionId":PENDING_ID}).to_string(), + kept.join("\n"), + json!({"type":"last-prompt","lastPrompt":"prompt one","sessionId":PENDING_ID}) + .to_string(), + ] + .join("\n"); + std::fs::write( + home.path() + .join("projects/-p") + .join(format!("{PENDING_ID}.jsonl")), + materialized, + ) + .unwrap(); + let materialized_snapshot = pending(&home, PENDING_ID, Some(&fork), None) + .await + .expect("the materialized fork has a snapshot"); + assert_eq!( + materialized_snapshot["turns"], pending_snapshot["turns"], + "the turns (ids included) are the same before and after the fork materializes" + ); + } + + /// A fork with no point holds the whole parent; the prefix follows the raw + /// parentUuid chain to the fork point, so a branch the parent abandoned (an + /// in-file rewind) and a sidechain line never enter it. + #[tokio::test] + async fn a_pending_forks_prefix_is_the_parents_chain_to_the_fork_point() { + let branched = [ + uuid_transcript(), + json!({"type":"user","uuid":"u2b","parentUuid":"a1","timestamp":"t5","message":{"role":"user","content":[{"type":"text","text":"prompt two again"}]}}).to_string(), + json!({"type":"assistant","uuid":"s1","isSidechain":true,"parentUuid":"u2b","timestamp":"t5b","message":{"role":"assistant","content":[{"type":"text","text":"sidechain noise"}]}}).to_string(), + json!({"type":"assistant","uuid":"a2b","parentUuid":"u2b","timestamp":"t6","message":{"role":"assistant","content":[{"type":"text","text":"answer two again"}]}}).to_string(), + ] + .join("\n"); + let home = pending_store(&[(PARENT_ID, &branched)]); + + let whole = recorded(fork_of(PARENT_ID, None), 1); + let snapshot = pending(&home, PENDING_ID, Some(&whole), None) + .await + .expect("a whole-parent fork has a snapshot"); + assert_eq!(turn_ids(&snapshot), vec!["u1", "a1", "u2b", "a2b"]); + + let abandoned = recorded(fork_of(PARENT_ID, Some("a2")), 1); + let snapshot = pending(&home, PENDING_ID, Some(&abandoned), None) + .await + .expect("a fork on the abandoned branch has a snapshot"); + assert_eq!(turn_ids(&snapshot), vec!["u1", "a1", "u2", "a2"]); + } + + /// A fork the CLI would refuse (its parent is gone, or no longer holds the + /// fork point) reads as a lost session; an unreadable parent store is never + /// lost. + #[tokio::test] + async fn a_pending_fork_without_its_fork_point_reads_as_gone() { + let home = pending_store(&[(PARENT_ID, &uuid_transcript())]); + let orphan = recorded(fork_of("d0d0d0d0-d0d0-4d0d-8d0d-d0d0d0d0d0d0", None), 1); + assert!(matches!( + pending(&home, PENDING_ID, Some(&orphan), None).await, + Err(ClaudeSnapshotError::NotFound) + )); + let missing_point = recorded(fork_of(PARENT_ID, Some("no-such-uuid")), 1); + assert!(matches!( + pending(&home, PENDING_ID, Some(&missing_point), None).await, + Err(ClaudeSnapshotError::NotFound) + )); + } + + /// G28 for pending sessions: a store that cannot be checked is Io (500) + /// whatever the genesis says, never a snapshot of the genesis (the + /// transcript may be there) and never lost. + #[cfg(unix)] + #[tokio::test] + async fn an_unreadable_store_is_io_for_a_pending_session_too() { + let home = pending_store(&[]); + let projects = home.path().join("projects"); + let Some(_locked) = UnreadableDir::make(&projects) else { + return; + }; + let fresh = recorded(crate::claude_launch::ClaudeGenesis::Fresh, 1); + let result = pending(&home, PENDING_ID, Some(&fresh), None).await; + assert!( + matches!(result, Err(ClaudeSnapshotError::Io(_))), + "expected Io, got {result:?}" + ); + } + + /// The transcript is the truth once it exists: a genesis not yet cleared + /// (the lazy clear runs after the first result) never hides it. + #[tokio::test] + async fn a_found_transcript_wins_over_a_genesis_not_yet_cleared() { + let home = pending_store(&[(PENDING_ID, &uuid_transcript())]); + let fresh = recorded(crate::claude_launch::ClaudeGenesis::Fresh, 1); + let snapshot = pending(&home, PENDING_ID, Some(&fresh), None) + .await + .expect("the transcript serves"); + assert_eq!(turn_ids(&snapshot), vec!["u1", "a1", "u2", "a2"]); + } + + /// A pending session after an undo carries the rollback record: its + /// markers ride the pending snapshot and its last operation floors the + /// revision, so the client's watermark never drops the post-undo snapshot. + #[tokio::test] + async fn a_pending_snapshots_revision_is_floored_by_the_rollback_record() { + let home = pending_store(&[(PARENT_ID, &uuid_transcript())]); + let mut record = claude_rollback_record(PARENT_ID, removed_slice_for(&["u2", "a2"])); + // No chain-root recheck: it reads this process's store, not the test's. + record.original_session_id = None; + let fork = recorded(fork_of(PARENT_ID, Some("a1")), 7); + let snapshot = pending(&home, PENDING_ID, Some(&fork), Some(&record)) + .await + .expect("a pending fork has a snapshot"); + assert_eq!(turn_ids(&snapshot), vec!["u1", "a1"]); + assert_eq!(snapshot["revision"], json!(record.last_op_at_ms)); + let bucket: Vec<&str> = snapshot["rolledBackTurns"] + .as_array() + .expect("the marker bucket") + .iter() + .filter_map(|t| t["turnId"].as_str()) + .collect(); + assert_eq!(bucket, vec!["u2", "a2"]); + } + /// Stage `/projects/-p/{orig,cur}.jsonl` under CLAUDE_CONFIG_DIR and /// return (home guard, original id, current id) — env restored by the caller. fn stage_rollback_home( diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index 45d1e8c76..5df35a520 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -1745,6 +1745,7 @@ impl FreshCodexState { } if let Err(e) = sink .record_binding(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, provider: "codex".into(), session_id: session_id.into(), mode: "freshcodex".into(), @@ -18243,6 +18244,7 @@ pub(crate) mod tests { // …but the parent's durable row knows the attribution (stamped by a // later merge — lineage-only payload so no settings change rides). fake.record_binding(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "codex".into(), session_id: parent_id.clone(), @@ -18437,6 +18439,7 @@ pub(crate) mod tests { let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); // A stamped LINEAGE-ONLY row (blank settings). fake.record_binding(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "codex".into(), session_id: "thread-lineage-adopt".into(), @@ -18529,6 +18532,7 @@ pub(crate) mod tests { // (cwd must be a REAL directory — the fork below spawns the child's // sidecar with the parent's inherited cwd.) fake.record_binding(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "codex".into(), session_id: "thread-row-seeded".into(), @@ -18638,6 +18642,7 @@ pub(crate) mod tests { // A settings-bearing row with NO stamps (recorded via an explicit // upsert, not the seed helper, so the row unambiguously has them unset). fake.record_binding(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "codex".into(), session_id: "thread-unattributed".into(), @@ -22193,6 +22198,7 @@ pub(crate) mod tests { let thread = "historical-thread-lineage"; // The lineage-only seed: a row exists with a blank settings snapshot. fake.record_binding(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "codex".into(), session_id: thread.into(), diff --git a/crates/freshell-freshagent/src/identity_sink.rs b/crates/freshell-freshagent/src/identity_sink.rs index 67401f36d..aa04e9b21 100644 --- a/crates/freshell-freshagent/src/identity_sink.rs +++ b/crates/freshell-freshagent/src/identity_sink.rs @@ -10,6 +10,7 @@ use std::sync::Arc; +pub use crate::claude_launch::ClaudeGenesis; use crate::rollback_record::RollbackRecord; #[cfg(test)] use crate::rollback_record::ROLLBACK_RECORD_VERSION; @@ -159,6 +160,75 @@ impl From> for ProvenanceUpdate { } } +/// What a binding write does to the row's genesis (claude core design §4.1; +/// the ledger's `GenesisWrite`). The genesis is not part of the settings +/// snapshot: it is set by the write that mints a session with no transcript +/// yet and cleared once the transcript is observed on disk, and every other +/// write keeps it. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum GenesisUpdate { + #[default] + Keep, + Set(ClaudeGenesis), + Clear, +} + +impl GenesisUpdate { + /// The genesis a `Set` stores in the binding row (the ledger keeps it + /// opaque); `None` for `Keep` and `Clear`. + pub fn stored_value(&self) -> Option { + match self { + Self::Set(genesis) => Some(genesis.to_value()), + Self::Keep | Self::Clear => None, + } + } + + pub fn is_clear(&self) -> bool { + matches!(self, Self::Clear) + } +} + +/// A binding row's genesis, with the row's last write time: the revision +/// floor of the snapshot a pending session serves (design §4.5). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RecordedGenesis { + pub genesis: ClaudeGenesis, + pub updated_at_ms: i64, +} + +impl RecordedGenesis { + /// The genesis a binding row stores (`raw`, opaque to the ledger) as the + /// claude lane reads it. A shape this server does not know (a newer + /// server's kind) reads as no genesis, the way an older server reads it, + /// and is logged. + pub fn from_row( + provider: &str, + session_id: &str, + raw: Option<&serde_json::Value>, + updated_at_ms: i64, + ) -> Option { + let raw = raw?; + match ClaudeGenesis::from_value(raw) { + Some(genesis) => Some(Self { + genesis, + updated_at_ms, + }), + None => { + tracing::warn!( + component = "freshagent.claude", + event = "freshagent.claude.genesis_unreadable", + provider, + session_id, + genesis = %raw, + "a binding row's genesis has a shape this server does not know; \ + the session is treated as having none" + ); + None + } + } + } +} + /// One fresh-agent identity event. Settings are a FULL snapshot (replace, /// not merge). `resolves_pending` names a pending marker (placeholder id) /// this binding supersedes. @@ -223,6 +293,8 @@ pub struct FreshAgentBindingUpsert { /// refresh. pub authoritative: bool, pub settings: FreshAgentSettings, + /// What this write does to the row's genesis (see [`GenesisUpdate`]). + pub genesis: GenesisUpdate, } impl FreshAgentBindingUpsert { @@ -358,6 +430,13 @@ pub trait PaneIdentitySink: Send + Sync { fn record_pending(&self, placeholder_id: &str, mode: &str, cwd: Option<&str>) -> SinkWrite; fn record_binding(&self, upsert: FreshAgentBindingUpsert) -> SinkWrite; fn load_settings(&self, provider: &str, session_id: &str) -> Option; + /// The fresh-agent binding row's genesis (claude core design §4.1), bound + /// or retired; `None` when it has none or there is no fresh-agent row. + /// Memory-only. + fn load_genesis(&self, provider: &str, session_id: &str) -> Option; + /// Drop the row's genesis: the session's transcript has been observed on + /// disk. AWAITED; no row, or a row without a genesis, is a no-op. + fn clear_genesis(&self, provider: &str, session_id: &str) -> SinkWrite; /// Task 3 semantics change: true iff a SETTINGS-BEARING record was /// persisted for this key — a lineage-only binding row (all-blank /// settings snapshot, recorded unconditionally at materialization so the @@ -777,6 +856,13 @@ pub(crate) struct FakeIdentitySink { /// "the lane carries the delayed-write fence" (pre-r29 every lane /// wrote (None, None) here on its refreshes). pub binding_pairs: std::sync::Mutex, + /// Claude core design §4.1: the row's CURRENT genesis per identity — the + /// ledger's genesis column in memory. Every `record_binding` applies its + /// [`GenesisUpdate`] (a `Keep` advances the row's write time, like the + /// ledger's `updated_at`), and `clear_genesis` drops it. + pub geneses: std::sync::Mutex>, + /// Every `clear_genesis` call, in order, whether or not a genesis existed. + pub genesis_clears: std::sync::Mutex>, } /// b8ke ext r29 F3: the observed (epoch, generation) pair a binding write @@ -971,6 +1057,7 @@ impl FakeIdentitySink { .insert((provider.into(), session_id.into()), s.clone()); } self.bindings.lock().unwrap().push(FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: provider.into(), session_id: session_id.into(), @@ -1381,8 +1468,45 @@ impl FakeIdentitySink { } } } + { + let key = (upsert.provider.clone(), upsert.session_id.clone()); + let now = crate::rollback_record::now_ms(); + let mut geneses = self.geneses.lock().unwrap(); + match &upsert.genesis { + GenesisUpdate::Keep => { + if let Some(recorded) = geneses.get_mut(&key) { + recorded.updated_at_ms = now; + } + } + GenesisUpdate::Set(genesis) => { + geneses.insert( + key, + RecordedGenesis { + genesis: genesis.clone(), + updated_at_ms: now, + }, + ); + } + GenesisUpdate::Clear => { + geneses.remove(&key); + } + } + } self.bindings.lock().unwrap().push(upsert); } + + /// Seed a row's genesis (a session minted with no transcript yet) as the + /// binding write that mints it would record it. + #[allow(dead_code)] + pub fn seed_genesis(&self, provider: &str, session_id: &str, genesis: ClaudeGenesis) { + self.geneses.lock().unwrap().insert( + (provider.into(), session_id.into()), + RecordedGenesis { + genesis, + updated_at_ms: crate::rollback_record::now_ms(), + }, + ); + } } #[cfg(test)] @@ -1462,6 +1586,26 @@ impl PaneIdentitySink for FakeIdentitySink { .get(&(provider.into(), session_id.into())) .cloned() } + fn load_genesis(&self, provider: &str, session_id: &str) -> Option { + self.geneses + .lock() + .unwrap() + .get(&(provider.into(), session_id.into())) + .cloned() + } + fn clear_genesis(&self, provider: &str, session_id: &str) -> SinkWrite { + self.genesis_clears + .lock() + .unwrap() + .push((provider.into(), session_id.into())); + if !self.fail_writes.load(std::sync::atomic::Ordering::SeqCst) { + self.geneses + .lock() + .unwrap() + .remove(&(provider.to_string(), session_id.to_string())); + } + self.write_result() + } fn was_recorded(&self, provider: &str, session_id: &str) -> bool { self.recorded .lock() @@ -2010,6 +2154,7 @@ mod tests { .await .expect("pending write ok"); fake.record_binding(FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: "ses_1".into(), @@ -2054,6 +2199,7 @@ mod tests { async fn fake_sink_blank_settings_binding_is_lineage_only() { let fake = Arc::new(FakeIdentitySink::default()); fake.record_binding(FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: "ses_blank".into(), @@ -2101,6 +2247,7 @@ mod tests { async fn fake_sink_lookup_by_create_request_id_resolves_lineage() { let fake = Arc::new(FakeIdentitySink::default()); fake.record_binding(FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: "ses_abc".into(), @@ -2240,6 +2387,7 @@ mod tests { // A stamped lineage-only row (blank settings) still answers. fake.record_binding(FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: "ses_prov".into(), @@ -2275,6 +2423,7 @@ mod tests { // A later conn-less write (all-None stamps) keeps them — and a partial // stamp touches NOTHING (the attribution fact is atomic). fake.record_binding(FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: "ses_prov".into(), @@ -2292,6 +2441,7 @@ mod tests { .await .expect("conn-less refresh ok"); fake.record_binding(FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: "ses_prov".into(), @@ -2330,6 +2480,7 @@ mod tests { // mirrored): an OLDER full-triple assertion never drags the // attribution back; an equal-or-newer one replaces it. fake.record_binding(FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: "ses_prov".into(), @@ -2355,6 +2506,7 @@ mod tests { assert_eq!(p.client_instance_id.as_deref(), Some("client-1")); assert_eq!(p.asserted_at, 111, "the older assertion never applies"); fake.record_binding(FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: "ses_prov".into(), @@ -2382,6 +2534,7 @@ mod tests { // A genuinely unattributed row answers None — never Some(default). fake.record_binding(FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: "ses_unstamped".into(), @@ -2410,6 +2563,7 @@ mod tests { async fn fake_sink_clear_provenance_erases_the_tracked_stamps() { let fake = Arc::new(FakeIdentitySink::default()); fake.record_binding(FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: "ses_clr".into(), @@ -2434,6 +2588,7 @@ mod tests { assert!(fake.load_provenance("opencode", "ses_clr").is_some()); fake.record_binding(FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: "ses_clr".into(), @@ -2457,6 +2612,7 @@ mod tests { ); // Inherit afterwards has nothing to keep: the stamps stay gone. fake.record_binding(FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: "ses_clr".into(), @@ -2485,6 +2641,7 @@ mod tests { async fn fake_sink_mirrors_the_kill_tombstone_fence_and_the_claim_clear() { let fake = Arc::new(FakeIdentitySink::default()); let upsert = || FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "claude".into(), session_id: "durable-m".into(), @@ -2598,6 +2755,7 @@ mod tests { "the refusal never revived" ); fake.record_binding(FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "claude".into(), session_id: "durable-cc".into(), @@ -2634,6 +2792,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn fake_orphan_gate_applies_at_release_against_the_tombstone_state() { let upsert = || FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "claude".into(), session_id: "durable-g".into(), diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index ea155eda9..396fb8209 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -86,9 +86,10 @@ pub use claude_snapshot::{ }; pub use codex::FreshCodexState; pub use identity_sink::{ - BindProvenance, ClaimCommit, CloseAnswer, FreshAgentBindingUpsert, FreshAgentSettings, - PaneIdentitySink, ProvenanceUpdate, SharedPaneIdentitySink, SinkAliasClearWrite, - SinkCloseError, SinkCloseWrite, SinkCommitWrite, SinkWrite, + BindProvenance, ClaimCommit, ClaudeGenesis, CloseAnswer, FreshAgentBindingUpsert, + FreshAgentSettings, GenesisUpdate, PaneIdentitySink, ProvenanceUpdate, RecordedGenesis, + SharedPaneIdentitySink, SinkAliasClearWrite, SinkCloseError, SinkCloseWrite, SinkCommitWrite, + SinkWrite, }; pub use opencode_ws::FreshOpencodeState; pub use rollback_record::{ @@ -5516,6 +5517,7 @@ async fn resume_session_ref_tab( if let Some(sink) = state.identity_sink() { if let Err(e) = sink .record_binding(identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, provider: PROVIDER.into(), session_id: durable_id.clone(), mode: SESSION_TYPE.into(), @@ -6574,6 +6576,7 @@ async fn send_keys( if let Some(sink) = state.identity_sink() { if let Err(e) = sink .record_binding(identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, provider: PROVIDER.into(), session_id: durable_id.clone(), mode: SESSION_TYPE.into(), diff --git a/crates/freshell-freshagent/src/opencode_ws.rs b/crates/freshell-freshagent/src/opencode_ws.rs index 9debab3ef..84c45f2b3 100644 --- a/crates/freshell-freshagent/src/opencode_ws.rs +++ b/crates/freshell-freshagent/src/opencode_ws.rs @@ -1913,6 +1913,7 @@ impl FreshOpencodeState { // PRE-PARK capture above — the row's state before this // handler's own re-park). binding_write_rollback = Some(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, provider: PROVIDER.into(), session_id: durable_id.clone(), mode: SESSION_TYPE.into(), @@ -1938,6 +1939,7 @@ impl FreshOpencodeState { }); let _ = self .record_binding_row(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, provider: PROVIDER.into(), session_id: durable_id.clone(), mode: SESSION_TYPE.into(), @@ -2528,6 +2530,7 @@ impl FreshOpencodeState { // the pane answers the typed recoverable error. if let Err(e) = self .record_binding_row(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, provider: PROVIDER.into(), session_id: durable_id.clone(), mode: SESSION_TYPE.into(), @@ -2690,6 +2693,7 @@ impl FreshOpencodeState { // `Inherit` and the ledger merge preserves them). let _ = self .record_binding_row(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, provider: PROVIDER.into(), session_id: acked_session_id.clone(), mode: SESSION_TYPE.into(), @@ -3166,6 +3170,7 @@ impl FreshOpencodeState { if let Some(durable_id) = durable_id { let _ = self .record_binding_row(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, provider: PROVIDER.into(), session_id: durable_id, mode: SESSION_TYPE.into(), @@ -5999,6 +6004,7 @@ impl FreshOpencodeState { // claim typed. if let Err(e) = self .record_binding_row(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, // Unified agent names: no naming fact on this ledger edge // (the fork's naming classification is driven by its // dedicated lane). @@ -7606,6 +7612,7 @@ impl FreshOpencodeState { // committed Live. if let Err(e) = self .record_binding_row(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, provider: PROVIDER.into(), session_id: session_id.to_string(), mode: SESSION_TYPE.into(), @@ -14028,6 +14035,7 @@ mod tests { let (state, mut rx) = state_with_durable_serve_session().await; let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); fake.record_binding(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, provider: "opencode".into(), session_id: DURABLE_ID.into(), mode: "freshopencode".into(), @@ -14089,6 +14097,7 @@ mod tests { // The lineage-only seed (blank settings — same construction the Task 3 // keying test uses). fake.record_binding(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, provider: "opencode".into(), session_id: DURABLE_ID.into(), mode: "freshopencode".into(), @@ -14928,6 +14937,7 @@ mod tests { // answers None). Same fixture as // `lineage_only_binding_does_not_arm_settings_reset_on_resume`. fake.record_binding(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, provider: "opencode".into(), session_id: DURABLE_ID.into(), mode: "freshopencode".into(), @@ -15199,6 +15209,7 @@ mod tests { let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); // The durable row from the pre-restart connection: settings + stamps. fake.record_binding(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, provider: "opencode".into(), session_id: DURABLE_ID.into(), mode: "freshopencode".into(), @@ -15306,6 +15317,7 @@ mod tests { // A settings-bearing row with NO stamps (an explicit upsert, not the // seed helper, so the row unambiguously has them unset). fake.record_binding(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, provider: "opencode".into(), session_id: DURABLE_ID.into(), mode: "freshopencode".into(), @@ -15462,6 +15474,7 @@ mod tests { // The parent's durable row knows the attribution (lineage-only // payload: no settings ride the seed). fake.record_binding(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, provider: "opencode".into(), session_id: DURABLE_ID.into(), mode: "freshopencode".into(), @@ -15551,6 +15564,7 @@ mod tests { // A stamped LINEAGE-ONLY row (blank settings): the only gate driver // here would be the provenance — exactly Finding 2's shape. fake.record_binding(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, provider: "opencode".into(), session_id: DURABLE_ID.into(), mode: "freshopencode".into(), diff --git a/crates/freshell-freshagent/src/pane_ops_tab_tests.rs b/crates/freshell-freshagent/src/pane_ops_tab_tests.rs index 39279e0e2..b019bc949 100644 --- a/crates/freshell-freshagent/src/pane_ops_tab_tests.rs +++ b/crates/freshell-freshagent/src/pane_ops_tab_tests.rs @@ -1302,6 +1302,7 @@ async fn rest_resume_resolves_placeholder_sessionref_through_the_ledger() { // exactly what the unconditional materialization write produces for a // default create) still answers `lookup_by_create_request_id`. sink.record_binding(FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: "ses_placeholder_resumed".into(), diff --git a/crates/freshell-freshagent/src/snapshot.rs b/crates/freshell-freshagent/src/snapshot.rs index 7f480846d..61128cc1e 100644 --- a/crates/freshell-freshagent/src/snapshot.rs +++ b/crates/freshell-freshagent/src/snapshot.rs @@ -43,9 +43,12 @@ //! required, so snapshots survive a server restart. When the session is LIVE, the route //! overlays its folded pending approvals/questions onto the disk-built JSON and flips the //! presence-of-pending `capabilities` gates (reload-while-pending — Task 3, matching -//! `normalize.ts:186-204, 226-232`). A missing transcript is a positive +//! `normalize.ts:186-204, 226-232`). A session with no transcript yet whose binding +//! row records a genesis (a zero-turn session, a pending fork) serves what the genesis +//! holds (claude core design §4.3); any other missing transcript is a positive //! denial: 404 with `code:'FRESH_AGENT_LOST_SESSION'` (the codex/opencode convention); a -//! read failure is a 500. An outright invalid enum member (e.g. `sessionType=bogus`) is a +//! read failure is a 500. The claude reads go through the claude lane, which reads the +//! transcript store its sidecars write. An outright invalid enum member (e.g. `sessionType=bogus`) is a //! 400, mirroring the reference's `ThreadParamsSchema.safeParse` failure (`router.ts:181-186`). use std::collections::HashMap; @@ -124,14 +127,11 @@ impl SnapshotState { .ok() .flatten()?, ("freshclaude" | "kilroy", "claude") => { - let rollback = self.claude.load_rollback_record(native_id).await; - let saved = crate::claude_snapshot::get_claude_snapshot( - session_type, - native_id, - rollback.as_ref(), - ) - .await - .ok()?; + let saved = self + .claude + .disk_snapshot(session_type, native_id) + .await + .ok()?; crate::native_history::readonly_snapshot(provider, saved).ok()? } ("freshopencode", "opencode") => { @@ -324,14 +324,9 @@ async fn get_snapshot( // snapshot (marker bucket + `rollback{canRedo, undoneDepth}` + revision // floor). The record is ledger-sync (memory-fast) and resolves by the // DURABLE id even when no session is live (durable+multi-client truth). - let rollback = state.claude.load_rollback_record(&thread_id).await; - match crate::claude_snapshot::get_claude_snapshot( - &session_type, - &thread_id, - rollback.as_ref(), - ) - .await - { + // Core design §4.3: a session with no transcript yet serves what its + // genesis holds (200), never the lost-session 404. + match state.claude.disk_snapshot(&session_type, &thread_id).await { Ok(mut snapshot) => { // Task 3 (reload-while-pending): overlay the session's LIVE pending // approvals/questions and flip the presence-of-pending gates @@ -682,6 +677,83 @@ mod tests { assert_eq!(body["code"], "FRESH_AGENT_LOST_SESSION"); } + /// Core design §4.3: a bound session with no transcript yet (a zero-turn + /// session, or a pending fork after an undo) answers 200 with what its + /// genesis holds, never the lost-session 404 the client would recover from: + /// a `Fresh` one holds nothing, a pending `Fork` its parent's prefix. + #[tokio::test] + async fn claude_locator_serves_a_bound_pending_session_from_its_genesis() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let parent = "a1a1a1a1-a1a1-4a1a-8a1a-a1a1a1a1a1a1"; + let zero_turn = "b2b2b2b2-b2b2-4b2b-8b2b-b2b2b2b2b2b2"; + let pending_fork = "c3c3c3c3-c3c3-4c3c-8c3c-c3c3c3c3c3c3"; + let home = tempfile::tempdir().unwrap(); + let dir = home.path().join("projects").join("-p"); + std::fs::create_dir_all(&dir).unwrap(); + let parent_text = [ + json!({"type":"user","uuid":"u1","parentUuid":null,"timestamp":"t1","message":{"role":"user","content":[{"type":"text","text":"prompt one"}]}}), + json!({"type":"assistant","uuid":"a1","parentUuid":"u1","timestamp":"t2","message":{"role":"assistant","content":[{"type":"text","text":"answer one"}]}}), + json!({"type":"user","uuid":"u2","parentUuid":"a1","timestamp":"t3","message":{"role":"user","content":[{"type":"text","text":"prompt two"}]}}), + json!({"type":"assistant","uuid":"a2","parentUuid":"u2","timestamp":"t4","message":{"role":"assistant","content":[{"type":"text","text":"answer two"}]}}), + ] + .iter() + .map(|v| v.to_string()) + .collect::>() + .join("\n"); + std::fs::write(dir.join(format!("{parent}.jsonl")), parent_text).unwrap(); + std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); + + let claude = claude_state(); + let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + claude.set_identity_sink(fake.clone()); + fake.seed_genesis("claude", zero_turn, crate::ClaudeGenesis::Fresh); + fake.seed_genesis( + "claude", + pending_fork, + crate::ClaudeGenesis::Fork { + parent: parent.to_string(), + at: Some("a1".to_string()), + drops_turn: Some("u2".to_string()), + }, + ); + let state = SnapshotState::new( + Arc::new("tok".to_string()), + codex_state(), + opencode_state(), + claude, + ); + + let (status, zero) = get_json_with_state(state.clone(), "kilroy", zero_turn).await; + let (fork_status, fork) = + get_json_with_state(state.clone(), "freshclaude", pending_fork).await; + let (lost_status, lost) = + get_json_with_state(state, "freshclaude", "d4d4d4d4-d4d4-4d4d-8d4d-d4d4d4d4d4d4").await; + std::env::remove_var("CLAUDE_CONFIG_DIR"); + + assert_eq!(status, StatusCode::OK, "{zero}"); + assert_eq!(zero["sessionType"], "kilroy"); + assert_eq!(zero["threadId"], zero_turn); + assert_eq!(zero["turns"], json!([])); + + assert_eq!(fork_status, StatusCode::OK, "{fork}"); + assert_eq!(fork["threadId"], pending_fork); + let ids: Vec<&str> = fork["turns"] + .as_array() + .expect("turns") + .iter() + .filter_map(|t| t["turnId"].as_str()) + .collect(); + assert_eq!( + ids, + vec!["u1", "a1"], + "the parent's prefix through the fork point" + ); + + // An unbound id with no transcript is still the lost session. + assert_eq!(lost_status, StatusCode::NOT_FOUND, "{lost}"); + assert_eq!(lost["code"], "FRESH_AGENT_LOST_SESSION"); + } + /// G28: an unreadable claude store is "cannot check", so the route answers /// 500 -- never the 404 `FRESH_AGENT_LOST_SESSION` the client treats as a /// lost session (and starts recovery for). diff --git a/crates/freshell-server/src/identity_sink.rs b/crates/freshell-server/src/identity_sink.rs index dda252137..220244825 100644 --- a/crates/freshell-server/src/identity_sink.rs +++ b/crates/freshell-server/src/identity_sink.rs @@ -4,8 +4,8 @@ use freshell_freshagent::{ BindProvenance, ClaimCommit, FreshAgentBindingUpsert, FreshAgentSettings, PaneIdentitySink, - RollbackRecord, SinkAliasClearWrite, SinkCloseError, SinkCloseWrite, SinkCommitWrite, - SinkWrite, + RecordedGenesis, RollbackRecord, SinkAliasClearWrite, SinkCloseError, SinkCloseWrite, + SinkCommitWrite, SinkWrite, }; use freshell_ws::pane_ledger::{CloseEnvelopeError, FreshAgentBindingWrite, PaneLedger}; use std::sync::Arc; @@ -330,7 +330,12 @@ impl PaneIdentitySink for LedgerIdentitySink { freshell_ws::pane_ledger::ProvenancePolicy::Clear } }; + let stored_genesis = upsert.genesis.stored_value(); let w = FreshAgentBindingWrite { + genesis: freshell_ws::pane_ledger::GenesisWrite::from_update( + stored_genesis.as_ref(), + upsert.genesis.is_clear(), + ), provider: &upsert.provider, session_id: &upsert.session_id, mode: &upsert.mode, @@ -432,6 +437,29 @@ impl PaneIdentitySink for LedgerIdentitySink { Some(s) } + fn load_genesis(&self, provider: &str, session_id: &str) -> Option { + // Memory-only against the write-through index, gated like + // `load_settings` (only fresh-agent rows carry a genesis). + let row = self.ledger.load_binding(provider, session_id)?; + if row.pane_kind.as_deref() != Some("fresh-agent") { + return None; + } + RecordedGenesis::from_row(provider, session_id, row.genesis.as_ref(), row.updated_at) + } + + fn clear_genesis(&self, provider: &str, session_id: &str) -> SinkWrite { + let ledger = self.ledger.clone(); + let (p, s) = (provider.to_string(), session_id.to_string()); + let now = now_ms(); + Box::pin(async move { + tokio::task::spawn_blocking(move || { + ledger.clear_binding_genesis(&p, &s, now).map(|_| ()) + }) + .await + .map_err(std::io::Error::other)? + }) + } + fn load_provenance(&self, provider: &str, session_id: &str) -> Option { // Focused-ep1-r4 Finding 2: the row's CURRENT stamps, memory-only via // the same write-through read + fresh-agent gate as `load_settings` @@ -763,6 +791,7 @@ mod tests { ))); let sink = LedgerIdentitySink::new(ledger.clone(), None); sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "codex".into(), session_id: "t1".into(), @@ -810,6 +839,7 @@ mod tests { let plugins = vec!["/workspace/.claude/plugins/local".to_string()]; let sink = LedgerIdentitySink::new(ledger, None); sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "claude".into(), session_id: "s1".into(), @@ -851,6 +881,89 @@ mod tests { ); } + /// Claude core design §4.1: the genesis rides the binding write, survives + /// a restart, is kept by a later settings write, and is dropped by the + /// lazy clear once the transcript is on disk. + #[tokio::test(flavor = "multi_thread")] + async fn genesis_writes_through_reads_back_and_clears() { + let tmp = tempfile::tempdir().unwrap(); + let ledger = Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + tmp.path().to_path_buf(), + ))); + let sink = LedgerIdentitySink::new(ledger, None); + let fork = freshell_freshagent::ClaudeGenesis::Fork { + parent: "p-1".into(), + at: Some("a-1".into()), + drops_turn: Some("u-2".into()), + }; + let upsert = |genesis, model: Option<&str>| FreshAgentBindingUpsert { + genesis, + name_transition: None, + provider: "claude".into(), + session_id: "s-pending".into(), + mode: "freshclaude".into(), + create_request_id: None, + resolves_pending: None, + supersedes: None, + provenance: freshell_freshagent::ProvenanceUpdate::Inherit, + observed_epoch: None, + observed_generation: None, + authoritative: false, + settings: FreshAgentSettings { + model: model.map(str::to_string), + ..FreshAgentSettings::default() + }, + }; + sink.record_binding(upsert( + freshell_freshagent::GenesisUpdate::Set(fork.clone()), + None, + )) + .await + .expect("awaited write succeeds"); + let recorded = sink + .load_genesis("claude", "s-pending") + .expect("the genesis is recorded"); + assert_eq!(recorded.genesis, fork); + + // A settings write (a model change before the first send) keeps it. + sink.record_binding(upsert( + freshell_freshagent::GenesisUpdate::Keep, + Some("sonnet"), + )) + .await + .expect("awaited write succeeds"); + let restarted = LedgerIdentitySink::new( + Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + tmp.path().to_path_buf(), + ))), + None, + ); + let kept = restarted + .load_genesis("claude", "s-pending") + .expect("the genesis survives a settings write and a restart"); + assert_eq!(kept.genesis, fork); + assert!(kept.updated_at_ms >= recorded.updated_at_ms); + + restarted + .clear_genesis("claude", "s-pending") + .await + .expect("awaited clear succeeds"); + assert_eq!(restarted.load_genesis("claude", "s-pending"), None); + assert_eq!( + restarted + .load_settings("claude", "s-pending") + .and_then(|s| s.model) + .as_deref(), + Some("sonnet"), + "the clear keeps the rest of the row" + ); + // Clearing an identity with no row is a no-op. + restarted + .clear_genesis("claude", "s-none") + .await + .expect("a no-op clear succeeds"); + } + #[tokio::test(flavor = "multi_thread")] async fn rollback_record_writes_through_and_reads_back() { let tmp = tempfile::tempdir().unwrap(); @@ -917,6 +1030,7 @@ mod tests { ); // The identity lanes keep their silent-degrade policy on a disabled store. sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "claude".into(), session_id: "s".into(), @@ -967,6 +1081,7 @@ mod tests { plugins: None, }; sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "claude".into(), session_id: "fork-new-uuid".into(), @@ -994,6 +1109,7 @@ mod tests { ); sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "codex".into(), session_id: "new-thread".into(), @@ -1039,6 +1155,7 @@ mod tests { // The pre-handoff fresh-agent row (the old owner's pair: gen 3). sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: SESSION.into(), @@ -1062,6 +1179,7 @@ mod tests { // The handoff target's binding write carries the SUPPLIED handoff // pair (the new owner: gen 4) — the row's fence baseline advances. sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: SESSION.into(), @@ -1086,6 +1204,7 @@ mod tests { // upsert, carrying its PRE-handoff pair) — refused typed. let err = sink .record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: SESSION.into(), @@ -1142,6 +1261,7 @@ mod tests { // The pre-handoff fresh-agent row (the old owner's pair: gen 3). sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: SESSION.into(), @@ -1182,6 +1302,7 @@ mod tests { // carrying its OBSERVED pre-handoff pair) — refused typed. let err = sink .record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: SESSION.into(), @@ -1293,6 +1414,7 @@ mod tests { authoritative: bool, ) -> FreshAgentBindingUpsert { FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "claude".into(), session_id: SID.into(), @@ -2039,6 +2161,7 @@ mod tests { ))); let sink = LedgerIdentitySink::new(ledger.clone(), None); sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: "ses_prov".into(), @@ -2086,6 +2209,7 @@ mod tests { let sink = LedgerIdentitySink::new(ledger.clone(), None); let asserted = now_ms() - 30_000; // "provenance captured 30s before the write" sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: "ses_late".into(), @@ -2146,6 +2270,7 @@ mod tests { // A stamped LINEAGE-ONLY row (blank settings) answers its stamps. sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: "ses_prov".into(), @@ -2179,6 +2304,7 @@ mod tests { // A conn-less refresh (all-`None` stamps) keeps them — the REAL // ledger's `Inherit` preserve, end to end. sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: "ses_prov".into(), @@ -2202,6 +2328,7 @@ mod tests { // A genuinely unattributed row answers None — never Some(default). sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: "ses_unstamped".into(), @@ -2268,6 +2395,7 @@ mod tests { ))); let sink = LedgerIdentitySink::new(ledger.clone(), None); let upsert = |provenance: freshell_freshagent::ProvenanceUpdate| FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: "ses_clr".into(), @@ -2327,6 +2455,7 @@ mod tests { ))); let sink = LedgerIdentitySink::new(ledger.clone(), None); sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: "ses_lookup".into(), @@ -2373,6 +2502,7 @@ mod tests { ))); let sink = LedgerIdentitySink::new(ledger.clone(), None); sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "claude".into(), session_id: "ses-to-kill".into(), @@ -2463,6 +2593,7 @@ mod tests { ))); let sink = LedgerIdentitySink::new(ledger.clone(), None); let bind = |id: &str, cr: &str| FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: id.into(), @@ -2589,6 +2720,7 @@ mod tests { ))); let sink = LedgerIdentitySink::new(ledger, None); sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "claude".into(), session_id: "ses-rb".into(), @@ -2639,6 +2771,7 @@ mod tests { ))); let sink = LedgerIdentitySink::new(ledger.clone(), None); sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "claude".into(), session_id: "ses-comp".into(), @@ -2706,6 +2839,7 @@ mod tests { ))); let sink = LedgerIdentitySink::new(ledger.clone(), None); sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "claude".into(), session_id: "d-alias".into(), @@ -2804,6 +2938,7 @@ mod tests { ))); let sink = LedgerIdentitySink::new(ledger.clone(), None); let upsert = |session_id: &str| FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "claude".into(), session_id: session_id.into(), @@ -2900,6 +3035,7 @@ mod tests { ))); let sink = LedgerIdentitySink::new(ledger.clone(), None); let upsert = |session_id: &str| FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "claude".into(), session_id: session_id.into(), @@ -3025,6 +3161,7 @@ mod tests { ))); let sink = LedgerIdentitySink::new(ledger.clone(), None); sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "opencode".into(), session_id: "ses_lineage".into(), diff --git a/crates/freshell-server/src/recovery_inventory_tests.rs b/crates/freshell-server/src/recovery_inventory_tests.rs index ad3fd3616..deefc88ad 100644 --- a/crates/freshell-server/src/recovery_inventory_tests.rs +++ b/crates/freshell-server/src/recovery_inventory_tests.rs @@ -103,6 +103,7 @@ fn binding_row_at( ) -> BindingRow { let (state, retired_reason, superseded_by) = state_parts; BindingRow { + genesis: None, ledger_version: LEDGER_VERSION, provider: provider.to_string(), session_id: session_id.to_string(), @@ -3113,6 +3114,7 @@ fn fresh_agent_create_completed_after_the_pane_closed_is_never_offered() { let ledger = PaneLedger::new(Some(tmp.path().to_path_buf())); ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + genesis: freshell_ws::pane_ledger::GenesisWrite::Keep, provider: "opencode", session_id: "S1", mode: "freshopencode", @@ -3177,6 +3179,7 @@ fn fresh_agent_create_asserted_inside_the_kill_window_stays_offered_despite_a_la let ledger = PaneLedger::new(Some(tmp.path().to_path_buf())); ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + genesis: freshell_ws::pane_ledger::GenesisWrite::Keep, provider: "opencode", session_id: "S1", mode: "freshopencode", @@ -3314,6 +3317,7 @@ fn supersession_after_the_freeze_judges_on_the_parents_assertion_time() { .expect("parent binding write"); ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + genesis: freshell_ws::pane_ledger::GenesisWrite::Keep, provider: "codex", session_id: "child-id", mode: "freshcodex", diff --git a/crates/freshell-ws/src/codex_association.rs b/crates/freshell-ws/src/codex_association.rs index 6b4c77a4b..6d234b13c 100644 --- a/crates/freshell-ws/src/codex_association.rs +++ b/crates/freshell-ws/src/codex_association.rs @@ -780,6 +780,7 @@ mod tests { state .pane_ledger .record_fresh_agent_binding(&crate::pane_ledger::FreshAgentBindingWrite { + genesis: crate::pane_ledger::GenesisWrite::Keep, provider: "codex", session_id: TID, mode: "freshcodex", diff --git a/crates/freshell-ws/src/invariants.rs b/crates/freshell-ws/src/invariants.rs index d63801010..2962570f0 100644 --- a/crates/freshell-ws/src/invariants.rs +++ b/crates/freshell-ws/src/invariants.rs @@ -1433,6 +1433,7 @@ mod tests { state .pane_ledger .record_fresh_agent_binding(&crate::pane_ledger::FreshAgentBindingWrite { + genesis: crate::pane_ledger::GenesisWrite::Keep, provider: "opencode", session_id: "ses_freshagent", mode: "freshopencode", diff --git a/crates/freshell-ws/src/opencode_association.rs b/crates/freshell-ws/src/opencode_association.rs index 946f29c2f..76af599a1 100644 --- a/crates/freshell-ws/src/opencode_association.rs +++ b/crates/freshell-ws/src/opencode_association.rs @@ -1319,6 +1319,7 @@ mod tests { state .pane_ledger .record_fresh_agent_binding(&crate::pane_ledger::FreshAgentBindingWrite { + genesis: crate::pane_ledger::GenesisWrite::Keep, provider: "opencode", session_id: SID, mode: "freshopencode", diff --git a/crates/freshell-ws/src/pane_ledger.rs b/crates/freshell-ws/src/pane_ledger.rs index 643962e9b..1f84fe4b7 100644 --- a/crates/freshell-ws/src/pane_ledger.rs +++ b/crates/freshell-ws/src/pane_ledger.rs @@ -874,6 +874,17 @@ pub struct BindingRow { /// only the request's plugins). #[serde(default, skip_serializing_if = "Option::is_none")] pub plugins: Option>, + /// Claude core design §4.1: how a session that has no transcript yet came + /// to be (`{"kind":"fresh"}`, or a lazy fork of a parent at a point). The + /// claude lane owns the shape (`freshell_freshagent::ClaudeGenesis`); the + /// ledger keeps it opaque, as it keeps the rollback payload. It stays + /// until the transcript has been observed on disk: every binding write + /// that is not about the genesis keeps it ([`GenesisWrite::Keep`]), so a + /// launch before the first transcript flush boots the same conversation + /// again and reconcile restarts the session instead of declaring it dead. + /// Serde-optional under LEDGER_VERSION 1: older rows parse to `None`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub genesis: Option, /// D8 (restore-open-sessions-only) provenance: the browser client + tab /// this binding was created from. What a write DOES with them is the /// tri-state [`ProvenancePolicy`] on the write structs (delta-r2 Finding @@ -1283,6 +1294,34 @@ pub struct ReattachWrite<'a> { pub now_ms: i64, } +/// What a fresh-agent binding write does to the row's genesis +/// ([`BindingRow::genesis`], claude core design §4.1). +#[derive(Debug, Clone, Copy, Default, PartialEq)] +pub enum GenesisWrite<'a> { + /// The row keeps the genesis it has (a new row has none). Every write that + /// is not about the genesis: settings, provenance, ownership refreshes. + #[default] + Keep, + /// The row's genesis becomes this value (the write that mints a session + /// with no transcript yet). + Set(&'a serde_json::Value), + /// The row has no genesis: its transcript has been observed on disk. + Clear, +} + +impl<'a> GenesisWrite<'a> { + /// The write for a lane's genesis update (the identity sink's + /// `GenesisUpdate`): `set` is the stored value of a new genesis, `clear` an + /// explicit clear; neither keeps the row's genesis. + pub fn from_update(set: Option<&'a serde_json::Value>, clear: bool) -> Self { + match (set, clear) { + (Some(genesis), _) => Self::Set(genesis), + (None, true) => Self::Clear, + (None, false) => Self::Keep, + } + } +} + /// One fresh-agent identity event's worth of binding-row input (P1.13). /// Settings are a FULL snapshot: callers always know the current values, /// so writes replace rather than merge. @@ -1300,6 +1339,10 @@ pub struct FreshAgentBindingWrite<'a> { /// G15: the claude pane's plugin directories (part of the settings /// snapshot this write records). pub plugins: Option<&'a [String]>, + /// What this write does to the row's genesis ([`BindingRow::genesis`]). + /// Not part of the settings snapshot: a write that only records settings + /// keeps the genesis. + pub genesis: GenesisWrite<'a>, /// G3 supersession (V8/A14): the OLD session id this binding replaces /// (codex crash-respawn). When `Some`, the old `(provider, supersedes)` /// row is retired and linked AFTER the new row persists. @@ -2246,6 +2289,9 @@ impl PaneLedger { permission_mode: None, effort: None, plugins: None, + // A terminal row records no genesis (the terminal CLI writes its + // own transcript; reconcile's terminal carve-out covers it). + genesis: None, client_instance_id, device_id, tab_key, @@ -2769,6 +2815,16 @@ impl PaneLedger { permission_mode: w.permission_mode.map(str::to_string), effort: w.effort.map(str::to_string), plugins: w.plugins.map(<[String]>::to_vec), + // Claude core design §4.1: the genesis is not part of the + // settings snapshot. Only the write that mints a pending session + // sets it and only the transcript's observation clears it; every + // other write keeps the same-key row's (a fork-chain child never + // inherits its parent's). + genesis: match w.genesis { + GenesisWrite::Keep => existing.and_then(|r| r.genesis.clone()), + GenesisWrite::Set(genesis) => Some(genesis.clone()), + GenesisWrite::Clear => None, + }, client_instance_id, device_id, tab_key, @@ -4538,6 +4594,42 @@ impl PaneLedger { .cloned() } + /// The row's genesis ([`BindingRow::genesis`]), bound or retired; `None` + /// when the row has none or there is no row. Memory-only. + pub fn binding_genesis(&self, provider: &str, session_id: &str) -> Option { + self.root.as_ref()?; + self.guard() + .bindings + .get(&(provider.to_string(), session_id.to_string())) + .and_then(|row| row.genesis.clone()) + } + + /// Drop the row's genesis: its transcript has been observed on disk + /// (claude core design §4.1). Everything else on the row stays. `Ok(false)` + /// with no write when there is no row or the row has no genesis. + pub fn clear_binding_genesis( + &self, + provider: &str, + session_id: &str, + now_ms: i64, + ) -> std::io::Result { + let Some(root) = &self.root else { + return Ok(false); + }; + let mut index = self.guard(); + let key = (provider.to_string(), session_id.to_string()); + let Some(row) = index.bindings.get(&key).filter(|row| row.genesis.is_some()) else { + return Ok(false); + }; + let row = BindingRow { + genesis: None, + updated_at: now_ms, + ..row.clone() + }; + self.write_binding(root, &mut index, &row)?; + Ok(true) + } + /// Fresh-agent Task 3 (`was_recorded` rekeying): true iff a FRESH-AGENT /// binding row carrying a SETTINGS-BEARING snapshot exists for this key — /// at least one of model/sandbox/permission_mode/effort/cwd/plugins set (the exact diff --git a/crates/freshell-ws/src/pane_ledger_tests.rs b/crates/freshell-ws/src/pane_ledger_tests.rs index 67bc9adf2..e3fbc4ef2 100644 --- a/crates/freshell-ws/src/pane_ledger_tests.rs +++ b/crates/freshell-ws/src/pane_ledger_tests.rs @@ -283,6 +283,7 @@ fn write_provenance_at( fn fa_write<'a>(provider: &'a str, session_id: &'a str, now_ms: i64) -> FreshAgentBindingWrite<'a> { FreshAgentBindingWrite { + genesis: GenesisWrite::Keep, provider, session_id, mode: provider, @@ -312,6 +313,7 @@ fn fa_write_fenced<'a>( generation: u64, ) -> FreshAgentBindingWrite<'a> { FreshAgentBindingWrite { + genesis: GenesisWrite::Keep, provider, session_id, mode: provider, @@ -344,6 +346,7 @@ fn fa_write_target<'a>( generation: u64, ) -> FreshAgentBindingWrite<'a> { FreshAgentBindingWrite { + genesis: GenesisWrite::Keep, provider, session_id, mode: provider, @@ -2957,6 +2960,7 @@ fn disabled_ledger_refuses_the_rollback_row_write_with_a_loud_error() { // The other lanes keep their existing disabled policy (silent no-op). ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + genesis: GenesisWrite::Keep, provider: "claude", session_id: "sid", mode: "freshclaude", @@ -4166,6 +4170,197 @@ fn a_fresh_agent_binding_keeps_the_panes_plugins_across_a_restart() { std::fs::remove_dir_all(&root).ok(); } +/// Claude core design §4.1: a session with no transcript yet records its +/// genesis in its binding row, so a restart can boot the same conversation +/// again (`ClaudeGenesis` owns the shape; the ledger keeps it opaque). +#[test] +fn a_fresh_agent_binding_keeps_its_genesis_across_a_restart() { + let root = temp_root("fa-genesis-round-trip"); + let fork = serde_json::json!({ + "kind": "fork", "parent": "p-1", "at": "a-1", "dropsTurn": "u-2", + }); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_fresh_agent_binding(&FreshAgentBindingWrite { + genesis: GenesisWrite::Set(&fork), + ..fa_write("claude", "s-pending", 1_000) + }) + .unwrap(); + assert_eq!( + ledger.load_binding("claude", "s-pending").unwrap().genesis, + Some(fork.clone()) + ); + assert_eq!( + ledger.binding_genesis("claude", "s-pending"), + Some(fork.clone()) + ); + + // A new ledger over the same root (a server restart) reads it back. + let reloaded = PaneLedger::new(Some(root.clone())); + assert_eq!( + reloaded.binding_genesis("claude", "s-pending"), + Some(fork.clone()) + ); + assert_eq!(reloaded.binding_genesis("claude", "s-unknown"), None); + std::fs::remove_dir_all(&root).ok(); +} + +/// Rows written before the genesis existed (every transcript-backed session) +/// parse with none, so they resume from their transcript exactly as before. +#[test] +fn a_row_written_before_the_genesis_field_parses_with_none() { + let root = temp_root("fa-genesis-legacy-parse"); + let ledger_dir = root.join("bindings").join("claude"); + std::fs::create_dir_all(&ledger_dir).unwrap(); + std::fs::write( + ledger_dir.join("s-legacy.json"), + serde_json::json!({ + "ledgerVersion": LEDGER_VERSION, + "provider": "claude", + "sessionId": "s-legacy", + "mode": "freshclaude", + "cwd": "/w", + "createdAt": 1_000i64, + "updatedAt": 1_000i64, + "lastObservedAt": 1_000i64, + "state": "bound", + "paneKind": "fresh-agent", + "model": "opus", + }) + .to_string(), + ) + .unwrap(); + let ledger = PaneLedger::new(Some(root.clone())); + let legacy = ledger + .load_binding("claude", "s-legacy") + .expect("a legacy row parses"); + assert_eq!(legacy.genesis, None); + assert_eq!(legacy.model.as_deref(), Some("opus")); + assert_eq!(ledger.binding_genesis("claude", "s-legacy"), None); + std::fs::remove_dir_all(&root).ok(); +} + +/// The genesis is cleared only once the transcript is on disk (§4.1): a write +/// that records settings (a model change before the first send, T7), a +/// provenance refresh or an ownership refresh keeps it; only an explicit +/// clear drops it. +#[test] +fn only_an_explicit_clear_drops_a_rows_genesis() { + let root = temp_root("fa-genesis-lazy"); + let fresh = serde_json::json!({ "kind": "fresh" }); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_fresh_agent_binding(&FreshAgentBindingWrite { + genesis: GenesisWrite::Set(&fresh), + ..fa_write("claude", "s-zero-turn", 1_000) + }) + .unwrap(); + // A settings write (the configure before the first send) keeps it. + ledger + .record_fresh_agent_binding(&FreshAgentBindingWrite { + model: Some("sonnet"), + permission_mode: Some("plan"), + ..fa_write("claude", "s-zero-turn", 2_000) + }) + .unwrap(); + let row = ledger.load_binding("claude", "s-zero-turn").unwrap(); + assert_eq!(row.model.as_deref(), Some("sonnet")); + assert_eq!( + row.genesis, + Some(fresh.clone()), + "a settings write keeps it" + ); + + // The lazy clear drops only the genesis, durably. + assert!(ledger + .clear_binding_genesis("claude", "s-zero-turn", 3_000) + .unwrap()); + let cleared = ledger.load_binding("claude", "s-zero-turn").unwrap(); + assert_eq!(cleared.genesis, None); + assert_eq!(cleared.model.as_deref(), Some("sonnet")); + assert_eq!(cleared.permission_mode.as_deref(), Some("plan")); + assert_eq!(cleared.state, RowState::Bound); + assert_eq!(cleared.updated_at, 3_000); + assert_eq!( + PaneLedger::new(Some(root.clone())).binding_genesis("claude", "s-zero-turn"), + None, + "the clear is durable" + ); + // Clearing again, or clearing a row that never had one, writes nothing. + assert!(!ledger + .clear_binding_genesis("claude", "s-zero-turn", 4_000) + .unwrap()); + assert_eq!( + ledger + .load_binding("claude", "s-zero-turn") + .unwrap() + .updated_at, + 3_000 + ); + assert!(!ledger + .clear_binding_genesis("claude", "s-no-row", 4_000) + .unwrap()); + assert!(ledger.load_binding("claude", "s-no-row").is_none()); + + // A binding write can also clear it outright. + ledger + .record_fresh_agent_binding(&FreshAgentBindingWrite { + genesis: GenesisWrite::Set(&fresh), + ..fa_write("claude", "s-write-clear", 1_000) + }) + .unwrap(); + ledger + .record_fresh_agent_binding(&FreshAgentBindingWrite { + genesis: GenesisWrite::Clear, + ..fa_write("claude", "s-write-clear", 2_000) + }) + .unwrap(); + assert_eq!(ledger.binding_genesis("claude", "s-write-clear"), None); + std::fs::remove_dir_all(&root).ok(); +} + +/// A fork-chain write (a rollback's child, `supersedes` the parent) starts its +/// own genesis: the child's row carries only what its own write sets, never the +/// parent's. +#[test] +fn a_superseding_binding_carries_only_its_own_genesis() { + let root = temp_root("fa-genesis-supersede"); + let parent_genesis = serde_json::json!({ "kind": "fresh" }); + let child_genesis = serde_json::json!({ "kind": "fork", "parent": "s-parent", "at": "a-1" }); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_fresh_agent_binding(&FreshAgentBindingWrite { + genesis: GenesisWrite::Set(&parent_genesis), + ..fa_write("claude", "s-parent", 1_000) + }) + .unwrap(); + ledger + .record_fresh_agent_binding(&FreshAgentBindingWrite { + supersedes: Some("s-parent"), + ..fa_write("claude", "s-plain-child", 2_000) + }) + .unwrap(); + assert_eq!(ledger.binding_genesis("claude", "s-plain-child"), None); + ledger + .record_fresh_agent_binding(&FreshAgentBindingWrite { + genesis: GenesisWrite::Set(&child_genesis), + supersedes: Some("s-plain-child"), + ..fa_write("claude", "s-fork-child", 3_000) + }) + .unwrap(); + assert_eq!( + ledger.binding_genesis("claude", "s-fork-child"), + Some(child_genesis) + ); + // The retired parent keeps the genesis it had (a stale pane's snapshot of + // a superseded pending session still shows what it held). + assert_eq!( + ledger.binding_genesis("claude", "s-parent"), + Some(parent_genesis) + ); + std::fs::remove_dir_all(&root).ok(); +} + #[test] fn stamped_marker_retention_keys_on_creation_time_never_assertion_time() { // The stamps are payload, not lifetime: retention reads only @@ -4605,6 +4800,7 @@ fn crash_mid_supersession_two_bound_rows_repaired_by_updated_at_tiebreak() { // index load sees the forged rows — the actual post-crash boot shape. for (sid, at) in [("th-old", 1_000i64), ("th-new", 2_000i64)] { let row = BindingRow { + genesis: None, ledger_version: LEDGER_VERSION, provider: "codex".into(), session_id: sid.into(), @@ -4734,6 +4930,7 @@ fn fresh_agent_binding_roundtrips_settings_and_pane_kind() { let ledger = PaneLedger::new(Some(root.clone())); ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + genesis: GenesisWrite::Keep, provider: "codex", session_id: "thread-1", mode: "freshcodex", @@ -4769,6 +4966,7 @@ fn fresh_agent_binding_upsert_preserves_created_at_and_refreshes_settings() { let root = temp_root("fresh-agent-upsert"); let ledger = PaneLedger::new(Some(root.clone())); let base = FreshAgentBindingWrite { + genesis: GenesisWrite::Keep, provider: "opencode", session_id: "ses_abc", mode: "freshopencode", @@ -4819,6 +5017,7 @@ fn supersedes_retires_the_old_row_and_links_the_chain() { let root = temp_root("fresh-agent-supersedes"); let ledger = PaneLedger::new(Some(root.clone())); let base = FreshAgentBindingWrite { + genesis: GenesisWrite::Keep, provider: "codex", session_id: "old-thread", mode: "freshcodex", @@ -4885,6 +5084,7 @@ fn fresh_agent_upsert_preserves_advisory_create_request_id_when_absent() { let root = temp_root("fresh-agent-req-id"); let ledger = PaneLedger::new(Some(root.clone())); let base = FreshAgentBindingWrite { + genesis: GenesisWrite::Keep, provider: "codex", session_id: "thread-1", mode: "freshcodex", @@ -4934,6 +5134,7 @@ fn fresh_agent_settings_recorded_keys_off_settings_bearing_rows() { let root = temp_root("fresh-agent-settings-recorded"); let ledger = PaneLedger::new(Some(root.clone())); let base = FreshAgentBindingWrite { + genesis: GenesisWrite::Keep, provider: "opencode", session_id: "ses_full", mode: "freshopencode", @@ -4985,6 +5186,7 @@ fn supersedes_of_a_missing_old_row_is_a_silent_noop() { let ledger = PaneLedger::new(Some(root.clone())); ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + genesis: GenesisWrite::Keep, provider: "codex", session_id: "new-thread", mode: "freshcodex", @@ -8257,6 +8459,7 @@ fn close_record_covers_row_is_the_shared_recovery_predicate() { live_terminal_id: Option<&str>, ) -> BindingRow { BindingRow { + genesis: None, ledger_version: LEDGER_VERSION, provider: "claude".to_string(), session_id: "sess-shared".to_string(), diff --git a/crates/freshell-ws/src/reconcile_freshagent.rs b/crates/freshell-ws/src/reconcile_freshagent.rs index a1dddfd0f..cadf0d305 100644 --- a/crates/freshell-ws/src/reconcile_freshagent.rs +++ b/crates/freshell-ws/src/reconcile_freshagent.rs @@ -27,6 +27,13 @@ //! succeed (validated: the real CLI prompt-exits on unresumable ids), the //! pane converges to the honest, bounded dead_session{respawn_exhausted} //! via the respawn cap instead of a false session_not_on_disk. +//! - Zero-turn and pending-fork CLAUDE fresh-agent sessions have no +//! transcript until their first prompt either, but unlike codex they are +//! minted with a genesis in their binding row (claude core design §4.1): +//! a disk miss for a bound claude id with a genesis and no on-disk +//! observation reads as present (`respawn`), so a restart brings the +//! session back instead of declaring it dead (§4.4, +//! [`claude_pending_genesis`]). //! - WATCH: codex `.jsonl.zst` cold-rollout compression (vendor feature, //! default-OFF today) would hide ≥7-day-old sessions from the `.jsonl`-only //! index walk ⇒ false Absent. Revisit if the vendor flag graduates. @@ -65,6 +72,39 @@ fn respawn_burn_skipped(state: &freshell_ownership::OwnershipState) -> bool { ) } +/// Claude core design §4.4 (the restart fix; the fresh-agent twin of the +/// terminal claude carve-out in `reconcile.rs`): a claude session whose +/// transcript does not exist yet but whose binding row records a genesis (a +/// zero-turn session, a pending fork after an undo) is pending, not dead — its +/// next launch boots it from the genesis — so a disk miss for it reads as +/// present (`respawn`, under the respawn cap). Only while the transcript has +/// never been seen on disk: the genesis is cleared lazily after the transcript +/// is observed, so a seen-then-gone transcript whose genesis survived is +/// deleted, not pending, and stays `dead_session`. +fn claude_pending_genesis(state: &crate::WsState, provider: &str, session_id: &str) -> bool { + if provider != "claude" + || state + .pane_ledger + .binding_genesis(provider, session_id) + .is_none() + { + return false; + } + if state + .session_existence + .ever_observed_on_disk(provider, session_id) + { + return false; + } + tracing::debug!( + target: "freshell_ws::reconcile_freshagent", + event = "freshagent.claude.reconcile_pending_genesis", + session_id = %session_id, + "a bound claude session with a genesis and no transcript yet reads as present" + ); + true +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FreshAgentPresence { Live, @@ -186,7 +226,9 @@ pub async fn build_snapshot( E::Present => FreshAgentPresence::OnDisk, E::ProviderUnavailable => FreshAgentPresence::Unknown, E::Absent => { - if state + if claude_pending_genesis(state, &sref.provider, &session_id) { + FreshAgentPresence::OnDisk + } else if state .session_existence .ever_observed(&sref.provider, &session_id) { diff --git a/crates/freshell-ws/tests/common/ledger_sink.rs b/crates/freshell-ws/tests/common/ledger_sink.rs index 1880e2686..53599b15c 100644 --- a/crates/freshell-ws/tests/common/ledger_sink.rs +++ b/crates/freshell-ws/tests/common/ledger_sink.rs @@ -87,7 +87,12 @@ impl PaneIdentitySink for LedgerSink { ProvenanceUpdate::Inherit => ProvenancePolicy::Inherit, ProvenanceUpdate::Clear => ProvenancePolicy::Clear, }; + let stored_genesis = upsert.genesis.stored_value(); ledger.record_fresh_agent_binding(&FreshAgentBindingWrite { + genesis: freshell_ws::pane_ledger::GenesisWrite::from_update( + stored_genesis.as_ref(), + upsert.genesis.is_clear(), + ), provider: &upsert.provider, session_id: &upsert.session_id, mode: &upsert.mode, @@ -145,6 +150,36 @@ impl PaneIdentitySink for LedgerSink { (settings != FreshAgentSettings::default()).then_some(settings) } + fn load_genesis( + &self, + provider: &str, + session_id: &str, + ) -> Option { + let row = self.ledger.load_binding(provider, session_id)?; + if row.pane_kind.as_deref() != Some("fresh-agent") { + return None; + } + freshell_freshagent::RecordedGenesis::from_row( + provider, + session_id, + row.genesis.as_ref(), + row.updated_at, + ) + } + + fn clear_genesis(&self, provider: &str, session_id: &str) -> SinkWrite { + let ledger = self.ledger.clone(); + let (p, s) = (provider.to_string(), session_id.to_string()); + let now = now_ms(); + Box::pin(async move { + tokio::task::spawn_blocking(move || { + ledger.clear_binding_genesis(&p, &s, now).map(|_| ()) + }) + .await + .map_err(std::io::Error::other)? + }) + } + fn was_recorded(&self, provider: &str, session_id: &str) -> bool { self.ledger .fresh_agent_settings_recorded(provider, session_id) diff --git a/crates/freshell-ws/tests/cross_kind_liveness.rs b/crates/freshell-ws/tests/cross_kind_liveness.rs index 249288ca1..7eaac66ed 100644 --- a/crates/freshell-ws/tests/cross_kind_liveness.rs +++ b/crates/freshell-ws/tests/cross_kind_liveness.rs @@ -816,7 +816,12 @@ impl PaneIdentitySink for TestLedgerSink { )); } } + let stored_genesis = upsert.genesis.stored_value(); let w = FreshAgentBindingWrite { + genesis: freshell_ws::pane_ledger::GenesisWrite::from_update( + stored_genesis.as_ref(), + upsert.genesis.is_clear(), + ), provider: &upsert.provider, session_id: &upsert.session_id, mode: &upsert.mode, @@ -900,6 +905,36 @@ impl PaneIdentitySink for TestLedgerSink { // provenance read-back is out of their contract. None } + fn load_genesis( + &self, + provider: &str, + session_id: &str, + ) -> Option { + let row = self.ledger.load_binding(provider, session_id)?; + if row.pane_kind.as_deref() != Some("fresh-agent") { + return None; + } + freshell_freshagent::RecordedGenesis::from_row( + provider, + session_id, + row.genesis.as_ref(), + row.updated_at, + ) + } + + fn clear_genesis(&self, provider: &str, session_id: &str) -> SinkWrite { + let ledger = self.ledger.clone(); + let (p, s) = (provider.to_string(), session_id.to_string()); + let now = Self::now_ms(); + Box::pin(async move { + tokio::task::spawn_blocking(move || { + ledger.clear_binding_genesis(&p, &s, now).map(|_| ()) + }) + .await + .map_err(std::io::Error::other)? + }) + } + fn was_recorded(&self, provider: &str, session_id: &str) -> bool { self.ledger.load_binding(provider, session_id).is_some() } @@ -3722,6 +3757,7 @@ async fn a_superseded_handoffs_stale_authoritative_write_is_refused_end_to_end() }; let err = sink .record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "claude".into(), session_id: sid.clone(), @@ -3905,6 +3941,7 @@ async fn the_failed_transition_repair_fences_and_reverts_end_to_end() { .load_binding("claude", &sid) .expect("the row before the late write"); sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "claude".into(), session_id: sid.clone(), @@ -3992,6 +4029,7 @@ async fn an_ordinary_codex_cold_resume_with_recovered_settings_lands_end_to_end( ); pane_ledger .record_fresh_agent_binding(&freshell_ws::pane_ledger::FreshAgentBindingWrite { + genesis: freshell_ws::pane_ledger::GenesisWrite::Keep, provider: "codex", session_id: &sid, mode: "freshcodex", 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 d2d574a1d..a5bbf9ccf 100644 --- a/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs +++ b/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs @@ -159,7 +159,12 @@ impl PaneIdentitySink for TestLedgerSink { let now = Self::now_ms(); Box::pin(async move { tokio::task::spawn_blocking(move || { + let stored_genesis = upsert.genesis.stored_value(); let w = freshell_ws::pane_ledger::FreshAgentBindingWrite { + genesis: freshell_ws::pane_ledger::GenesisWrite::from_update( + stored_genesis.as_ref(), + upsert.genesis.is_clear(), + ), provider: &upsert.provider, session_id: &upsert.session_id, mode: &upsert.mode, @@ -198,6 +203,36 @@ impl PaneIdentitySink for TestLedgerSink { plugins: row.plugins, }) } + fn load_genesis( + &self, + provider: &str, + session_id: &str, + ) -> Option { + let row = self.ledger.load_binding(provider, session_id)?; + if row.pane_kind.as_deref() != Some("fresh-agent") { + return None; + } + freshell_freshagent::RecordedGenesis::from_row( + provider, + session_id, + row.genesis.as_ref(), + row.updated_at, + ) + } + + fn clear_genesis(&self, provider: &str, session_id: &str) -> SinkWrite { + let ledger = self.ledger.clone(); + let (p, s) = (provider.to_string(), session_id.to_string()); + let now = Self::now_ms(); + Box::pin(async move { + tokio::task::spawn_blocking(move || { + ledger.clear_binding_genesis(&p, &s, now).map(|_| ()) + }) + .await + .map_err(std::io::Error::other)? + }) + } + fn was_recorded(&self, provider: &str, session_id: &str) -> bool { self.ledger.load_binding(provider, session_id).is_some() } diff --git a/crates/freshell-ws/tests/freshagent_claude_rollback.rs b/crates/freshell-ws/tests/freshagent_claude_rollback.rs index 9da75a4d7..ea63ad7c3 100644 --- a/crates/freshell-ws/tests/freshagent_claude_rollback.rs +++ b/crates/freshell-ws/tests/freshagent_claude_rollback.rs @@ -241,7 +241,12 @@ impl PaneIdentitySink for TestLedgerSink { let now = Self::now_ms(); Box::pin(async move { tokio::task::spawn_blocking(move || { + let stored_genesis = upsert.genesis.stored_value(); let w = FreshAgentBindingWrite { + genesis: freshell_ws::pane_ledger::GenesisWrite::from_update( + stored_genesis.as_ref(), + upsert.genesis.is_clear(), + ), provider: &upsert.provider, session_id: &upsert.session_id, mode: &upsert.mode, @@ -303,6 +308,36 @@ impl PaneIdentitySink for TestLedgerSink { // provenance read-back is out of their contract. None } + fn load_genesis( + &self, + provider: &str, + session_id: &str, + ) -> Option { + let row = self.ledger.load_binding(provider, session_id)?; + if row.pane_kind.as_deref() != Some("fresh-agent") { + return None; + } + freshell_freshagent::RecordedGenesis::from_row( + provider, + session_id, + row.genesis.as_ref(), + row.updated_at, + ) + } + + fn clear_genesis(&self, provider: &str, session_id: &str) -> SinkWrite { + let ledger = self.ledger.clone(); + let (p, s) = (provider.to_string(), session_id.to_string()); + let now = Self::now_ms(); + Box::pin(async move { + tokio::task::spawn_blocking(move || { + ledger.clear_binding_genesis(&p, &s, now).map(|_| ()) + }) + .await + .map_err(std::io::Error::other)? + }) + } + fn was_recorded(&self, provider: &str, session_id: &str) -> bool { self.ledger.load_binding(provider, session_id).is_some() } diff --git a/crates/freshell-ws/tests/pane_reconcile_freshagent.rs b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs index a503841f4..301ac9976 100644 --- a/crates/freshell-ws/tests/pane_reconcile_freshagent.rs +++ b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs @@ -39,7 +39,11 @@ static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(( #[derive(Default)] struct StubProbe { answers: std::sync::Mutex>, + /// Identities this process saw on disk (the index or a locator hit). observed: std::sync::Mutex>, + /// Identities only the ledger remembers (production's `ever_observed` + /// counts ledger bindings; `ever_observed_on_disk` does not). + minted: std::sync::Mutex>, } impl freshell_ws::existence::SessionExistenceProbe for StubProbe { fn exists(&self, provider: &str, session_id: &str) -> SessionExistence { @@ -51,6 +55,10 @@ impl freshell_ws::existence::SessionExistenceProbe for StubProbe { .unwrap_or(SessionExistence::Unknown) } fn ever_observed(&self, provider: &str, session_id: &str) -> bool { + let key = (provider.to_string(), session_id.to_string()); + self.observed.lock().unwrap().contains(&key) || self.minted.lock().unwrap().contains(&key) + } + fn ever_observed_on_disk(&self, provider: &str, session_id: &str) -> bool { self.observed .lock() .unwrap() @@ -714,6 +722,7 @@ async fn old_thread_claim_after_crash_respawn_answers_the_new_terminus() { server .pane_ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + genesis: freshell_ws::pane_ledger::GenesisWrite::Keep, provider: "codex", session_id: "old-t", mode: "freshcodex", @@ -736,6 +745,7 @@ async fn old_thread_claim_after_crash_respawn_answers_the_new_terminus() { server .pane_ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + genesis: freshell_ws::pane_ledger::GenesisWrite::Keep, provider: "codex", session_id: "new-t", mode: "freshcodex", @@ -771,6 +781,119 @@ async fn old_thread_claim_after_crash_respawn_answers_the_new_terminus() { assert_eq!(verdicts[0]["corrected"], true); } +/// A claude fresh-agent binding row for `session_id`, written the way a lane +/// records it, with `genesis` (claude core design §4.1) when given. +fn seed_claude_binding(ledger: &PaneLedger, session_id: &str, genesis: Option<&Value>) { + ledger + .record_fresh_agent_binding(&FreshAgentBindingWrite { + genesis: genesis.map_or(freshell_ws::pane_ledger::GenesisWrite::Keep, |genesis| { + freshell_ws::pane_ledger::GenesisWrite::Set(genesis) + }), + provider: "claude", + session_id, + mode: "freshclaude", + cwd: Some("/w"), + create_request_id: None, + model: Some("opus"), + sandbox: None, + permission_mode: None, + effort: None, + plugins: None, + supersedes: None, + provenance: freshell_ws::pane_ledger::ProvenancePolicy::Inherit, + observed_epoch: None, + observed_generation: None, + authoritative: false, + now_ms: 1_000, + }) + .unwrap(); +} + +/// Claude core design §4.4 (the restart fix): a bound claude session whose +/// transcript does not exist yet but whose binding row carries a genesis (a +/// zero-turn session, a pending fork after an undo) is restarted after a +/// reboot: Absent on disk, never seen there, genesis set => `respawn`. The +/// same miss with no genesis stays `dead_session` (the ledger remembers the +/// id), and so does a genesis-bearing id whose transcript was once seen and is +/// now gone (its genesis is stale: deleted, not pending). +#[tokio::test] +async fn a_bound_claude_session_with_a_genesis_and_no_transcript_yet_answers_respawn() { + let probe = std::sync::Arc::new(StubProbe::default()); + for id in ["zero-turn", "pending-fork", "no-genesis", "deleted"] { + probe + .answers + .lock() + .unwrap() + .insert(("claude".into(), id.into()), SessionExistence::Absent); + probe + .minted + .lock() + .unwrap() + .insert(("claude".into(), id.into())); + } + probe + .observed + .lock() + .unwrap() + .insert(("claude".into(), "deleted".into())); + let server = spawn_server_with_probe(probe).await; + let fresh = serde_json::json!({ "kind": "fresh" }); + let fork = serde_json::json!({ "kind": "fork", "parent": "parent-id", "at": "a-1" }); + seed_claude_binding(&server.pane_ledger, "zero-turn", Some(&fresh)); + seed_claude_binding(&server.pane_ledger, "pending-fork", Some(&fork)); + seed_claude_binding(&server.pane_ledger, "no-genesis", None); + seed_claude_binding(&server.pane_ledger, "deleted", Some(&fresh)); + + let (mut ws, _ready) = connect(&server.url, true, true).await; + let verdicts = reconcile_request( + &mut ws, + serde_json::json!([ + { "paneKey": "z", "kind": "fresh-agent", "sessionRef": {"provider": "claude", "sessionId": "zero-turn"} }, + { "paneKey": "f", "kind": "fresh-agent", "sessionRef": {"provider": "claude", "sessionId": "pending-fork"} }, + { "paneKey": "n", "kind": "fresh-agent", "sessionRef": {"provider": "claude", "sessionId": "no-genesis"} }, + { "paneKey": "d", "kind": "fresh-agent", "sessionRef": {"provider": "claude", "sessionId": "deleted"} } + ]), + ) + .await; + assert_eq!(verdicts[0]["verdict"], "respawn", "{verdicts}"); + assert_eq!(verdicts[0]["sessionRef"]["sessionId"], "zero-turn"); + assert_eq!(verdicts[1]["verdict"], "respawn", "{verdicts}"); + assert_eq!(verdicts[1]["sessionRef"]["sessionId"], "pending-fork"); + assert_eq!(verdicts[2]["verdict"], "dead_session", "{verdicts}"); + assert_eq!(verdicts[2]["reason"], "session_not_on_disk"); + assert_eq!(verdicts[3]["verdict"], "dead_session", "{verdicts}"); + assert_eq!(verdicts[3]["reason"], "session_not_on_disk"); +} + +/// The genesis-backed respawn is subject to the existing respawn cap: a +/// pending session that never comes back live converges to +/// `dead_session{respawn_exhausted}` instead of looping. +#[tokio::test] +async fn a_pending_claude_session_that_never_comes_back_exhausts_the_respawn_cap() { + let probe = std::sync::Arc::new(StubProbe::default()); + probe.answers.lock().unwrap().insert( + ("claude".into(), "never-back".into()), + SessionExistence::Absent, + ); + let server = spawn_server_with_probe(probe).await; + seed_claude_binding( + &server.pane_ledger, + "never-back", + Some(&serde_json::json!({ "kind": "fresh" })), + ); + let (mut ws, _ready) = connect(&server.url, true, true).await; + let pane = serde_json::json!([ + { "paneKey": "p", "kind": "fresh-agent", "sessionRef": {"provider": "claude", "sessionId": "never-back"} } + ]); + for attempt in 0..freshell_ws::reconcile_freshagent::FRESH_AGENT_RESPAWN_CAP { + let verdicts = reconcile_request(&mut ws, pane.clone()).await; + assert_eq!(verdicts[0]["verdict"], "respawn", "attempt {attempt}"); + } + let verdicts = reconcile_request(&mut ws, pane).await; + assert_eq!(verdicts[0]["verdict"], "dead_session"); + assert_eq!(verdicts[0]["reason"], "respawn_exhausted"); +} + // ── dead_session verdict observability (WARN log at the result choke point) ── // // A `dead_session` verdict parks a pane in the client's dead-sessions dialog From 33df6f5d75d138a7a8ecc615d8bc24c81a4f9a59 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:22:00 -0700 Subject: [PATCH 06/24] feat(freshclaude): a pending session boots from its genesis; the genesis clears once its transcript is on disk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the C12 genesis: reconcile now answers `respawn` for a bound claude session that has a genesis and no transcript yet, so the attach that follows has to boot it. It does now, and the genesis is cleared lazily, as design §4.1-§4.3 describe. Still inert in production until C13/C23 write a genesis. - `decide_boot` (claude_launch.rs) takes the binding row's genesis and the §4.2 rows: a found transcript resumes whatever the genesis says; absent + `Fresh` boots a fresh conversation under the session's own id (T3); absent + `Fork` boots the same lazy fork of the parent under the session's own id, from the parent's recorded directory (T6), and is lost when the parent is gone; absent + none is lost; unreadable is transient. - `ClaudeLaunchSpec.session_id`: every create line now carries `sessionId` (null unless a fresh or forked boot writes under a minted id; the sidecar's `bootOptions` already reads it). - The attach-resume (and the handoff resume, which is one) boots through `FreshClaudeState::decide_attach_boot` (claude/genesis.rs). A launch that finds the transcript of a session whose row still has a genesis clears it. - The lazy clear at results: after every `sdk.result` the consumer checks the session's row (one memory read); with a genesis set it looks for the transcript up to 10 times, 200 ms apart, and clears the genesis at the first find. A transcript that never appears (a CLI death in the flush window) leaves it set, so the next launch boots from it again. - Logs: `freshagent.claude.genesis_boot` (info), `genesis_cleared` (info, trigger result|launch, attempt), `genesis_kept` (warn), `genesis_clear_failed` (warn). Tests: - claude_launch: each new `decide_boot` row (found wins over Fresh and Fork, Fresh boots under its own id, Fork boots the fork from the parent's directory, gone parent lost, unreadable parent transient, unreadable store transient) and the create line's `sessionId`. Red: 4 failed with the genesis ignored. - claude/genesis.rs: the clear waits for the flushed transcript; a transcript never flushed (or an unreadable store) keeps the genesis; a failed clear write is reported and keeps it. Red under two mutations (clear without the find; a single look). - New freshagent_claude_genesis.rs (RealSidecarSim, real ledger): a zero-turn session reattached after a restart boots fresh under its own id (the simulator's `query()` options), its first turn writes that id's transcript and the genesis clears; a pending fork reboots the same fork (resume = parent, resumeSessionAt = the fork point, sessionId = the child), its pending snapshot's turn ids lead the materialized fork's, the parent is untouched, and the genesis clears. Red against the previous commit's runtime: both attaches answered the lost frame (INVALID_SESSION_ID). Dropping only the result hook keeps the genesis forever (red). - freshagent_claude_real_cli.rs, design §11.5 item 10 on both rigs (RealSidecarSim and the pinned CLI against the local endpoint): a zero-turn restart boots fresh with the same id and clears its genesis. Red against the previous commit's runtime on both rigs. - The rig gains `seed_pending_session`, `wait_for_transcript`, `wait_for_genesis_cleared`, `RigConnection::attach` and `init_session_id`. Deviation from the design: the typed boot-failure retries of §4.2 (SESSION_ID_IN_USE once as a resume, CONVERSATION_NOT_FOUND once per the genesis) are not here; they belong with `establish_runtime`'s boot owner (C13 rollback, C20 `ensure_runtime`). A refused genesis boot is answered transient by the attach, as every refused resume is today. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 37 +-- .../freshell-freshagent/src/claude/genesis.rs | 293 +++++++++++++++++- .../freshell-freshagent/src/claude_launch.rs | 267 ++++++++++++++-- crates/freshell-ws/tests/common/claude_rig.rs | 104 +++++++ .../tests/freshagent_claude_genesis.rs | 197 ++++++++++++ .../tests/freshagent_claude_real_cli.rs | 43 ++- 6 files changed, 898 insertions(+), 43 deletions(-) create mode 100644 crates/freshell-ws/tests/freshagent_claude_genesis.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 124c2cfd6..e33f0b837 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -76,7 +76,7 @@ use freshell_protocol::{ }; use crate::claude_handshake::{read_created, BootContext}; -use crate::claude_launch::{BootDecision, ClaudeBoot, ClaudeLaunchSpec}; +use crate::claude_launch::{ClaudeBoot, ClaudeLaunchSpec}; use crate::{FreshAgentCreateDedup, FreshAgentCreateOutcome, SharedPaneIdentitySink}; /// The runtime provider (`AGENT_SESSION_TYPES.claude.provider`). @@ -6895,8 +6895,9 @@ impl FreshClaudeState { } /// The not-tracked resume (codex `ensure_session_resumable` analog, file-store - /// flavored): transcript-present gate -> spawn sidecar with a resume boot -> - /// register under the CLIENT's id -> idle snapshot. `lease_guard` (Task 13): the + /// flavored): boot decision (a found transcript resumes; a session with no + /// transcript yet boots from its binding row's genesis, design §4.2) -> spawn + /// sidecar with that boot -> register under the CLIENT's id -> idle snapshot. `lease_guard` (Task 13): the /// attach-path D8 lease -- the kill handle is armed after the spawn and the lease /// completed (bound to the map key) at registration; error paths tear down their /// own sidecar and leave the guard for the caller to `fail()`. @@ -6945,28 +6946,19 @@ impl FreshClaudeState { ); // Which conversation boots (design §4.2, I8): a found transcript resumes; // a positive denial only on a CLEAN miss in every candidate root - // (CLAUDE_CONFIG_DIR > CLAUDE_HOME > platform home, ledger A3); an - // unreadable store (or no root at all) cannot CHECK, so it must not deny. - let (boot, transcript_cwd) = - match crate::claude_launch::decide_boot(durable, self.locate_transcript(durable)) { - BootDecision::Boot { boot, cwd } => (boot, cwd), - BootDecision::Lost => return Err(ResumeClaudeError::NotFound), - BootDecision::Transient(error) => { - crate::claude_snapshot::log_transcript_unreadable( - "attach_resume", - durable, - &error, - ); - return Err(ResumeClaudeError::Transient(error.to_string())); - } - }; + // (CLAUDE_CONFIG_DIR > CLAUDE_HOME > platform home, ledger A3) of a session + // with no genesis; a session with no transcript yet boots from the genesis + // its binding row records (§4.3: a zero-turn session or a pending fork + // after a restart); an unreadable store (or no root at all) cannot CHECK, + // so it must not deny. + let sink = self.identity_sink(); + let (boot, transcript_cwd, minted_id) = self.decide_attach_boot(durable, sink.as_ref())?; // 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 // PROVES prior recording yet no snapshot is recoverable. Never-recorded // transcripts (the entire pre-existing ~/.claude/projects population — // resume_for_attach exists FOR them) stay silent. - let sink = self.identity_sink(); let recovered = sink .as_ref() .and_then(|s| s.load_settings("claude", durable)); @@ -7006,7 +6998,8 @@ impl FreshClaudeState { resume_cwd, &rec, self.hosted_mcp, - ); + ) + .with_session_id(minted_id); // Register under the CLIENT's id: the consumer stamps the map key on every // envelope and the frozen client routes by envelope sessionId // (fresh-agent-ws.ts:180-183) -- a fresh key would strand the pane. The @@ -8147,6 +8140,10 @@ impl FreshClaudeState { } Some("sdk.result") => { fold_terminal_edge(&in_turn, &turn_tracker); + // 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.clear_genesis_after_result(&session_id).await; } Some("sdk.status") if value.get("status").and_then(Value::as_str) == Some("idle") => diff --git a/crates/freshell-freshagent/src/claude/genesis.rs b/crates/freshell-freshagent/src/claude/genesis.rs index 953e97e22..d84740bbf 100644 --- a/crates/freshell-freshagent/src/claude/genesis.rs +++ b/crates/freshell-freshagent/src/claude/genesis.rs @@ -1,17 +1,111 @@ -//! The claude lane's reads of a session's genesis (core-runtime design §4.1, -//! §4.3 and §4.5). +//! The claude lane's side of a session's genesis (core-runtime design §4.1, +//! §4.2, §4.3 and §4.5). //! //! A session that has no transcript yet (a fresh session before its first //! prompt, or a pending fork) records how it came to be in its binding row //! ([`ClaudeGenesis`]) until the transcript is observed on disk. The lane reads //! it to serve the session's snapshot (`Fresh` holds nothing, a pending `Fork` -//! its parent's prefix), always from the transcript store its sidecars write. +//! its parent's prefix) and to boot the session again before its transcript +//! exists (after a restart or a crash), always against the transcript store its +//! sidecars write. It clears it lazily, only once the transcript is on disk: a +//! launch that finds the transcript clears it, and so does the first result +//! after the transcript is flushed (each line lags its SDK frame, T3), so a CLI +//! death in that window never loses the session. + +use std::time::Duration; use super::*; -use crate::claude_snapshot::ClaudeSnapshotError; +use crate::claude_launch::{decide_boot, BootDecision}; +use crate::claude_snapshot::{ClaudeSnapshotError, TranscriptLocation}; use crate::identity_sink::RecordedGenesis; +/// How many times the lazy clear looks for the transcript after a result. +pub(crate) const GENESIS_CLEAR_ATTEMPTS: u32 = 10; +/// How long the lazy clear waits between looks (a transcript line lags its SDK +/// frame by 95-200 ms, T3). +pub(crate) const GENESIS_CLEAR_INTERVAL: Duration = Duration::from_millis(200); + +/// What one lazy clear did. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum GenesisClear { + /// The transcript was on disk at the `attempt`-th look; the genesis is gone. + Cleared { attempt: u32 }, + /// The transcript never appeared: the genesis stays, so the next launch + /// boots the session from it again. + NotObserved, + /// The transcript was on disk but the ledger write failed: the genesis + /// stays (harmless: a found transcript always wins), the next result or + /// launch clears it. + Failed(String), +} + +/// Clear `session_id`'s genesis once its transcript is on disk in `roots`: +/// look up to `attempts` times, `interval` apart, and clear at the first find. +/// A store that cannot be read is not a find. +pub(crate) async fn clear_genesis_once_observed( + sink: &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()); + let found = tokio::task::spawn_blocking(move || { + matches!( + crate::claude_snapshot::locate_transcript_state_in(&store, &id), + TranscriptLocation::Found(_) + ) + }) + .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()), + }; + } + if attempt < attempts { + tokio::time::sleep(interval).await; + } + } + GenesisClear::NotObserved +} + +/// One structured line per lazy clear. +fn log_genesis_clear(session_id: &str, genesis: &str, trigger: &str, outcome: &GenesisClear) { + match outcome { + GenesisClear::Cleared { attempt } => tracing::info!( + component = "freshagent.claude", + event = "freshagent.claude.genesis_cleared", + session_id = %session_id, + genesis, + trigger, + attempt, + "the session's transcript is on disk; its genesis is cleared" + ), + GenesisClear::NotObserved => tracing::warn!( + component = "freshagent.claude", + event = "freshagent.claude.genesis_kept", + session_id = %session_id, + genesis, + trigger, + attempts = GENESIS_CLEAR_ATTEMPTS, + "the session's transcript did not appear; its genesis stays, so the next launch boots it again" + ), + GenesisClear::Failed(error) => tracing::warn!( + component = "freshagent.claude", + event = "freshagent.claude.genesis_clear_failed", + session_id = %session_id, + genesis, + trigger, + error = %error, + "the session's transcript is on disk but its genesis could not be cleared; a later result or launch retries" + ), + } +} + impl FreshClaudeState { /// The durable session id `any_id` names: a live session's adopted claude /// id (or its map key until it has one), resolved like every handler here @@ -57,4 +151,195 @@ impl FreshClaudeState { ) .await } + + /// Which conversation an attach that resumes `durable` boots (design §4.2): + /// [`decide_boot`] over the lane's store and the binding row's genesis. The + /// boot, the directory the conversation was recorded in, and the id a fresh + /// 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 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 location = self.locate_transcript(durable); + let found = matches!(location, TranscriptLocation::Found(_)); + let decision = decide_boot( + durable, + location, + recorded.as_ref().map(|recorded| &recorded.genesis), + |id| self.locate_transcript(id), + ); + match decision { + BootDecision::Boot { + boot, + cwd, + session_id, + } => { + if let (Some(recorded), Some(sink)) = (recorded.as_ref(), sink) { + if found { + self.clear_found_genesis(sink, durable, recorded); + } else { + tracing::info!( + component = "freshagent.claude", + event = "freshagent.claude.genesis_boot", + session_id = %durable, + genesis = recorded.genesis.kind(), + boot = boot.kind(), + "the session has no transcript yet; it boots from its genesis" + ); + } + } + Ok((boot, cwd, session_id)) + } + BootDecision::Lost => Err(ResumeClaudeError::NotFound), + BootDecision::Transient(error) => { + crate::claude_snapshot::log_transcript_unreadable("attach_resume", durable, &error); + Err(ResumeClaudeError::Transient(error.to_string())) + } + } + } + + /// A launch found the transcript of a session whose row still records a + /// genesis: clear it now, off the launch's path. + fn clear_found_genesis( + &self, + sink: &crate::identity_sink::SharedPaneIdentitySink, + durable: &str, + recorded: &RecordedGenesis, + ) { + let (sink, durable, genesis) = (sink.clone(), durable.to_string(), recorded.genesis.kind()); + let roots = self.transcript_roots(); + tokio::spawn(async move { + let outcome = + clear_genesis_once_observed(&sink, roots, &durable, 1, Duration::ZERO).await; + log_genesis_clear(&durable, genesis, "launch", &outcome); + }); + } + + /// After a result of the session `session_key` names: when its binding row + /// still records a genesis, clear it once the transcript is flushed (the + /// lazy clear, design §4.1). Detached; a session with no genesis costs one + /// memory read. + pub(super) async fn clear_genesis_after_result(&self, session_key: &str) { + let Some(sink) = self.identity_sink() else { + return; + }; + let durable = self.durable_session_id(session_key).await; + let Some(recorded) = sink.load_genesis(PROVIDER, &durable) else { + return; + }; + let roots = self.transcript_roots(); + tokio::spawn(async move { + let outcome = clear_genesis_once_observed( + &sink, + roots, + &durable, + GENESIS_CLEAR_ATTEMPTS, + GENESIS_CLEAR_INTERVAL, + ) + .await; + log_genesis_clear(&durable, recorded.genesis.kind(), "result", &outcome); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::claude_launch::ClaudeGenesis; + use crate::identity_sink::{FakeIdentitySink, PaneIdentitySink, SharedPaneIdentitySink}; + + const ID: &str = "e5e5e5e5-e5e5-4e5e-8e5e-e5e5e5e5e5e5"; + + fn sink_with_genesis() -> (Arc, SharedPaneIdentitySink) { + let fake = Arc::new(FakeIdentitySink::default()); + fake.seed_genesis(PROVIDER, ID, ClaudeGenesis::Fresh); + let shared: SharedPaneIdentitySink = fake.clone(); + (fake, shared) + } + + fn transcript_path(store: &std::path::Path) -> PathBuf { + store + .join("projects") + .join("-w") + .join(format!("{ID}.jsonl")) + } + + /// §4.1: the genesis is cleared only after the transcript is observed. The + /// transcript is flushed a moment after the result (T3); the clear keeps + /// looking and clears at the first find. + #[tokio::test] + async fn the_genesis_clears_once_the_transcript_is_flushed() { + let store = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(store.path().join("projects")).unwrap(); + let (fake, sink) = sink_with_genesis(); + let path = transcript_path(store.path()); + let flush = tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(60)).await; + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "{}\n").unwrap(); + }); + let outcome = clear_genesis_once_observed( + &sink, + vec![store.path().to_path_buf()], + ID, + 50, + Duration::from_millis(10), + ) + .await; + flush.await.unwrap(); + assert!( + matches!(outcome, GenesisClear::Cleared { attempt } if attempt > 1), + "{outcome:?}" + ); + assert_eq!(fake.load_genesis(PROVIDER, ID), None); + } + + /// §4.1/§4.3: a CLI death before the transcript is flushed never loses the + /// session: with no transcript (or a store that cannot be read) the genesis + /// stays, so the next launch boots the session from it again. + #[tokio::test] + async fn a_transcript_never_flushed_keeps_the_genesis() { + let store = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(store.path().join("projects")).unwrap(); + let (fake, sink) = sink_with_genesis(); + for roots in [vec![store.path().to_path_buf()], Vec::new()] { + let outcome = + clear_genesis_once_observed(&sink, roots, ID, 3, Duration::from_millis(1)).await; + assert_eq!(outcome, GenesisClear::NotObserved); + } + assert_eq!( + fake.load_genesis(PROVIDER, ID).map(|r| r.genesis), + Some(ClaudeGenesis::Fresh) + ); + assert!( + fake.genesis_clears.lock().unwrap().is_empty(), + "nothing was cleared" + ); + } + + /// A clear whose ledger write fails is reported and leaves the genesis (a + /// found transcript wins at the next launch anyway). + #[tokio::test] + async fn a_failed_clear_is_reported_and_keeps_the_genesis() { + let store = tempfile::tempdir().unwrap(); + let path = transcript_path(store.path()); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, "{}\n").unwrap(); + let (fake, sink) = sink_with_genesis(); + fake.set_fail_writes(true); + let outcome = clear_genesis_once_observed( + &sink, + vec![store.path().to_path_buf()], + ID, + 3, + Duration::from_millis(1), + ) + .await; + assert!(matches!(outcome, GenesisClear::Failed(_)), "{outcome:?}"); + assert!(fake.load_genesis(PROVIDER, ID).is_some()); + } } diff --git a/crates/freshell-freshagent/src/claude_launch.rs b/crates/freshell-freshagent/src/claude_launch.rs index 0df00629f..067cc6e93 100644 --- a/crates/freshell-freshagent/src/claude_launch.rs +++ b/crates/freshell-freshagent/src/claude_launch.rs @@ -163,6 +163,9 @@ pub(crate) struct ClaudeLaunchSpec { pub(crate) hosted_mcp: bool, /// The client's `modelSelection` on a create, passed through as sent. pub(crate) model_selection: Option, + /// The id a fresh or forked boot writes the conversation under (the + /// session's own id when it boots from its genesis, design §4.2). + pub(crate) session_id: Option, } impl ClaudeLaunchSpec { @@ -189,9 +192,16 @@ impl ClaudeLaunchSpec { plugins: settings.plugins.clone(), hosted_mcp, model_selection: None, + session_id: None, } } + /// This launch writing a fresh or forked conversation under `session_id`. + pub(crate) fn with_session_id(mut self, session_id: Option) -> Self { + self.session_id = session_id; + self + } + /// This launch carrying the client's `modelSelection` (a create's). pub(crate) fn with_model_selection(mut self, model_selection: Option) -> Self { self.model_selection = model_selection.filter(|selection| !selection.is_null()); @@ -200,8 +210,9 @@ impl ClaudeLaunchSpec { /// The sidecar `create` line. Every line carries the same keys, whatever /// the launch: `type`, `protocol`, `requestId`, `sessionType`, `boot`, and - /// `cwd`, `model`, `permissionMode`, `effort`, `plugins` (each `null` when - /// unset) and `hostedMcp`; `modelSelection` only when the launch has one. + /// `sessionId`, `cwd`, `model`, `permissionMode`, `effort`, `plugins` (each + /// `null` when unset) and `hostedMcp`; `modelSelection` only when the + /// launch has one. pub(crate) fn create_request(&self) -> Value { let mut line = Map::new(); line.insert("type".into(), json!("create")); @@ -209,6 +220,7 @@ impl ClaudeLaunchSpec { line.insert("requestId".into(), json!(self.request_id)); line.insert("sessionType".into(), json!(self.session_type)); line.insert("boot".into(), self.boot.wire()); + line.insert("sessionId".into(), json!(self.session_id)); line.insert("cwd".into(), json!(self.cwd)); line.insert("model".into(), json!(self.model)); line.insert("permissionMode".into(), json!(self.permission_mode)); @@ -231,6 +243,10 @@ pub(crate) enum BootDecision { Boot { boot: ClaudeBoot, cwd: Option, + /// The id a fresh or forked boot writes the conversation under: the + /// session's own id when it boots from its genesis. `None` for a + /// resume (the conversation already has its id). + session_id: Option, }, /// The store holds no transcript for the session: it is gone. Lost, @@ -239,22 +255,63 @@ pub(crate) enum BootDecision { Transient(LocateError), } -/// Which conversation a session boots, from where its transcript is (design -/// §4.2): a found transcript resumes, an absent one is lost, and a store that -/// could not be checked is transient. The genesis rows of §4.2 (a session with -/// no transcript yet that may still boot) arrive with the binding row's genesis -/// (C12). -pub(crate) fn decide_boot(session_id: &str, location: TranscriptLocation) -> BootDecision { - match location { - TranscriptLocation::Found(path) => { +/// Which conversation a session boots (design §4.2), from where its transcript +/// is and the genesis its binding row records: +/// +/// | transcript | genesis | boot | +/// |---|---|---| +/// | found | any (cleared lazily) | resume it | +/// | absent | `Fresh` | a fresh conversation under the session's id | +/// | absent | `Fork` | the lazy fork of the parent, under the session's id, from the parent's recorded directory; lost when the parent is gone | +/// | absent | none | lost | +/// | unreadable | any | transient, never lost | +/// +/// `locate` finds a fork genesis's parent transcript. +pub(crate) fn decide_boot( + session_id: &str, + location: TranscriptLocation, + genesis: Option<&ClaudeGenesis>, + locate: impl Fn(&str) -> TranscriptLocation, +) -> BootDecision { + match (location, genesis) { + (TranscriptLocation::Found(path), _) => { let (resume, cwd) = resume_target(session_id, &path); BootDecision::Boot { boot: ClaudeBoot::Resume { resume }, cwd, + session_id: None, } } - TranscriptLocation::Absent => BootDecision::Lost, - TranscriptLocation::Unreadable(error) => BootDecision::Transient(error), + (TranscriptLocation::Unreadable(error), _) => BootDecision::Transient(error), + (TranscriptLocation::Absent, None) => BootDecision::Lost, + (TranscriptLocation::Absent, Some(ClaudeGenesis::Fresh)) => BootDecision::Boot { + boot: ClaudeBoot::Fresh, + cwd: None, + session_id: Some(session_id.to_string()), + }, + ( + TranscriptLocation::Absent, + Some(ClaudeGenesis::Fork { + parent, + at, + drops_turn, + }), + ) => match locate(parent) { + TranscriptLocation::Found(parent_path) => { + let (resume, cwd) = resume_target(parent, &parent_path); + BootDecision::Boot { + boot: ClaudeBoot::Fork { + resume, + at: at.clone(), + drops_turn: drops_turn.clone(), + }, + cwd, + session_id: Some(session_id.to_string()), + } + } + TranscriptLocation::Absent => BootDecision::Lost, + TranscriptLocation::Unreadable(error) => BootDecision::Transient(error), + }, } } @@ -394,6 +451,7 @@ mod tests { "requestId": "req-1", "sessionType": "kilroy", "boot": { "kind": "resume", "resume": "P" }, + "sessionId": null, "cwd": "/w", "model": "opus", "permissionMode": "plan", @@ -492,10 +550,11 @@ mod tests { &format!("{{\"type\":\"user\",\"cwd\":{}}}\n", json!(cwd)), ); assert_eq!( - decide_boot("S", TranscriptLocation::Found(path)), + decide_boot("S", TranscriptLocation::Found(path), None, no_parent), BootDecision::Boot { boot: ClaudeBoot::Resume { resume: "S".into() }, cwd: Some(cwd), + session_id: None, } ); } @@ -509,12 +568,18 @@ mod tests { "{\"type\":\"user\",\"cwd\":\"/no/such/directory/anymore\"}\n", ); assert_eq!( - decide_boot("S", TranscriptLocation::Found(path.clone())), + decide_boot( + "S", + TranscriptLocation::Found(path.clone()), + None, + no_parent + ), BootDecision::Boot { boot: ClaudeBoot::Resume { resume: path.to_string_lossy().to_string() }, cwd: None, + session_id: None, } ); } @@ -524,10 +589,11 @@ mod tests { let store = tempfile::tempdir().unwrap(); let path = transcript(store.path(), "S", ""); assert_eq!( - decide_boot("S", TranscriptLocation::Found(path)), + decide_boot("S", TranscriptLocation::Found(path), None, no_parent), BootDecision::Boot { boot: ClaudeBoot::Resume { resume: "S".into() }, cwd: None, + session_id: None, } ); } @@ -535,7 +601,139 @@ mod tests { #[test] fn an_absent_transcript_is_lost_and_an_unreadable_store_is_transient() { assert_eq!( - decide_boot("S", TranscriptLocation::Absent), + decide_boot("S", TranscriptLocation::Absent, None, no_parent), + BootDecision::Lost + ); + let error = LocateError::Io { + root: "/store".into(), + kind: std::io::ErrorKind::PermissionDenied, + message: "denied".into(), + }; + assert_eq!( + decide_boot( + "S", + TranscriptLocation::Unreadable(error.clone()), + None, + no_parent + ), + BootDecision::Transient(error) + ); + assert_eq!( + decide_boot( + "S", + TranscriptLocation::Unreadable(LocateError::NoStoreRoot), + None, + no_parent + ), + BootDecision::Transient(LocateError::NoStoreRoot) + ); + } + + /// A session with no genesis has no parent to look up. + fn no_parent(id: &str) -> TranscriptLocation { + panic!("decide_boot looked up {id} for a session with no fork genesis") + } + + fn fork_genesis(parent: &str) -> ClaudeGenesis { + ClaudeGenesis::Fork { + parent: parent.into(), + at: Some("a1".into()), + drops_turn: Some("u2".into()), + } + } + + /// §4.2: the transcript is the truth once it exists. A genesis not yet + /// cleared (the lazy clear runs after the first result) never makes a + /// conversation boot again from scratch. + #[test] + fn a_found_transcript_resumes_whatever_the_genesis_says() { + let store = tempfile::tempdir().unwrap(); + let path = transcript(store.path(), "S", ""); + for genesis in [ClaudeGenesis::Fresh, fork_genesis("P")] { + assert_eq!( + decide_boot( + "S", + TranscriptLocation::Found(path.clone()), + Some(&genesis), + no_parent + ), + BootDecision::Boot { + boot: ClaudeBoot::Resume { resume: "S".into() }, + cwd: None, + session_id: None, + }, + "{genesis:?}" + ); + } + } + + /// §4.2/§4.3: a zero-turn session (create, no send, then a restart or a + /// crash) boots a fresh conversation under its own id again (T3: nothing is + /// on disk, so the id cannot collide). + #[test] + fn an_absent_transcript_with_a_fresh_genesis_boots_fresh_under_its_own_id() { + assert_eq!( + decide_boot( + "S", + TranscriptLocation::Absent, + Some(&ClaudeGenesis::Fresh), + no_parent + ), + BootDecision::Boot { + boot: ClaudeBoot::Fresh, + cwd: None, + session_id: Some("S".into()), + } + ); + } + + /// §4.2/§4.3: a pending fork (undo, then a restart before sending) boots + /// the same lazy fork of its parent again under its own id (T6), from the + /// parent's recorded directory, where the CLI finds the parent by id. + #[test] + fn an_absent_transcript_with_a_fork_genesis_boots_the_fork_under_its_own_id() { + let store = tempfile::tempdir().unwrap(); + let recorded = tempfile::tempdir().unwrap(); + let cwd = recorded.path().to_str().unwrap().to_string(); + let parent = transcript( + store.path(), + "P", + &format!("{{\"type\":\"user\",\"cwd\":{}}}\n", json!(cwd)), + ); + let locate = |id: &str| { + assert_eq!(id, "P", "only the parent is looked up"); + TranscriptLocation::Found(parent.clone()) + }; + assert_eq!( + decide_boot( + "S", + TranscriptLocation::Absent, + Some(&fork_genesis("P")), + locate + ), + BootDecision::Boot { + boot: ClaudeBoot::Fork { + resume: "P".into(), + at: Some("a1".into()), + drops_turn: Some("u2".into()), + }, + cwd: Some(cwd), + session_id: Some("S".into()), + } + ); + } + + /// A pending fork whose parent is gone cannot boot (the CLI refuses it), so + /// the session is lost; a parent store that cannot be checked is transient. + #[test] + fn a_fork_genesis_whose_parent_is_gone_is_lost_and_an_unreadable_parent_transient() { + assert_eq!( + decide_boot( + "S", + TranscriptLocation::Absent, + Some(&fork_genesis("P")), + |_: &str| TranscriptLocation::Absent + ), BootDecision::Lost ); let error = LocateError::Io { @@ -544,18 +742,51 @@ mod tests { message: "denied".into(), }; assert_eq!( - decide_boot("S", TranscriptLocation::Unreadable(error.clone())), + decide_boot( + "S", + TranscriptLocation::Absent, + Some(&fork_genesis("P")), + |_: &str| TranscriptLocation::Unreadable(error.clone()) + ), BootDecision::Transient(error) ); + } + + /// G28 holds with a genesis too: a store that cannot be checked may hold + /// the transcript, so nothing boots from the genesis. + #[test] + fn an_unreadable_store_is_transient_whatever_the_genesis_says() { assert_eq!( decide_boot( "S", - TranscriptLocation::Unreadable(LocateError::NoStoreRoot) + TranscriptLocation::Unreadable(LocateError::NoStoreRoot), + Some(&ClaudeGenesis::Fresh), + no_parent ), BootDecision::Transient(LocateError::NoStoreRoot) ); } + /// The create line names the id a fresh or forked boot writes under + /// (`sessionId`, the sidecar's `bootOptions` input), null when the launch + /// has none. + #[test] + fn a_create_line_names_the_id_a_genesis_boot_writes_under() { + let spec = ClaudeLaunchSpec::new( + "req", + "freshclaude", + ClaudeBoot::Fresh, + None, + &FreshAgentSettings::default(), + false, + ); + assert_eq!(spec.create_request().get("sessionId"), Some(&Value::Null)); + assert_eq!( + spec.with_session_id(Some("S".into())).create_request()["sessionId"], + json!("S") + ); + } + #[test] fn a_resume_id_makes_a_resume_boot_and_none_a_fresh_one() { assert_eq!(ClaudeBoot::resume_or_fresh(None), ClaudeBoot::Fresh); diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index 87ff6db10..46701c623 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -352,6 +352,75 @@ impl ClaudeRig { .filter(|row| row["kind"] == kind) .collect() } + + /// Record a binding row for `session_id` with `genesis` (core design §4.1) + /// the way a lane that mints a session with no transcript yet writes it: + /// a fresh-agent row with the rig's workspace as its cwd. Needs + /// [`RigOptions::durable_ledger`]. + pub fn seed_pending_session(&self, session_id: &str, genesis: &Value) { + self.pane_ledger + .as_ref() + .expect("seeding a binding row needs the rig's durable ledger") + .record_fresh_agent_binding(&freshell_ws::pane_ledger::FreshAgentBindingWrite { + provider: "claude", + session_id, + mode: "freshclaude", + cwd: Some(self.workspace.to_str().expect("utf-8 path")), + create_request_id: None, + model: None, + sandbox: None, + permission_mode: None, + effort: None, + plugins: None, + genesis: freshell_ws::pane_ledger::GenesisWrite::Set(genesis), + supersedes: None, + provenance: freshell_ws::pane_ledger::ProvenancePolicy::Inherit, + observed_epoch: None, + observed_generation: None, + authoritative: false, + now_ms: 1_000, + }) + .expect("seed the binding row"); + } + + /// The transcript `.jsonl` under this rig's CLAUDE_CONFIG_DIR, + /// once the CLI wrote it. + pub async fn wait_for_transcript(&self, session_id: &str) -> PathBuf { + let name = format!("{session_id}.jsonl"); + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + if let Some(path) = self + .transcripts() + .into_iter() + .find(|path| path.file_name().and_then(|n| n.to_str()) == Some(name.as_str())) + { + return path; + } + assert!( + tokio::time::Instant::now() < deadline, + "no transcript {name}; found {:?}", + self.transcripts() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + + /// Returns once the binding row of `session_id` no longer records a genesis + /// (the lazy clear, core design §4.1). Needs [`RigOptions::durable_ledger`]. + pub async fn wait_for_genesis_cleared(&self, session_id: &str) { + let ledger = self + .pane_ledger + .as_ref() + .expect("a genesis lives in the rig's durable ledger"); + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while ledger.binding_genesis("claude", session_id).is_some() { + assert!( + tokio::time::Instant::now() < deadline, + "the genesis of {session_id} was never cleared" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + } } /// One client connection to a rig's server. Every frame it reads is kept in @@ -531,6 +600,41 @@ impl RigConnection { .count() } + /// The pane reattaches to the durable `session_id` (a reload, or the first + /// attach after a restart); returns once the attach is acknowledged. + pub async fn attach(&mut self, session_id: &str) { + self.send(json!({ + "type": "freshAgent.attach", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": session_id, + "sessionRef": { "provider": "claude", "sessionId": session_id }, + })) + .await; + self.wait_for("the attach acknowledgement", |v| { + v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.session.snapshot" + && v["event"]["sessionId"] == session_id + }) + .await; + } + + /// The durable claude id the first turn of the session `pane_id` names + /// reported (`freshAgent.session.init`; its envelope carries the pane's id, + /// and a create's event the sidecar's own id). + pub fn init_session_id(&self, pane_id: &str) -> String { + self.frames + .iter() + .find_map(|v| { + (v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.session.init" + && (v["sessionId"] == pane_id || v["event"]["sessionId"] == pane_id)) + .then(|| v["event"]["cliSessionId"].as_str().map(str::to_string)) + .flatten() + }) + .expect("the first turn reported the durable id") + } + /// `freshAgent.kill`, then wait for `freshAgent.killed{success:true}`. pub async fn kill(&mut self, session_id: &str) { self.send(json!({ diff --git a/crates/freshell-ws/tests/freshagent_claude_genesis.rs b/crates/freshell-ws/tests/freshagent_claude_genesis.rs new file mode 100644 index 000000000..69817a9fe --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_genesis.rs @@ -0,0 +1,197 @@ +//! A claude session with no transcript yet boots from its genesis (core-runtime +//! design §4.1-§4.3, C12). +//! +//! A session's binding row records how it came to be (`{"kind":"fresh"}`, or a +//! lazy fork of a parent at a point) until its transcript is observed on disk. +//! After a restart the pane's attach finds no live session and no transcript, and +//! the lane boots the session again from that genesis, under the session's own id; +//! once the first turn's transcript is on disk the genesis is cleared. +//! +//! Each test runs the real sidecar over the CLI simulator (RealSidecarSim) and a +//! real pane ledger, seeds the binding row the way a lane that mints a pending +//! session writes it (nothing writes one before C13), and checks the `query()` +//! options the simulator recorded, the transcript the CLI wrote and the row. + +mod common; + +use std::time::Duration; + +use serde_json::{json, Value}; + +use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions, RIG_FRAME_BUDGET}; +use freshell_ws::pane_ledger::PaneLedger; + +async fn ledger_rig() -> (ClaudeRig, std::sync::Arc) { + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let ledger = rig.pane_ledger.clone().expect("the rig has a ledger"); + (rig, ledger) +} + +/// One finished turn per prompt (each `freshAgent.result`). +async fn converse(conn: &mut RigConnection, session_id: &str, prompts: &[&str]) { + for (n, prompt) in prompts.iter().enumerate() { + let results_before = conn.events_seen("freshAgent.result"); + conn.send_text(session_id, &format!("send-{session_id}-{n}"), prompt) + .await; + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while conn.events_seen("freshAgent.result") == results_before { + assert!( + tokio::time::Instant::now() < deadline, + "turn {n} never finished" + ); + conn.read_for(Duration::from_millis(50)).await; + } + } +} + +/// The `query()` options the simulator recorded, the first matching `predicate`. +async fn wait_for_query(rig: &ClaudeRig, what: &str, predicate: impl Fn(&Value) -> bool) -> Value { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + let options: Vec = rig + .ledger_rows("query") + .into_iter() + .map(|row| row["options"].clone()) + .collect(); + if let Some(found) = options.iter().find(|o| predicate(o)) { + return found.clone(); + } + assert!( + tokio::time::Instant::now() < deadline, + "no query() call for {what}; recorded: {options:?}" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +fn turn_ids(snapshot: &Value) -> Vec { + snapshot["turns"] + .as_array() + .expect("turns") + .iter() + .filter_map(|t| t["turnId"].as_str().map(str::to_string)) + .collect() +} + +/// §4.3 "create, no send, server restart": the zero-turn session boots a fresh +/// conversation under its own id again (T3), its first turn writes that id's +/// transcript, and the genesis is then cleared. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_zero_turn_session_reboots_fresh_under_its_own_id_and_its_genesis_clears() { + let (rig, ledger) = ledger_rig().await; + let id = uuid::Uuid::new_v4().to_string(); + rig.seed_pending_session(&id, &json!({ "kind": "fresh" })); + let mut conn = rig.connect().await; + + conn.attach(&id).await; + let boot = wait_for_query(&rig, "the fresh boot", |o| o["sessionId"] == id.as_str()).await; + assert_eq!( + boot.get("resume"), + None, + "a fresh boot resumes nothing: {boot}" + ); + assert_eq!( + boot.get("forkSession"), + None, + "a fresh boot forks nothing: {boot}" + ); + assert_eq!( + ledger.binding_genesis("claude", &id), + Some(json!({ "kind": "fresh" })), + "the genesis stays until the transcript is on disk" + ); + + converse(&mut conn, &id, &["first prompt"]).await; + assert_eq!( + conn.init_session_id(&id), + id, + "the CLI ran under the session's id" + ); + rig.wait_for_transcript(&id).await; + rig.wait_for_genesis_cleared(&id).await; + let row = ledger.load_binding("claude", &id).expect("the row stays"); + assert_eq!(row.genesis, None); + assert_eq!(row.cwd.as_deref(), rig.workspace.to_str()); +} + +/// §4.3 "undo, then restart before sending": the pending fork boots the same lazy +/// fork of its parent again under its own id (T6). Before its first prompt its +/// snapshot is the parent's prefix; once that prompt writes the fork's transcript +/// the same turns (ids included) lead it, the parent is untouched, and the +/// genesis is cleared. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_pending_fork_reboots_the_same_fork_and_keeps_its_turn_ids() { + let (rig, _) = ledger_rig().await; + let mut conn = rig.connect().await; + + // The parent: a conversation of two turns. + let parent_pane = conn.create("create-parent").await; + converse(&mut conn, &parent_pane, &["first prompt", "second prompt"]).await; + let parent = conn.init_session_id(&parent_pane); + let parent_path = rig.wait_for_transcript(&parent).await; + let parent_before = std::fs::read_to_string(&parent_path).expect("read the parent"); + let first_answer = parent_before + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .find(|line| line["type"] == "assistant") + .and_then(|line| line["uuid"].as_str().map(str::to_string)) + .expect("the parent's first answer"); + + let child = uuid::Uuid::new_v4().to_string(); + rig.seed_pending_session( + &child, + &json!({ "kind": "fork", "parent": parent, "at": first_answer }), + ); + let pending = rig + .fresh_claude + .get_snapshot(freshell_protocol::SessionType::Freshclaude, &child) + .await + .expect("a pending fork has a snapshot"); + let pending_turns = turn_ids(&pending); + assert_eq!( + pending_turns.last(), + Some(&first_answer), + "the parent's prefix through the fork point: {pending}" + ); + + conn.attach(&child).await; + let boot = wait_for_query(&rig, "the fork boot", |o| { + o["forkSession"] == true && o["sessionId"] == child.as_str() + }) + .await; + assert_eq!(boot["resume"], json!(parent), "{boot}"); + assert_eq!(boot["resumeSessionAt"], json!(first_answer), "{boot}"); + + converse(&mut conn, &child, &["a different second prompt"]).await; + assert_eq!( + conn.init_session_id(&child), + child, + "the fork ran under the session's id" + ); + rig.wait_for_transcript(&child).await; + let materialized = rig + .fresh_claude + .get_snapshot(freshell_protocol::SessionType::Freshclaude, &child) + .await + .expect("the fork has a snapshot"); + let materialized_turns = turn_ids(&materialized); + assert!( + materialized_turns.len() > pending_turns.len() + && materialized_turns[..pending_turns.len()] == pending_turns[..], + "the pending turns lead the materialized fork with the same ids: \ + pending {pending_turns:?}, materialized {materialized_turns:?}" + ); + assert_eq!( + std::fs::read_to_string(&parent_path).expect("read the parent"), + parent_before, + "the fork never rewrites its parent" + ); + rig.wait_for_genesis_cleared(&child).await; +} diff --git a/crates/freshell-ws/tests/freshagent_claude_real_cli.rs b/crates/freshell-ws/tests/freshagent_claude_real_cli.rs index 044181a55..003fb7cbc 100644 --- a/crates/freshell-ws/tests/freshagent_claude_real_cli.rs +++ b/crates/freshell-ws/tests/freshagent_claude_real_cli.rs @@ -12,7 +12,7 @@ mod common; -use common::claude_rig::{ClaudeRig, RigKind}; +use common::claude_rig::{ClaudeRig, RigKind, RigOptions}; use common::log_capture::{self, CapturedEvent}; use serde_json::json; use tracing::Level; @@ -213,9 +213,45 @@ async fn a_forwarded_turn_complete_is_logged(kind: RigKind) { assert_eq!(edges.len(), 1, "one logged edge per edge sent: {edges:?}"); } +/// Design §11.5 item 10, zero-turn restart: a session created and never sent to +/// has no transcript yet (T3). After a restart its pane's attach boots it again +/// from the `Fresh` genesis its binding row records, under its own id; its first +/// turn writes that id's transcript, and the genesis is then cleared (§4.1). +async fn a_zero_turn_session_restarts_fresh_under_its_own_id(kind: RigKind) { + let rig = ClaudeRig::start_with_options( + kind, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let id = uuid::Uuid::new_v4().to_string(); + rig.seed_pending_session(&id, &json!({ "kind": "fresh" })); + let mut conn = rig.connect().await; + + conn.attach(&id).await; + conn.send_text(&id, "send-1", "hello").await; + let result = conn.wait_for_event("freshAgent.result").await; + assert_eq!(result["event"]["result"], "success", "{result}"); + assert_eq!( + conn.init_session_id(&id), + id, + "the CLI ran under the session's id" + ); + rig.wait_for_transcript(&id).await; + rig.wait_for_genesis_cleared(&id).await; + conn.kill(&id).await; +} + mod sim { use super::*; + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_zero_turn_session_restarts_fresh_under_its_own_id() { + super::a_zero_turn_session_restarts_fresh_under_its_own_id(RigKind::Sim).await; + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sidecar_log_records_reach_the_server_log() { super::sidecar_log_records_reach_the_server_log(RigKind::Sim).await; @@ -279,6 +315,11 @@ mod sim { mod cli { use super::*; + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_zero_turn_session_restarts_fresh_under_its_own_id() { + super::a_zero_turn_session_restarts_fresh_under_its_own_id(RigKind::Cli).await; + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn sidecar_log_records_reach_the_server_log() { super::sidecar_log_records_reach_the_server_log(RigKind::Cli).await; From 948686dda936430cda59cc4ffa1b8a8be3c52975 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Thu, 22 Oct 2026 23:40:45 -0700 Subject: [PATCH 07/24] refactor(freshclaude): the result-time genesis check runs off the sidecar's output reader The lazy genesis clear's check after each `sdk.result` resolved the session's durable id inline in the consumer, which takes the sessions and cli_index locks: the reader that drains the sidecar's stdout must not wait on those (a send holding the sessions lock across a blocked stdin write, G27, would then wait on the reader). The whole check now runs in the detached task (`spawn_genesis_clear_after_result`); the reader only reads whether a sink is installed. No behavior change: the genesis lanes (RealSidecarSim and the pinned CLI) and the freshagent lib suite are green. Also documents the genesis row in handle_attach's decision table. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 5 +++-- .../freshell-freshagent/src/claude/genesis.rs | 20 ++++++++++--------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index e33f0b837..88fbfaf71 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -6474,7 +6474,8 @@ impl FreshClaudeState { /// | 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) | `lost_session_frame` -- positive denial: the store is the authority (honest even under the 30-day GC, ledger A4) | + /// | 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) { @@ -8143,7 +8144,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.clear_genesis_after_result(&session_id).await; + state.spawn_genesis_clear_after_result(&session_id); } Some("sdk.status") if value.get("status").and_then(Value::as_str) == Some("idle") => diff --git a/crates/freshell-freshagent/src/claude/genesis.rs b/crates/freshell-freshagent/src/claude/genesis.rs index d84740bbf..3e02390f0 100644 --- a/crates/freshell-freshagent/src/claude/genesis.rs +++ b/crates/freshell-freshagent/src/claude/genesis.rs @@ -221,21 +221,23 @@ impl FreshClaudeState { /// After a result of the session `session_key` names: when its binding row /// still records a genesis, clear it once the transcript is flushed (the - /// lazy clear, design §4.1). Detached; a session with no genesis costs one - /// memory read. - pub(super) async fn clear_genesis_after_result(&self, session_key: &str) { + /// lazy clear, design §4.1). Detached from the caller (the sidecar's output + /// 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 durable = self.durable_session_id(session_key).await; - let Some(recorded) = sink.load_genesis(PROVIDER, &durable) else { - return; - }; - let roots = self.transcript_roots(); + let (state, session_key) = (self.clone(), session_key.to_string()); tokio::spawn(async move { + let durable = state.durable_session_id(&session_key).await; + let Some(recorded) = sink.load_genesis(PROVIDER, &durable) else { + return; + }; let outcome = clear_genesis_once_observed( &sink, - roots, + state.transcript_roots(), &durable, GENESIS_CLEAR_ATTEMPTS, GENESIS_CLEAR_INTERVAL, From 67c965bb306290c46b2852e62f7e02359b54c2e0 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:14:50 -0700 Subject: [PATCH 08/24] test(claude): pin the drops-turn guard on an absorbed send; the simulator matches it Decision 2 of the core design (undo refuses honestly when Claude folded another message into the turn) rests on a CLI fact no scenario pinned yet. Probing the pinned CLI 2.1.237 against the loopback provider showed: - a send absorbed during a tool call is written into that turn as an `attachment{type: queued_command, source_uuid}`, never as a prompt line; - a fork that undoes that turn with `resumeDropsTurn` declaring its prompt is refused at boot: "range contains absorbed queued content". New contract scenario `absorbed-undo` and fact (T10 T5) pin both on the real CLI. The simulator diverged on both (it wrote the absorbed send as a user line and only checked the first discarded entry), so the conformance comparison was red. It now records absorbed sends as queued_command attachments and runs a port of the CLI's validator: leading furniture is skipped, the declared prompt must start the range, and a second prompt, a task notification, a compaction summary, absorbed content or a non-furniture attachment in the range refuses, with the CLI's own reasons. Unit tests pin the port's other ranges (a second prompt, a notification, a range not starting with the declared prompt). Red: conformance for absorbed-undo differed (the sim booted the guarded fork). Green: the contract subset (absorbed, T5, T6, T10, fork, turns conformance) 8/8; claude-cli-sim + sidecar unit tests 154/154. Co-Authored-By: Claude Opus 5.5 --- test/fixtures/claude-cli-sim/query.mjs | 15 +- test/fixtures/claude-cli-sim/tools.mjs | 1 - test/fixtures/claude-cli-sim/transcript.mjs | 131 ++++++++++++++++++ .../providers/claude-cli-contract.test.ts | 18 +++ .../providers/claude-cli-scenarios.mjs | 36 +++++ .../providers/claude-cli-sim.test.ts | 31 +++++ 6 files changed, 226 insertions(+), 6 deletions(-) diff --git a/test/fixtures/claude-cli-sim/query.mjs b/test/fixtures/claude-cli-sim/query.mjs index d910bd38b..7a036d892 100644 --- a/test/fixtures/claude-cli-sim/query.mjs +++ b/test/fixtures/claude-cli-sim/query.mjs @@ -80,6 +80,7 @@ import { toolCall } from './tools.mjs' import { claudeConfigDir, CLI_VERSION, + dropsTurnRefusal, findTranscript, forkPrefix, readTranscriptLines, @@ -411,11 +412,8 @@ class SimulatedCli { try { prefix = forkPrefix(parentLines, resumeSessionAt) } catch (error) { return { message: error.message } } if (resumeDropsTurn) { const discarded = parentLines.slice(prefix.length).filter((line) => typeof line.uuid === 'string') - const first = discarded[0] - if (!first || first.type !== 'user' || first.uuid !== resumeDropsTurn) { - const shown = first ? ` [type=${first.type}, uuid=${first.uuid}]` : '' - return { message: `Resume rejected by --resume-drops-turn: resuming at ${resumeSessionAt} would discard entries not attributable to turn ${resumeDropsTurn}: range does not start with the declared turn prompt; first discarded entry 0${shown}` } - } + const reason = dropsTurnRefusal(discarded, resumeDropsTurn) + if (reason) return { message: `Resume rejected by --resume-drops-turn: resuming at ${resumeSessionAt} would discard entries not attributable to turn ${resumeDropsTurn}: ${reason}` } } this.transcript = forkSession ? new Transcript({ configDir: this.configDir, cwd: this.cwd, sessionId: this.sessionId, flushMs: (ms) => this.ms(ms), prefix }) @@ -705,6 +703,13 @@ class SimulatedCli { const block = { tool_use_id: toolUseId, type: 'tool_result', content, ...(isError ? { is_error: true } : { is_error: false }) } this.emit({ type: 'user', message: { role: 'user', content: [block] }, parent_tool_use_id: null, uuid, timestamp: new Date().toISOString(), tool_use_result: toolUseResult, ...extra }) this.transcript.append({ type: 'user', uuid, message: { role: 'user', content: [block] }, toolUseResult }) + // Sends absorbed while the tool ran join the turn as queued_command + // attachments, never as prompts of their own (T10). + for (const cmd of this.turn?.absorbed ?? []) { + if (cmd.started || cmd.recorded) continue + cmd.recorded = true + this.transcript.append({ type: 'attachment', attachment: { type: 'queued_command', prompt: cmd.content, ...(cmd.uuid ? { source_uuid: cmd.uuid } : {}), commandMode: 'prompt', timestamp: new Date().toISOString() } }) + } this.transcript.append({ type: 'attachment', attachment: { type: 'total_tokens_reminder', text: '15000000 tokens left' } }) } diff --git a/test/fixtures/claude-cli-sim/tools.mjs b/test/fixtures/claude-cli-sim/tools.mjs index 68369fe30..880180075 100644 --- a/test/fixtures/claude-cli-sim/tools.mjs +++ b/test/fixtures/claude-cli-sim/tools.mjs @@ -193,7 +193,6 @@ function startAbsorbed(cli, turn) { cli.emit({ type: 'user', message: { role: 'user', content: cmd.content }, parent_tool_use_id: null, uuid, timestamp: new Date().toISOString(), isReplay: true }) if (cmd.uuid) cli.emit({ type: 'command_lifecycle', command_uuid: cmd.uuid, state: 'started' }) cli.recordPrompt(cmd) - cli.transcript.append({ type: 'user', uuid, message: { role: 'user', content: cmd.content }, promptSource: 'sdk', permissionMode: cli.permissionMode }) turn.absorbedTexts.push(cmd.text) } } diff --git a/test/fixtures/claude-cli-sim/transcript.mjs b/test/fixtures/claude-cli-sim/transcript.mjs index 6f89ef983..aca2778bc 100644 --- a/test/fixtures/claude-cli-sim/transcript.mjs +++ b/test/fixtures/claude-cli-sim/transcript.mjs @@ -154,3 +154,134 @@ export function forkPrefix(parentLines, at) { if (index < 0) throw new Error(`No message found with message.uuid of: ${at}`) return parentLines.slice(0, index + 1) } + +// ── the --resume-drops-turn guard (T5; decision 2 of the core design) ─────── +// +// A port of the pinned CLI's validator. A truncating resume that declares +// `resumeDropsTurn: D` is refused unless every entry it discards (the parent's +// chain entries after the fork point) is attributable to the turn D prompted: +// after any leading furniture, D's own prompt, then only that turn's assistant +// replies, tool results, system lines, meta lines and furniture attachments. +// A send absorbed mid-turn (a `queued_command` attachment), a task +// notification, another prompt or a compaction summary in the range refuses. + +const INTERRUPT_MARKERS = ['[Request interrupted by user]', '[Request interrupted by user for tool use]'] +const DECLINE_TEXT = "The user doesn't want to take this action right now. STOP what you are doing and wait for the user to tell you how to proceed." +const REJECT_TEXT = "The user doesn't want to proceed with this tool use. The tool use was rejected (eg. if it was a file edit, the new_string was NOT written to the file). STOP what you are doing and wait for the user to tell you how to proceed." +const CORRECTION_NOTE = " Note: The user's next message may contain a correction or preference. Pay close attention — if they explain what went wrong or how they'd prefer you to work, consider saving that to memory for future sessions." +const CANCELLED_TOOL_TEXTS = new Set([...INTERRUPT_MARKERS, DECLINE_TEXT, DECLINE_TEXT + CORRECTION_NOTE, REJECT_TEXT, REJECT_TEXT + CORRECTION_NOTE]) +const INTERRUPTION_PREFIXES = [...INTERRUPT_MARKERS, DECLINE_TEXT] +/** Attachment kinds the CLI treats as context furniture (the pinned CLI's list). */ +const FURNITURE_ATTACHMENTS = new Set([ + 'agent_listing_delta', 'agent_mention', 'peer_mention', 'already_read_file', 'attention_budget', 'audio_transcript', + 'auto_mode', 'auto_mode_exit', 'budget_usd', 'command_permissions', 'compact_file_reference', 'context_efficiency', + 'critical_system_reminder', 'date_change', 'deferred_tools_delta', 'diagnostics', 'directory', 'dynamic_skill', + 'edited_image_file', 'edited_text_file', 'file', 'goal_status', 'hook_additional_context', 'hook_blocking_error', + 'hook_cancelled', 'hook_deferred_tool', 'hook_error_during_execution', 'hook_non_blocking_error', + 'hook_permission_decision', 'hook_plugin_listing', 'hook_stopped_continuation', 'hook_success', + 'hook_system_message', 'invoked_skills', 'max_turns_reached', 'mcp_instructions_delta', 'mcp_dropped_tools_delta', + 'mcp_resource', 'memory_update', 'nested_memory', 'opened_file_in_ide', 'output_style', 'output_token_usage', + 'pdf_reference', 'plan_file_reference', 'plan_mode', 'plan_mode_exit', 'plan_mode_reentry', 'proactivity', + 'read_truncation_notice', 'bash_output_audience_note', 'relevant_memories', 'selected_lines_in_diff', + 'selected_lines_in_ide', 'silent_turn_reminder', 'skill_listing', 'structured_output', 'task_reminder', + 'team_context', 'teammate_shutdown_batch', 'todo_reminder', 'token_usage', 'tool_search_usage_reminder', + 'total_tokens_reminder', 'batching_reminder', 'batching_reminder_sent', 'ultra_effort_enter', 'ultra_effort_exit', + 'ultrathink_effort', 'workflow_keyword_request', 'workflow_size_guideline_change', +]) +/** Furniture that still belongs to the kept turn when it leads the range. */ +const KEPT_TURN_PAYLOAD = new Set(['mcp_resource', 'structured_output']) +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +const contentOf = (line) => line.message?.content +const isHumanOrigin = (line) => line.origin === undefined || line.origin.kind === 'human' || line.origin.kind === 'auto-continuation' + +function isInterruptMarker(line) { + const content = contentOf(line) + if (typeof content === 'string') return INTERRUPT_MARKERS.includes(content) + return Array.isArray(content) && content.length === 1 && content[0]?.type === 'text' && INTERRUPT_MARKERS.includes(content[0].text) +} + +function isCancelledToolBatch(line) { + const content = contentOf(line) + return Array.isArray(content) && content.length > 0 + && content.every((b) => b?.type === 'tool_result' && b.is_error === true && typeof b.content === 'string' && CANCELLED_TOOL_TEXTS.has(b.content)) +} + +function isAllToolResults(line) { + const content = contentOf(line) + return Array.isArray(content) && content.length > 0 && content.every((b) => b?.type === 'tool_result') +} + +function isInterruption(line) { + if (line.type !== 'user') return false + const content = contentOf(line) + if (typeof content === 'string') return INTERRUPTION_PREFIXES.some((prefix) => content.startsWith(prefix)) + return Array.isArray(content) && content.length > 0 && content.every((b) => { + const text = b?.type === 'text' ? b.text : (b?.type === 'tool_result' && b.is_error === true ? b.content : undefined) + return typeof text === 'string' && INTERRUPTION_PREFIXES.some((prefix) => text.startsWith(prefix)) + }) +} + +function isSyntheticNoReply(line) { + const content = contentOf(line) + return line.message?.model === '' && Array.isArray(content) && content.length === 1 + && content[0]?.type === 'text' && content[0].text === 'No response requested.' +} + +/** Entries the guard skips before the declared prompt. */ +function isLeadingFurniture(line) { + if (line.type === 'user') { + return isHumanOrigin(line) && line.isCompactSummary !== true + && (isInterruptMarker(line) || isCancelledToolBatch(line) || (line.isMeta === true && line.promptSource === undefined)) + } + if (line.type === 'assistant') return isSyntheticNoReply(line) + if (line.type === 'attachment') return !KEPT_TURN_PAYLOAD.has(line.attachment?.type) && FURNITURE_ATTACHMENTS.has(line.attachment?.type) + return line.type === 'system' || line.type === 'progress' +} + +function describeEntry(line, index) { + const kind = line.type === 'attachment' ? ` (${line.attachment?.type})` : '' + return `entry ${index} [type=${line.type}${kind}, uuid=${line.uuid}]` +} + +/** + * Why the CLI refuses a resume that discards `discarded` (chain entries in + * file order) declaring the turn `declared`, or null when it accepts it. + */ +export function dropsTurnRefusal(discarded, declared) { + if (!UUID_RE.test(declared)) return `declared turn id is not a UUID: ${declared}` + let start = 0 + while (start < discarded.length && isLeadingFurniture(discarded[start])) start++ + if (start === discarded.length) return null + const prompt = discarded[start] + if (!(prompt.type === 'user' && prompt.uuid === declared)) return `range does not start with the declared turn prompt; first discarded ${describeEntry(prompt, start)}` + if (prompt.isMeta === true || prompt.isCompactSummary === true || prompt.stackedExpansion === true || isInterruption(prompt) || isAllToolResults(prompt)) { + return `declared turn id names a non-prompt user entry; ${describeEntry(prompt, start)}` + } + if (!isHumanOrigin(prompt)) return `declared turn id names an externally-sourced entry; ${describeEntry(prompt, start)}` + if (discarded.slice(start).some((line) => line.type === 'attachment' && line.attachment?.type === 'poll_events')) return 'range contains a delivered poll-event record' + for (let i = start + 1; i < discarded.length; i++) { + const line = discarded[i] + switch (line.type) { + case 'assistant': + case 'progress': + case 'system': + continue + case 'attachment': + if (line.attachment?.type === 'queued_command') return `range contains absorbed queued content; ${describeEntry(line, i)}` + if (!FURNITURE_ATTACHMENTS.has(line.attachment?.type)) return `range contains a non-furniture attachment; ${describeEntry(line, i)}` + continue + case 'user': + if (line.uuid === declared) continue + if (line.isCompactSummary === true) return `range contains a compaction summary; ${describeEntry(line, i)}` + if (!isHumanOrigin(line)) return `range contains an externally-sourced user entry; ${describeEntry(line, i)}` + if (line.stackedExpansion === true || isInterruptMarker(line) || isAllToolResults(line)) continue + if (line.isMeta === true && line.promptSource !== undefined) return `range contains a system-injected turn prompt; ${describeEntry(line, i)}` + if (line.isMeta === true) continue + return `range contains a user entry not attributable to the declared turn; ${describeEntry(line, i)}` + default: + return `range contains an unrecognized entry; ${describeEntry(line, i)}` + } + } + return null +} diff --git a/test/integration/providers/claude-cli-contract.test.ts b/test/integration/providers/claude-cli-contract.test.ts index 0322520c1..1616c4eb7 100644 --- a/test/integration/providers/claude-cli-contract.test.ts +++ b/test/integration/providers/claude-cli-contract.test.ts @@ -267,6 +267,24 @@ describe(`pinned Claude CLI ${PINNED_CLI_VERSION} contract (real CLI, loopback p expect(at(events, isMsg(isLifecycle('u4', 'queued')))).toBeLessThan(queuedFirst) }, SCENARIO_TIMEOUT) + it('T10 T5: an absorbed send is recorded as a queued_command attachment inside its turn, so a guarded fork that undoes that turn is refused', async () => { + const r = await real('absorbed-undo') + const { parent, afterGuard } = r.observations + // The absorbed send has no prompt line of its own: it is attachment content + // of the tool turn (the replay still carries our uuid, T8). + expect(parent.absorbed).toEqual([{ source: 'u3', prompt: [{ type: 'text', text: 'absorbed during tool' }], mode: 'prompt' }]) + const shape = parent.shape as Array<{ type: string, uuid: string }> + expect(shape.some((line) => line.uuid === 'u3')).toBe(false) + expect(shape.filter((line) => line.type === 'user' && line.uuid.startsWith('u')).map((line) => line.uuid)).toEqual(['u1', 'u2']) + // Undoing the tool turn with the guard declaring its prompt is refused at + // boot, and the refusal names the absorbed content. + const guarded = messagesOf(r, 'guarded') + expect(guarded).toHaveLength(1) + expect(guarded[0]).toMatchObject({ type: 'result', subtype: 'error_during_execution', num_turns: 0 }) + expect(guarded[0].errors[0]).toMatch(/^Resume rejected by --resume-drops-turn: resuming at keepAt would discard entries not attributable to turn u2: range contains absorbed queued content; entry \d+ \[type=attachment \(queued_command\), uuid=cli\]$/) + expect(afterGuard.files.map((f: string) => path.basename(f))).toEqual(['P.jsonl']) + }, SCENARIO_TIMEOUT) + it('T11: a warm interrupt ends the turn with error_during_execution, aborted_streaming or aborted_tools, diagnostic errors and a cancelled lifecycle; the receipt comes first', async () => { const r = await real('interrupts') const events = eventsOf(r, 'S') diff --git a/test/integration/providers/claude-cli-scenarios.mjs b/test/integration/providers/claude-cli-scenarios.mjs index 98e306698..d52d0b445 100644 --- a/test/integration/providers/claude-cli-scenarios.mjs +++ b/test/integration/providers/claude-cli-scenarios.mjs @@ -452,6 +452,42 @@ const SCENARIOS = { ctx.observe('afterRefusals', { files: await ctx.files() }) }, + /** + * T10 T5 (decision 2 of the core design): the transcript records a send + * absorbed during a tool call as a `queued_command` attachment inside that + * turn, so a guarded fork that undoes the turn is refused: the discarded + * range holds content the declared turn's prompt did not bring. + */ + async 'absorbed-undo'(ctx) { + const P = ctx.uuid('P') + const s = ctx.open('S', { options: { sessionId: P } }) + s.push('turn one', 'u1') + await s.waitForResults(1) + s.once(isAssistantTool, () => setTimeout(() => s.push('absorbed during tool', 'u3'), 500)) + // 1.5 s keeps the Bash call clear of the CLI's ~3 s background-task promotion. + const u2 = s.push('please TOOL:sleep=1.5', 'u2') + await s.waitForResults(2, 30_000) + await s.waitForCount(isStateIdle, 2, 10_000, 'idle') + ctx.need(await ctx.pollTranscript(P, 4000, 2), 'parent transcript') + await s.close() + await sleep(300) + const lines = await ctx.fixture.readTranscript(P) + const u2Index = lines.findIndex((line) => line.uuid === u2) + const keepAt = ctx.need(lines.slice(0, u2Index).reverse().find((line) => typeof line.uuid === 'string')?.uuid, 'fork point') + ctx.label(keepAt, 'keepAt') + ctx.observe('parent', { + shape: transcriptShape(lines), + absorbed: lines + .filter((line) => line.type === 'attachment' && line.attachment?.type === 'queued_command') + .map((line) => ({ source: line.attachment.source_uuid, prompt: line.attachment.prompt, mode: line.attachment.commandMode })), + }) + const guarded = ctx.open('guarded', { options: { resume: P, resumeSessionAt: keepAt, forkSession: true, sessionId: ctx.uuid('F'), resumeDropsTurn: u2 } }) + await guarded.init() + await Promise.race([guarded.done, sleep(8000)]) + await guarded.close() + ctx.observe('afterGuard', { files: await ctx.files() }) + }, + /** T8 T9 T10: a send absorbed during a tool call, then a send queued during streamed text. */ async turns(ctx) { const s = ctx.open('S') diff --git a/test/integration/providers/claude-cli-sim.test.ts b/test/integration/providers/claude-cli-sim.test.ts index 99dc17f4b..6526599f9 100644 --- a/test/integration/providers/claude-cli-sim.test.ts +++ b/test/integration/providers/claude-cli-sim.test.ts @@ -15,6 +15,7 @@ import path from 'node:path' import readline from 'node:readline' import { afterEach, describe, expect, it } from 'vitest' import { query } from '../../fixtures/claude-cli-sim/query.mjs' +import { dropsTurnRefusal } from '../../fixtures/claude-cli-sim/transcript.mjs' import { REPO_ROOT } from './claude-cli-trace' const SIDECAR = path.join(REPO_ROOT, 'crates/freshell-claude-sidecar/index.mjs') @@ -346,6 +347,36 @@ describe('Claude CLI simulator programs', () => { }) }) +// The simulator's port of the CLI's --resume-drops-turn validator. The +// absorbed-send refusal is compared with the real CLI (absorbed-undo in +// claude-cli-conformance.ts); these pin the other ranges a rollback relies on. +describe('the drops-turn guard port', () => { + const U = (n: number) => `00000000-0000-4000-8000-00000000000${n}` + const prompt = (uuid: string, text = 'hi') => ({ type: 'user', uuid, message: { role: 'user', content: text }, promptSource: 'sdk' }) + const reply = (uuid: string) => ({ type: 'assistant', uuid, message: { role: 'assistant', content: [{ type: 'text', text: 'ok' }] } }) + const tokens = (uuid: string) => ({ type: 'attachment', uuid, attachment: { type: 'total_tokens_reminder' } }) + + it('accepts a range that is the declared turn alone, its furniture included', () => { + expect(dropsTurnRefusal([prompt(U(1)), tokens(U(2)), reply(U(3))], U(1))).toBeNull() + }) + + it('refuses a range holding a second prompt: one guard declares one turn', () => { + expect(dropsTurnRefusal([prompt(U(1)), reply(U(2)), prompt(U(3)), reply(U(4))], U(1))) + .toBe(`range contains a user entry not attributable to the declared turn; entry 2 [type=user, uuid=${U(3)}]`) + }) + + it('refuses a range holding a task notification (an externally-sourced user entry)', () => { + const notification = { ...prompt(U(3), 'done'), origin: { kind: 'task-notification' } } + expect(dropsTurnRefusal([prompt(U(1)), reply(U(2)), notification, reply(U(4))], U(1))) + .toBe(`range contains an externally-sourced user entry; entry 2 [type=user, uuid=${U(3)}]`) + }) + + it('refuses a range that does not start with the declared prompt', () => { + expect(dropsTurnRefusal([reply(U(2)), prompt(U(1))], U(1))) + .toBe(`range does not start with the declared turn prompt; first discarded entry 0 [type=assistant, uuid=${U(2)}]`) + }) +}) + describe('the real sidecar over the simulator (FRESHELL_CLAUDE_SDK_QUERY_MODULE)', () => { const children: ChildProcess[] = [] afterEach(() => { From b8a2aaad5d920c1863db2e4b0de2b65b59973a05 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:24:00 -0700 Subject: [PATCH 09/24] fix(freshclaude): rollback steps follow the turns the pane renders (N7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One predicate, `is_rollback_anchor`, now decides where an undo step starts (core design §4.5). It replaces `is_user_anchor` and shares its pieces with the transcript projector (`is_synthetic_line`, the local-command output parser, the task-notification check), so a step starts exactly where the pane renders a turn the user started: a prompt or a slash-command line. On real CLI 2.1.237 lines, the old predicate also started a step at the `Compacted` line after `/compact` and at a background agent's task notification. Undoing "to" the compaction output undid only that output, and undoing after a background agent reported back undid only the notification turn. Now the `/compact` step goes whole and the notification turn belongs to the step that launched the agent. A step that starts with a slash command refills the composer with the command as typed (`/compact keep it short`), not its XML. The `resumeDropsTurn` guard follows the CLI's real validator (pinned by the `absorbed-undo` contract scenario): it declares exactly one turn, so `drops_turn_guard` arms it only when the fork discards exactly one prompted turn, never a command step (the CLI does not attribute the command's output line to it) and never several turns. It replaces `raw_chain_successor` and `ResumePoint.guard_uuid`; undo and redo both use it. Red: the four new real-fixture tests and the guard unit test failed under the old predicate (mutation check). Green: `cargo test -p freshell-freshagent --lib claude` 429/429; `freshagent_claude_rollback` 7/7. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 16 +- .../src/claude_snapshot.rs | 226 +++++++++++------- .../src/claude_snapshot_real_cli_tests.rs | 161 +++++++++++++ 3 files changed, 314 insertions(+), 89 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 88fbfaf71..6d5ad941f 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -5726,11 +5726,9 @@ impl FreshClaudeState { } }; let tip = snap::raw_chain_tip(&root_text); - // The resumeDropsTurn guard uuid = the RAW chain entry at the - // removed step's first position (task 4 review nit 4 — SDK-exact - // + cheaper than the display projection; brief/SDK semantics: - // the guard declares the discarded turn's prompt). - let guard = point.guard_uuid.clone(); + // The resumeDropsTurn guard: the one prompted turn the fork + // discards, when it discards exactly one (decision 2). + let guard = snap::drops_turn_guard(&text, point.resume_at_uuid.as_deref()); // can_redo after the undo: the removed content provably exists in // the original beyond the resume point (on the r2 first-turn leg // the fresh conversation's tip is NONE — strictly beyond holds @@ -5865,10 +5863,10 @@ impl FreshClaudeState { let restored = snap::restored_slice_turns(&original_text, ¤t_text, &resume_at); let can_redo_after_redo = original_tip.as_deref() != Some(resume_at.as_str()); - // The guard = the first ORIGINAL chain entry strictly AFTER the - // resume point (`None` — redoing TO the tip — omits it: the - // discard range is vacuous, never fabricated). - let guard = snap::raw_chain_successor(&original_text, &resume_at); + // The guard: the one prompted turn of the original the redo + // still leaves out, when exactly one (`None` redoing TO the + // tip: nothing is discarded). + let guard = snap::drops_turn_guard(&original_text, Some(&resume_at)); ( original, original_path, diff --git a/crates/freshell-freshagent/src/claude_snapshot.rs b/crates/freshell-freshagent/src/claude_snapshot.rs index b901dbd49..71d761ec1 100644 --- a/crates/freshell-freshagent/src/claude_snapshot.rs +++ b/crates/freshell-freshagent/src/claude_snapshot.rs @@ -744,15 +744,7 @@ pub(crate) fn parse_transcript_turn_indexed( // Real transcripts flag synthetic/subagent lines (ledger A5): skip them. // The compact summary (`isCompactSummary`) is the model-facing recap of the // compacted history; the visible marker comes from `system/compact_boundary`. - if [ - "isMeta", - "isSidechain", - "isCompactSummary", - "isVisibleInTranscriptOnly", - ] - .iter() - .any(|k| obj.get(*k).and_then(Value::as_bool) == Some(true)) - { + if is_synthetic_line(obj) { return None; } let line_uuid = obj @@ -891,6 +883,20 @@ pub(crate) fn parse_transcript_turn_indexed( Some(turn_json(&turn_id, ordinal, role, items, None, obj)) } +/// A line the CLI writes for itself or for a subagent, never shown as a turn +/// and never a rollback step: meta lines (the local-command caveat), sidechain +/// lines, the compaction summary, transcript-only lines. +fn is_synthetic_line(obj: &Value) -> bool { + [ + "isMeta", + "isSidechain", + "isCompactSummary", + "isVisibleInTranscriptOnly", + ] + .iter() + .any(|k| obj.get(*k).and_then(Value::as_bool) == Some(true)) +} + /// Display text for an image attachment or an image tool result. const IMAGE_PLACEHOLDER: &str = "[image]"; @@ -1346,48 +1352,68 @@ pub(crate) fn raw_lcp_end(current: &str, original: &str) -> Option { end } -/// The first raw-chain entry strictly AFTER `uuid` — the source of the -/// `resumeDropsTurn` guard (the uuid of the first message the resume discards). -/// `None` when `uuid` is the tip or absent: the guard is then OMITTED (a -/// vacuous discard — never fabricated). -pub(crate) fn raw_chain_successor(transcript: &str, uuid: &str) -> Option { +/// The `resumeDropsTurn` guard for a fork of `transcript` that keeps its raw +/// chain through `keep` (core design §4.2, decision 2): the uuid of the one +/// prompted turn the fork discards, so the CLI refuses the fork +/// (`FORK_GUARD_REFUSED`) when that turn absorbed a send or a task +/// notification the pane never showed as its own turn. `None` (no guard, the +/// SDK's unvalidated truncation) when the fork discards nothing, starts its +/// discard mid-turn, discards more than one turn (the CLI's guard declares +/// exactly one: a second prompt in the range always refuses), discards a +/// slash command's step (the CLI does not attribute the command's output line +/// to it), or boots fresh (`keep` is `None`). +pub(crate) fn drops_turn_guard(transcript: &str, keep: Option<&str>) -> Option { + let keep = keep?; let chain = raw_chain(transcript); - let pos = chain.iter().position(|l| l.uuid == uuid)?; - chain.get(pos + 1).map(|l| l.uuid.clone()) -} - -/// A user-ANCHOR line: a real user prompt (type `user`, not meta/sidechain, -/// text-bearing content). A tool_result carrier is NEVER an anchor — steps -/// split at prompts only. -fn is_user_anchor(obj: &Value) -> bool { - if obj.get("type").and_then(Value::as_str) != Some("user") { - return false; - } - if [ - "isMeta", - "isSidechain", - "isCompactSummary", - "isVisibleInTranscriptOnly", - ] - .iter() - .any(|k| obj.get(*k).and_then(Value::as_bool) == Some(true)) + let kept = chain.iter().position(|line| line.uuid == keep)?; + let (first, rest) = chain.get(kept + 1..)?.split_first()?; + (is_prompt_anchor(&first.obj) && !rest.iter().any(|line| is_rollback_anchor(&line.obj))) + .then(|| first.uuid.clone()) +} + +/// A rollback ANCHOR (core design §4.5, transcript plan N7): a line that +/// starts a turn the pane renders as the user's. The one predicate every +/// rollback step and the transcript projector share: a real prompt anchors, +/// and so does a slash-command invocation (`/x`, +/// rendered as what the user typed). The CLI's own lines never do: the +/// local-command caveat and other meta lines, sidechain lines, the compaction +/// summary ([`is_synthetic_line`], which the projector skips), a command's +/// `` output (projected as a system row), a task +/// notification (folded into its delegation), and a tool-result carrier. +pub(crate) fn is_rollback_anchor(obj: &Value) -> bool { + if obj.get("type").and_then(Value::as_str) != Some("user") + || is_synthetic_line(obj) + || is_task_notification_line(obj) { return false; } - match obj.get("message") { - Some(Value::String(text)) => !text.trim().is_empty(), + let blocks: Vec = match obj.get("message") { + Some(Value::String(text)) => vec![json!({ "type": "text", "text": text })], Some(Value::Object(m)) => match m.get("content") { - Some(Value::String(text)) => !text.trim().is_empty(), - Some(Value::Array(blocks)) => blocks.iter().any(|b| { - b.get("type").and_then(Value::as_str) == Some("text") - && b.get("text") - .and_then(Value::as_str) - .is_some_and(|t| !t.trim().is_empty()) - }), - _ => false, + Some(Value::String(text)) => vec![json!({ "type": "text", "text": text })], + Some(Value::Array(blocks)) => blocks.clone(), + _ => return false, }, - _ => false, + _ => return false, + }; + if local_command_user_turn(&blocks, "", 0, obj).is_some() { + return false; } + blocks + .iter() + .any(|b| match b.get("type").and_then(Value::as_str) { + Some("text") => b + .get("text") + .and_then(Value::as_str) + .is_some_and(|t| !t.trim().is_empty()), + Some("image") => true, + _ => false, + }) +} + +/// A rollback anchor that is a prompt to the model, not a slash command. +fn is_prompt_anchor(obj: &Value) -> bool { + is_rollback_anchor(obj) && slash_command_text(&user_prompt_text(obj)).is_none() } /// The anchor line's plain prompt text (the composer-refill payload): a plain @@ -1409,12 +1435,19 @@ fn user_prompt_text(obj: &Value) -> String { } } -/// Chain positions of every user-anchor line, in chain order (the step starts). -fn user_anchor_positions(chain: &[ChainLine]) -> Vec { +/// What the user typed to start the anchor's step (the composer refill): the +/// prompt text, or the slash command as typed (`/compact keep it short`). +fn anchor_refill_text(obj: &Value) -> String { + let text = user_prompt_text(obj); + slash_command_text(&text).unwrap_or(text) +} + +/// Chain positions of every rollback anchor, in chain order (the step starts). +fn rollback_anchor_positions(chain: &[ChainLine]) -> Vec { chain .iter() .enumerate() - .filter_map(|(i, l)| is_user_anchor(&l.obj).then_some(i)) + .filter_map(|(i, l)| is_rollback_anchor(&l.obj).then_some(i)) .collect() } @@ -1437,16 +1470,13 @@ pub(crate) enum ResumeTarget { /// One resolved rollback: where the fork keeps (`resume_at_uuid`; `None` = /// "before the first message" — LEGAL per r2: the handler takes the /// fresh-conversation leg, never a refusal), the removed display-turn slice -/// (the marker bucket + ack payload), and the composer-refill prompt. -/// `guard_uuid` is the `resumeDropsTurn` guard sourced from the RAW chain entry -/// at the first removed position (SDK-exact + cheaper than the display -/// projection — task 4 review nit 4); `None` on the first-turn leg (no fork → -/// no guard is ever armed). +/// (the marker bucket + ack payload), and the composer-refill prompt. The +/// fork's `resumeDropsTurn` guard comes from [`drops_turn_guard`] over the +/// transcript the fork actually reads. pub(crate) struct ResumePoint { pub resume_at_uuid: Option, pub removed_turns: Vec, pub prompt_text: String, - pub guard_uuid: Option, } #[derive(Debug)] @@ -1474,7 +1504,7 @@ pub(crate) fn resolve_resume_point( if chain.is_empty() { return Err(ResumeResolveError::Empty); } - let anchors = user_anchor_positions(&chain); + let anchors = rollback_anchor_positions(&chain); if anchors.is_empty() { return Err(ResumeResolveError::Empty); } @@ -1516,20 +1546,11 @@ pub(crate) fn resolve_resume_point( .is_some_and(|id| removed_uuids.contains(id)) }) .collect(); - let prompt_text = user_prompt_text(&chain[first_remove_pos].obj); - // The resumeDropsTurn guard = the RAW chain entry at the first removed - // position (task 4 review nit 4: SDK-exact + cheaper than re-deriving it - // from the first removed DISPLAY turn — the projection filters carriers). - // Omitted on the first-turn leg (`resume_at_uuid: None` = the fresh - // conversation — no fork, so no guard is ever armed). - let guard_uuid = resume_at_uuid - .as_ref() - .map(|_| chain[first_remove_pos].uuid.clone()); + let prompt_text = anchor_refill_text(&chain[first_remove_pos].obj); Ok(ResumePoint { resume_at_uuid, removed_turns, prompt_text, - guard_uuid, }) } @@ -1575,11 +1596,11 @@ pub(crate) fn restored_slice_turns( .is_some_and(|id| slice_uuids.contains(id)) }) .collect(); - let anchors = user_anchor_positions(&orig); + let anchors = rollback_anchor_positions(&orig); let prompt_text = anchors .iter() .find(|&&a| a >= start_pos && a <= end_pos) - .map(|&a| user_prompt_text(&orig[a].obj)) + .map(|&a| anchor_refill_text(&orig[a].obj)) .unwrap_or_default(); RestoredSlice { turns, prompt_text } } @@ -1601,7 +1622,7 @@ pub(crate) fn redo_resume_target( use crate::rollback_record::RollbackModeReq; let orig = raw_chain(original); let cur = raw_chain(current); - let anchors = user_anchor_positions(&orig); + let anchors = rollback_anchor_positions(&orig); match op.mode { RollbackModeReq::Step => match cur.last() { // r2 first-turn case: the current chain is EMPTY; restore the first @@ -3098,6 +3119,64 @@ pub(crate) mod tests { assert_eq!(to_anchor.prompt_text, "prompt two"); } + /// The guard declares the one prompted turn a fork discards (the SDK's + /// `resumeDropsTurn`), and nothing when the fork discards nothing or + /// more than one turn, or when it boots fresh. + #[test] + fn the_drops_turn_guard_declares_the_one_prompted_turn_a_fork_discards() { + let transcript = uuid_transcript_with_carriers(); + assert_eq!( + drops_turn_guard(&transcript, Some("tr1")).as_deref(), + Some("u2") + ); + assert_eq!( + drops_turn_guard(&transcript, Some("a1")), + None, + "the discarded part starts with the kept turn's tool result" + ); + assert_eq!( + drops_turn_guard(&transcript, Some("c1")), + None, + "the discarded part starts mid-turn" + ); + assert_eq!( + drops_turn_guard(&transcript, Some("a2")), + None, + "nothing is discarded" + ); + assert_eq!( + drops_turn_guard(&transcript, None), + None, + "a fresh boot forks nothing" + ); + assert_eq!( + drops_turn_guard(&transcript, Some("gone")), + None, + "an unknown keep point" + ); + let three_turns = [ + json!({"type":"user","uuid":"u1","parentUuid":null,"message":{"role":"user","content":"one"}}), + json!({"type":"assistant","uuid":"a1","parentUuid":"u1","message":{"role":"assistant","content":[{"type":"text","text":"1"}]}}), + json!({"type":"user","uuid":"u2","parentUuid":"a1","message":{"role":"user","content":"two"}}), + json!({"type":"assistant","uuid":"a2","parentUuid":"u2","message":{"role":"assistant","content":[{"type":"text","text":"2"}]}}), + json!({"type":"user","uuid":"u3","parentUuid":"a2","message":{"role":"user","content":"three"}}), + json!({"type":"assistant","uuid":"a3","parentUuid":"u3","message":{"role":"assistant","content":[{"type":"text","text":"3"}]}}), + ] + .iter() + .map(Value::to_string) + .collect::>() + .join("\n"); + assert_eq!( + drops_turn_guard(&three_turns, Some("a2")).as_deref(), + Some("u3") + ); + assert_eq!( + drops_turn_guard(&three_turns, Some("a1")), + None, + "two prompted turns: one guard declares one turn" + ); + } + #[test] fn resolve_resume_point_unknown_target_is_not_found() { assert!(resolve_resume_point( @@ -3266,19 +3345,6 @@ pub(crate) mod tests { assert_eq!(slice.prompt_text, "prompt one"); } - #[test] - fn raw_chain_successor_names_the_first_entry_after_a_keep_point() { - // The resumeDropsTurn guard uuid source: the first chain entry AFTER the - // keep point (the first-to-discard prompt), None when the keep point IS - // the tip (vacuous discard — the guard is omitted, never fabricated). - assert_eq!( - raw_chain_successor(&two_step_original(), "a1").as_deref(), - Some("u2") - ); - assert_eq!(raw_chain_successor(&two_step_original(), "a2"), None); - assert_eq!(raw_chain_successor(&two_step_original(), "gone"), None); - } - // ── kata 1wxv Task 5: snapshot rollback surfacing (claude/kilroy) ───────── // // Stamps are STATIC `{undo:true, redo:true}` (runtime failure classifies at diff --git a/crates/freshell-freshagent/src/claude_snapshot_real_cli_tests.rs b/crates/freshell-freshagent/src/claude_snapshot_real_cli_tests.rs index 9298f3cbc..e40cc98e1 100644 --- a/crates/freshell-freshagent/src/claude_snapshot_real_cli_tests.rs +++ b/crates/freshell-freshagent/src/claude_snapshot_real_cli_tests.rs @@ -482,3 +482,164 @@ fn native_history_shows_compaction_command_output_errors_and_image_placeholders( .contains("iVBORw0KGgo")); } } + +/// The one chain line (it has a uuid) whose message text starts with `prefix`. +fn line_starting(transcript: &str, prefix: &str) -> Value { + let matches: Vec = fixture_lines(transcript) + .into_iter() + .filter(|line| line["uuid"].is_string() && message_text(line).starts_with(prefix)) + .collect(); + assert_eq!(matches.len(), 1, "one line starting {prefix:?}"); + matches.into_iter().next().unwrap() +} + +/// N7 (core design §4.5): a rollback step starts where the pane shows a turn +/// the user started. On real CLI 2.1.237 lines: a prompt and a slash-command +/// invocation (`/compact`, `/cost` written as `/usage`, `/context`) anchor a +/// step; the CLI's caveat (meta), the command's own `` +/// line, the compaction summary, a task notification and a tool-result +/// carrier never do. +#[test] +fn the_rollback_anchor_is_a_prompt_or_a_slash_command_on_real_cli_lines() { + let compact = real_cli_fixture!("compact-ok"); + for prefix in [ + "hi there", + "after compact", + "/compact", + "/usage", + "/context", + ] { + assert!( + is_rollback_anchor(&line_starting(compact, prefix)), + "{prefix:?} anchors a step" + ); + } + let caveat = fixture_lines(compact) + .into_iter() + .find(|line| line["isMeta"] == json!(true)) + .expect("a caveat line"); + assert!(!is_rollback_anchor(&caveat), "the caveat (meta)"); + assert!(!is_rollback_anchor(&line_starting( + compact, + "Compacted" + ))); + let summary = fixture_lines(compact) + .into_iter() + .find(|line| line["isCompactSummary"] == json!(true)) + .expect("the compaction summary"); + assert!(!is_rollback_anchor(&summary), "the compaction summary"); + let cost_output = fixture_lines(compact) + .into_iter() + .find(|line| line["subtype"] == json!("local_command")) + .expect("a local command's output"); + assert!( + !is_rollback_anchor(&cost_output), + "a local command's output" + ); + + let agent = real_cli_fixture!("agent-async"); + assert!(is_rollback_anchor(&line_starting( + agent, + "GO delegate in the background" + ))); + let notification = fixture_lines(agent) + .into_iter() + .find(|line| line["origin"]["kind"] == json!("task-notification")) + .expect("the task notification"); + assert!(!is_rollback_anchor(¬ification), "a task notification"); + let carrier = fixture_lines(agent) + .into_iter() + .find(|line| line["type"] == json!("user") && message_text(line).is_empty()) + .expect("a tool-result carrier"); + assert!(!is_rollback_anchor(&carrier), "a tool-result carrier"); +} + +/// Undo steps match the turns the pane renders: "undo to" the `/compact` +/// command's output row undoes the `/compact` step whole (command plus +/// output), and the composer gets back what the user typed. +#[test] +fn undoing_a_slash_command_step_takes_its_output_with_it_on_real_cli_lines() { + let transcript = real_cli_fixture!("compact-ok"); + let command = uuid_of(transcript, |line| { + message_text(line).starts_with("/compact") + }); + let output = uuid_of(transcript, |line| { + message_text(line).starts_with("Compacted") + }); + let point = resolve_resume_point(transcript, "s", ResumeTarget::ToTurn(output.clone())) + .expect("resolves"); + let removed: Vec<&str> = point + .removed_turns + .iter() + .filter_map(|turn| turn["turnId"].as_str()) + .collect(); + assert_eq!( + removed.first().copied(), + Some(command.as_str()), + "{removed:?}" + ); + assert!(removed.contains(&output.as_str()), "{removed:?}"); + assert_eq!(point.prompt_text, "/compact keep it short"); +} + +/// A task notification starts no step of its own: undoing the last step of a +/// session whose background agent reported back undoes the prompt that +/// launched it, notification turn included. +#[test] +fn a_task_notification_turn_belongs_to_the_step_before_it_on_real_cli_lines() { + let transcript = real_cli_fixture!("agent-async"); + let point = resolve_resume_point(transcript, "s", ResumeTarget::Step).expect("resolves"); + assert_eq!( + point.resume_at_uuid, None, + "the only step is the first prompt" + ); + assert_eq!(point.prompt_text, "GO delegate in the background"); + let reply = uuid_of(transcript, |line| { + message_text(line) == "The background check finished." + }); + assert!(point + .removed_turns + .iter() + .any(|turn| turn["turnId"] == json!(reply))); +} + +/// The `resumeDropsTurn` guard declares ONE turn (the CLI refuses a range +/// holding a second prompt): it is armed only when the discarded part of the +/// fork's parent is exactly one prompted turn, and never for a slash +/// command's step, whose output line the CLI does not attribute to it. +#[test] +fn the_drops_turn_guard_is_armed_only_for_one_prompted_turn() { + let transcript = real_cli_fixture!("compact-ok"); + let after_compact = uuid_of(transcript, |line| message_text(line) == "after compact"); + let reply_four = uuid_of(transcript, |line| message_text(line) == "reply 4"); + let before_after_compact = fixture_lines(transcript) + .into_iter() + .find(|line| line["uuid"] == json!(after_compact)) + .and_then(|line| line["parentUuid"].as_str().map(str::to_string)) + .expect("a parent"); + // Keeping everything before "after compact" discards it AND the two + // commands after it: more than one turn, so no guard. + assert_eq!( + drops_turn_guard(transcript, Some(&before_after_compact)), + None + ); + // Keeping through reply 4 discards only the `/usage` and `/context` + // command steps: commands, so no guard. + assert_eq!(drops_turn_guard(transcript, Some(&reply_four)), None); + // Undoing the failed `/compact` at the tip of compact-fail discards one + // command step: commands are never guarded. + let failed = real_cli_fixture!("compact-fail"); + let point = resolve_resume_point(failed, "s", ResumeTarget::Step).expect("resolves"); + assert_eq!(point.prompt_text, "/compact"); + assert_eq!( + drops_turn_guard(failed, point.resume_at_uuid.as_deref()), + None + ); + // Undoing "third msg" there discards it and the `/compact`: two turns. + let third = uuid_of(failed, |line| message_text(line) == "third msg"); + let point = resolve_resume_point(failed, "s", ResumeTarget::ToTurn(third)).expect("resolves"); + assert_eq!( + drops_turn_guard(failed, point.resume_at_uuid.as_deref()), + None + ); +} From 0e6cfec2740d7b74cb8778d6ac8092f0bbf284a5 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 00:33:43 -0700 Subject: [PATCH 10/24] test(claude): pin the drops-turn guard on a background agent's notification; the simulator writes it The second CLI fact decision 2 rests on, probed and pinned like the first: a background agent's completion is written into the turn that launched it as a user line of origin `task-notification` (no prompt of its own, no SDK message), so a guarded fork that undoes that turn is refused at boot: "range contains an externally-sourced user entry". New contract scenario `notification-undo` and fact (T19 T5). The simulator wrote no notification line at all for a CLI-initiated turn (red in the conformance comparison: the transcript shape differed and the guarded fork booted). Its CLI turn now writes the notification line (with the task's id, tool-use id, status, summary and result) and the context attachment before the reply, as the pinned CLI does. Green: contract subset (notification, absorbed, T19, cli-turn conformance) 7/7; claude-cli-sim + sidecar unit tests 158/158. Co-Authored-By: Claude Opus 5.5 --- test/fixtures/claude-cli-sim/query.mjs | 26 +++++++++++++--- .../providers/claude-cli-contract.test.ts | 12 +++++++ .../providers/claude-cli-scenarios.mjs | 31 +++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/test/fixtures/claude-cli-sim/query.mjs b/test/fixtures/claude-cli-sim/query.mjs index 7a036d892..46230c265 100644 --- a/test/fixtures/claude-cli-sim/query.mjs +++ b/test/fixtures/claude-cli-sim/query.mjs @@ -116,6 +116,19 @@ function markerSteps(text) { } } +/** The `` a finished background task leaves in the transcript. */ +function taskNotificationXml(task) { + return [ + '', + `${task.task_id}`, + ...(task.toolUseId ? [`${task.toolUseId}`] : []), + 'completed', + `Agent "${task.description ?? 'background task'}" finished`, + `${SUBAGENT_TEXT}`, + '', + ].join('\n') +} + class AbortError extends Error { constructor(message) { super(message) @@ -533,8 +546,9 @@ class SimulatedCli { this.background(this.runCommand(cmd)) return } - if (this.cliTurns.shift()) { - this.background(this.runCliTurn()) + const task = this.cliTurns.shift() + if (task) { + this.background(this.runCliTurn(task)) return } if (this.busy) { @@ -575,10 +589,14 @@ class SimulatedCli { } /** A turn the CLI starts by itself: a background task finished (T19). */ - async runCliTurn() { + async runCliTurn(task) { const turn = new Turn(null) this.turn = turn await this.sleep(T.queuedToStarted) + // The CLI writes the completion into the transcript as a user line of + // origin task-notification: no prompt of its own, and no SDK message. + this.transcript.append({ type: 'user', message: { role: 'user', content: taskNotificationXml(task) }, origin: { kind: 'task-notification' } }) + this.transcript.append({ type: 'attachment', attachment: { type: 'total_tokens_reminder', text: '15000000 tokens left' } }) this.emit(this.systemInit()) await this.sleep(T.initToStatus) this.emit({ type: 'system', subtype: 'status', status: 'requesting' }) @@ -719,7 +737,7 @@ class SimulatedCli { this.emit({ type: 'system', subtype: 'background_tasks_changed', tasks: [] }) this.emit({ type: 'system', subtype: 'task_updated', task_id: task.task_id, patch: { status: 'completed', end_time: Date.now() } }) this.emit({ type: 'system', subtype: 'task_notification', task_id: task.task_id, tool_use_id: toolUseId ?? null, status: 'completed', output_file: '', summary: SUBAGENT_TEXT, usage: { total_tokens: 1, tool_uses: 0, duration_ms: T.subagent } }) - this.cliTurns.push(task) + this.cliTurns.push({ ...task, toolUseId }) if (!this.turn && !this.finishing) { if (!this.busy) { this.busy = true diff --git a/test/integration/providers/claude-cli-contract.test.ts b/test/integration/providers/claude-cli-contract.test.ts index 1616c4eb7..9f04bc764 100644 --- a/test/integration/providers/claude-cli-contract.test.ts +++ b/test/integration/providers/claude-cli-contract.test.ts @@ -285,6 +285,18 @@ describe(`pinned Claude CLI ${PINNED_CLI_VERSION} contract (real CLI, loopback p expect(afterGuard.files.map((f: string) => path.basename(f))).toEqual(['P.jsonl']) }, SCENARIO_TIMEOUT) + it('T19 T5: a background agent\'s completion is a task-notification line inside the launching turn, so a guarded fork that undoes that turn is refused', async () => { + const r = await real('notification-undo') + const { parent } = r.observations + expect(parent.notifications).toEqual([{ type: 'user', origin: 'task-notification', xml: true }]) + const shape = parent.shape as Array<{ type: string, uuid: string }> + expect(shape.filter((line) => line.type === 'user' && line.uuid.startsWith('u')).map((line) => line.uuid)).toEqual(['u1', 'u2']) + const guarded = messagesOf(r, 'guarded') + expect(guarded).toHaveLength(1) + expect(guarded[0]).toMatchObject({ type: 'result', subtype: 'error_during_execution', num_turns: 0 }) + expect(guarded[0].errors[0]).toMatch(/^Resume rejected by --resume-drops-turn: resuming at keepAt would discard entries not attributable to turn u2: range contains an externally-sourced user entry; entry \d+ \[type=user, uuid=cli\]$/) + }, SCENARIO_TIMEOUT) + it('T11: a warm interrupt ends the turn with error_during_execution, aborted_streaming or aborted_tools, diagnostic errors and a cancelled lifecycle; the receipt comes first', async () => { const r = await real('interrupts') const events = eventsOf(r, 'S') diff --git a/test/integration/providers/claude-cli-scenarios.mjs b/test/integration/providers/claude-cli-scenarios.mjs index d52d0b445..2490c9cac 100644 --- a/test/integration/providers/claude-cli-scenarios.mjs +++ b/test/integration/providers/claude-cli-scenarios.mjs @@ -488,6 +488,37 @@ const SCENARIOS = { ctx.observe('afterGuard', { files: await ctx.files() }) }, + /** + * T19 T5: a background agent's completion is written into the turn that + * launched it as a user line of origin `task-notification` (no prompt of its + * own), so a guarded fork that undoes the launching turn is refused: the + * discarded range holds an externally-sourced entry. + */ + async 'notification-undo'(ctx) { + const P = ctx.uuid('P') + const s = ctx.open('S', { options: { sessionId: P } }) + s.push('turn one', 'u1') + await s.waitForResults(1) + const u2 = s.push('AGENT:async', 'u2') + await s.waitForResults(3, 30_000) + await s.waitForCount(isStateIdle, 2, 10_000, 'idle') + ctx.need(await ctx.pollTranscript(P, 4000, 2), 'parent transcript') + await s.close() + await sleep(300) + const lines = await ctx.fixture.readTranscript(P) + const u2Index = lines.findIndex((line) => line.uuid === u2) + const keepAt = ctx.need(lines.slice(0, u2Index).reverse().find((line) => typeof line.uuid === 'string')?.uuid, 'fork point') + ctx.label(keepAt, 'keepAt') + ctx.observe('parent', { + shape: transcriptShape(lines), + notifications: lines.filter((line) => line.origin?.kind === 'task-notification').map((line) => ({ type: line.type, origin: line.origin.kind, xml: typeof line.message?.content === 'string' && line.message.content.startsWith('') })), + }) + const guarded = ctx.open('guarded', { options: { resume: P, resumeSessionAt: keepAt, forkSession: true, sessionId: ctx.uuid('F'), resumeDropsTurn: u2 } }) + await guarded.init() + await Promise.race([guarded.done, sleep(8000)]) + await guarded.close() + }, + /** T8 T9 T10: a send absorbed during a tool call, then a send queued during streamed text. */ async turns(ctx) { const s = ctx.open('S') From 710af4979367b52d41f61c3563d9e5509ea038d0 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:30:17 -0700 Subject: [PATCH 11/24] fix(freshclaude): undo/redo through a boot-verified fork runtime (G01) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rollback killed the live sidecar, respawned a fork with no prompt and then waited up to 45 s for a `system/init` the real CLI only sends with a prompt (T1), so every undo and redo failed against the pinned CLI and left the pane with no runtime. Now (core design §4.5, §4.6, C13): - The rollback mints the replacement's durable id C and boots it through `establish_runtime` while the original A is still live: a lazy fork of the session's EFFECTIVE transcript (`Fork{source, at, dropsTurn}`), or a fresh conversation for a first-turn undo. The sidecar answers `created` once the CLI booted it (T2/T6), so no init is ever awaited. C registers already adopted (map key = envelope stamp = durable id = C) and takes over A's turn lock, busy truth and attention clock. - Source collapse: undoing a pending fork forks its parent at the earlier point (chains never nest), so two undos with no send in between work. - One awaited ledger batch writes C's binding (genesis, A's settings and plugins, `supersedes: A`) with the rollback record re-keyed to C. Then ownership moves A→C in one coordinator step (`rekey_live` now takes the original's map key as the expected owner), every id that named A (its map key, its durable id, their aliases, its naming handle) resolves to C, and A's sidecar is stopped through the graceful teardown path (#859). - Decision 2: a fork the CLI refuses because the undone turn absorbed another message (the drops-turn guard) is ROLLBACK_REFUSED with "Claude folded another message into that turn, so it can't be undone to that point."; a fork point the CLI no longer has is ROLLBACK_REFUSED too. Never retried without the guard. Any failure before the move compensates the record and tears C down; A is untouched and live. - A send that waited on the turn lock while the runtime was replaced goes to C through the durable supersession chain (`PaneIdentitySink:: chain_terminus`, the ledger's `lookup_by_session`), never to the stopped A and never to a revived A. A restart before the next send boots C again from its genesis (C12). - Deleted: `read_session_init`, `rollback_spawn_create`, the init pre-read, `RollbackAdoption` and the consumer's preseeded adoption (the completion variant of the adoption is test-only now). The handler moves to `claude/rollback.rs`. - Client: a claude `materialized` re-key to a durable id makes that id the session's durable id; the replaced session's `cliSessionId` no longer survives the merge and points the pane's sessionRef back at A (C reports no init before its first prompt). - New JSONL events: freshagent.claude.rollback_boot (info), rollback_refused (warn), rollback_binding_write_failed (error), rollback_commit_stale (invariant), rollback_completed (info), send_followed_replacement (info); runtime_established/not_established carry lane "rollback". Tests (red first: on the old code every RealSidecarSim undo was unanswered within 20 s because no init ever arrived): - freshagent_claude_rollback.rs rewritten onto RealSidecarSim with a real ledger and the coordinator (13): undo answers in time with A→C materialized and the guarded fork under C; the first send writes C; redo; two undos without a send (source collapse); first-turn undo boots fresh and redo restores; decision 2 refusal with A live and taking a send; a fork that cannot boot leaves A live; a parked send lands on C through the ledger chain; a restart keeps the pending fork's prefix; the fork's log binds to C; the three G28 unreadable-store cases. - Real-CLI lane (both rigs): undo, send, redo refused after the send, undo and redo, two undos without a send, send. - Launch parity: the rollback now completes (it awaited only the fork's launch before). - Server sink: the supersession chain leads to the live replacement, across a restart. Client slice: the claude re-key adopts the durable id. - In-crate fake: a boot under a minted `sessionId` reports init at its first send (T1); the rollback helper resolves the replacement. - e2e fresh-agent-rollback-rust.spec.ts: the claude and kilroy lanes run the production sidecar over the simulator; new lane: two undos, a server restart before sending, the prefix is kept and the next prompt runs. Deviations: a redo all the way to the root's tip still forks the root (at its tip) instead of resuming it (design: `Resume{root}`), so the chain never cycles. `name_transition` is not declared on the replacement's binding (the server sink ignores it; the pending naming handle moves with the record). The decision-2 lane uses a background agent's notification: a send absorbed during a tool call is refused the same way by the CLI (pinned by the contract), but until C17 such a session still reads busy (G08), so its undo answers BUSY_TURN first. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 1701 ++------------ .../src/claude/establish.rs | 67 +- .../src/claude/rollback.rs | 1474 ++++++++++++ .../src/claude_test_holds.rs | 57 +- .../freshell-freshagent/src/identity_sink.rs | 29 + crates/freshell-server/src/identity_sink.rs | 58 + crates/freshell-ws/tests/common/claude_rig.rs | 56 +- .../freshell-ws/tests/common/ledger_sink.rs | 8 + .../tests/freshagent_claude_launch_parity.rs | 11 +- .../tests/freshagent_claude_real_cli.rs | 182 ++ .../tests/freshagent_claude_rollback.rs | 2077 ++++++----------- src/store/freshAgentSlice.ts | 10 + .../specs/fresh-agent-rollback-rust.spec.ts | 278 ++- .../unit/client/store/freshAgentSlice.test.ts | 46 + 14 files changed, 3021 insertions(+), 3033 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude/rollback.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 6d5ad941f..aeb4abadc 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -559,22 +559,6 @@ struct PendingQuestionEntry { questions: Value, } -/// kata 1wxv Task 4: the rollback-fork adoption wiring handed to the stdout -/// consumer. The handler PREREAD the respawned sidecar's `sdk.session.init` -/// line (the `--resume-drops-turn` refusal watch lives in that pre-read, before -/// any durable state moves); the consumer runs the adoption for it EXACTLY like -/// an in-stream init (cli_index insert + AWAITED binding carrying -/// `supersedes` = the pre-rollback durable id + the rollback-row re-key old→new -/// inside the SAME awaited batch), then resolves `adopted_tx` so the parked -/// rollback handler proceeds with the new durable id. -struct RollbackAdoption { - /// The pre-rollback durable id this create supersedes. - supersedes: String, - /// The preread `sdk.session.init` line (consumed by the handler's pre-read). - preseeded_init: Value, - adopted_tx: tokio::sync::oneshot::Sender>, -} - /// One live freshclaude session: the Node sidecar it drives + its stdout consumer. struct ClaudeSession { configuration: ClaudeConfiguration, @@ -866,10 +850,19 @@ impl FreshClaudeState { /// end-of-output path after it recorded the dead session, before it reaps the /// sidecar and reports the exit, until the returned hold is released. #[cfg(any(test, feature = "test-support"))] - pub fn hold_exit_reports(&self) -> Arc { + pub fn hold_exit_reports(&self) -> Arc { self.test_holds.hold_exit_report() } + /// Test seam (see [`crate::claude_test_holds`]): park every later undo or + /// redo after it wrote its durable record (holding the session's turn + /// lock), before it boots the replacement runtime, until the returned hold + /// is released. + #[cfg(any(test, feature = "test-support"))] + pub fn hold_rollback_boot(&self) -> Arc { + self.test_holds.hold_rollback_boot() + } + /// Spawn one claude sidecar process (create, attach-resume and rollback all /// come through here) for a session of `session_type`. async fn spawn_sidecar_process(&self, session_type: &str) -> Result { @@ -1587,7 +1580,8 @@ impl FreshClaudeState { } /// b8ke focused episode-2 round-2 F1: commit the rollback's claim by - /// RE-KEYING — the start's `Starting{op}` record under the OLD durable + /// RE-KEYING (C13: `expected_live_session_key` is the original runtime's + /// map key, `live_session_key` the replacement's) — the start's `Starting{op}` record under the OLD durable /// id moves to the NEW client-visible durable id in ONE atomic registry /// step (`commit_live_rekey`: never both-Live, never both-Vacant), the /// stamp is retained under the NEW id (the lane's kill/exit paths key @@ -1604,6 +1598,7 @@ impl FreshClaudeState { old_durable_id: &str, ticket: &mut Option, new_durable_id: &str, + expected_live_session_key: &str, live_session_key: &str, pid: Option, initiator: &str, @@ -1663,7 +1658,7 @@ impl FreshClaudeState { PROVIDER, old_durable_id, new_durable_id, - live_session_key, + expected_live_session_key, replacement_owner, initiator, // b8ke ext r6 F5: the rekey transition's operation id — @@ -2452,6 +2447,7 @@ impl FreshClaudeState { naming: Some(&msg), partial_runtime_key: resume_sid.clone().or_else(|| fresh_claim_key.clone()), start_pid_slot: &start_pid_slot, + inherit: None, }, &mut lease_guard, &mut own_ticket, @@ -4121,6 +4117,53 @@ impl FreshClaudeState { // ── freshAgent.send (WS) ───────────────────────────────────────────────────────── + /// The live session a send resolved before it waited on `turn_lock`, + /// after the wait (design §4.5 and §4.8): the same map key while it is + /// still registered; else, when an undo or redo replaced that runtime + /// while the send waited, the live end of the durable ledger chain from + /// the session's durable id (`durable`), then the wire id's own + /// resolution. Only a session that took this very lock over counts: a + /// send never lands on a runtime it did not serialize behind. + async fn live_key_after_turn_wait( + &self, + map_key: &str, + wire_id: &str, + durable: Option<&str>, + turn_lock: &Arc>, + ) -> Option { + if self.sessions.lock().await.contains_key(map_key) { + return Some(map_key.to_string()); + } + let terminus = durable.and_then(|durable| { + self.identity_sink() + .and_then(|sink| sink.chain_terminus(PROVIDER, durable)) + }); + let candidates = terminus.iter().map(String::as_str).chain([wire_id]); + for candidate in candidates { + let Some(key) = self.resolve_session_key(candidate).await else { + continue; + }; + let same_lock = self + .sessions + .lock() + .await + .get(&key) + .is_some_and(|session| Arc::ptr_eq(&session.turn_lock, turn_lock)); + if same_lock { + tracing::info!(target: "freshell_freshagent::claude", + event = "freshagent.claude.send_followed_replacement", + session_id = %wire_id, + replaced = %map_key, + replacement = %key, + through_ledger_chain = terminus.as_deref() == Some(candidate), + "a send that waited behind a rollback goes to the replacement runtime" + ); + return Some(key); + } + } + None + } + /// Handle a `freshAgent.send` for claude: push the user turn into the sidecar's SDK /// input stream, then broadcast `freshAgent.send.accepted`. The stdout consumer surfaces /// the unified attention edge (sidecar `sdk.turn.complete` — minted for EVERY result @@ -4159,7 +4202,7 @@ impl FreshClaudeState { // membership. A miss persisting after the rollback exits means the session // is genuinely gone (a provably-rejected fork is torn down) — the honest // SESSION_NOT_FOUND leg. - let (map_key, turn_lock, in_turn, turn_tracker) = loop { + let (map_key, turn_lock, in_turn, turn_tracker, durable_before_wait) = loop { let handles = match self.resolve_session_key(&session_id).await { Some(key) => { let guard = self.sessions.lock().await; @@ -4169,6 +4212,7 @@ impl FreshClaudeState { s.turn_lock.clone(), s.in_turn.clone(), s.turn_tracker.clone(), + s.cli_session_id.clone(), ) }) } @@ -4191,6 +4235,23 @@ impl FreshClaudeState { // resolved before the teardown window serializes identically to one that // parked through it. let _turn = turn_lock.lock().await; + // An undo or redo that held this lock may have replaced the runtime + // the send resolved (C13: the replacement registers under its own id + // and takes this same lock over). The send goes to the replacement, + // found through the durable supersession chain, never to the stopped + // original and never to a revived one. + let Some(map_key) = self + .live_key_after_turn_wait( + &map_key, + &session_id, + durable_before_wait.as_deref(), + &turn_lock, + ) + .await + else { + self.refuse_send_to_missing_session(&request_id, &session_id, msg.session_type); + return; + }; // Arm the coordinator guard only after the send has waited behind any // earlier turn. A lifecycle transition during that wait advances the // generation, so the captured fence is checked against the owner that @@ -5098,81 +5159,6 @@ impl FreshClaudeState { // ── freshAgent.undo / freshAgent.redo (kata 1wxv Task 4; fork-at-point) ──── - /// Decision 6: pending cards inside undone turns are CANCELLED, never silently - /// resolved. Emits the exact `freshAgent.permission.cancelled` / - /// `freshAgent.question.cancelled` frames the fold consumes, one per parked - /// entry, then clears the pending map. Invoked BEFORE the old sidecar is - /// torn down. - async fn emit_pending_cancellations( - &self, - map_key: &str, - session_id: &str, - session_type: &str, - ) { - let pending = { - let guard = self.sessions.lock().await; - guard.get(map_key).map(|s| Arc::clone(&s.pending)) - }; - let Some(pending) = pending else { return }; - let (permissions, questions) = { - let mut p = pending.lock().expect("pending lock"); - ( - std::mem::take(&mut p.permissions), - std::mem::take(&mut p.questions), - ) - }; - for entry in permissions { - self.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { - event: json!({ - "type": "freshAgent.permission.cancelled", - "sessionId": session_id, - "requestId": entry.request_id, - }), - provider: PROVIDER.to_string(), - session_id: session_id.to_string(), - session_type: session_type.to_string(), - })); - } - for entry in questions { - self.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { - event: json!({ - "type": "freshAgent.question.cancelled", - "sessionId": session_id, - "requestId": entry.request_id, - }), - provider: PROVIDER.to_string(), - session_id: session_id.to_string(), - session_type: session_type.to_string(), - })); - } - } - - /// Task-4 compensation: a fork/create failure AFTER the rollback record's - /// successful pre-write UNDOES that pre-write before the refusal is - /// answered — fork-at-point provably never mutates the ORIGINAL's history - /// (the Stage-2 hash-identical invariant), so the ledger must not describe a - /// rollback that never took effect. The provider-mutation triad (codex's - /// Rpc-vs-transport split) has no claude analog: EVERY claude failure leg is - /// provably-unmoved by construction. Task 4 review (Minor-3): when the - /// pre-op record was ABSENT, the faithful restore of "nothing was here" is - /// a DELETE of the just-created row — never a fabricated empty record. - async fn compensate_rollback_record( - &self, - durable_id: &str, - existing: Option, - ) { - let Some(sink) = self.identity_sink() else { - return; - }; - let result = match existing { - Some(restore) => sink.record_rollback(PROVIDER, durable_id, restore).await, - None => sink.delete_rollback(PROVIDER, durable_id).await, - }; - if let Err(e) = result { - tracing::warn!(error = %e, session = %durable_id, "freshagent.claude.rollback_compensate_failed"); - } - } - /// The pane re-key ride (kata 1wxv Task 4): the existing /// `freshAgent.session.materialized` broadcast shape (codex's mint-new respawn /// precedent), old client-facing id → new adopted durable id. @@ -5219,1232 +5205,6 @@ impl FreshClaudeState { self.broadcast(&message); } - /// Handle a `freshAgent.undo` / `freshAgent.redo` for claude/kilroy (kata 1wxv - /// Task 4) — conversation rollback emulated by FORK-AT-POINT through the - /// sidecar: pre-write the durable record, cancel parked cards, kill the old - /// sidecar, and recreate it with the SDK `query()` options lane (`{resume, - /// resumeSessionAt, forkSession: true, resumeDropsTurn: }` — the - /// standalone `forkSession()` fn is FORBIDDEN, it remaps every uuid), then - /// adopt the minted durable id through the existing sdk.session.init machinery - /// (cli_index insert + AWAITED binding WITH supersedes + rollback-row re-key - /// old→new inside the same awaited batch). - /// - /// Redo re-forks at a LATER point from the retained ORIGINAL session (the - /// chain root), gated by the tip+LCP validity contract: the original's raw - /// chain tip must still equal the recorded `original_tip_uuid` AND the current - /// chain must still be a strict prefix of it — else `REDO_UNAVAILABLE` + - /// `REDO_REMOVED_HISTORY_COPY` (compaction/snips legitimately move things). - /// - /// First-turn rollback is LEGAL (r2): `resume_at_uuid: None` means "before the - /// first message" — the handler creates a FRESH conversation (NO resume keys; - /// the empty fresh transcript IS the rollback target) and records the - /// discarded session as the redo source. - /// - /// Ordering (durable-BEFORE-mutation + r2 lock discipline): toTurn validation - /// → rollback_in_flight single-flight (FIRST) → per-session turn lock (held - /// across the WHOLE handler) → busy gate (`in_turn` — the FIFO tracker's - /// DERIVED busy cache — ⇒ BUSY_TURN, the SOLE mid-turn protection; no - /// sidecar traffic at all on a refused attempt, ep2-r2) → - /// create-resume lease claim on the OLD durable id (so a concurrent attach - /// cannot bind the pre-rollback id mid-fork) → transcript reads + resume math - /// → AWAITED record pre-write (a pre-write failure REFUSES with - /// `LEDGER_WRITE_REFUSAL_COPY`, provider never touched) → pending-cancel - /// frames → kill+recreate (one `resumeDropsTurn` guard retry: the refusal - /// prefix maps to the plain-resume recovery, never surfaced raw) → adoption - /// → `materialized` OLD→NEW broadcast + envelope-stamp flip → rolledBack - /// broadcast → requesting-sink ack. A fork/create/adoption failure AFTER the - /// successful pre-write COMPENSATES the record (rewrites the pre-op record) - /// before the refusal is answered — the ledger never describes a rollback - /// the provider provably rejected. A rollback NEVER chimes. - pub async fn handle_rollback( - &self, - op: crate::rollback_record::RollbackRequest, - reply_sink: freshell_terminal::FrameSink, - ) { - use crate::claude_snapshot as snap; - use crate::rollback_record::*; - - let Some(map_key) = self.resolve_session_key(&op.session_id).await else { - reply_sink(rollback_error_frame( - &op, - "INVALID_SESSION_ID", - "claude session not found", - )); - return; - }; - // `turnId` absent on a toTurn frame is a SERVER-side validation error - // (never a zod refinement — the frozen contract keeps bare objects). - if op.mode == RollbackModeReq::ToTurn && op.turn_id.is_none() { - reply_sink(rollback_error_frame( - &op, - "INVALID_ROLLBACK_TARGET", - "rollback toTurn requires a turnId", - )); - return; - } - let ( - durable_id, - in_turn, - turn_tracker, - turn_lock, - session_type, - last_synthesized_complete_at, - ) = { - let guard = self.sessions.lock().await; - match guard.get(&map_key) { - Some(s) => ( - s.cli_session_id.clone().unwrap_or_else(|| map_key.clone()), - s.in_turn.clone(), - s.turn_tracker.clone(), - s.turn_lock.clone(), - session_type_str(op.session_type), - // Task 5: the fork continues the SAME per-session - // death-mint clock (carried like `in_turn` — the fork is - // the same logical session lifetime, so a death-mint - // after the fork can never regress past one before it). - s.last_synthesized_complete_at.clone(), - ), - None => { - reply_sink(rollback_error_frame( - &op, - "INVALID_SESSION_ID", - "claude session not found", - )); - return; - } - } - }; - // Rollback-vs-rollback single-flight (lock order: FIRST, before the turn - // lock — never the reverse). handle_send never acquires this registry — - // it only POLLS membership to park through the teardown→respawn window - // (task 4 review C1). - let Some(_guard) = self.rollback_in_flight.try_acquire(&durable_id) else { - reply_sink(rollback_error_frame( - &op, - "INTERNAL_ERROR", - &format!("rollback already in progress for {durable_id}"), - )); - return; - }; - // b8ke ext r29 F2 + focused ep5 r4 F3: the rollback's observed - // generation fence — the wire's observedEpoch/observedGeneration - // pair (additive since ext r21 F2) is HONORED verbatim: a request - // already stale on ARRIVAL (its pair predates a completed - // handoff/crash advance) can never be made current — the claim - // under the coordinator lock typed-refuses it, so a queued - // old-generation rollback can never tear down or roll back a - // newer runtime. A half-sent pair is the typed INVALID_FENCE - // refusal (the F7 discipline). And since ep5 r4 F3 the - // FULLY-ABSENT pair is the SAME typed refusal on a - // coordinator-wired lane — the ext-r13-F3 entry-observation - // discipline this replaces was the laundering the round-4 - // reviewer rejects: a handler-START observation can detect an - // ownership change occurring AFTER it, but nothing can detect - // that an unfenced request was already stale WHEN RECEIVED - // (queued under generation N, arriving after the session moved - // away and returned at N+2 — indistinguishable from a current - // request, so it could adopt and roll back the NEWER owner). - // The refusal is the actionable contract (identical to the - // opencode lanes' FENCE_REQUIRED): the client re-observes the - // owner record and retries WITH the pair. Only the UNWIRED lane - // (no coordinator — the legacy no-coordinator lane) proceeds - // unfenced: there is nothing to launder against. - let rollback_fence = - match crate::ownership_lane::wire_fence(op.observed_epoch, op.observed_generation) { - Ok(fence) => fence, - Err(err) => { - tracing::warn!(target: "freshell_freshagent::claude", - session_id = %durable_id, request_id = %op.request_id, code = err.code(), - "fresh_agent_rollback_refused: the observed fence is half-sent (invalid)"); - reply_sink(rollback_error_frame(&op, err.code(), err.message())); - return; - } - }; - let rollback_fence = match rollback_fence { - Some(fence) => Some(fence), - None => { - if self.ownership.is_some() { - tracing::warn!(target: "freshell_freshagent::claude", - session_id = %durable_id, request_id = %op.request_id, - code = "INVALID_FENCE", - "fresh_agent_rollback_refused: the rollback carried no observed \\ - ownership pair against a coordinator-wired lane — the client \\ - re-observes the owner record and retries with the pair (kata \\ - b8ke focused ep5 r4 F3)"); - reply_sink(rollback_error_frame( - &op, - "INVALID_FENCE", - "the rollback carried no observed ownership pair; re-observe the \\ - session's owner record and retry", - )); - return; - } - None - } - }; - // Held for the REST of this handler. in_turn is set by handle_send UNDER - // this same lock BEFORE the sidecar write (the check-then-set window is - // closed): observed false here means no op is in flight. Focused ep1-r1 - // F1 through ep2-r2: `in_turn` is the FIFO tracker's DERIVED busy truth - // — recomputed from `TurnTracker::busy()` inside every mutation's - // critical section — so the gate stays closed through every op that is - // queued or mid-compaction, whether its turn ended technically (the - // interrupted active turn included, ep2-r2 F2: the queue survives it) - // or only structurally (every queued op owes its own terminal edge). - let _turn = turn_lock.lock().await; - if in_turn.load(std::sync::atomic::Ordering::SeqCst) { - // ep3-r3 F1: owed debt broken down — queue entries that are ONLY - // unpromoted compacts (nothing running, no candidate pending, no - // Turn owed) are provably quiescent and absorb (see - // [`absorb_unstarted_compact_debt`]). Everything else is genuinely - // mid-execution risk and refuses. - if !absorb_unstarted_compact_debt(&in_turn, &turn_tracker) { - reply_sink(rollback_error_frame( - &op, - "BUSY_TURN", - ROLLBACK_BUSY_MESSAGE, - )); - return; - } - } - - // Lease discipline: claim the OLD durable id exactly like the - // create-resume path so a concurrent attach cannot bind the pre-rollback - // id mid-fork. A REFUSAL LEG — before any record write or teardown. - // kata b8ke Task 3: the coordinator claim comes FIRST — before the - // provider lease — so the rollback window (kill + re-resume of the - // same durable lane) is cross-kind-authoritative. The "we ARE the - // bound live owner" case surfaces as AdoptLive (same-kind live). - let rollback_lease_id = format!("rollback-{}", uuid::Uuid::new_v4()); - // b8ke delta round-2 F2: the rollback-respawn start's watchdog - // machinery (the sidecar pid slot + the settle guard — see the - // create-resume claim site). - let start_pid_slot = crate::ownership_lane::sidecar_pid_cancel_slot(); - let mut _start_cancellation: Option = None; - let granted_ticket = match self.begin_lane_claim_at( - &durable_id, - &rollback_lease_id, - rollback_fence, - "freshclaude/rollback", - ) { - crate::ownership_lane::LaneClaim::Granted(ticket) => Some(ticket), - crate::ownership_lane::LaneClaim::Unwired => None, - crate::ownership_lane::LaneClaim::Adopt => { - // Same-kind live under this exact id (the lease arm's "we - // ARE the bound live owner" twin): proceed WITHOUT a claim - // — the binding already names this lane. - None - } - crate::ownership_lane::LaneClaim::Refused(outcome) => { - tracing::warn!(target: "freshell_freshagent::claude", - session_id = %durable_id, outcome = ?outcome, - "fresh_agent_rollback_refused: the ownership coordinator refused the \ - claim (kata b8ke cross-kind authority)" - ); - reply_sink(rollback_error_frame( - &op, - "SESSION_RESERVED", - "Another resume for this session is in flight", - )); - return; - } - }; - let mut lease_guard: Option = None; - // b8ke delta round-2 F2 + focused episode-2 round-3 F5: the granted - // claim registers its REAL cancellation + settle with the watchdog - // IMMEDIATELY — the rollback's respawn helper (`rollback_spawn_ - // create`) spawns the sidecar and then performs TWO sequential - // 45-second-budget reads; a watchdog sweep in those waits must - // find armed, cancellable evidence, never a fence with nothing - // registered. The sidecar pid slot arms when the child exists - // (below); the PARTIAL runtime registers NOW with the - // kind-appropriate identity (pid unknown until the spawn returns — - // the same evidence shape the codex spawn hook rearms with the - // real pid). - // b8ke ext r13 F3: the Adopt arm holds REAL authority across the - // replace window — the guard arms on the live incumbent's key and - // is held through the kill + respawn + register + rekey (this - // handler's scope), so a handoff or stop begin inside the window - // answers the typed Blocked outcome and can NEVER start a terminal - // beside the replacement (pre-r13 the Adopt arm proceeded with NO - // claim, and the rekey's verify was the only — post-overlap — - // defense). - // b8ke ext r38 F1: the arm is the ATOMIC ADOPT — the request's - // ORIGINAL observed fence (the pair the coordinator's claim already - // verified when it answered AdoptLive) plus the EXPECTED - // fresh-agent owner, validated in ONE coordinator-lock decision. - // Pre-r38 the arm RE-OBSERVED whatever was live and armed on it - // (the discard-and-re-observe laundering): a terminal handoff - // committing between the claim and the arm armed the guard on the - // TERMINAL owner and the rollback spawned a Fresh Claude sidecar - // beside the terminal before the late rekey tore it down — two - // live writers. The adopt closes the window: the observed pair - // names the record the rollback decided on; any advance or a - // foreign owner refuses typed BEFORE anything is killed or - // spawned. - let mut _rollback_adopt_guard: Option = None; - if granted_ticket.is_none() && self.ownership.is_some() { - // Test seam (b8ke ext r38 F1): park with the claim ANSWERED - // (AdoptLive) but the adopt guard NOT yet armed — the - // claim-to-arm race test's deterministic hold (the test - // commits a terminal handoff against the key mid-park). - if let (Some(parked), Some(release)) = ( - self.rollback_adopt_parked.as_ref(), - self.rollback_adopt_release.as_ref(), - ) { - parked.notify_one(); - let _ = release.notified().await; - } - let resolved = self.resolve_ownership_key(&durable_id); - // The Adopt arm only fires on a WIRED lane, where the - // original pair is REQUIRED (the ep5-r4-F3 gate above refused - // the absent pair typed). - let Some(adopt_fence) = rollback_fence else { - tracing::warn!(target: "freshell_freshagent::claude", - session_id = %durable_id, - "fresh_agent_rollback_refused: the Adopt arm requires the \ - original observed pair (unreachable — the gate above refused \ - the absent pair)" - ); - reply_sink(rollback_error_frame( - &op, - "SESSION_RESERVED", - "Another resume for this session is in flight", - )); - return; - }; - let expected_fresh_owner = freshell_ownership::OwnerIdentity { - kind: freshell_ownership::RuntimeOwnerKind::FreshAgent, - terminal_id: None, - live_session_key: None, - pid: None, - ownership_id: None, - }; - match crate::ownership_lane::arm_adopt_guard( - &self.ownership, - PROVIDER, - &resolved, - &format!("rollback-adopt-{rollback_lease_id}"), - &expected_fresh_owner, - adopt_fence, - "freshclaude/rollback-adopt", - ) { - crate::ownership_lane::LaneAttachGuard::Armed(guard) => { - _rollback_adopt_guard = Some(guard); - } - crate::ownership_lane::LaneAttachGuard::Unwired => {} - crate::ownership_lane::LaneAttachGuard::Refused => { - tracing::warn!(target: "freshell_freshagent::claude", - session_id = %durable_id, - "fresh_agent_rollback_refused: the Adopt arm's atomic adopt \ - refused to arm (ownership advanced or a foreign owner holds \ - the key) — the rollback aborts typed, nothing is killed or \ - spawned" - ); - reply_sink(rollback_error_frame( - &op, - "SESSION_RESERVED", - "Another resume for this session is in flight", - )); - return; - } - } - } - let mut own_ticket = granted_ticket; - if own_ticket.is_some() { - _start_cancellation = Some( - crate::ownership_lane::register_start_cancellation_for_ticket( - &self.ownership, - PROVIDER, - &durable_id, - &own_ticket, - crate::ownership_lane::pid_slot_cancellation(&start_pid_slot), - ), - ); - crate::ownership_lane::register_partial_fresh_runtime( - &self.ownership, - PROVIDER, - &durable_id, - &own_ticket, - &map_key, - None, - ); - } - for round in 0..2u8 { - match self.leases.claim( - PROVIDER, - &durable_id, - &rollback_lease_id, - crate::session_lease::now_epoch_ms(), - ) { - crate::session_lease::FreshSessionClaim::Acquired => { - lease_guard = Some(crate::FreshSessionLeaseGuard::armed( - Arc::clone(&self.leases), - PROVIDER, - &durable_id, - &rollback_lease_id, - )); - break; - } - crate::session_lease::FreshSessionClaim::BoundLive { live_session_key } => { - if live_session_key == map_key { - // We ARE the bound live owner of this exact id — proceed - // WITHOUT a lease (the binding already names this map key, - // which the fork keeps). - break; - } - reply_sink(rollback_error_frame( - &op, - "SESSION_RESERVED", - "Another resume for this session is in flight", - )); - return; - } - crate::session_lease::FreshSessionClaim::Held { .. } => { - reply_sink(rollback_error_frame( - &op, - "SESSION_RESERVED", - "Another resume for this session is in flight", - )); - return; - } - crate::session_lease::FreshSessionClaim::ExpiredNeedsKill { pid, ownership_id } => { - if round == 0 - && crate::session_lease::kill_and_confirm_tree_dead( - pid, - CLAUDE_SIDECAR_OWNERSHIP_ENV, - &ownership_id, - ) - .await - { - self.leases - .force_release_after_confirmed_kill(PROVIDER, &durable_id); - continue; - } - tracing::error!(target: "invariant", pid, session_id = %durable_id, - "fresh_agent_lease_expired_kill_unconfirmed: holding closed"); - reply_sink(rollback_error_frame( - &op, - "SESSION_RESERVED", - "Another resume for this session is in flight", - )); - return; - } - } - } - - let now = now_ms(); - let existing = self - .identity_sink() - .and_then(|s| s.load_rollback(PROVIDER, &durable_id)); - - let ( - resume_from, - source_path, - resume_at_uuid, - removed_turns, - prompt_text, - chain_root, - original_tip_uuid, - can_redo_after, - guard_uuid, - ) = match op.direction { - RollbackDirection::Undo => { - let path = match self.locate_transcript(&durable_id) { - snap::TranscriptLocation::Found(path) => path, - snap::TranscriptLocation::Absent => { - reply_sink(rollback_error_frame( - &op, - "NOTHING_TO_UNDO", - UNDO_EMPTY_MESSAGE, - )); - return; - } - snap::TranscriptLocation::Unreadable(error) => { - reply_sink(snap::unreadable_rollback_refusal(&op, &durable_id, &error)); - return; - } - }; - let text = match std::fs::read_to_string(&path) { - Ok(t) => t, - Err(e) => { - reply_sink(rollback_error_frame(&op, "INTERNAL_ERROR", &e.to_string())); - return; - } - }; - let target = match op.mode { - RollbackModeReq::Step => snap::ResumeTarget::Step, - RollbackModeReq::ToTurn => { - snap::ResumeTarget::ToTurn(op.turn_id.clone().expect("validated")) - } - }; - let point = match snap::resolve_resume_point(&text, &durable_id, target) { - Ok(p) => p, // resume_at_uuid None = "before the first message" — LEGAL (r2) - Err(snap::ResumeResolveError::Empty) => { - reply_sink(rollback_error_frame( - &op, - "NOTHING_TO_UNDO", - UNDO_EMPTY_MESSAGE, - )); - return; - } - Err(_) => { - reply_sink(rollback_error_frame( - &op, - "INVALID_ROLLBACK_TARGET", - &format!("turn {:?} is not in this conversation", op.turn_id), - )); - return; - } - }; - // Epoch rule (r2/r3): an undo landing while redo_destroyed is set - // re-roots the chain to the CURRENT durable id — the retained old - // original describes a branch a resend already permanently - // replaced; O's redo stays permanently dead. - let chain_root = existing - .as_ref() - .filter(|r| !r.redo_destroyed) - .and_then(|r| r.original_session_id.clone()) - .unwrap_or_else(|| durable_id.clone()); - // Tip anchor at UNDO time = the last raw-chain uuid of the - // CHAIN-ROOT transcript (at the first undo the current file IS the root). - let root_text = if chain_root == durable_id { - text.clone() - } else { - match snap::locate_transcript(&chain_root) - .and_then(|p| std::fs::read_to_string(p).ok()) - { - Some(t) => t, - None => { - reply_sink(rollback_error_frame( - &op, - "INTERNAL_ERROR", - "chain-root transcript unreadable", - )); - return; - } - } - }; - let tip = snap::raw_chain_tip(&root_text); - // The resumeDropsTurn guard: the one prompted turn the fork - // discards, when it discards exactly one (decision 2). - let guard = snap::drops_turn_guard(&text, point.resume_at_uuid.as_deref()); - // can_redo after the undo: the removed content provably exists in - // the original beyond the resume point (on the r2 first-turn leg - // the fresh conversation's tip is NONE — strictly beyond holds - // whenever a tip exists). - ( - durable_id.clone(), - path, - point.resume_at_uuid, - point.removed_turns, - point.prompt_text, - chain_root, - tip, - true, - guard, - ) - } - RollbackDirection::Redo => { - let Some(record) = existing.clone() else { - reply_sink(rollback_error_frame( - &op, - "REDO_UNAVAILABLE", - REDO_EMPTY_MESSAGE, - )); - return; - }; - if record.redo_destroyed { - reply_sink(rollback_error_frame( - &op, - "REDO_UNAVAILABLE", - REDO_DESTROYED_MESSAGE, - )); - return; - } - let Some(original) = record.original_session_id.clone() else { - reply_sink(rollback_error_frame( - &op, - "REDO_UNAVAILABLE", - REDO_EMPTY_MESSAGE, - )); - return; - }; - let original_path = match self.locate_transcript(&original) { - snap::TranscriptLocation::Found(path) => path, - snap::TranscriptLocation::Absent => { - reply_sink(rollback_error_frame( - &op, - "REDO_UNAVAILABLE", - REDO_REMOVED_HISTORY_COPY, - )); - return; - } - snap::TranscriptLocation::Unreadable(error) => { - reply_sink(snap::unreadable_rollback_refusal(&op, &original, &error)); - return; - } - }; - let original_text = match std::fs::read_to_string(&original_path) { - Ok(t) => t, - Err(e) => { - reply_sink(rollback_error_frame(&op, "INTERNAL_ERROR", &e.to_string())); - return; - } - }; - let Some(current_path) = snap::locate_transcript(&durable_id) else { - reply_sink(rollback_error_frame( - &op, - "INTERNAL_ERROR", - "current transcript missing", - )); - return; - }; - // Task 4 review (Minor-2): classify the read OUTCOME — a missing - // file (or a raced deletion) is the DELIBERATE vacuous-empty leg - // (the post-first-turn-undo empty transcript; LCP admits the - // first group end), but any REAL read error on this - // existing-expected path is a loud INTERNAL_ERROR, exactly like - // the original's read leg above — never a silent empty-chain - // degradation yielding a false `redone` ack plus a pointless fork. - let current_text = match std::fs::read_to_string(¤t_path) { - Ok(t) => t, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(), - Err(e) => { - reply_sink(rollback_error_frame(&op, "INTERNAL_ERROR", &e.to_string())); - return; - } - }; - // Redo validity contract (wire design; LBC-9): the original must - // BE the history the undo observed — raw-chain tip == the - // recorded tip — AND the current chain must still be a strict - // prefix of it (the LCP resolves PAST the current tip). Any - // miss => loud REDO_UNAVAILABLE + REDO_REMOVED_HISTORY_COPY; - // never silently re-fork over moved history. - let original_tip = snap::raw_chain_tip(&original_text); - if original_tip != record.original_tip_uuid { - reply_sink(rollback_error_frame( - &op, - "REDO_UNAVAILABLE", - REDO_REMOVED_HISTORY_COPY, - )); - return; - } - if snap::raw_lcp_end(¤t_text, &original_text) - != snap::raw_chain_tip(¤t_text) - { - reply_sink(rollback_error_frame( - &op, - "REDO_UNAVAILABLE", - REDO_REMOVED_HISTORY_COPY, - )); - return; - } - // The resume point = the LAST raw-chain uuid of the restored - // step's group — its assistant tail (r3 boundary rule). - let resume_at = match snap::redo_resume_target(&original_text, ¤t_text, &op) { - Ok(Some(uuid)) => uuid, - Ok(None) => { - reply_sink(rollback_error_frame( - &op, - "REDO_UNAVAILABLE", - REDO_EMPTY_MESSAGE, - )); - return; - } - Err(msg) => { - reply_sink(rollback_error_frame(&op, "INVALID_ROLLBACK_TARGET", &msg)); - return; - } - }; - // The ack payload for a redo = the RESTORED slice (display - // projection of the original's range just-after-the-prefix - // through the resume point). - let restored = - snap::restored_slice_turns(&original_text, ¤t_text, &resume_at); - let can_redo_after_redo = original_tip.as_deref() != Some(resume_at.as_str()); - // The guard: the one prompted turn of the original the redo - // still leaves out, when exactly one (`None` redoing TO the - // tip: nothing is discarded). - let guard = snap::drops_turn_guard(&original_text, Some(&resume_at)); - ( - original, - original_path, - Some(resume_at), - restored.turns, - restored.prompt_text, - record.original_session_id.clone().expect("checked above"), - record.original_tip_uuid.clone(), - can_redo_after_redo, - guard, - ) - } - }; - - // Durable record FIRST (durable-BEFORE-mutation): computed from the - // pre-mutation reads, keyed by the CURRENT durable id, AWAITED before - // anything is torn down. The sdk.session.init adoption leg re-keys the - // row old→new inside its awaited binding batch — exactly one - // rollback-record-specific write, always pre-mutation. r3 UNION rule: - // `entries` accumulates; a NEW epoch freezes every prior entry (they stay - // first) and NEVER clears the bucket. - let mut record = existing - .clone() - .unwrap_or_else(|| RollbackRecord::empty(now)); - match op.direction { - RollbackDirection::Undo => { - // Epoch rule (r3 + delta-r1 F8's literal bookkeeping): an undo - // landing while redo_destroyed was set (or whose chain root - // re-roots away from the prior record's) is a NEW epoch — bump - // `current_epoch` (every existing entry freezes with its own - // epoch), the destroyed bit clears so the record's redo fields - // describe the NEW chain (the prior chain's redo stays - // permanently dead), `entries` is NEVER cleared. Same-epoch - // undos splice the new (earlier-in-conversation) entry BEFORE - // the existing current-epoch block — positions never read - // timestamps. - let new_epoch = record.redo_destroyed - || (existing - .as_ref() - .is_some_and(|r| r.original_session_id.is_some()) - && existing - .as_ref() - .and_then(|r| r.original_session_id.clone()) - .as_deref() - != Some(chain_root.as_str())); - if new_epoch { - record.redo_destroyed = false; - record.begin_new_epoch(); - } - record.splice_undo_entry( - RollbackEntry { - removed_turns: removed_turns.clone(), - prompt_text: prompt_text.clone(), - at_ms: now, - epoch: record.current_epoch, - }, - now, - ); - } - RollbackDirection::Redo => { - // The restored turns leave the CURRENT-epoch marker portion (they - // are live again); frozen prior-epoch markers can never match a - // restorable id (the redo contract's LCP/tip validation can only - // restore from the current chain root). - let restored_id_set: std::collections::HashSet<&str> = removed_turns - .iter() - .filter_map(|t| { - t.get("turnId") - .or_else(|| t.get("id")) - .and_then(Value::as_str) - }) - .collect(); - record.entries.retain_mut(|e| { - e.removed_turns.retain(|t| { - !t.get("turnId") - .or_else(|| t.get("id")) - .and_then(Value::as_str) - .is_some_and(|id| restored_id_set.contains(id)) - }); - !e.removed_turns.is_empty() - }); - } - } - record.original_session_id = Some(chain_root.clone()); - record.original_tip_uuid = original_tip_uuid.clone(); - record.last_op_at_ms = now; - record.set_can_redo(can_redo_after, now); - if let Some(sink) = self.identity_sink() { - if let Err(e) = sink - .record_rollback(PROVIDER, &durable_id, record.clone()) - .await - { - tracing::warn!(error = %e, session = %durable_id, "freshagent.claude.rollback_pre_write_failed"); - reply_sink(rollback_error_frame( - &op, - "INTERNAL_ERROR", - LEDGER_WRITE_REFUSAL_COPY, - )); - return; - } - } - - // ep3-r5 F1 (the admit→teardown window): the gate's quiescence-proof is - // consumed ONCE at admission — a compact can arm and start (status - // folded, candidate set, gate re-boosted by ep3-r4 F1) while this - // already-admitted handler is doing transcript I/O. The quiesce probe - // above makes all provably-written evidence folded-visible; the - // recheck here (at the point of no return, immediately before - // teardown) aborts the rollback with BUSY_TURN plus a compensating - // ledger rewrite whenever that truth revived (the durable pre-write - // provably matches the provider: nothing was mutated). - if let Ok(ms) = std::env::var("FRESHELL_TEST_CLAUDE_ROLLBACK_PRE_TEARDOWN_MS") { - // Test-only knob: parks this handler in the admit→teardown window so - // the recheck choreography is deterministic (never in production). - if let Ok(ms) = ms.parse::() { - tokio::time::sleep(Duration::from_millis(ms)).await; - } - } - - // Focused ep4-r2/ep4-r3 (the post-admission evidence race, repaired - // for real): absorbed compact debt can STILL start in the sidecar - // after admission — the dispatch lifecycle is: sidecar input queue → - // SAME-TICK handoff when the SDK consumer is awaiting → SDK turn - // start (its `compacting` status folds the candidate here). The - // quiesce probe closes every leg of that lifecycle atomically: the - // sidecar (1) DROPS never-handed compacts from its own input queue - // (cancellation the SDK surface cannot provide for queue items), (2) - // reports in-flight/handed truth, and (3) answers on its stdout - // stream AFTER every already-emitted piece of evidence has been folded - // (stream order), correlated by probeId so a stale receipt can never - // close the probe. The verdict gates below plus the recheck cover the - // whole shape: all-clear → proceed; any busy signal or no answer → - // BUSY_TURN + compensating ledger rewrite. Skip when no live evidence - // path exists: write failure (sidecar already dead), or a FINISHED - // consumer (its EOF reaped the busy truth; nothing fresh can arrive). - let mut probe_armed = false; - let mut probe_rx = None; - let mut probe_id = String::new(); - { - let mut guard = self.sessions.lock().await; - if let Some(session) = guard.get_mut(&map_key) { - if session.consumer.is_finished() { - // EOF already zeroed the tracker — the recheck sees it. - } else { - let (tx, rx) = tokio::sync::oneshot::channel::(); - probe_id = uuid::Uuid::new_v4().to_string(); - *session - .rollback_probe_slot - .lock() - .expect("rollback probe slot lock") = Some((probe_id.clone(), tx)); - // ep4-r3: the quiesce is its own request type — the sidecar - // drains never-handed queued compacts and answers with its - // own queue truth (the SDK's queued inputs cannot be - // cancelled once handed). - let probe_req = json!({ - "type": "rollback.quiesce", - "sessionId": session.sidecar_session_id, - "probeId": probe_id, - }); - match write_line(&mut session.stdin, &probe_req).await { - Ok(()) => { - probe_armed = true; - probe_rx = Some(rx); - } - Err(_) => { - let _ = session - .rollback_probe_slot - .lock() - .expect("rollback probe slot lock") - .take(); - } - } - } - } - } - if probe_armed { - let timeout_ms = std::env::var("FRESHELL_TEST_CLAUDE_ROLLBACK_PROBE_TIMEOUT_MS") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(3000); - let verdict = - tokio::time::timeout(Duration::from_millis(timeout_ms), probe_rx.unwrap()).await; - match verdict { - Ok(Ok(QuiesceVerdict { - cancelled_queue, - in_flight_turn: false, - handed_compact_likely: false, - })) => { - if cancelled_queue > 0 { - tracing::info!( - session = %map_key, - cancelled = cancelled_queue, - "freshagent.claude.rollback_quiesce_cancelled_queue" - ); - } - } - Ok(Ok(verdict)) => { - // ep4-r3 F1: provider work crossed the un-cancellable - // handoff (a turn mid-flight, or a compact already handed - // to an awaiting SDK consumer) — refuse. - tracing::info!(session = %map_key, ?verdict, "freshagent.claude.rollback_quiesce_busy"); - // ep4-r6 F5: never fabricate an empty row for a rollback - // that never happened — the shared helper DELETES the - // pre-write when the pre-op record was absent. - self.compensate_rollback_record(&durable_id, existing.clone()) - .await; - reply_sink(rollback_error_frame( - &op, - "BUSY_TURN", - ROLLBACK_BUSY_MESSAGE, - )); - return; - } - _ => { - // Timeout (or a dropped sender — impossible in practice - // since the slot outlives this handler): the absorb premise - // is unproven — refuse rather than tear a live provider - // flow down blind. Clear OUR slot (a stale receipt must - // never fire a later probe — ep4-r3 F2 defense in depth - // on top of probeId correlation). - if let Some(session) = self.sessions.lock().await.get(&map_key) { - let mut slot = session - .rollback_probe_slot - .lock() - .expect("rollback probe slot lock"); - if slot.as_ref().is_some_and(|(id, _)| *id == probe_id) { - slot.take(); - } - } - // ep4-r6 F5: never fabricate an empty row for a rollback - // that never happened — the shared helper DELETES the - // pre-write when the pre-op record was absent. - self.compensate_rollback_record(&durable_id, existing.clone()) - .await; - reply_sink(rollback_error_frame( - &op, - "BUSY_TURN", - ROLLBACK_BUSY_MESSAGE, - )); - return; - } - } - } - - let revived = { - let tracker = turn_tracker.lock().expect("turn tracker lock"); - tracker.running.is_some() - || tracker.compact_candidate - || tracker.queued.iter().any(|op| *op == TrackedOp::Turn) - }; - if revived { - // Compensate the pre-op durable record: the provider was never - // touched, so the ledger must describe nothing happening — - // restore the pre-op record, and DELETE the just-written row when - // there was none (never fabricate, ep4-r6 F5). - self.compensate_rollback_record(&durable_id, existing.clone()) - .await; - reply_sink(rollback_error_frame( - &op, - "BUSY_TURN", - ROLLBACK_BUSY_MESSAGE, - )); - return; - } - - // Cancel pending cards BEFORE teardown (decision 6) — both spawn legs. - self.emit_pending_cancellations(&map_key, &op.session_id, session_type) - .await; - - // Pre-compute the create-drive inputs: recovered settings + the resume - // value/cwd (ledger A15 slug scoping; the transcript-PATH escape hatch - // when the original cwd is gone). The fork READS: undo ⇒ the CURRENT - // session's file; redo ⇒ the chain-root ORIGINAL's file (`resume_from`) - // -- `source_path`, the file the arm above already located and read - // (G28: never a second scan that a store turning unreadable could miss - // AFTER the pre-write, with teardown next). - let recovered = self - .identity_sink() - .and_then(|s| s.load_settings(PROVIDER, &durable_id)); - // resume value (the fork leg): the durable id under its ORIGINAL cwd's - // slug scope when that cwd survives; else the transcript PATH (the - // verified cli.js escape hatch bypassing slug scoping). - let (resume_value, original_cwd) = - crate::claude_launch::resume_target(&resume_from, &source_path); - let spawn_cwd = original_cwd - .or_else(|| op.cwd.clone()) - .or_else(|| recovered.as_ref().and_then(|r| r.cwd.clone())); - // G15: the respawn runs with the session's recorded settings, plugins - // included, and the lane's hosted MCP selection. - let launch_settings = recovered.clone().unwrap_or_default(); - // Tear down the old sidecar (handle_kill discipline): remove the record - // FIRST so its consumer's exit arm stays silent (evicted=false), then the - // shared kill-family teardown (G31: the shutdown request, escalation only - // after the grace period, the ownership sweep, the tree confirmation), - // and the create-dedup eviction so a replayed requestId genuinely - // re-spawns. - let old = self.sessions.lock().await.remove(&map_key); - self.create_dedup - .clear_for_session(|record| record.session_id == map_key) - .await; - if let Some(session) = old { - // An unconfirmed tree is logged and keeps escalating detached; the - // fork does not wait on it (it never did). - let _ = self.teardown_removed_session_logged(session).await; - } - - // Kill + recreate via `spawn_sidecar` with the computed options. The - // create payload is selected by `resume_at_uuid`: - // * Some(uuid) — fork-at-point (resume + resumeSessionAt + forkSession); - // * None (r2 first-turn rollback) — a FRESH conversation with NO resume - // keys at all (the empty fresh transcript IS the rollback target). - // ONE guard retry: a create whose early output carries the - // `Resume rejected by --resume-drops-turn:` prefix is retried ONCE with - // resumeDropsTurn omitted (the plain-resume recovery per the SDK docs — - // the raw guard text is never surfaced). - let mut guard_retried = guard_uuid.is_none(); // no guard => nothing to retry - let spawned = loop { - let boot = match &resume_at_uuid { - Some(resume_at) => ClaudeBoot::Fork { - resume: resume_value.clone(), - at: Some(resume_at.clone()), - // attempt 1 arms the guard; the retried recovery omits it. - drops_turn: guard_uuid.clone().filter(|_| !guard_retried), - }, - None => ClaudeBoot::Fresh, - }; - let spec = ClaudeLaunchSpec::new( - format!("rollback-{}", uuid::Uuid::new_v4()), - session_type, - boot, - spawn_cwd.clone(), - &launch_settings, - self.hosted_mcp, - ); - match self - .rollback_spawn_create(&spec, lease_guard.as_mut()) - .await - { - Ok(ok) => break Ok(ok), - Err(RollbackSpawnError::GuardRefusal) if !guard_retried => { - tracing::warn!(session = %durable_id, - "freshagent.claude.rollback_guard_refused_retrying_plain"); - guard_retried = true; - continue; - } - Err(RollbackSpawnError::GuardRefusal) => { - break Err("the --resume-drops-turn guard refusal persisted after the plain-resume recovery retry".to_string()) - } - Err(RollbackSpawnError::Other(message)) => break Err(message), - } - }; - let spawned = match spawned { - Ok(ok) => ok, - Err(err) => { - self.compensate_rollback_record(&durable_id, existing.clone()) - .await; - if let Some(mut g) = lease_guard.take() { - g.fail(); - } - reply_sink(rollback_error_frame(&op, "INTERNAL_ERROR", &err)); - return; - } - }; - let RollbackSpawned { - child, - tree_guard, - stdin, - reader, - ownership_id, - log_labels, - sidecar_session_id, - preseeded_init, - cli_id, - } = spawned; - // kata b8ke Task 3: the respawn's partial runtime (the watchdog's - // reap target) + the pid for the commit's owner identity (captured - // before `child` moves into the session entry). - let sidecar_pid = child.id(); - // b8ke delta round-2 F2: arm the registered start cancellation. - crate::ownership_lane::arm_sidecar_pid_slot(&start_pid_slot, sidecar_pid); - crate::ownership_lane::register_partial_fresh_runtime( - &self.ownership, - PROVIDER, - &durable_id, - &own_ticket, - &map_key, - sidecar_pid, - ); - - // Register the replacement session under the SAME map key, INHERITING - // the turn lock + busy truth handles (a mid-rollback send serializes on - // the SAME lock regardless of when it resolved its handle). - let (adopted_tx, adopted_rx) = tokio::sync::oneshot::channel(); - let broadcast_id = Arc::new(std::sync::Mutex::new(op.session_id.clone())); - log_labels.bind_session(&broadcast_id); - let pending = Arc::new(std::sync::Mutex::new(ClaudePending::default())); - // The respawn is fresh: nothing announced yet; the consumer's status - // fold owns the tracked status from here. - let last_status = Arc::new(std::sync::Mutex::new("idle".to_string())); - // Task 5: the fork CARRIES the pre-rollback session's death-mint - // clock (extracted with the other carried handles above) — the fork - // continues the same logical session lifetime, so its Rust-minted - // `at`s stay ordered against anything the session minted before. - // Spawned and registered under the sessions map lock (review PRB-C10): no - // line the consumer reads can find the replacement session missing. - { - let mut sessions = self.sessions.lock().await; - let consumer = self.spawn_consumer( - reader, - map_key.clone(), - session_type.to_string(), - sidecar_session_id.clone(), - recovered.clone(), - Arc::clone(&broadcast_id), - Arc::clone(&pending), - Arc::clone(&last_status), - in_turn.clone(), - turn_tracker.clone(), - Arc::clone(&last_synthesized_complete_at), - Some(RollbackAdoption { - supersedes: durable_id.clone(), - preseeded_init, - adopted_tx, - }), - // Conn-less lane (D8): the rollback respawn has no client - // connection — the ledger merge inherits the superseded parent's - // stamps. - None, - ); - self.test_holds.registration().await; - sessions.insert( - map_key.clone(), - ClaudeSession { - configuration: ClaudeConfiguration { - settings: recovered.clone().unwrap_or_default(), - ..ClaudeConfiguration::default() - }, - stdin, - child, - tree_guard, - ownership_id, - consumer, - sidecar_session_id, - cli_session_id: Some(cli_id.clone()), - // b8ke focused episode-2 round-2 F2: the ROLLBACK's claim - // owns this respawn's coordinator transition — the - // adoption's Blocked arm correlates through the op id. - owning_operation: own_ticket.as_ref().map(|t| t.operation_id().to_string()), - owning_generation: None, - broadcast_id: Arc::clone(&broadcast_id), - pending, - in_turn: in_turn.clone(), - turn_tracker: turn_tracker.clone(), - last_synthesized_complete_at, - turn_lock: turn_lock.clone(), - rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), - last_status, - }, - ); - } - - // Adoption rides the EXISTING sdk.session.init consumer (preseeded): - // cli_index insert + AWAITED binding WITH supersedes + rollback-row - // re-key old→new inside the same awaited batch — then resolves this - // channel with the adopted durable id. - let adopted_id = match tokio::time::timeout(SIDECAR_CREATE_BUDGET, adopted_rx).await { - Ok(Ok(Some(id))) if !id.is_empty() => id, - _ => { - tracing::error!(target: "invariant", session = %durable_id, - "freshagent.claude.rollback_adoption_failed: the consumer never resolved the init adoption"); - self.compensate_rollback_record(&durable_id, existing.clone()) - .await; - // Tear the fork down — an unadopted fork must never answer sends. - self.teardown_rollback_fork(&map_key).await; - if let Some(mut g) = lease_guard.take() { - g.fail(); - } - reply_sink(rollback_error_frame( - &op, - "INTERNAL_ERROR", - "rollback adoption failed on the forked sidecar", - )); - return; - } - }; - - // Lease completion: bind the OLD durable id to the live map key and - // release the lease in one lock scope. A revoked lease means we must NOT - // keep the fork — tear down, compensate, refuse. - if let Some(mut g) = lease_guard.take() { - if !g.complete(&map_key) { - self.compensate_rollback_record(&durable_id, existing.clone()) - .await; - self.teardown_rollback_fork(&map_key).await; - g.fail(); // own tree torn down — reopen the key - reply_sink(rollback_error_frame( - &op, - "INTERNAL_ERROR", - "session lease revoked during rollback; torn down", - )); - return; - } - } - - // kata b8ke Task 3 + focused episode-2 round-2 F1: the rollback's - // replacement session is registered — commit `Live{FreshAgent}` - // under the CLIENT-VISIBLE durable id. The fork's - // `sdk.session.init` minted `adopted_id`, and the materialization - // below replaces every pane's sessionRef with it — so the - // coordinator record RE-KEYS to the same id (one atomic registry - // step: the old key goes Vacant, the new key goes Live; the old id - // becomes a resolvable alias). One source of truth: a later - // handoff on the pane's sessionRef finds the prior owner, stops - // it, and transfers atomically — never a Vacant-looking split - // identity with a live runtime beside a second writer. - if let Err(outcome) = self.commit_lane_claim_rekey_at( - &durable_id, - &mut own_ticket, - &adopted_id, - &map_key, - sidecar_pid, - "claude/rollback", - ) { - tracing::error!(target: "invariant", - provider = PROVIDER, session_id = %durable_id, - outcome = ?outcome, - "freshagent.claude.rollback_commit_stale: the coordinator moved on while \ - the rollback registered; the uncommitted session is torn down" - ); - self.compensate_rollback_record(&durable_id, existing.clone()) - .await; - self.teardown_rollback_fork(&map_key).await; - reply_sink(rollback_error_frame( - &op, - "INTERNAL_ERROR", - "session ownership changed during rollback; torn down", - )); - return; - } - - // Pane re-key: the existing materialized broadcast (old → new) goes out - // BEFORE any frame stamped with the new id; the envelope-stamp flip - // follows it so the re-key never outruns the pane. - self.broadcast_materialized(&op.session_id, &adopted_id, session_type) - .await; - *broadcast_id.lock().expect("broadcast id lock") = adopted_id.clone(); - - let removed_ids: Vec = removed_turns - .iter() - .filter_map(|t| { - t.get("turnId") - .or_else(|| t.get("id")) - .and_then(Value::as_str) - .map(str::to_string) - }) - .collect(); - // Re-stamp the op for the NEW live id so every outbound frame names the - // adopted session. - let switch = RollbackRequest { - session_id: adopted_id.clone(), - ..op - }; - self.broadcast(&rollback_broadcast_frame( - &switch, - &adopted_id, - &removed_ids, - record.can_redo(), - )); - reply_sink(rollback_ack_frame( - &switch, - &adopted_id, - Some(&prompt_text), - &removed_ids, - record.can_redo(), - Some(&adopted_id), - )); - } - /// Resolve a client-addressed session id to the sessions-map key (Task 10b): the id /// itself when tracked, else through [`Self::cli_index`] (durable UUID -> map key). /// The map is never re-keyed (in-flight consumers hold the placeholder key); @@ -7028,6 +5788,7 @@ impl FreshClaudeState { naming: None, partial_runtime_key: Some(durable.to_string()), start_pid_slot: &start_pid_slot, + inherit: None, }, lease_guard, own_ticket, @@ -7086,12 +5847,13 @@ impl FreshClaudeState { })); } - /// The sdk.session.init adoption block (the consumer's in-stream arm AND the - /// rollback-fork preseeded lane share it): record the durable Claude UUID in - /// cli_index + the session record, then AWAIT-write the identity binding row - /// (V8/A11). On the rollback lane the row carries `supersedes: old durable - /// id`, and the rollback-row re-key old→new rides the SAME awaited batch - /// (server-side sink). A failed write is surfaced user-visibly, never + /// The sdk.session.init adoption block (the consumer's in-stream arm; a + /// rollback no longer adopts through it, C13: its replacement registers + /// already adopted): record the durable Claude UUID in cli_index + the + /// session record, then AWAIT-write the identity binding row (V8/A11). A + /// `supersedes` caller's row carries `supersedes: old durable id`, and the + /// rollback-row re-key old→new rides the SAME awaited batch (server-side + /// sink). A failed write is surfaced user-visibly, never /// warn-and-drop, then the identity event proceeds. No-laundering guard /// (V7/A10): never persist an all-blank snapshot UNLESS a supersession edge /// is being written (a supersession write always goes through — it is the @@ -7124,12 +5886,12 @@ impl FreshClaudeState { .await } - /// [`Self::adopt_session_init`] with the rollback preseed's completion - /// hook: `Deferred` outcomes settle asynchronously, and the deferred - /// task answers `Some(cli_id)` (published) or `None` (abandoned) on - /// the caller's channel — the rollback NEVER continues on an - /// unpublished id (finding 2: a failed publication fails the - /// adoption, not just the alias bookkeeping). + /// [`Self::adopt_session_init`] with a completion hook: `Deferred` + /// outcomes settle asynchronously, and the deferred task answers + /// `Some(cli_id)` (published) or `None` (abandoned) on the caller's + /// channel. Only tests observe the deferred adoption this way since the + /// rollback stopped adopting a pre-read init (C13). + #[cfg(test)] #[allow(clippy::too_many_arguments)] // the adoption field set + the completion channel async fn adopt_session_init_with_completion( &self, @@ -7142,10 +5904,9 @@ impl FreshClaudeState { provenance: Option<&crate::BindProvenance>, on_settled: Option>>, ) -> SessionInitAdoptionOutcome { - // The preseed runs on the consumer task BEFORE the rollback - // handler inserts the replacement session — the registration gate - // must decide synchronously so the completion hook routes to the - // deferred task on `Deferred`. + // The adoption can run before its session is registered — the + // registration gate must decide synchronously so the completion hook + // routes to the deferred task on `Deferred`. let registered = self.sessions.lock().await.contains_key(session_id); if !registered { self.defer_session_init_adoption( @@ -7986,15 +6747,9 @@ impl FreshClaudeState { // arm below can mint AFTER the eviction has already torn the session // record out of the map. last_synthesized_complete_at: Arc>>, - // kata 1wxv Task 4: Some ONLY on the rollback fork/fresh respawn — the - // handler PREREAD the sdk.session.init line, so the consumer runs the - // adoption for it FIRST (supersedes-aware), then resolves the parked - // rollback handler with the adopted durable id. - adoption: Option, - // D8: the creating connection's provenance stamps, handed to both - // `adopt_session_init` arms (the rollback preseed lane and the - // in-stream `sdk.session.init`). `None` on conn-less consumers - // (rollback respawn, attach-resume) — the ledger merge keeps prior + // D8: the creating connection's provenance stamps, handed to the + // in-stream `sdk.session.init` adoption. `None` on conn-less consumers + // (rollback replacement, attach-resume) — the ledger merge keeps prior // stamps. provenance: Option, ) -> tokio::task::JoinHandle<()> { @@ -8003,48 +6758,6 @@ impl FreshClaudeState { let identity_sink = self.identity_sink(); let state = self.clone(); tokio::spawn(async move { - // Rollback-fork preseed (kata 1wxv Task 4): adoption via the EXISTING - // sdk.session.init adoption block (cli_index insert + AWAITED binding - // WITH supersedes — the rollback-row re-key old→new rides the SAME - // awaited batch server-side), then emit the mapped init frame, THEN - // resolve the parked rollback handler with the adopted durable id. - if let Some(adoption) = adoption { - let cli_id = adoption - .preseeded_init - .get("cliSessionId") - .and_then(Value::as_str) - .map(str::to_string); - if let Some(cli_id) = cli_id.as_deref() { - // b8ke focused episode-2 round-3 F2: the preseed - // adoption routes through the completion hook — a - // `Deferred` outcome hands the channel to the deferred - // task (which answers `Some(id)` on publication or - // `None` on abandon), and an `Abandoned` outcome - // answers `None` HERE: the rollback handler tears the - // replacement down instead of continuing with a live - // runtime unreachable by its advertised id. - let _ = state - .adopt_session_init_with_completion( - cli_id, - &session_id, - &session_type, - settings.as_ref(), - Some(&adoption.supersedes), - identity_sink.clone(), - provenance.as_ref(), - Some(adoption.adopted_tx), - ) - .await; - let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); - if let Some(frame) = - sdk_line_to_frame(&adoption.preseeded_init, &stamp, &session_type) - { - let _ = broadcast_tx.send(frame); - } - } else { - let _ = adoption.adopted_tx.send(None); - } - } // The sidecar's report of its SDK stream's end (its last line before it // exits), read by the end-of-output path below. let mut stream_ended: Option = None; @@ -9254,171 +7967,6 @@ fn lost_session_frame(session_id: &str, session_type: SessionType) -> ServerMess }) } -/// The `resumeDropsTurn` refusal prefix (SDK-documented): an -/// `error_during_execution` result whose message begins with this. NEVER -/// surfaced raw — the consumer maps it to the plain-resume recovery (one retry -/// with the guard omitted; the refusal is deterministic, so it is never retried -/// twice). -const RESUME_DROPS_TURN_REFUSAL_PREFIX: &str = "Resume rejected by --resume-drops-turn:"; - -/// The `create.failed` code of the same refusal when the CLI reports it at boot -/// (`crates/freshell-claude-sidecar/boot.mjs`). -const FORK_GUARD_REFUSED_CODE: &str = "FORK_GUARD_REFUSED"; - -/// Why one rollback-respawn attempt failed. -enum RollbackSpawnError { - /// The create's early output carried the `--resume-drops-turn` refusal - /// prefix — retry ONCE with the guard omitted. - GuardRefusal, - Other(String), -} - -/// The pieces of a successful rollback respawn, ready for registration + the -/// consumer's preseeded adoption. -struct RollbackSpawned { - child: Child, - tree_guard: crate::claude_spawn::SidecarTreeGuard, - stdin: ChildStdin, - /// The stdout line reader, positioned AFTER the preread `sdk.session.init`. - reader: tokio::io::Lines>, - ownership_id: String, - /// The labels the respawn's stderr log carries (bound at registration). - log_labels: crate::claude_sidecar_io::SidecarLogLabels, - /// The sidecar-keyed id (`created.sessionId`). - sidecar_session_id: String, - /// The preread `sdk.session.init` line (the consumer adopts it preseeded). - preseeded_init: Value, - /// The new durable id (parsed from the preread init). - cli_id: String, -} - -impl FreshClaudeState { - /// Rollback post-spawn failure teardown (kata 1wxv Task 4): drop the - /// freshly-registered fork's map record, kill its sidecar tree, and clear - /// this map key's cli_index aliases — used identically by the acceptance- - /// failure and lease-revoked legs (an unadopted/uncompleted fork must never - /// answer sends). - async fn teardown_rollback_fork(&self, map_key: &str) { - if let Some(session) = self.sessions.lock().await.remove(map_key) { - session.consumer.abort(); - let mut child = session.child; - let _ = child.start_kill(); - reap_owned_claude_sidecars(&session.ownership_id); - } - // Finding 1: demote (not drop) the torn-down fork's aliases. - self.evict_cli_index_aliases(map_key).await; - } - - /// One rollback-respawn attempt (kata 1wxv Task 4): boot a sidecar for - /// `spec` ([`Self::boot_sidecar`]: spawn, arm the lease kill handle, write - /// the create line, read `created`), then PREREAD its output until the - /// `sdk.session.init` line — the `--resume-drops-turn` refusal - /// watch lives in this pre-read, BEFORE any durable state moves (the refusal - /// is retried by the caller with the guard omitted; the pre-read ordering is - /// how a refused fork can never adopt). - async fn rollback_spawn_create( - &self, - spec: &ClaudeLaunchSpec, - lease_guard: Option<&mut crate::FreshSessionLeaseGuard>, - ) -> Result { - let BootedSidecar { - mut child, - stdin, - mut reader, - ownership_id, - log_labels, - tree_guard, - created, - } = self - .boot_sidecar(spec, lease_guard, None) - .await - .map_err(|error| match error { - // The CLI refuses a fork whose drops-turn guard does not hold at - // boot (T5): the same refusal the init pre-read below watches for. - EstablishError::Boot(failure) if failure.is_refusal(FORK_GUARD_REFUSED_CODE) => { - RollbackSpawnError::GuardRefusal - } - other => RollbackSpawnError::Other(other.message(EstablishLane::Create)), - })?; - match read_session_init(&mut reader, SIDECAR_CREATE_BUDGET).await { - Ok(init) => { - let cli_id = init - .get("cliSessionId") - .and_then(Value::as_str) - .map(str::to_string) - .unwrap_or_default(); - Ok(RollbackSpawned { - child, - tree_guard, - stdin, - reader, - ownership_id, - log_labels, - sidecar_session_id: created.session_id, - preseeded_init: init, - cli_id, - }) - } - Err(err) => { - let _ = child.start_kill(); - reap_owned_claude_sidecars(&ownership_id); - Err(err) - } - } - } -} - -/// Read the respawned sidecar's output until `sdk.session.init`, mapping each -/// line to its outcome: the init line (returned verbatim for preseeded -/// adoption), or — checked FIRST — any line carrying the -/// `--resume-drops-turn` refusal prefix (never surfaced raw). Other early lines -/// (e.g. a pre-init `sdk.status`) are dropped at this seam; the consumer resumes -/// at the NEXT line after init. -async fn read_session_init( - reader: &mut tokio::io::Lines>, - budget: Duration, -) -> Result { - let read = async { - loop { - match reader.next_line().await { - Ok(Some(line)) => { - let trimmed = line.trim(); - if trimmed.is_empty() { - continue; - } - if trimmed.contains(RESUME_DROPS_TURN_REFUSAL_PREFIX) { - return Err(RollbackSpawnError::GuardRefusal); - } - let Ok(value) = serde_json::from_str::(trimmed) else { - continue; - }; - if value.get("type").and_then(Value::as_str) == Some("sdk.session.init") { - return Ok(value); - } - // pre-init non-init line: tolerated, dropped. - } - Ok(None) => { - return Err(RollbackSpawnError::Other( - "sidecar stdout closed before sdk.session.init".to_string(), - )) - } - Err(e) => { - return Err(RollbackSpawnError::Other(format!( - "sidecar stdout read error: {e}" - ))) - } - } - } - }; - match tokio::time::timeout(budget, read).await { - Ok(result) => result, - Err(_) => Err(RollbackSpawnError::Other(format!( - "sidecar did not answer sdk.session.init within {}s", - budget.as_secs() - ))), - } -} - // ── Node sidecar spawn ────────────────────────────────────────────────────────────────── /// A just-spawned claude sidecar: the owned child with its stdin and stdout, the @@ -10010,9 +8558,8 @@ pub use create_outcome::ClaudeCreateOutcome; mod establish; mod genesis; -use establish::{ - BootedSidecar, DurableClaim, EstablishError, EstablishLane, EstablishPlan, RegisterUnder, -}; +mod rollback; +use establish::{DurableClaim, EstablishLane, EstablishPlan, RegisterUnder}; #[cfg(test)] mod teardown_tests; @@ -11447,6 +9994,8 @@ const liveSessions = new Set( // cancellation authority at the pre-handoff residence. const compactQueue = [] const configuredSettings = new Map() +// Sessions booted under a server-minted id whose init waits for the first send. +const deferredInit = new Map() const rl = readline.createInterface({ input: process.stdin, terminal: false }) rl.on('line', (line) => { const trimmed = line.trim() @@ -11470,10 +10019,15 @@ rl.on('line', (line) => { 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') + if (msg.sessionId) { + // A boot under a server-minted id (a rollback's replacement, a boot + // from a genesis): the CLI runs under that id and, like the real CLI + // (T1), reports it only once a prompt runs — at the first send. + deferredInit.set(sessionId, msg.sessionId) + return + } // Mirror the real sidecar's post-create init: echo the resume boot's id as the // durable id when present (resume continuity), else a fixed fake uuid. - // Task 4 (kata 1wxv): a fork boot mints a FRESH durable id, - // never an echo — fork-at-point adoption depends on the id changing. const cliSessionId = msg.boot?.kind === 'fork' ? `fork-${process.pid}-${counter}-0000-4000-8000-000000000000` : (msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') @@ -11494,6 +10048,11 @@ rl.on('line', (line) => { // Test hook: lets tests kill the sidecar THROUGH the public API to exercise // the consumer-exit eviction path (ledger A9). if (msg.text === '__exit__') process.exit(0) + const deferredCliId = deferredInit.get(msg.sessionId) + if (deferredCliId) { + deferredInit.delete(msg.sessionId) + console.log(JSON.stringify({ type: 'sdk.session.init', sessionId: msg.sessionId, cliSessionId: deferredCliId, model: 'fake-model', cwd: '/tmp', tools: [] })) + } // Task 4 (kata 1wxv) in_turn edge hooks: each magic text emits ONE canned sdk.* // line so the four-edge/fail-closed busy contract can drive every edge. if (msg.text === '__emit_result_error__') { @@ -15902,6 +14461,7 @@ rl.on('line', (line) => { &mut no_ticket, new_dur, &placeholder, + &placeholder, Some(replacement_pid), "claude/rollback", ) @@ -19714,7 +18274,6 @@ rl.on('line', (line) => { Arc::new(std::sync::atomic::AtomicBool::new(false)), Arc::new(std::sync::Mutex::new(TurnTracker::default())), Arc::new(std::sync::Mutex::new(None)), - None, None, // provenance: test lane is conn-less (D8) ); @@ -21121,7 +19680,6 @@ rl.on('line', (line) => { Arc::clone(&in_turn), Arc::clone(&turn_tracker), Arc::clone(&last_synthesized_complete_at), - None, None, // provenance: test lane is conn-less (D8) ); st.sessions.lock().await.insert( @@ -21267,7 +19825,6 @@ rl.on('line', (line) => { Arc::clone(&in_turn), Arc::clone(&turn_tracker), Arc::clone(&last_synthesized_complete_at), - None, None, // provenance: test lane is conn-less (D8) ); st.sessions.lock().await.insert( @@ -21300,17 +19857,23 @@ rl.on('line', (line) => { stdin_log } - /// Poll until the session's `cli_session_id` differs from `prior` (the fork - /// adoption mints a NEW durable id mid-rollback) or the budget dies. + /// Poll until the session `map_key` names (C13: the rollback's + /// replacement, which the map key resolves to once it took the original's + /// place) has a `cli_session_id` other than `prior` (the replacement's + /// durable id), or the budget dies. async fn await_adopted_durable(st: &FreshClaudeState, map_key: &str, prior: &str) -> String { let deadline = tokio::time::Instant::now() + Duration::from_secs(15); loop { - let current = st - .sessions - .lock() - .await - .get(map_key) - .and_then(|s| s.cli_session_id.clone()); + let key = st.resolve_session_key(map_key).await; + let current = match key { + Some(key) => st + .sessions + .lock() + .await + .get(&key) + .and_then(|s| s.cli_session_id.clone()), + None => None, + }; if let Some(current) = current { if current != prior { return current; diff --git a/crates/freshell-freshagent/src/claude/establish.rs b/crates/freshell-freshagent/src/claude/establish.rs index 0f6a89669..b39fd053a 100644 --- a/crates/freshell-freshagent/src/claude/establish.rs +++ b/crates/freshell-freshagent/src/claude/establish.rs @@ -14,9 +14,11 @@ //! ``` //! //! The guards are the ones the create and attach handlers used to run inline, -//! moved here unchanged, so the kill/claim/commit race tests pin this one path. The -//! rollback respawn boots through `boot_sidecar` too; its registration still waits -//! for the fork's first turn until C13 moves it onto `establish_runtime`. +//! moved here unchanged, so the kill/claim/commit race tests pin this one path. +//! An undo or redo establishes its replacement runtime here too (C13): it boots +//! the replacement under the id the rollback minted, registered as already +//! adopted, while the original is still live; the rollback then moves +//! ownership and stops the original (`claude/rollback.rs`). use super::*; @@ -28,6 +30,8 @@ use crate::claude_launch::ClaudeLaunchSpec; pub(super) enum EstablishLane { Create, AttachResume, + /// An undo or redo's replacement runtime. + Rollback, } impl EstablishLane { @@ -35,6 +39,7 @@ impl EstablishLane { match self { Self::Create => "create", Self::AttachResume => "attach-resume", + Self::Rollback => "rollback", } } } @@ -46,6 +51,20 @@ pub(super) enum RegisterUnder { /// The attaching pane's id: an attach-resume (the client routes the pane's /// frames by it). Pane(String), + /// A durable id the server minted and the CLI boots under (a rollback's + /// replacement): registered as already adopted, so it is the map key, the + /// envelope stamp and the session's durable id at once. + Adopted(String), +} + +/// The per-session state a replacement runtime takes over from the runtime it +/// replaces (an undo or redo, design §4.6): the turn lock a parked send waits +/// on, the busy truth and the attention clock. Only the process changes. +pub(super) struct InheritedRuntime { + pub(super) turn_lock: Arc>, + pub(super) in_turn: Arc, + pub(super) turn_tracker: Arc>, + pub(super) last_synthesized_complete_at: Arc>>, } /// The durable session a launch claims, with the identity's dead state as it @@ -84,6 +103,8 @@ pub(super) struct EstablishPlan<'a> { pub(super) partial_runtime_key: Option, /// The start's cancellation slot: the watchdog signals the spawned sidecar. pub(super) start_pid_slot: &'a crate::ownership_lane::SidecarPidSlot, + /// The state a replacement takes over (a rollback); `None` starts afresh. + pub(super) inherit: Option, } /// A runtime that is registered and committed. @@ -95,6 +116,8 @@ pub(super) struct Established { freshell_protocol::session_names::SessionNameRef, freshell_protocol::session_names::SessionNameRecord, )>, + /// The established sidecar's pid (the coordinator's owner identity). + pub(super) sidecar_pid: Option, } /// Why a runtime was not established. Everything the launch spawned is torn down. @@ -360,6 +383,7 @@ impl FreshClaudeState { naming, partial_runtime_key, start_pid_slot, + inherit, } = plan; let start = StartWatch { pid_slot: start_pid_slot, @@ -392,9 +416,10 @@ impl FreshClaudeState { // into the session record). let sidecar_pid = child.id(); let minted = created.session_id; - let map_key = match register_under { - RegisterUnder::Minted => minted.clone(), - RegisterUnder::Pane(pane) => pane, + let (map_key, adopted) = match register_under { + RegisterUnder::Minted => (minted.clone(), None), + RegisterUnder::Pane(pane) => (pane, None), + RegisterUnder::Adopted(id) => (id.clone(), Some(id)), }; // Unified agent names (Task 2): a create's pending handle is admitted with @@ -477,16 +502,26 @@ impl FreshClaudeState { // map lock (review PRB-C10): the consumer reaches its session only // through that lock, so no line it reads (the boot's command catalog, a // CLI death right after the boot) can find the session missing. - let durable = claim.as_ref().map(|claim| claim.durable.clone()); + let durable = claim + .as_ref() + .map(|claim| claim.durable.clone()) + .or(adopted); let configuration_settings = recorded_settings.clone().unwrap_or_default(); let broadcast_id = Arc::new(std::sync::Mutex::new(map_key.clone())); log_labels.bind_session(&broadcast_id); let pending = Arc::new(std::sync::Mutex::new(ClaudePending::default())); - let in_turn = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let turn_tracker = Arc::new(std::sync::Mutex::new(TurnTracker::default())); - // Task 5: the session's death-mint clock (see the field doc). - let last_synthesized_complete_at = Arc::new(std::sync::Mutex::new(None)); - let turn_lock = Arc::new(TokioMutex::new(())); + let InheritedRuntime { + turn_lock, + in_turn, + turn_tracker, + // Task 5: the session's death-mint clock (see the field doc). + last_synthesized_complete_at, + } = inherit.unwrap_or_else(|| InheritedRuntime { + turn_lock: Arc::new(TokioMutex::new(())), + in_turn: Arc::new(std::sync::atomic::AtomicBool::new(false)), + turn_tracker: Arc::new(std::sync::Mutex::new(TurnTracker::default())), + last_synthesized_complete_at: Arc::new(std::sync::Mutex::new(None)), + }); // A just-booted session has announced nothing: no turn can be in flight // before the client sends. let last_status = Arc::new(std::sync::Mutex::new("idle".to_string())); @@ -504,7 +539,6 @@ impl FreshClaudeState { Arc::clone(&in_turn), Arc::clone(&turn_tracker), Arc::clone(&last_synthesized_complete_at), - None, provenance, ); self.test_holds.registration().await; @@ -564,7 +598,7 @@ impl FreshClaudeState { } } // Round 6 (Finding 2): the seat→durable mapping is durable at registration. - if let Some(durable) = durable.as_deref() { + if let Some(durable) = claim.as_ref().map(|claim| claim.durable.as_str()) { if let Some(sink) = self.identity_sink() { if let Err(e) = sink .record_alias_tombstone( @@ -644,7 +678,9 @@ impl FreshClaudeState { if let Err(outcome) = self.commit_lane_claim_at(own_ticket, &map_key, sidecar_pid) { let event = match lane { EstablishLane::Create => "freshagent.claude.create_commit_stale", - EstablishLane::AttachResume => "freshagent.claude.attach_commit_stale", + EstablishLane::AttachResume | EstablishLane::Rollback => { + "freshagent.claude.attach_commit_stale" + } }; tracing::error!(target: "invariant", event, provider = PROVIDER, session_id = ?durable, @@ -664,6 +700,7 @@ impl FreshClaudeState { Ok(Established { map_key, naming_projection, + sidecar_pid, }) } } diff --git a/crates/freshell-freshagent/src/claude/rollback.rs b/crates/freshell-freshagent/src/claude/rollback.rs new file mode 100644 index 000000000..1fc814d3a --- /dev/null +++ b/crates/freshell-freshagent/src/claude/rollback.rs @@ -0,0 +1,1474 @@ +//! Undo and redo for claude/kilroy (core-runtime design §4.5 and §4.6, C13). +//! +//! A rollback replaces the session's runtime with a boot-verified fork (or a +//! fresh conversation for a first-turn undo) under a durable id minted here. +//! The replacement boots while the original is still live (`establish_runtime` +//! answers once the CLI booted it, T2/T6), so a refused or failed boot leaves +//! the original untouched. Only after the replacement is registered and its +//! binding written does ownership move to it and the original stop. + +use super::*; + +use crate::claude_launch::{ClaudeBoot, ClaudeGenesis, ClaudeLaunchSpec}; +use crate::claude_snapshot as snap; +use crate::rollback_record::*; + +use super::establish::{ + EstablishError, EstablishLane, EstablishPlan, InheritedRuntime, RegisterUnder, +}; + +/// Decision 2: the CLI refused the fork's drops-turn guard because the turn +/// being undone absorbed another message (a send made during a tool call, a +/// background agent's notice) the pane never showed as its own turn. +pub(crate) const ROLLBACK_FOLDED_TURN_MESSAGE: &str = + "Claude folded another message into that turn, so it can't be undone to that point."; + +/// The CLI no longer has the point the rollback forks at. +pub(crate) const ROLLBACK_POINT_GONE_MESSAGE: &str = + "That point is no longer in Claude's copy of the conversation, so it can't be undone to."; + +/// The `create.failed` codes of a fork the CLI refused (`claude-sidecar/boot.mjs`). +const FORK_GUARD_REFUSED_CODE: &str = "FORK_GUARD_REFUSED"; +const RESUME_POINT_NOT_FOUND_CODE: &str = "RESUME_POINT_NOT_FOUND"; + +/// What a rollback replaces the session's conversation with, from the reads. +struct RollbackPlan { + /// The display turns the undo removes (or the redo restores). + removed_turns: Vec, + /// The composer refill (the removed step's prompt as typed). + prompt_text: String, + /// The redo chain's root and its raw-chain tip at undo time. + chain_root: String, + original_tip_uuid: Option, + can_redo_after: bool, + /// How the replacement comes to be (its binding row records it). + genesis: ClaudeGenesis, + /// The conversation whose transcript the fork reads, and where it is. + source: String, + source_path: Option, +} + +/// A session's effective transcript (design §4.5): what it holds now, and the +/// real transcript a fork of it reads. +struct EffectiveTranscript { + /// The conversation as the session holds it. + text: String, + /// The session a fork of it reads: itself, or, for a pending fork, its + /// parent (source collapse). + source: String, + /// That source's own transcript text and path (empty / `None` for a + /// `Fresh` session, which has none). + source_text: String, + source_path: Option, +} + +/// Why a session's effective transcript could not be read. +enum EffectiveTranscriptError { + /// No transcript and no genesis: nothing the store knows. + Missing, + /// The store could not be checked (G28: never "empty"). + Unreadable(snap::LocateError), + /// A located transcript (or a pending fork's parent) could not be read. + Read(String), +} + +impl EffectiveTranscriptError { + /// The rollback refusal for this (`Missing` is the caller's to word). + fn refusal(&self, op: &RollbackRequest, session_id: &str) -> freshell_protocol::ServerMessage { + match self { + Self::Unreadable(error) => snap::unreadable_rollback_refusal(op, session_id, error), + Self::Missing => rollback_error_frame(op, "INTERNAL_ERROR", "transcript missing"), + Self::Read(message) => rollback_error_frame(op, "INTERNAL_ERROR", message), + } + } +} + +/// The rollback error a replacement that was not established answers with. +fn rollback_failure(error: &EstablishError) -> (&'static str, String) { + match error { + EstablishError::Boot(failure) if failure.is_refusal(FORK_GUARD_REFUSED_CODE) => { + ("ROLLBACK_REFUSED", ROLLBACK_FOLDED_TURN_MESSAGE.to_string()) + } + EstablishError::Boot(failure) if failure.is_refusal(RESUME_POINT_NOT_FOUND_CODE) => { + ("ROLLBACK_REFUSED", ROLLBACK_POINT_GONE_MESSAGE.to_string()) + } + other => ("INTERNAL_ERROR", other.message(EstablishLane::Rollback)), + } +} + +impl FreshClaudeState { + /// Decision 6: pending cards inside undone turns are CANCELLED, never silently + /// resolved. Emits the exact `freshAgent.permission.cancelled` / + /// `freshAgent.question.cancelled` frames the fold consumes, one per parked + /// entry, then clears the pending map. Invoked BEFORE the old sidecar is + /// torn down. + pub(super) async fn emit_pending_cancellations( + &self, + map_key: &str, + session_id: &str, + session_type: &str, + ) { + let pending = { + let guard = self.sessions.lock().await; + guard.get(map_key).map(|s| Arc::clone(&s.pending)) + }; + let Some(pending) = pending else { return }; + let (permissions, questions) = { + let mut p = pending.lock().expect("pending lock"); + ( + std::mem::take(&mut p.permissions), + std::mem::take(&mut p.questions), + ) + }; + for entry in permissions { + self.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { + event: json!({ + "type": "freshAgent.permission.cancelled", + "sessionId": session_id, + "requestId": entry.request_id, + }), + provider: PROVIDER.to_string(), + session_id: session_id.to_string(), + session_type: session_type.to_string(), + })); + } + for entry in questions { + self.broadcast(&ServerMessage::FreshAgentEvent(FreshAgentEvent { + event: json!({ + "type": "freshAgent.question.cancelled", + "sessionId": session_id, + "requestId": entry.request_id, + }), + provider: PROVIDER.to_string(), + session_id: session_id.to_string(), + session_type: session_type.to_string(), + })); + } + } + + /// Task-4 compensation: a fork/create failure AFTER the rollback record's + /// successful pre-write UNDOES that pre-write before the refusal is + /// answered — fork-at-point provably never mutates the ORIGINAL's history + /// (the Stage-2 hash-identical invariant), so the ledger must not describe a + /// rollback that never took effect. The provider-mutation triad (codex's + /// Rpc-vs-transport split) has no claude analog: EVERY claude failure leg is + /// provably-unmoved by construction. Task 4 review (Minor-3): when the + /// pre-op record was ABSENT, the faithful restore of "nothing was here" is + /// a DELETE of the just-created row — never a fabricated empty record. + async fn compensate_rollback_record( + &self, + durable_id: &str, + existing: Option, + ) { + let Some(sink) = self.identity_sink() else { + return; + }; + let result = match existing { + Some(restore) => sink.record_rollback(PROVIDER, durable_id, restore).await, + None => sink.delete_rollback(PROVIDER, durable_id).await, + }; + if let Err(e) = result { + tracing::warn!(error = %e, session = %durable_id, "freshagent.claude.rollback_compensate_failed"); + } + } + + /// Handle a `freshAgent.undo` / `freshAgent.redo` for claude/kilroy (core + /// design §4.5, C13; kata 1wxv Task 4 for the record and its redo contract): + /// conversation rollback by a boot-verified FORK RUNTIME. + /// + /// The replacement gets a fresh durable id C, minted here, and a genesis + /// (§4.1): `Fork{source, at, dropsTurn}` (the SDK's lazy fork: `resume`, + /// `resumeSessionAt`, `forkSession`, `sessionId: C`; the standalone + /// `forkSession()` fn is never used, it remaps every uuid), or `Fresh` when + /// the undo removes the first step (r2: legal; the empty conversation is + /// the target). The source is the session's EFFECTIVE transcript: its own + /// file, or, for a session that is itself a pending fork, its parent's + /// prefix (source collapse: chains never nest, so a second undo with no + /// send in between forks the parent at the earlier point). Redo re-forks + /// the chain root at a later point under the tip+LCP validity contract + /// (`REDO_UNAVAILABLE` + `REDO_REMOVED_HISTORY_COPY` when history moved). + /// + /// Ordering (durable-BEFORE-mutation, spawn before teardown): toTurn + /// validation → `rollback_in_flight` single-flight (FIRST) → turn lock + /// (held across the whole handler; a send parked on it re-resolves to the + /// replacement afterwards) → busy gate (`BUSY_TURN`; the tracker plus the + /// quiesce probe until C17) → coordinator claim and lease on the original + /// → transcript reads and resume math → AWAITED record pre-write → quiesce + /// probe → pending-card cancellations → `establish_runtime(C)` while the + /// original is still live: the sidecar answers `created` only once the CLI + /// booted the fork (T6: it writes nothing until its first prompt), so no + /// init is ever awaited (G01) → ONE awaited ledger batch (binding C with + /// its genesis, the original's settings and `supersedes`, and the rollback + /// record re-keyed to C) → ownership moves to C → the original's record + /// is replaced by C's (aliases follow) and its sidecar is stopped as a + /// requested teardown → `materialized` + rolledBack broadcasts → the + /// requester's ack. Any failure before the move compensates the record and + /// tears C down; the original is untouched and live. A fork the CLI refuses + /// because the turn being undone absorbed another message (the + /// drops-turn guard, decision 2) or whose point is gone is + /// `ROLLBACK_REFUSED`, never retried without the guard. A rollback never + /// chimes. + pub async fn handle_rollback( + &self, + op: crate::rollback_record::RollbackRequest, + reply_sink: freshell_terminal::FrameSink, + ) { + let Some(map_key) = self.resolve_session_key(&op.session_id).await else { + reply_sink(rollback_error_frame( + &op, + "INVALID_SESSION_ID", + "claude session not found", + )); + return; + }; + // `turnId` absent on a toTurn frame is a SERVER-side validation error + // (never a zod refinement — the frozen contract keeps bare objects). + if op.mode == RollbackModeReq::ToTurn && op.turn_id.is_none() { + reply_sink(rollback_error_frame( + &op, + "INVALID_ROLLBACK_TARGET", + "rollback toTurn requires a turnId", + )); + return; + } + let ( + durable_id, + in_turn, + turn_tracker, + turn_lock, + session_type, + last_synthesized_complete_at, + ) = { + let guard = self.sessions.lock().await; + match guard.get(&map_key) { + Some(s) => ( + s.cli_session_id.clone().unwrap_or_else(|| map_key.clone()), + s.in_turn.clone(), + s.turn_tracker.clone(), + s.turn_lock.clone(), + session_type_str(op.session_type), + // Task 5: the fork continues the SAME per-session + // death-mint clock (carried like `in_turn` — the fork is + // the same logical session lifetime, so a death-mint + // after the fork can never regress past one before it). + s.last_synthesized_complete_at.clone(), + ), + None => { + reply_sink(rollback_error_frame( + &op, + "INVALID_SESSION_ID", + "claude session not found", + )); + return; + } + } + }; + // Rollback-vs-rollback single-flight (lock order: FIRST, before the turn + // lock — never the reverse). handle_send never acquires this registry — + // it only POLLS membership while a miss resolves (task 4 review C1); a + // send that resolved the session waits on its turn lock instead, and + // follows the replacement after it (C13). + let Some(_guard) = self.rollback_in_flight.try_acquire(&durable_id) else { + reply_sink(rollback_error_frame( + &op, + "INTERNAL_ERROR", + &format!("rollback already in progress for {durable_id}"), + )); + return; + }; + // b8ke ext r29 F2 + focused ep5 r4 F3: the rollback's observed + // generation fence — the wire's observedEpoch/observedGeneration + // pair (additive since ext r21 F2) is HONORED verbatim: a request + // already stale on ARRIVAL (its pair predates a completed + // handoff/crash advance) can never be made current — the claim + // under the coordinator lock typed-refuses it, so a queued + // old-generation rollback can never tear down or roll back a + // newer runtime. A half-sent pair is the typed INVALID_FENCE + // refusal (the F7 discipline). And since ep5 r4 F3 the + // FULLY-ABSENT pair is the SAME typed refusal on a + // coordinator-wired lane — the ext-r13-F3 entry-observation + // discipline this replaces was the laundering the round-4 + // reviewer rejects: a handler-START observation can detect an + // ownership change occurring AFTER it, but nothing can detect + // that an unfenced request was already stale WHEN RECEIVED + // (queued under generation N, arriving after the session moved + // away and returned at N+2 — indistinguishable from a current + // request, so it could adopt and roll back the NEWER owner). + // The refusal is the actionable contract (identical to the + // opencode lanes' FENCE_REQUIRED): the client re-observes the + // owner record and retries WITH the pair. Only the UNWIRED lane + // (no coordinator — the legacy no-coordinator lane) proceeds + // unfenced: there is nothing to launder against. + let rollback_fence = + match crate::ownership_lane::wire_fence(op.observed_epoch, op.observed_generation) { + Ok(fence) => fence, + Err(err) => { + tracing::warn!(target: "freshell_freshagent::claude", + session_id = %durable_id, request_id = %op.request_id, code = err.code(), + "fresh_agent_rollback_refused: the observed fence is half-sent (invalid)"); + reply_sink(rollback_error_frame(&op, err.code(), err.message())); + return; + } + }; + let rollback_fence = match rollback_fence { + Some(fence) => Some(fence), + None => { + if self.ownership.is_some() { + tracing::warn!(target: "freshell_freshagent::claude", + session_id = %durable_id, request_id = %op.request_id, + code = "INVALID_FENCE", + "fresh_agent_rollback_refused: the rollback carried no observed \\ + ownership pair against a coordinator-wired lane — the client \\ + re-observes the owner record and retries with the pair (kata \\ + b8ke focused ep5 r4 F3)"); + reply_sink(rollback_error_frame( + &op, + "INVALID_FENCE", + "the rollback carried no observed ownership pair; re-observe the \\ + session's owner record and retry", + )); + return; + } + None + } + }; + // Held for the REST of this handler. in_turn is set by handle_send UNDER + // this same lock BEFORE the sidecar write (the check-then-set window is + // closed): observed false here means no op is in flight. Focused ep1-r1 + // F1 through ep2-r2: `in_turn` is the FIFO tracker's DERIVED busy truth + // — recomputed from `TurnTracker::busy()` inside every mutation's + // critical section — so the gate stays closed through every op that is + // queued or mid-compaction, whether its turn ended technically (the + // interrupted active turn included, ep2-r2 F2: the queue survives it) + // or only structurally (every queued op owes its own terminal edge). + let _turn = turn_lock.lock().await; + if in_turn.load(std::sync::atomic::Ordering::SeqCst) { + // ep3-r3 F1: owed debt broken down — queue entries that are ONLY + // unpromoted compacts (nothing running, no candidate pending, no + // Turn owed) are provably quiescent and absorb (see + // [`absorb_unstarted_compact_debt`]). Everything else is genuinely + // mid-execution risk and refuses. + if !absorb_unstarted_compact_debt(&in_turn, &turn_tracker) { + reply_sink(rollback_error_frame( + &op, + "BUSY_TURN", + ROLLBACK_BUSY_MESSAGE, + )); + return; + } + } + + // Lease discipline: claim the OLD durable id exactly like the + // create-resume path so a concurrent attach cannot bind the pre-rollback + // id mid-fork. A REFUSAL LEG — before any record write or boot. + // kata b8ke Task 3: the coordinator claim comes FIRST — before the + // provider lease — so the rollback window (the replacement's boot and + // the move of ownership onto it) is cross-kind-authoritative. The "we + // ARE the bound live owner" case surfaces as AdoptLive (same-kind live). + let rollback_lease_id = format!("rollback-{}", uuid::Uuid::new_v4()); + // b8ke delta round-2 F2: the replacement start's watchdog + // machinery (the sidecar pid slot + the settle guard — see the + // create-resume claim site). + let start_pid_slot = crate::ownership_lane::sidecar_pid_cancel_slot(); + let mut _start_cancellation: Option = None; + let granted_ticket = match self.begin_lane_claim_at( + &durable_id, + &rollback_lease_id, + rollback_fence, + "freshclaude/rollback", + ) { + crate::ownership_lane::LaneClaim::Granted(ticket) => Some(ticket), + crate::ownership_lane::LaneClaim::Unwired => None, + crate::ownership_lane::LaneClaim::Adopt => { + // Same-kind live under this exact id (the lease arm's "we + // ARE the bound live owner" twin): proceed WITHOUT a claim + // — the binding already names this lane. + None + } + crate::ownership_lane::LaneClaim::Refused(outcome) => { + tracing::warn!(target: "freshell_freshagent::claude", + session_id = %durable_id, outcome = ?outcome, + "fresh_agent_rollback_refused: the ownership coordinator refused the \ + claim (kata b8ke cross-kind authority)" + ); + reply_sink(rollback_error_frame( + &op, + "SESSION_RESERVED", + "Another resume for this session is in flight", + )); + return; + } + }; + let mut lease_guard: Option = None; + // b8ke delta round-2 F2 + focused episode-2 round-3 F5: the granted + // claim registers its REAL cancellation + settle with the watchdog + // IMMEDIATELY — the replacement's boot waits up to the + // 45-second create budget for the CLI; a watchdog sweep in that wait + // must find armed, cancellable evidence, never a fence with nothing + // registered. The sidecar pid slot arms when the child exists + // (below); the PARTIAL runtime registers NOW with the + // kind-appropriate identity (pid unknown until the spawn returns — + // the same evidence shape the codex spawn hook rearms with the + // real pid). + // b8ke ext r13 F3: the Adopt arm holds REAL authority across the + // replace window — the guard arms on the live incumbent's key and + // is held through the replacement's boot + register + rekey and the + // original's stop (this handler's scope), so a handoff or stop begin inside the window + // answers the typed Blocked outcome and can NEVER start a terminal + // beside the replacement (pre-r13 the Adopt arm proceeded with NO + // claim, and the rekey's verify was the only — post-overlap — + // defense). + // b8ke ext r38 F1: the arm is the ATOMIC ADOPT — the request's + // ORIGINAL observed fence (the pair the coordinator's claim already + // verified when it answered AdoptLive) plus the EXPECTED + // fresh-agent owner, validated in ONE coordinator-lock decision. + // Pre-r38 the arm RE-OBSERVED whatever was live and armed on it + // (the discard-and-re-observe laundering): a terminal handoff + // committing between the claim and the arm armed the guard on the + // TERMINAL owner and the rollback spawned a Fresh Claude sidecar + // beside the terminal before the late rekey tore it down — two + // live writers. The adopt closes the window: the observed pair + // names the record the rollback decided on; any advance or a + // foreign owner refuses typed BEFORE anything is killed or + // spawned. + let mut _rollback_adopt_guard: Option = None; + if granted_ticket.is_none() && self.ownership.is_some() { + // Test seam (b8ke ext r38 F1): park with the claim ANSWERED + // (AdoptLive) but the adopt guard NOT yet armed — the + // claim-to-arm race test's deterministic hold (the test + // commits a terminal handoff against the key mid-park). + if let (Some(parked), Some(release)) = ( + self.rollback_adopt_parked.as_ref(), + self.rollback_adopt_release.as_ref(), + ) { + parked.notify_one(); + let _ = release.notified().await; + } + let resolved = self.resolve_ownership_key(&durable_id); + // The Adopt arm only fires on a WIRED lane, where the + // original pair is REQUIRED (the ep5-r4-F3 gate above refused + // the absent pair typed). + let Some(adopt_fence) = rollback_fence else { + tracing::warn!(target: "freshell_freshagent::claude", + session_id = %durable_id, + "fresh_agent_rollback_refused: the Adopt arm requires the \ + original observed pair (unreachable — the gate above refused \ + the absent pair)" + ); + reply_sink(rollback_error_frame( + &op, + "SESSION_RESERVED", + "Another resume for this session is in flight", + )); + return; + }; + let expected_fresh_owner = freshell_ownership::OwnerIdentity { + kind: freshell_ownership::RuntimeOwnerKind::FreshAgent, + terminal_id: None, + live_session_key: None, + pid: None, + ownership_id: None, + }; + match crate::ownership_lane::arm_adopt_guard( + &self.ownership, + PROVIDER, + &resolved, + &format!("rollback-adopt-{rollback_lease_id}"), + &expected_fresh_owner, + adopt_fence, + "freshclaude/rollback-adopt", + ) { + crate::ownership_lane::LaneAttachGuard::Armed(guard) => { + _rollback_adopt_guard = Some(guard); + } + crate::ownership_lane::LaneAttachGuard::Unwired => {} + crate::ownership_lane::LaneAttachGuard::Refused => { + tracing::warn!(target: "freshell_freshagent::claude", + session_id = %durable_id, + "fresh_agent_rollback_refused: the Adopt arm's atomic adopt \ + refused to arm (ownership advanced or a foreign owner holds \ + the key) — the rollback aborts typed, nothing is killed or \ + spawned" + ); + reply_sink(rollback_error_frame( + &op, + "SESSION_RESERVED", + "Another resume for this session is in flight", + )); + return; + } + } + } + let mut own_ticket = granted_ticket; + if own_ticket.is_some() { + _start_cancellation = Some( + crate::ownership_lane::register_start_cancellation_for_ticket( + &self.ownership, + PROVIDER, + &durable_id, + &own_ticket, + crate::ownership_lane::pid_slot_cancellation(&start_pid_slot), + ), + ); + crate::ownership_lane::register_partial_fresh_runtime( + &self.ownership, + PROVIDER, + &durable_id, + &own_ticket, + &map_key, + None, + ); + } + for round in 0..2u8 { + match self.leases.claim( + PROVIDER, + &durable_id, + &rollback_lease_id, + crate::session_lease::now_epoch_ms(), + ) { + crate::session_lease::FreshSessionClaim::Acquired => { + lease_guard = Some(crate::FreshSessionLeaseGuard::armed( + Arc::clone(&self.leases), + PROVIDER, + &durable_id, + &rollback_lease_id, + )); + break; + } + crate::session_lease::FreshSessionClaim::BoundLive { live_session_key } => { + if live_session_key == map_key { + // We ARE the bound live owner of this exact id — proceed + // WITHOUT a lease (the binding already names this map key, + // which the fork keeps). + break; + } + reply_sink(rollback_error_frame( + &op, + "SESSION_RESERVED", + "Another resume for this session is in flight", + )); + return; + } + crate::session_lease::FreshSessionClaim::Held { .. } => { + reply_sink(rollback_error_frame( + &op, + "SESSION_RESERVED", + "Another resume for this session is in flight", + )); + return; + } + crate::session_lease::FreshSessionClaim::ExpiredNeedsKill { pid, ownership_id } => { + if round == 0 + && crate::session_lease::kill_and_confirm_tree_dead( + pid, + CLAUDE_SIDECAR_OWNERSHIP_ENV, + &ownership_id, + ) + .await + { + self.leases + .force_release_after_confirmed_kill(PROVIDER, &durable_id); + continue; + } + tracing::error!(target: "invariant", pid, session_id = %durable_id, + "fresh_agent_lease_expired_kill_unconfirmed: holding closed"); + reply_sink(rollback_error_frame( + &op, + "SESSION_RESERVED", + "Another resume for this session is in flight", + )); + return; + } + } + } + + let now = now_ms(); + let existing = self + .identity_sink() + .and_then(|s| s.load_rollback(PROVIDER, &durable_id)); + + let plan = match op.direction { + RollbackDirection::Undo => { + let current = match self.effective_transcript(&durable_id) { + Ok(current) => current, + Err(EffectiveTranscriptError::Missing) => { + reply_sink(rollback_error_frame( + &op, + "NOTHING_TO_UNDO", + UNDO_EMPTY_MESSAGE, + )); + return; + } + Err(error) => { + reply_sink(error.refusal(&op, &durable_id)); + return; + } + }; + let target = match op.mode { + RollbackModeReq::Step => snap::ResumeTarget::Step, + RollbackModeReq::ToTurn => { + snap::ResumeTarget::ToTurn(op.turn_id.clone().expect("validated")) + } + }; + let point = match snap::resolve_resume_point(¤t.text, &durable_id, target) { + Ok(p) => p, // resume_at_uuid None = "before the first message" — LEGAL (r2) + Err(snap::ResumeResolveError::Empty) => { + reply_sink(rollback_error_frame( + &op, + "NOTHING_TO_UNDO", + UNDO_EMPTY_MESSAGE, + )); + return; + } + Err(_) => { + reply_sink(rollback_error_frame( + &op, + "INVALID_ROLLBACK_TARGET", + &format!("turn {:?} is not in this conversation", op.turn_id), + )); + return; + } + }; + // Epoch rule (r2/r3): an undo landing while redo_destroyed is set + // re-roots the chain to the CURRENT durable id — the retained old + // original describes a branch a resend already permanently + // replaced; O's redo stays permanently dead. + let chain_root = existing + .as_ref() + .filter(|r| !r.redo_destroyed) + .and_then(|r| r.original_session_id.clone()) + .unwrap_or_else(|| durable_id.clone()); + // Tip anchor at UNDO time = the last raw-chain uuid of the + // CHAIN-ROOT transcript (at the first undo the current + // conversation IS the root). + let root_text = if chain_root == durable_id { + current.text.clone() + } else { + match self.read_located_transcript(&chain_root) { + Ok(Some(text)) => text, + Ok(None) => { + reply_sink(rollback_error_frame( + &op, + "INTERNAL_ERROR", + "chain-root transcript unreadable", + )); + return; + } + Err(error) => { + reply_sink(error.refusal(&op, &chain_root)); + return; + } + } + }; + let tip = snap::raw_chain_tip(&root_text); + // The fork reads the source's REAL transcript, so the guard is + // computed over it (§4.5 source collapse: a pending fork's + // source is its parent, past the point the earlier undo kept). + let genesis = match point.resume_at_uuid.clone() { + Some(at) => ClaudeGenesis::Fork { + parent: current.source.clone(), + drops_turn: snap::drops_turn_guard(¤t.source_text, Some(&at)), + at: Some(at), + }, + None => ClaudeGenesis::Fresh, + }; + // can_redo after the undo: the removed content provably exists in + // the original beyond the resume point (on the r2 first-turn leg + // the fresh conversation's tip is NONE — strictly beyond holds + // whenever a tip exists). + RollbackPlan { + removed_turns: point.removed_turns, + prompt_text: point.prompt_text, + chain_root, + original_tip_uuid: tip, + can_redo_after: true, + genesis, + source: current.source, + source_path: current.source_path, + } + } + RollbackDirection::Redo => { + let Some(record) = existing.clone() else { + reply_sink(rollback_error_frame( + &op, + "REDO_UNAVAILABLE", + REDO_EMPTY_MESSAGE, + )); + return; + }; + if record.redo_destroyed { + reply_sink(rollback_error_frame( + &op, + "REDO_UNAVAILABLE", + REDO_DESTROYED_MESSAGE, + )); + return; + } + let Some(original) = record.original_session_id.clone() else { + reply_sink(rollback_error_frame( + &op, + "REDO_UNAVAILABLE", + REDO_EMPTY_MESSAGE, + )); + return; + }; + let original_path = match self.locate_transcript(&original) { + snap::TranscriptLocation::Found(path) => path, + snap::TranscriptLocation::Absent => { + reply_sink(rollback_error_frame( + &op, + "REDO_UNAVAILABLE", + REDO_REMOVED_HISTORY_COPY, + )); + return; + } + snap::TranscriptLocation::Unreadable(error) => { + reply_sink(snap::unreadable_rollback_refusal(&op, &original, &error)); + return; + } + }; + let original_text = match std::fs::read_to_string(&original_path) { + Ok(t) => t, + Err(e) => { + reply_sink(rollback_error_frame(&op, "INTERNAL_ERROR", &e.to_string())); + return; + } + }; + // The current conversation is the session's effective + // transcript: empty after a first-turn undo, the kept prefix of + // a pending fork. A real read error is a loud INTERNAL_ERROR, + // never a silent empty chain yielding a false `redone` ack. + let current_text = match self.effective_transcript(&durable_id) { + Ok(current) => current.text, + Err(EffectiveTranscriptError::Missing) => { + reply_sink(rollback_error_frame( + &op, + "INTERNAL_ERROR", + "current transcript missing", + )); + return; + } + Err(error) => { + reply_sink(error.refusal(&op, &durable_id)); + return; + } + }; + // Redo validity contract (wire design; LBC-9): the original must + // BE the history the undo observed — raw-chain tip == the + // recorded tip — AND the current chain must still be a strict + // prefix of it (the LCP resolves PAST the current tip). Any + // miss => loud REDO_UNAVAILABLE + REDO_REMOVED_HISTORY_COPY; + // never silently re-fork over moved history. + let original_tip = snap::raw_chain_tip(&original_text); + if original_tip != record.original_tip_uuid { + reply_sink(rollback_error_frame( + &op, + "REDO_UNAVAILABLE", + REDO_REMOVED_HISTORY_COPY, + )); + return; + } + if snap::raw_lcp_end(¤t_text, &original_text) + != snap::raw_chain_tip(¤t_text) + { + reply_sink(rollback_error_frame( + &op, + "REDO_UNAVAILABLE", + REDO_REMOVED_HISTORY_COPY, + )); + return; + } + // The resume point = the LAST raw-chain uuid of the restored + // step's group — its assistant tail (r3 boundary rule). + let resume_at = match snap::redo_resume_target(&original_text, ¤t_text, &op) { + Ok(Some(uuid)) => uuid, + Ok(None) => { + reply_sink(rollback_error_frame( + &op, + "REDO_UNAVAILABLE", + REDO_EMPTY_MESSAGE, + )); + return; + } + Err(msg) => { + reply_sink(rollback_error_frame(&op, "INVALID_ROLLBACK_TARGET", &msg)); + return; + } + }; + // The ack payload for a redo = the RESTORED slice (display + // projection of the original's range just-after-the-prefix + // through the resume point). + let restored = + snap::restored_slice_turns(&original_text, ¤t_text, &resume_at); + let can_redo_after_redo = original_tip.as_deref() != Some(resume_at.as_str()); + // The guard: the one prompted turn of the original the redo + // still leaves out, when exactly one (`None` redoing TO the + // tip: nothing is discarded). + let drops_turn = snap::drops_turn_guard(&original_text, Some(&resume_at)); + RollbackPlan { + removed_turns: restored.turns, + prompt_text: restored.prompt_text, + chain_root: original.clone(), + original_tip_uuid: record.original_tip_uuid.clone(), + can_redo_after: can_redo_after_redo, + genesis: ClaudeGenesis::Fork { + parent: original.clone(), + at: Some(resume_at), + drops_turn, + }, + source: original, + source_path: Some(original_path), + } + } + }; + let RollbackPlan { + removed_turns, + prompt_text, + chain_root, + original_tip_uuid, + can_redo_after, + genesis, + source, + source_path, + } = plan; + + // Durable record FIRST (durable-BEFORE-mutation): computed from the + // pre-mutation reads, keyed by the CURRENT durable id, AWAITED before + // anything boots. The replacement's binding batch re-keys the row + // old→new inside the same awaited write — exactly one + // rollback-record-specific write, always pre-mutation. r3 UNION rule: + // `entries` accumulates; a NEW epoch freezes every prior entry (they stay + // first) and NEVER clears the bucket. + let mut record = existing + .clone() + .unwrap_or_else(|| RollbackRecord::empty(now)); + match op.direction { + RollbackDirection::Undo => { + // Epoch rule (r3 + delta-r1 F8's literal bookkeeping): an undo + // landing while redo_destroyed was set (or whose chain root + // re-roots away from the prior record's) is a NEW epoch — bump + // `current_epoch` (every existing entry freezes with its own + // epoch), the destroyed bit clears so the record's redo fields + // describe the NEW chain (the prior chain's redo stays + // permanently dead), `entries` is NEVER cleared. Same-epoch + // undos splice the new (earlier-in-conversation) entry BEFORE + // the existing current-epoch block — positions never read + // timestamps. + let new_epoch = record.redo_destroyed + || (existing + .as_ref() + .is_some_and(|r| r.original_session_id.is_some()) + && existing + .as_ref() + .and_then(|r| r.original_session_id.clone()) + .as_deref() + != Some(chain_root.as_str())); + if new_epoch { + record.redo_destroyed = false; + record.begin_new_epoch(); + } + record.splice_undo_entry( + RollbackEntry { + removed_turns: removed_turns.clone(), + prompt_text: prompt_text.clone(), + at_ms: now, + epoch: record.current_epoch, + }, + now, + ); + } + RollbackDirection::Redo => { + // The restored turns leave the CURRENT-epoch marker portion (they + // are live again); frozen prior-epoch markers can never match a + // restorable id (the redo contract's LCP/tip validation can only + // restore from the current chain root). + let restored_id_set: std::collections::HashSet<&str> = removed_turns + .iter() + .filter_map(|t| { + t.get("turnId") + .or_else(|| t.get("id")) + .and_then(Value::as_str) + }) + .collect(); + record.entries.retain_mut(|e| { + e.removed_turns.retain(|t| { + !t.get("turnId") + .or_else(|| t.get("id")) + .and_then(Value::as_str) + .is_some_and(|id| restored_id_set.contains(id)) + }); + !e.removed_turns.is_empty() + }); + } + } + record.original_session_id = Some(chain_root.clone()); + record.original_tip_uuid = original_tip_uuid.clone(); + record.last_op_at_ms = now; + record.set_can_redo(can_redo_after, now); + if let Some(sink) = self.identity_sink() { + if let Err(e) = sink + .record_rollback(PROVIDER, &durable_id, record.clone()) + .await + { + tracing::warn!(error = %e, session = %durable_id, "freshagent.claude.rollback_pre_write_failed"); + reply_sink(rollback_error_frame( + &op, + "INTERNAL_ERROR", + LEDGER_WRITE_REFUSAL_COPY, + )); + return; + } + } + + // ep3-r5 F1 (the admit→boot window): the gate's quiescence-proof is + // consumed ONCE at admission — a compact can arm and start (status + // folded, candidate set, gate re-boosted by ep3-r4 F1) while this + // already-admitted handler is doing transcript I/O. The quiesce probe + // above makes all provably-written evidence folded-visible; the + // recheck here (immediately before the replacement boots) aborts the rollback with BUSY_TURN plus a compensating + // ledger rewrite whenever that truth revived (the durable pre-write + // provably matches the provider: nothing was mutated). + if let Ok(ms) = std::env::var("FRESHELL_TEST_CLAUDE_ROLLBACK_PRE_TEARDOWN_MS") { + // Test-only knob: parks this handler in the admit→boot window so + // the recheck choreography is deterministic (never in production). + if let Ok(ms) = ms.parse::() { + tokio::time::sleep(Duration::from_millis(ms)).await; + } + } + + // Focused ep4-r2/ep4-r3 (the post-admission evidence race, repaired + // for real): absorbed compact debt can STILL start in the sidecar + // after admission — the dispatch lifecycle is: sidecar input queue → + // SAME-TICK handoff when the SDK consumer is awaiting → SDK turn + // start (its `compacting` status folds the candidate here). The + // quiesce probe closes every leg of that lifecycle atomically: the + // sidecar (1) DROPS never-handed compacts from its own input queue + // (cancellation the SDK surface cannot provide for queue items), (2) + // reports in-flight/handed truth, and (3) answers on its stdout + // stream AFTER every already-emitted piece of evidence has been folded + // (stream order), correlated by probeId so a stale receipt can never + // close the probe. The verdict gates below plus the recheck cover the + // whole shape: all-clear → proceed; any busy signal or no answer → + // BUSY_TURN + compensating ledger rewrite. Skip when no live evidence + // path exists: write failure (sidecar already dead), or a FINISHED + // consumer (its EOF reaped the busy truth; nothing fresh can arrive). + let mut probe_armed = false; + let mut probe_rx = None; + let mut probe_id = String::new(); + { + let mut guard = self.sessions.lock().await; + if let Some(session) = guard.get_mut(&map_key) { + if session.consumer.is_finished() { + // EOF already zeroed the tracker — the recheck sees it. + } else { + let (tx, rx) = tokio::sync::oneshot::channel::(); + probe_id = uuid::Uuid::new_v4().to_string(); + *session + .rollback_probe_slot + .lock() + .expect("rollback probe slot lock") = Some((probe_id.clone(), tx)); + // ep4-r3: the quiesce is its own request type — the sidecar + // drains never-handed queued compacts and answers with its + // own queue truth (the SDK's queued inputs cannot be + // cancelled once handed). + let probe_req = json!({ + "type": "rollback.quiesce", + "sessionId": session.sidecar_session_id, + "probeId": probe_id, + }); + match write_line(&mut session.stdin, &probe_req).await { + Ok(()) => { + probe_armed = true; + probe_rx = Some(rx); + } + Err(_) => { + let _ = session + .rollback_probe_slot + .lock() + .expect("rollback probe slot lock") + .take(); + } + } + } + } + } + if probe_armed { + let timeout_ms = std::env::var("FRESHELL_TEST_CLAUDE_ROLLBACK_PROBE_TIMEOUT_MS") + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(3000); + let verdict = + tokio::time::timeout(Duration::from_millis(timeout_ms), probe_rx.unwrap()).await; + match verdict { + Ok(Ok(QuiesceVerdict { + cancelled_queue, + in_flight_turn: false, + handed_compact_likely: false, + })) => { + if cancelled_queue > 0 { + tracing::info!( + session = %map_key, + cancelled = cancelled_queue, + "freshagent.claude.rollback_quiesce_cancelled_queue" + ); + } + } + Ok(Ok(verdict)) => { + // ep4-r3 F1: provider work crossed the un-cancellable + // handoff (a turn mid-flight, or a compact already handed + // to an awaiting SDK consumer) — refuse. + tracing::info!(session = %map_key, ?verdict, "freshagent.claude.rollback_quiesce_busy"); + // ep4-r6 F5: never fabricate an empty row for a rollback + // that never happened — the shared helper DELETES the + // pre-write when the pre-op record was absent. + self.compensate_rollback_record(&durable_id, existing.clone()) + .await; + reply_sink(rollback_error_frame( + &op, + "BUSY_TURN", + ROLLBACK_BUSY_MESSAGE, + )); + return; + } + _ => { + // Timeout (or a dropped sender — impossible in practice + // since the slot outlives this handler): the absorb premise + // is unproven — refuse rather than tear a live provider + // flow down blind. Clear OUR slot (a stale receipt must + // never fire a later probe — ep4-r3 F2 defense in depth + // on top of probeId correlation). + if let Some(session) = self.sessions.lock().await.get(&map_key) { + let mut slot = session + .rollback_probe_slot + .lock() + .expect("rollback probe slot lock"); + if slot.as_ref().is_some_and(|(id, _)| *id == probe_id) { + slot.take(); + } + } + // ep4-r6 F5: never fabricate an empty row for a rollback + // that never happened — the shared helper DELETES the + // pre-write when the pre-op record was absent. + self.compensate_rollback_record(&durable_id, existing.clone()) + .await; + reply_sink(rollback_error_frame( + &op, + "BUSY_TURN", + ROLLBACK_BUSY_MESSAGE, + )); + return; + } + } + } + + let revived = { + let tracker = turn_tracker.lock().expect("turn tracker lock"); + tracker.running.is_some() + || tracker.compact_candidate + || tracker.queued.iter().any(|op| *op == TrackedOp::Turn) + }; + if revived { + // Compensate the pre-op durable record: the provider was never + // touched, so the ledger must describe nothing happening — + // restore the pre-op record, and DELETE the just-written row when + // there was none (never fabricate, ep4-r6 F5). + self.compensate_rollback_record(&durable_id, existing.clone()) + .await; + reply_sink(rollback_error_frame( + &op, + "BUSY_TURN", + ROLLBACK_BUSY_MESSAGE, + )); + return; + } + + // Cancel pending cards (decision 6) before the replacement boots. + self.emit_pending_cancellations(&map_key, &op.session_id, session_type) + .await; + + // The replacement's launch: the original's recorded settings (plugins + // included, G15) and the lane's hosted MCP selection; a fork resumes + // its source by id under the source's ORIGINAL cwd while that cwd + // exists (ledger A15 slug scoping), else by the transcript PATH (the + // verified cli.js escape hatch). The path is the one the reads above + // located (G28: never a second scan that a store turning unreadable + // could miss). + let recovered = self + .identity_sink() + .and_then(|s| s.load_settings(PROVIDER, &durable_id)); + let located = source_path + .as_deref() + .map(|path| crate::claude_launch::resume_target(&source, path)); + let spawn_cwd = located + .as_ref() + .and_then(|(_, cwd)| cwd.clone()) + .or_else(|| op.cwd.clone()) + .or_else(|| recovered.as_ref().and_then(|r| r.cwd.clone())); + let boot = match &genesis { + ClaudeGenesis::Fresh => ClaudeBoot::Fresh, + ClaudeGenesis::Fork { at, drops_turn, .. } => ClaudeBoot::Fork { + resume: located + .as_ref() + .map(|(resume, _)| resume.clone()) + .unwrap_or_else(|| source.clone()), + at: at.clone(), + drops_turn: drops_turn.clone(), + }, + }; + let mut launch_settings = recovered.clone().unwrap_or_default(); + if spawn_cwd.is_some() { + launch_settings.cwd = spawn_cwd.clone(); + } + let replacement = uuid::Uuid::new_v4().to_string(); + tracing::info!(target: "freshell_freshagent::claude", + event = "freshagent.claude.rollback_boot", + session_id = %durable_id, + replacement = %replacement, + direction = ?op.direction, + genesis = genesis.kind(), + drops_turn_guard = matches!(&genesis, ClaudeGenesis::Fork { drops_turn: Some(_), .. }), + "the rollback boots its replacement runtime while the original stays live" + ); + self.test_holds.rollback_boot().await; + let spec = ClaudeLaunchSpec::new( + format!("rollback-{}", uuid::Uuid::new_v4()), + session_type, + boot, + spawn_cwd, + &launch_settings, + self.hosted_mcp, + ) + .with_session_id(Some(replacement.clone())); + // A lease the rollback took on the original completes onto the + // replacement (the original's durable id resolves to it); a lease the + // original already held as its live owner is cleared once it is gone. + let lease_held = lease_guard.is_some(); + let established = match self + .establish_runtime( + EstablishPlan { + lane: EstablishLane::Rollback, + spec, + register_under: RegisterUnder::Adopted(replacement.clone()), + claim: None, + rekey_provisional_claim: false, + recorded_settings: Some(launch_settings.clone()), + // b8ke focused episode-2 round-2 F2: the ROLLBACK's claim + // owns this runtime's coordinator transition — the + // adoption's Blocked arm correlates through the op id. + owning_operation: own_ticket.as_ref().map(|t| t.operation_id().to_string()), + owning_generation: None, + // Conn-less lane (D8): the ledger merge inherits the + // superseded parent's stamps. + provenance: None, + naming: None, + partial_runtime_key: own_ticket.as_ref().map(|_| durable_id.clone()), + start_pid_slot: &start_pid_slot, + inherit: Some(InheritedRuntime { + turn_lock: turn_lock.clone(), + in_turn: in_turn.clone(), + turn_tracker: turn_tracker.clone(), + last_synthesized_complete_at: Arc::clone(&last_synthesized_complete_at), + }), + }, + &mut lease_guard, + &mut own_ticket, + ) + .await + { + Ok(established) => established, + Err(error) => { + // Nothing moved: the original is untouched and live. + self.compensate_rollback_record(&durable_id, existing.clone()) + .await; + let (code, message) = rollback_failure(&error); + tracing::warn!(target: "freshell_freshagent::claude", + event = "freshagent.claude.rollback_refused", + session_id = %durable_id, + replacement = %replacement, + code, + failure = %error.message(EstablishLane::Rollback), + "the replacement runtime was not established; the original stays live" + ); + reply_sink(rollback_error_frame(&op, code, &message)); + return; + } + }; + + // ONE awaited ledger batch: the replacement's binding (its genesis, + // the original's settings, `supersedes` = the original, which retires + // the original's row and links it to the replacement) with the + // rollback record re-keyed to the replacement in the same write. + if let Some(sink) = self.identity_sink() { + let fenced = own_ticket.as_ref().map(|ticket| ticket.generation()); + if let Err(error) = sink + .record_binding(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Set(genesis.clone()), + provider: PROVIDER.into(), + session_id: replacement.clone(), + mode: session_type.to_string(), + create_request_id: None, + resolves_pending: None, + supersedes: Some(durable_id.clone()), + name_transition: None, + provenance: None.into(), + observed_epoch: fenced.and(self.ownership.as_ref().map(|r| r.boot_epoch())), + observed_generation: fenced, + authoritative: false, + settings: launch_settings.clone(), + }) + .await + { + tracing::error!(target: "freshell_freshagent::claude", + event = "freshagent.claude.rollback_binding_write_failed", + session_id = %durable_id, replacement = %replacement, error = %error, + "the replacement's binding could not be written; it is torn down and the \ + original stays live" + ); + self.unwind_replacement(&replacement, &durable_id, lease_held) + .await; + self.compensate_rollback_record(&durable_id, existing.clone()) + .await; + reply_sink(rollback_error_frame( + &op, + "INTERNAL_ERROR", + LEDGER_WRITE_REFUSAL_COPY, + )); + return; + } + } + + // Ownership moves to the replacement in ONE coordinator step (the + // original's record re-keys; the original's id becomes a resolvable + // alias). The claim/adopt guard has held the key since admission, so a + // refusal here is an invariant breach. + if let Err(outcome) = self.commit_lane_claim_rekey_at( + &durable_id, + &mut own_ticket, + &replacement, + &map_key, + &replacement, + established.sidecar_pid, + "claude/rollback", + ) { + tracing::error!(target: "invariant", + event = "freshagent.claude.rollback_commit_stale", + provider = PROVIDER, session_id = %durable_id, replacement = %replacement, + outcome = ?outcome, + "the coordinator moved on while the rollback held its claim; the replacement \ + is torn down and the original stays live" + ); + self.unwind_replacement(&replacement, &durable_id, lease_held) + .await; + self.compensate_rollback_record(&durable_id, existing.clone()) + .await; + if let Some(sink) = self.identity_sink() { + let _ = sink.delete_rollback(PROVIDER, &replacement).await; + } + reply_sink(rollback_error_frame( + &op, + "INTERNAL_ERROR", + "session ownership changed during rollback; torn down", + )); + return; + } + + // The replacement takes the original's place: every id that named the + // original (its map key, its durable id) now names the replacement, + // and the original's sidecar is stopped as a requested teardown (its + // record is gone first, so its end of output stays silent; #859's + // graceful stop). + if let Some(original) = self + .replace_runtime(&map_key, &durable_id, &replacement) + .await + { + let _teardown: LoggedTeardown = self.teardown_removed_session_logged(original).await; + } + if !lease_held { + self.leases.clear_binding(PROVIDER, &durable_id); + } + + // Pane re-key: the materialized broadcast (old → new) goes out BEFORE + // the rollback frames stamped with the new id. + self.broadcast_materialized(&op.session_id, &replacement, session_type) + .await; + let removed_ids: Vec = removed_turns + .iter() + .filter_map(|t| { + t.get("turnId") + .or_else(|| t.get("id")) + .and_then(Value::as_str) + .map(str::to_string) + }) + .collect(); + // Re-stamp the op for the NEW live id so every outbound frame names the + // replacement. + let switch = RollbackRequest { + session_id: replacement.clone(), + ..op + }; + self.broadcast(&rollback_broadcast_frame( + &switch, + &replacement, + &removed_ids, + record.can_redo(), + )); + tracing::info!(target: "freshell_freshagent::claude", + event = "freshagent.claude.rollback_completed", + session_id = %durable_id, + replacement = %replacement, + direction = ?switch.direction, + removed = removed_ids.len(), + "the rollback's replacement is live and the original is stopped" + ); + reply_sink(rollback_ack_frame( + &switch, + &replacement, + Some(&prompt_text), + &removed_ids, + record.can_redo(), + Some(&replacement), + )); + } + + /// The session's effective transcript (design §4.5): its transcript when + /// the CLI wrote one; before that, what its genesis holds (a pending fork + /// holds its parent's chain through the fork point, a fresh session + /// nothing). + fn effective_transcript( + &self, + session_id: &str, + ) -> Result { + match self.locate_transcript(session_id) { + snap::TranscriptLocation::Found(path) => { + let text = std::fs::read_to_string(&path) + .map_err(|e| EffectiveTranscriptError::Read(e.to_string()))?; + Ok(EffectiveTranscript { + source_text: text.clone(), + text, + source: session_id.to_string(), + source_path: Some(path), + }) + } + snap::TranscriptLocation::Unreadable(error) => { + Err(EffectiveTranscriptError::Unreadable(error)) + } + snap::TranscriptLocation::Absent => { + let genesis = self + .identity_sink() + .and_then(|sink| sink.load_genesis(PROVIDER, session_id)) + .map(|recorded| recorded.genesis); + match genesis { + None => Err(EffectiveTranscriptError::Missing), + Some(ClaudeGenesis::Fresh) => Ok(EffectiveTranscript { + text: String::new(), + source: session_id.to_string(), + source_text: String::new(), + source_path: None, + }), + Some(ClaudeGenesis::Fork { parent, at, .. }) => { + let path = match self.locate_transcript(&parent) { + snap::TranscriptLocation::Found(path) => path, + snap::TranscriptLocation::Absent => { + return Err(EffectiveTranscriptError::Read(format!( + "the conversation this one was forked from ({parent}) is gone" + ))) + } + snap::TranscriptLocation::Unreadable(error) => { + return Err(EffectiveTranscriptError::Unreadable(error)) + } + }; + let parent_text = std::fs::read_to_string(&path) + .map_err(|e| EffectiveTranscriptError::Read(e.to_string()))?; + let text = snap::fork_prefix_transcript(&parent_text, at.as_deref()) + .ok_or_else(|| { + EffectiveTranscriptError::Read(format!( + "the point this conversation was forked at is no longer in {parent}" + )) + })?; + Ok(EffectiveTranscript { + text, + source: parent, + source_text: parent_text, + source_path: Some(path), + }) + } + } + } + } + } + + /// `session_id`'s transcript text, when the store has one (`Ok(None)` + /// when it has none). + fn read_located_transcript( + &self, + session_id: &str, + ) -> Result, EffectiveTranscriptError> { + match self.locate_transcript(session_id) { + snap::TranscriptLocation::Found(path) => std::fs::read_to_string(&path) + .map(Some) + .map_err(|e| EffectiveTranscriptError::Read(e.to_string())), + snap::TranscriptLocation::Absent => Ok(None), + snap::TranscriptLocation::Unreadable(error) => { + Err(EffectiveTranscriptError::Unreadable(error)) + } + } + } + + /// The replacement takes the original's place (design §4.6, the rollback + /// re-key): the original's record leaves the sessions map, and every id + /// that resolved to it (its map key, its durable id, their aliases) + /// resolves to the replacement, as does its pending naming handle. The + /// original's record is returned for its teardown. Lock order `cli_index` + /// then `sessions`, as the adoption's publication takes them. + async fn replace_runtime( + &self, + original_key: &str, + original_durable: &str, + replacement: &str, + ) -> Option { + let original = { + let mut index = self.cli_index.lock().await; + let mut sessions = self.sessions.lock().await; + let original = sessions.remove(original_key); + for mapped in index.values_mut() { + if mapped == original_key { + *mapped = replacement.to_string(); + } + } + index.insert(original_key.to_string(), replacement.to_string()); + index.insert(original_durable.to_string(), replacement.to_string()); + original + }; + let handle = self.naming_handles.lock().await.remove(original_key); + if let Some(handle) = handle { + self.naming_handles + .lock() + .await + .insert(replacement.to_string(), handle); + } + // A replayed create requestId of the original must not answer with + // its retired map key. + self.create_dedup + .clear_for_session(|record| record.session_id == original_key) + .await; + original + } + + /// Tear down a registered replacement whose rollback cannot complete; the + /// original stays live. A lease the rollback held on the original was + /// completed onto the replacement and is cleared with it. + async fn unwind_replacement( + &self, + replacement: &str, + original_durable: &str, + lease_held: bool, + ) { + if let Some(session) = self.sessions.lock().await.remove(replacement) { + let _teardown: LoggedTeardown = self.teardown_removed_session_logged(session).await; + } + self.evict_cli_index_aliases(replacement).await; + if lease_held { + self.leases.clear_binding(PROVIDER, original_durable); + } + } +} diff --git a/crates/freshell-freshagent/src/claude_test_holds.rs b/crates/freshell-freshagent/src/claude_test_holds.rs index 17d9fed82..747cef837 100644 --- a/crates/freshell-freshagent/src/claude_test_holds.rs +++ b/crates/freshell-freshagent/src/claude_test_holds.rs @@ -9,6 +9,10 @@ //! - Exit report ([`ClaudeTestHolds::hold_exit_report`]): the end-of-output path //! parks after it recorded the dead session, before it reaps the sidecar and //! reports the exit, so a resume can land while the dead sidecar is reaped. +//! - Rollback boot ([`ClaudeTestHolds::hold_rollback_boot`]): an undo or redo +//! parks after it wrote its durable record, holding the session's turn lock, +//! before it boots the replacement runtime, so a test can park a send behind +//! it or change the transcript store under it. use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -19,31 +23,41 @@ use tokio::sync::Notify; #[derive(Default)] pub(crate) struct ClaudeTestHolds { registration: Mutex>, - exit_report: Mutex>>, + exit_report: Mutex>>, + rollback_boot: Mutex>>, } -/// A parked exit report: [`Self::parked`] returns once an end-of-output path -/// reached the hold, and the report goes on after [`Self::release`]. +/// One parked step: [`Self::parked`] returns once the step reached the hold, +/// and the step goes on after [`Self::release`]. #[derive(Default)] -pub struct ExitReportHold { +pub struct StepHold { parked: Notify, release: Notify, } #[cfg(any(test, feature = "test-support"))] -impl ExitReportHold { - /// Wait until an end-of-output path is parked at the hold. +impl StepHold { + /// Wait until the step is parked at the hold. pub async fn parked(&self) { self.parked.notified().await; } - /// Let the parked exit report go on. + /// Let the parked step go on. pub fn release(&self) { // `notify_one` keeps a permit for a waiter that has not arrived yet. self.release.notify_one(); } } +impl StepHold { + /// Park here until released (a test's hold). + async fn park(&self) { + // `notify_one` keeps a permit for a test that is not waiting yet. + self.parked.notify_one(); + self.release.notified().await; + } +} + impl ClaudeTestHolds { /// Make every later session registration wait `hold` between starting the /// session's consumer and inserting its record. @@ -55,12 +69,21 @@ impl ClaudeTestHolds { /// Park every later end-of-output path before it reaps the sidecar and /// reports the exit, until the returned hold is released. #[cfg(any(test, feature = "test-support"))] - pub(crate) fn hold_exit_report(&self) -> Arc { - let hold = Arc::new(ExitReportHold::default()); + pub(crate) fn hold_exit_report(&self) -> Arc { + let hold = Arc::new(StepHold::default()); *self.exit_report.lock().expect("exit report hold lock") = Some(Arc::clone(&hold)); hold } + /// Park every later undo or redo before it boots its replacement runtime, + /// until the returned hold is released. + #[cfg(any(test, feature = "test-support"))] + pub(crate) fn hold_rollback_boot(&self) -> Arc { + let hold = Arc::new(StepHold::default()); + *self.rollback_boot.lock().expect("rollback boot hold lock") = Some(Arc::clone(&hold)); + hold + } + /// The registration hold, if a test set one. pub(crate) async fn registration(&self) { let hold = *self.registration.lock().expect("registration hold lock"); @@ -77,9 +100,19 @@ impl ClaudeTestHolds { .expect("exit report hold lock") .clone(); if let Some(hold) = hold { - // `notify_one` keeps a permit for a test that is not waiting yet. - hold.parked.notify_one(); - hold.release.notified().await; + hold.park().await; + } + } + + /// The rollback-boot hold, if a test set one. + pub(crate) async fn rollback_boot(&self) { + let hold = self + .rollback_boot + .lock() + .expect("rollback boot hold lock") + .clone(); + if let Some(hold) = hold { + hold.park().await; } } } diff --git a/crates/freshell-freshagent/src/identity_sink.rs b/crates/freshell-freshagent/src/identity_sink.rs index aa04e9b21..e694a7a26 100644 --- a/crates/freshell-freshagent/src/identity_sink.rs +++ b/crates/freshell-freshagent/src/identity_sink.rs @@ -522,6 +522,14 @@ pub trait PaneIdentitySink: Send + Sync { provider: &str, create_request_id: &str, ) -> Option; + /// The live end of the supersession chain that starts at `session_id` + /// (claude core design §4.5: an undo or redo's replacement supersedes the + /// session it replaced): `Some(terminus)` only when a later session + /// superseded `session_id`. Memory-only, like `load_settings`. A sink that + /// keeps no supersession chain answers `None`. + fn chain_terminus(&self, _provider: &str, _session_id: &str) -> Option { + None + } /// Retire-on-kill (delta-review round 5, restore-open-sessions-only): an /// explicit `freshAgent.kill` is an INTENTIONAL session end — the pane's /// binding row retires `Closed` on every kill entry point, so the @@ -2140,6 +2148,27 @@ impl PaneIdentitySink for FakeIdentitySink { }) .map(|b| b.session_id.clone()) } + + fn chain_terminus(&self, provider: &str, session_id: &str) -> Option { + // The ledger's `supersededBy` walk over the bindings log's + // `supersedes` edges (newest edge wins), with its hop cap. + let bindings = self.bindings.lock().unwrap(); + let successor = |id: &str| { + bindings + .iter() + .rev() + .find(|b| b.provider == provider && b.supersedes.as_deref() == Some(id)) + .map(|b| b.session_id.clone()) + }; + let mut current = successor(session_id)?; + for _ in 0..32 { + match successor(¤t) { + Some(next) if next != current => current = next, + _ => break, + } + } + Some(current) + } } #[cfg(test)] diff --git a/crates/freshell-server/src/identity_sink.rs b/crates/freshell-server/src/identity_sink.rs index 220244825..953d680c0 100644 --- a/crates/freshell-server/src/identity_sink.rs +++ b/crates/freshell-server/src/identity_sink.rs @@ -518,6 +518,14 @@ impl PaneIdentitySink for LedgerIdentitySink { .map(|row| row.session_id) } + fn chain_terminus(&self, provider: &str, session_id: &str) -> Option { + // Memory-only chain walk (`supersededBy` links to the live terminus). + self.ledger + .lookup_by_session(provider, session_id) + .filter(|resolution| resolution.corrected) + .map(|resolution| resolution.row.session_id) + } + /// Retire-on-kill (delta-review round 5, restore-open-sessions-only): the /// kill handlers' awaited retire batch — the same awaited-spawn_blocking /// discipline as `record_binding`. Delta-r6-r4 (focused-episode-6 round @@ -884,6 +892,56 @@ mod tests { /// Claude core design §4.1: the genesis rides the binding write, survives /// a restart, is kept by a later settings write, and is dropped by the /// lazy clear once the transcript is on disk. + /// Claude core design §4.5 (C13): a send that waited behind an undo + /// finds the replacement through the durable supersession chain. The + /// chain follows every hop to the live end, survives a restart (it is + /// the ledger's), and names nothing for an id nothing superseded. + #[tokio::test] + async fn the_supersession_chain_leads_to_the_live_replacement() { + let tmp = tempfile::tempdir().unwrap(); + let ledger = Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + tmp.path().to_path_buf(), + ))); + let sink = LedgerIdentitySink::new(ledger, None); + let upsert = |session_id: &str, supersedes: Option<&str>| FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, + name_transition: None, + provider: "claude".into(), + session_id: session_id.into(), + mode: "freshclaude".into(), + create_request_id: None, + resolves_pending: None, + supersedes: supersedes.map(str::to_string), + provenance: freshell_freshagent::ProvenanceUpdate::Inherit, + observed_epoch: None, + observed_generation: None, + authoritative: false, + settings: FreshAgentSettings { + cwd: Some("/w".into()), + ..FreshAgentSettings::default() + }, + }; + for (id, supersedes) in [("s-a", None), ("s-c", Some("s-a")), ("s-d", Some("s-c"))] { + sink.record_binding(upsert(id, supersedes)) + .await + .expect("awaited write succeeds"); + } + assert_eq!(sink.chain_terminus("claude", "s-a").as_deref(), Some("s-d")); + assert_eq!(sink.chain_terminus("claude", "s-c").as_deref(), Some("s-d")); + assert_eq!(sink.chain_terminus("claude", "s-d"), None, "the live end"); + assert_eq!(sink.chain_terminus("claude", "s-unknown"), None); + let restarted = LedgerIdentitySink::new( + Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + tmp.path().to_path_buf(), + ))), + None, + ); + assert_eq!( + restarted.chain_terminus("claude", "s-a").as_deref(), + Some("s-d") + ); + } + #[tokio::test(flavor = "multi_thread")] async fn genesis_writes_through_reads_back_and_clears() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index 46701c623..adc40ee91 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -97,6 +97,10 @@ pub struct ClaudeRig { pub fresh_claude: freshell_freshagent::FreshClaudeState, /// The local fake Anthropic endpoint ([`RigKind::Cli`] only), killed on drop. _provider: Option, + /// What the server was started with, for [`Self::restart`]. + environment: Vec<(String, String)>, + options: RigOptions, + server: tokio::task::JoinHandle<()>, _root: tempfile::TempDir, } @@ -213,7 +217,8 @@ impl ClaudeRig { url, http_url, fresh_claude, - } = spawn_rig_server(environment, &options, pane_ledger.clone()).await; + server, + } = spawn_rig_server(environment.clone(), &options, pane_ledger.clone()).await; eprintln!( "{}", json!({ @@ -231,10 +236,43 @@ impl ClaudeRig { ledger, fresh_claude, _provider: provider, + environment, + options, + server, _root: root, } } + /// Restart the rig's server the way a server restart does: the claude lane + /// stops every sidecar (server shutdown), the server goes away, and a new + /// one starts on a new port over the same transcript store and the same + /// ledger directory, read back from disk. Connections to the old server + /// are gone; connect again. + pub async fn restart(&mut self) { + self.fresh_claude.shutdown().await; + self.server.abort(); + let pane_ledger = self.pane_ledger.as_ref().map(|_| { + Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + self._root.path().join("pane-ledger"), + ))) + }); + let RigServer { + url, + http_url, + fresh_claude, + server, + } = spawn_rig_server(self.environment.clone(), &self.options, pane_ledger.clone()).await; + eprintln!( + "{}", + json!({ "severity": "info", "event": "claude_rig.restarted", "url": url }) + ); + self.url = url; + self.http_url = http_url; + self.fresh_claude = fresh_claude; + self.pane_ledger = pane_ledger; + self.server = server; + } + /// A WS connection that has completed the handshake (`ready` seen). pub async fn connect(&self) -> RigConnection { let (mut ws, _) = tokio_tungstenite::connect_async(&self.url) @@ -539,6 +577,18 @@ impl RigConnection { self.send(frame).await; } + /// The owner pair (epoch, generation) of the latest `session.runtimeOwner` + /// frame this connection read for the durable id `durable`. + pub fn owner_pair(&self, durable: &str) -> Option<(u64, u64)> { + self.frames.iter().rev().find_map(|v| { + (v["type"] == "session.runtimeOwner" + && v["provider"] == "claude" + && v["sessionId"] == durable) + .then(|| Some((v["epoch"].as_u64()?, v["generation"].as_u64()?))) + .flatten() + }) + } + /// 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. fn observed_owner(&self, session_id: &str) -> Option<(u64, u64)> { @@ -737,6 +787,7 @@ struct RigServer { url: String, http_url: String, fresh_claude: freshell_freshagent::FreshClaudeState, + server: tokio::task::JoinHandle<()>, } /// A freshell-ws server on an ephemeral loopback port (never 3001/3002) with fresh @@ -850,12 +901,13 @@ async fn spawn_rig_server( .await .expect("bind an ephemeral loopback port"); let addr = listener.local_addr().expect("local addr"); - tokio::spawn(async move { + let server = tokio::spawn(async move { let _ = axum::serve(listener, router).await; }); RigServer { url: format!("ws://{addr}/ws"), http_url: format!("http://{addr}"), fresh_claude: lane, + server, } } diff --git a/crates/freshell-ws/tests/common/ledger_sink.rs b/crates/freshell-ws/tests/common/ledger_sink.rs index 53599b15c..f07d1ace0 100644 --- a/crates/freshell-ws/tests/common/ledger_sink.rs +++ b/crates/freshell-ws/tests/common/ledger_sink.rs @@ -251,6 +251,14 @@ impl PaneIdentitySink for LedgerSink { .map(|row| row.session_id) } + fn chain_terminus(&self, provider: &str, session_id: &str) -> Option { + // Memory-only chain walk (`supersededBy` links to the live terminus). + self.ledger + .lookup_by_session(provider, session_id) + .filter(|resolution| resolution.corrected) + .map(|resolution| resolution.row.session_id) + } + fn retire_closed(&self, provider: &str, session_id: &str) -> SinkCloseWrite { let ledger = self.ledger.clone(); let (p, s) = (provider.to_string(), session_id.to_string()); diff --git a/crates/freshell-ws/tests/freshagent_claude_launch_parity.rs b/crates/freshell-ws/tests/freshagent_claude_launch_parity.rs index 8030f02ae..230b0ab1c 100644 --- a/crates/freshell-ws/tests/freshagent_claude_launch_parity.rs +++ b/crates/freshell-ws/tests/freshagent_claude_launch_parity.rs @@ -231,9 +231,14 @@ async fn a_rollback_respawn_carries_the_bound_plugins_and_the_hosted_mcp() { })) .await; - // The undo boots a fork of the conversation through its first turn. (Until C13 - // the rollback then waits for a turn that never comes, G01; the fork's launch - // is what this test pins, and the rig's teardown stops the fork.) + // The undo boots a fork of the conversation through its first turn, and + // answers once the CLI booted it (C13). + let ack = conn + .wait_for("the undo's ack", |v| { + v["type"] == "freshAgent.event" && v["event"]["requestId"] == "undo-1" + }) + .await; + assert_eq!(ack["event"]["type"], "freshAgent.rolledBack", "{ack}"); let fork = wait_for_query(&rig, "the undo's fork", |o| { o["forkSession"] == true && o["resume"] == durable }) diff --git a/crates/freshell-ws/tests/freshagent_claude_real_cli.rs b/crates/freshell-ws/tests/freshagent_claude_real_cli.rs index 003fb7cbc..ce48c5f19 100644 --- a/crates/freshell-ws/tests/freshagent_claude_real_cli.rs +++ b/crates/freshell-ws/tests/freshagent_claude_real_cli.rs @@ -244,9 +244,186 @@ async fn a_zero_turn_session_restarts_fresh_under_its_own_id(kind: RigKind) { conn.kill(&id).await; } +/// One finished turn (its `freshAgent.result`) for `text` sent to `session_id`, +/// and its lines on disk: the CLI flushes a transcript line 95-200 ms after its +/// SDK frame (T3), and an undo reads the transcript. +async fn turn( + rig: &ClaudeRig, + conn: &mut common::claude_rig::RigConnection, + session_id: &str, + request_id: &str, + text: &str, +) { + let results = conn.events_seen("freshAgent.result"); + conn.send_text(session_id, request_id, text).await; + let deadline = tokio::time::Instant::now() + common::claude_rig::RIG_FRAME_BUDGET; + while conn.events_seen("freshAgent.result") == results { + assert!( + tokio::time::Instant::now() < deadline, + "the turn {request_id} never finished" + ); + conn.read_for(std::time::Duration::from_millis(50)).await; + } + let durable = conn.init_session_id(session_id); + let path = rig.wait_for_transcript(&durable).await; + loop { + let lines: Vec = std::fs::read_to_string(&path) + .unwrap_or_default() + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect(); + let prompt = lines.iter().rposition(|line| { + line["type"] == "user" && line["message"]["content"][0]["text"] == text + }); + if prompt.is_some_and(|at| lines[at..].iter().any(|line| line["type"] == "assistant")) { + return; + } + assert!( + tokio::time::Instant::now() < deadline, + "the turn {request_id} never reached disk" + ); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } +} + +/// An undo or redo from the pane `pane_id` of the session `durable`, fenced +/// with the owner pair the client holds; returns the requester's answer. +async fn rollback( + conn: &mut common::claude_rig::RigConnection, + kind: &str, + pane_id: &str, + durable: &str, + request_id: &str, +) -> serde_json::Value { + let (epoch, generation) = conn.owner_pair(durable).expect("an owner record"); + conn.send(json!({ + "type": format!("freshAgent.{kind}"), + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": pane_id, + "requestId": request_id, + "mode": "step", + "observedEpoch": epoch, + "observedGeneration": generation, + })) + .await; + conn.wait_for(&format!("the answer to {request_id}"), |v| { + v["type"] == "freshAgent.event" + && v["event"]["requestId"] == request_id + && matches!( + v["event"]["type"].as_str(), + Some("freshAgent.rolledBack" | "freshAgent.redone" | "freshAgent.error") + ) + }) + .await["event"] + .clone() +} + +/// The replacement id an undo or redo ack names (and that it succeeded). +fn replacement(answer: &serde_json::Value, expected: &str) -> String { + assert_eq!(answer["type"], expected, "{answer}"); + answer["newSessionId"] + .as_str() + .expect("the ack names the replacement") + .to_string() +} + +/// The prompts a session's snapshot holds, in order. +async fn prompts_of(rig: &ClaudeRig, session_id: &str) -> Vec { + let snapshot = rig + .fresh_claude + .get_snapshot(freshell_protocol::SessionType::Freshclaude, session_id) + .await + .unwrap_or_else(|error| panic!("a snapshot of {session_id}: {error:?}")); + snapshot["turns"] + .as_array() + .expect("turns") + .iter() + .filter(|turn| turn["role"] == "user") + .filter_map(|turn| turn["items"][0]["text"].as_str().map(str::to_string)) + .collect() +} + +/// Design §11.5 item 5 (C13, G01): undo, send, redo, and two undos with no send +/// between them, each answered once the CLI booted the replacement (no init is +/// awaited). A send after an undo runs in the replacement (the CLI reports its +/// id) and destroys redo; an undo of that conversation and its redo restore the +/// sent turn; a second undo before any send forks the original again. +async fn undo_send_redo_and_two_undos_without_a_send(kind: RigKind) { + let rig = ClaudeRig::start_with_options( + kind, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let pane = conn.create("create-1").await; + turn(&rig, &mut conn, &pane, "send-1", "first prompt").await; + turn(&rig, &mut conn, &pane, "send-2", "second prompt").await; + turn(&rig, &mut conn, &pane, "send-3", "third prompt").await; + let a = conn.init_session_id(&pane); + + let c = replacement( + &rollback(&mut conn, "undo", &pane, &a, "undo-1").await, + "freshAgent.rolledBack", + ); + assert_eq!( + prompts_of(&rig, &c).await, + ["first prompt", "second prompt"] + ); + turn(&rig, &mut conn, &c, "send-4", "another third prompt").await; + assert_eq!( + conn.init_session_id(&c), + c, + "the send ran in the replacement" + ); + let refused = rollback(&mut conn, "redo", &c, &c, "redo-after-send").await; + assert_eq!( + refused["code"], "REDO_UNAVAILABLE", + "a send destroys redo: {refused}" + ); + + let d = replacement( + &rollback(&mut conn, "undo", &c, &c, "undo-2").await, + "freshAgent.rolledBack", + ); + let e = replacement( + &rollback(&mut conn, "redo", &d, &d, "redo-1").await, + "freshAgent.redone", + ); + assert_eq!( + prompts_of(&rig, &e).await, + ["first prompt", "second prompt", "another third prompt"] + ); + + let f1 = replacement( + &rollback(&mut conn, "undo", &e, &e, "undo-3").await, + "freshAgent.rolledBack", + ); + let f2 = replacement( + &rollback(&mut conn, "undo", &f1, &f1, "undo-4").await, + "freshAgent.rolledBack", + ); + assert_eq!(prompts_of(&rig, &f2).await, ["first prompt"]); + turn(&rig, &mut conn, &f2, "send-5", "after two undos").await; + assert_eq!(conn.init_session_id(&f2), f2); + assert_eq!( + prompts_of(&rig, &f2).await, + ["first prompt", "after two undos"] + ); + conn.kill(&f2).await; +} + mod sim { use super::*; + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn undo_send_redo_and_two_undos_without_a_send() { + super::undo_send_redo_and_two_undos_without_a_send(RigKind::Sim).await; + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_zero_turn_session_restarts_fresh_under_its_own_id() { super::a_zero_turn_session_restarts_fresh_under_its_own_id(RigKind::Sim).await; @@ -315,6 +492,11 @@ mod sim { mod cli { use super::*; + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn undo_send_redo_and_two_undos_without_a_send() { + super::undo_send_redo_and_two_undos_without_a_send(RigKind::Cli).await; + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn a_zero_turn_session_restarts_fresh_under_its_own_id() { super::a_zero_turn_session_restarts_fresh_under_its_own_id(RigKind::Cli).await; diff --git a/crates/freshell-ws/tests/freshagent_claude_rollback.rs b/crates/freshell-ws/tests/freshagent_claude_rollback.rs index ea63ad7c3..c0aa1dd9f 100644 --- a/crates/freshell-ws/tests/freshagent_claude_rollback.rs +++ b/crates/freshell-ws/tests/freshagent_claude_rollback.rs @@ -1,1231 +1,784 @@ -//! Kata 1wxv Task 4: end-to-end claude rollback dispatch through the REAL WS -//! pipeline against a scripted fake sidecar. The inline fake implements the -//! production sidecar protocol plus the fork-at-point additions: on create with -//! `forkSession:true` it mints a NEW cliSessionId and writes the transcript PREFIX -//! (up to and including `resumeSessionAt`) for the child under the fake -//! CLAUDE_CONFIG_DIR; every create line is logged to `$FAKE_ROLLBACK_SIDECAR_LOG` -//! for assertion. The pane-ledger rollback row is real (`PaneLedger` rooted under -//! the temp HOME's `.freshell/pane-ledger`), and the fresh-agent snapshot REST -//! route is mounted on the same axum app (mirroring freshell-server's main.rs). +//! Undo and redo for freshclaude through a boot-verified fork runtime (core +//! design §4.5 and §4.6, C13; audit G01). //! -//! Harness conventions mirror `freshagent_claude_kill_interrupt.rs`: real axum -//! server on an ephemeral loopback port, real tokio-tungstenite client, the -//! `FRESHELL_CLAUDE_SIDECAR`/`FRESHELL_CLAUDE_NODE` production env-var overrides, -//! and one `CLAUDE_ENV_LOCK` serializing the env-var mutation. +//! Every test runs the REAL claude sidecar over the CLI simulator +//! (RealSidecarSim), with a real pane ledger and the ownership coordinator wired +//! the way main.rs wires them. The simulator boots like the pinned CLI: a +//! conversation boots with no prompt, `system/init` arrives only with a turn +//! (T1), and a lazy fork writes nothing until its first prompt (T6). The pre-C13 +//! rollback waited for an init after the respawn, which the real CLI never sends +//! without a prompt, so on this rig every undo timed out (G01). +//! +//! An undo mints the replacement's id (C), boots the fork of the conversation's +//! effective transcript under C while the original (A) is still live, records +//! C's binding (genesis `Fork{A, at}` or `Fresh`, `supersedes: A`) with the +//! rollback record re-keyed to C, moves ownership A→C, and only then stops A. mod common; -use common::{connect_and_capture_inventory, TestWs, AUTH_TOKEN}; -use std::io::Write as _; use std::path::{Path, PathBuf}; -use std::sync::Arc; use std::time::Duration; -use futures_util::{SinkExt, StreamExt}; use serde_json::{json, Value}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::Message as WsMessage; - -use freshell_freshagent::{ - ClaimCommit, FreshAgentBindingUpsert, FreshAgentSettings, PaneIdentitySink, RollbackRecord, - SinkAliasClearWrite, SinkCloseWrite, SinkCommitWrite, SinkWrite, -}; -use freshell_ws::pane_ledger::{FreshAgentBindingWrite, PaneLedger}; -use freshell_ws::WsState; - -/// Serializes every test in this file (process-global env vars + one fake store). -static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - -// ── rollback-aware fake claude sidecar ────────────────────────────────────── - -/// The production protocol (`create`/`send`/`shutdown`) plus fork-at-point: a -/// create carrying `forkSession:true` mints a NEW durable cli id and writes the -/// resume transcript's PREFIX (up to and including `resumeSessionAt`, walked along -/// the raw parentUuid chain) as the child's file — the source file is NEVER -/// touched (Stage-2-proven SDK invariant: the original's JSONL is hash-identical -/// after a fork). A ``-th `send` appends a uuid-chained u/a pair (the -/// user line carries the verbatim prompt text) and answers with the turn's -/// `sdk.result` (the in_turn clear edge, as the real sidecar ends a turn) — and -/// NEVER a `sdk.turn.complete`, so no completion chime can leak into the rollback -/// capture. -const FAKE_ROLLBACK_SIDECAR_SOURCE: &str = r#" -import fs from 'node:fs' -import path from 'node:path' -import readline from 'node:readline' -const logPath = process.env.FRESHELL_TEST_CLAUDE_ROLLBACK_LOG -const storeRoot = process.env.CLAUDE_CONFIG_DIR -const projDir = path.join(storeRoot, 'projects', 'fakeproj') +use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions, RIG_FRAME_BUDGET}; +use freshell_protocol::SessionType; -let mintCounter = 0 -let cliId = null -let turnCount = 0 +/// An undo answers within this budget. The pre-C13 rollback answered only after +/// its 45 s init wait expired. +const ROLLBACK_BUDGET: Duration = Duration::from_secs(20); -function transcriptPath(id) { return path.join(projDir, `${id}.jsonl`) } -function readLines(id) { - try { return fs.readFileSync(transcriptPath(id), 'utf8').split('\n').filter(Boolean) } catch { return [] } -} -function mintCliId() { - mintCounter += 1 - return `ffffffff-0000-4000-8000-${String(process.pid).padStart(4, '0')}${String(mintCounter).padStart(4, '0')}`.slice(0, 36) -} -function prefixThrough(lines, atUuid) { - // The RAW parentUuid chain walk: keep up to AND including the named uuid - // (resumeSessionAt semantics), every line — display filtering is NEVER the - // chain's business. - const byUuid = new Map() - for (const line of lines) { - try { - const v = JSON.parse(line) - if (v.uuid) byUuid.set(v.uuid, { line, parent: v.parentUuid ?? null }) - } catch { /* skip */ } - } - const keep = new Set() - let cur = atUuid - while (cur && byUuid.has(cur)) { - keep.add(cur) - cur = byUuid.get(cur).parent - } - return lines.filter((line) => { - try { const v = JSON.parse(line); return v.uuid ? keep.has(v.uuid) : false } catch { return false } - }) +async fn rollback_rig() -> ClaudeRig { + ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await } -let placeholderCounter = 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') { - // Log the WHOLE create request verbatim — the harness asserts resume/fork keys. - if (logPath) fs.appendFileSync(logPath, `${JSON.stringify(msg)}\n`) - placeholderCounter += 1 - const sessionId = `fake-sidecar-${process.pid}-${placeholderCounter}` - fs.mkdirSync(projDir, { recursive: true }) - if (msg.boot?.kind === 'fork') { - // Fork-at-point: NEW durable id, child file = the parent transcript's prefix. - const src = msg.boot?.resume - let lines = readLines(src) - if (msg.boot?.resumeSessionAt) lines = prefixThrough(lines, msg.boot?.resumeSessionAt) - cliId = mintCliId() - turnCount = 0 - fs.writeFileSync(transcriptPath(cliId), lines.length ? `${lines.join('\n')}\n` : '') - } else { - cliId = mintCliId() - turnCount = 0 - fs.writeFileSync(transcriptPath(cliId), '') - } - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') - process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId: cliId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') - process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') - } else if (msg.type === 'send') { - turnCount += 1 - let parent = null - for (const line of readLines(cliId)) { - try { const v = JSON.parse(line); if (v.uuid) parent = v.uuid } catch { /* skip */ } +fn ledger(rig: &ClaudeRig) -> &freshell_ws::pane_ledger::PaneLedger { + rig.pane_ledger.as_deref().expect("the rig has a ledger") +} + +/// One finished turn per prompt (each `freshAgent.result`), each on disk before +/// the next: the CLI flushes a transcript line 95-200 ms after its SDK frame +/// (T3), and an undo reads the transcript. +async fn converse(rig: &ClaudeRig, conn: &mut RigConnection, session_id: &str, prompts: &[&str]) { + for (n, prompt) in prompts.iter().enumerate() { + let results_before = conn.events_seen("freshAgent.result"); + conn.send_text(session_id, &format!("send-{session_id}-{n}"), prompt) + .await; + wait_for_results(conn, results_before + 1, prompt).await; + let path = rig + .wait_for_transcript(&conn.init_session_id(session_id)) + .await; + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + let lines = chain_lines(&path); + let at = lines.iter().rposition(|line| { + line["type"] == "user" && line["message"]["content"][0]["text"] == *prompt + }); + if at.is_some_and(|at| lines[at..].iter().any(|line| line["type"] == "assistant")) { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "the turn for {prompt:?} never reached disk" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } } - const u = `u${turnCount}` - const a = `a${turnCount}` - const user = { type: 'user', uuid: u, parentUuid: parent, timestamp: `t${turnCount}a`, cwd: '/tmp', message: { role: 'user', content: [{ type: 'text', text: msg.text }] } } - const assistant = { type: 'assistant', uuid: a, parentUuid: u, timestamp: `t${turnCount}b`, message: { role: 'assistant', content: [{ type: 'text', text: `answer ${turnCount}` }] } } - fs.appendFileSync(transcriptPath(cliId), `${JSON.stringify(user)}\n${JSON.stringify(assistant)}\n`) - // The rollback capture surface must NEVER see a completion chime; the - // turn's result is the busy-clear edge (no per-turn idle, like the real - // sidecar). - process.stdout.write(JSON.stringify({ type: 'sdk.result', sessionId: msg.sessionId, result: 'success', durationMs: 1, costUsd: 0, usage: {} }) + '\n') - } else if (msg.type === 'rollback.quiesce') { - // ep4-r3 wire parity: rollback's pre-teardown probe — this fake never has - // an inflight turn at that point (its sends settle their statuses - // immediately) and nothing sits in an SDK-input queue. - process.stdout.write(JSON.stringify({ type: 'sdk.rollback.quiesced', sessionId: msg.sessionId, probeId: msg.probeId ?? null, cancelledQueue: 0, inFlightTurn: false, handedCompactLikely: false }) + '\n') - } else if (msg.type === 'interrupt') { - // ep4-r2 wire parity: the real sidecar always answers with the signed - // settle; on this fake nothing is ever in flight mid-rollback, matching - // the real sidecar's 'no in-flight SDK query' answer (NO trailing result - // — no turn exists to terminate). - process.stdout.write(JSON.stringify({ type: 'sdk.interrupt_settled', sessionId: msg.sessionId, ok: false, message: 'no in-flight SDK query' }) + '\n') - } else if (msg.type === 'shutdown') { - process.exit(0) - } -}) -"#; - -/// One test's installed rig: fake sidecar on disk + env vars + temp HOME. -struct RollbackRig { - home: tempfile::TempDir, - sidecar_log: PathBuf, } -impl RollbackRig { - fn install() -> Self { - let home = tempfile::tempdir().expect("temp home"); - let dir = home.path().join("rig"); - std::fs::create_dir_all(&dir).expect("rig dir"); - let script = dir.join("fake-claude-rollback-sidecar.mjs"); - std::fs::write(&script, FAKE_ROLLBACK_SIDECAR_SOURCE).expect("write fake sidecar"); - let sidecar_log = dir.join("creates.log"); - std::fs::write(&sidecar_log, "").expect("init creates log"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("FRESHELL_TEST_CLAUDE_ROLLBACK_LOG", &sidecar_log); - std::env::set_var("CLAUDE_CONFIG_DIR", home.path().join("claude-store")); - Self { home, sidecar_log } - } - fn ledger_dir(&self) -> PathBuf { - self.home.path().join(".freshell/pane-ledger") - } - /// Every create request the fake sidecar received, in order. - fn create_lines(&self) -> Vec { - std::fs::read_to_string(&self.sidecar_log) - .unwrap_or_default() - .lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| serde_json::from_str(l).expect("create log line is JSON")) - .collect() - } -} -impl Drop for RollbackRig { - fn drop(&mut self) { - std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); - std::env::remove_var("FRESHELL_CLAUDE_NODE"); - std::env::remove_var("FRESHELL_TEST_CLAUDE_ROLLBACK_LOG"); - std::env::remove_var("CLAUDE_CONFIG_DIR"); +async fn wait_for_results(conn: &mut RigConnection, count: usize, what: &str) { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while conn.events_seen("freshAgent.result") < count { + assert!( + tokio::time::Instant::now() < deadline, + "the turn for {what:?} never finished; frames: {:?}", + conn.frames + ); + conn.read_for(Duration::from_millis(50)).await; } } -/// The rollback-record-provisioning identity sink, backed by the REAL pane -/// ledger — mirrors `freshell-server/src/identity_sink.rs`'s LedgerIdentitySink -/// (the ws-test crate cannot depend on freshell-server: the dep edge runs the -/// other way), INCLUDING the claude-adoption rollback-row re-key that rides the -/// awaited binding batch. -struct TestLedgerSink { - ledger: Arc, -} -impl TestLedgerSink { - fn now_ms() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) - } -} -impl PaneIdentitySink for TestLedgerSink { - fn record_pending(&self, placeholder_id: &str, mode: &str, cwd: Option<&str>) -> SinkWrite { - let ledger = self.ledger.clone(); - let (p, m, c) = ( - placeholder_id.to_string(), - mode.to_string(), - cwd.map(str::to_string), - ); - let now = Self::now_ms(); - Box::pin(async move { - tokio::task::spawn_blocking(move || { - ledger.record_pending( - &p, - &m, - c.as_deref(), - None, - freshell_ws::pane_ledger::ProvenanceStamps::default(), - now, +/// `freshAgent.undo` / `freshAgent.redo` from the pane `pane_id`, fenced with the +/// owner pair the client holds for the session's durable id (the client sends it +/// with every rollback). +async fn request_rollback( + conn: &mut RigConnection, + kind: &str, + pane_id: &str, + durable: &str, + request_id: &str, + turn_id: Option<&str>, +) { + let (epoch, generation) = conn + .owner_pair(durable) + .unwrap_or_else(|| panic!("an owner record for {durable} was broadcast")); + let mut frame = json!({ + "type": format!("freshAgent.{kind}"), + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": pane_id, + "requestId": request_id, + "mode": if turn_id.is_some() { "toTurn" } else { "step" }, + "observedEpoch": epoch, + "observedGeneration": generation, + }); + if let Some(turn_id) = turn_id { + frame["turnId"] = json!(turn_id); + } + conn.send(frame).await; +} + +/// The requester's answer to rollback `request_id`: the ack (`rolledBack` / +/// `redone`) or the rollback error, within [`ROLLBACK_BUDGET`]. +async fn rollback_answer(conn: &mut RigConnection, request_id: &str) -> Value { + let started = tokio::time::Instant::now(); + let answer = tokio::time::timeout( + ROLLBACK_BUDGET, + conn.wait_for(&format!("the answer to rollback {request_id}"), |v| { + v["type"] == "freshAgent.event" + && v["event"]["requestId"] == request_id + && matches!( + v["event"]["type"].as_str(), + Some("freshAgent.rolledBack" | "freshAgent.redone" | "freshAgent.error") ) - }) - .await - .map_err(std::io::Error::other)? - }) - } - fn record_binding(&self, upsert: FreshAgentBindingUpsert) -> SinkWrite { - let ledger = self.ledger.clone(); - let now = Self::now_ms(); - Box::pin(async move { - tokio::task::spawn_blocking(move || { - let stored_genesis = upsert.genesis.stored_value(); - let w = FreshAgentBindingWrite { - genesis: freshell_ws::pane_ledger::GenesisWrite::from_update( - stored_genesis.as_ref(), - upsert.genesis.is_clear(), - ), - provider: &upsert.provider, - session_id: &upsert.session_id, - mode: &upsert.mode, - cwd: upsert.settings.cwd.as_deref(), - create_request_id: upsert.create_request_id.as_deref(), - model: upsert.settings.model.as_deref(), - sandbox: upsert.settings.sandbox.as_deref(), - permission_mode: upsert.settings.permission_mode.as_deref(), - effort: upsert.settings.effort.as_deref(), - supersedes: upsert.supersedes.as_deref(), - provenance: freshell_ws::pane_ledger::ProvenancePolicy::Inherit, - observed_epoch: None, - observed_generation: None, - authoritative: upsert.authoritative, - now_ms: now, - plugins: upsert.settings.plugins.as_deref(), - }; - ledger.record_fresh_agent_binding(&w)?; - // kata 1wxv Task 4 (claude adoption): the rollback row re-keys - // old→new in the SAME awaited batch as the binding write. - if upsert.provider == "claude" { - if let Some(old_id) = upsert.supersedes.as_deref() { - if old_id != upsert.session_id { - if let Some(payload) = - ledger.load_rollback_row(&upsert.provider, old_id) - { - ledger.record_rollback_row( - &upsert.provider, - &upsert.session_id, - &payload, - now, - )?; - if let Err(e) = ledger.delete_rollback_row(&upsert.provider, old_id) { - tracing::warn!(error = %e, session = %old_id, "rollback row re-key: old row delete failed (cosmetic)"); - } - } - } - } - } - if let Some(p) = upsert.resolves_pending.as_deref() { - let _ = ledger.delete_pending(p); - } - Ok(()) - }) - .await - .map_err(std::io::Error::other)? - }) - } - fn load_settings(&self, _provider: &str, _session_id: &str) -> Option { - None - } - fn load_provenance( - &self, - _provider: &str, - _session_id: &str, - ) -> Option { - // Rollback-focused double (like `load_settings` above): these - // rollback tests never cold-attach a codex/opencode session, so the - // provenance read-back is out of their contract. - None - } - fn load_genesis( - &self, - provider: &str, - session_id: &str, - ) -> Option { - let row = self.ledger.load_binding(provider, session_id)?; - if row.pane_kind.as_deref() != Some("fresh-agent") { - return None; - } - freshell_freshagent::RecordedGenesis::from_row( - provider, - session_id, - row.genesis.as_ref(), - row.updated_at, + }), + ) + .await + .unwrap_or_else(|_| { + panic!( + "rollback {request_id} was not answered within {ROLLBACK_BUDGET:?}; frames: {:?}", + conn.frames ) - } - - fn clear_genesis(&self, provider: &str, session_id: &str) -> SinkWrite { - let ledger = self.ledger.clone(); - let (p, s) = (provider.to_string(), session_id.to_string()); - let now = Self::now_ms(); - Box::pin(async move { - tokio::task::spawn_blocking(move || { - ledger.clear_binding_genesis(&p, &s, now).map(|_| ()) - }) - .await - .map_err(std::io::Error::other)? - }) - } - - fn was_recorded(&self, provider: &str, session_id: &str) -> bool { - self.ledger.load_binding(provider, session_id).is_some() - } - fn record_rollback( - &self, - provider: &str, - session_id: &str, - record: RollbackRecord, - ) -> SinkWrite { - let ledger = self.ledger.clone(); - let (p, s) = (provider.to_string(), session_id.to_string()); - Box::pin(async move { - tokio::task::spawn_blocking(move || { - let payload = serde_json::to_value(&record).map_err(std::io::Error::other)?; - ledger.record_rollback_row(&p, &s, &payload, TestLedgerSink::now_ms()) - }) - .await - .map_err(std::io::Error::other)? - }) - } - fn repair_failed_transition( - &self, - provider: &str, - session_id: &str, - epoch: u64, - generation: u64, - ) -> SinkWrite { - let ledger = self.ledger.clone(); - let (p, s) = (provider.to_string(), session_id.to_string()); - let now = Self::now_ms(); - Box::pin(async move { - tokio::task::spawn_blocking(move || { - ledger.repair_failed_transition_binding(&p, &s, epoch, generation, now) - }) - .await - .map_err(std::io::Error::other)? - }) - } - fn load_rollback(&self, provider: &str, session_id: &str) -> Option { - // Mirror of freshell-server's LedgerIdentitySink: the shared migrating - // reader owns the version gate + the legacy epochless-union migration - // (focused ep1-r1 F3, absence-keyed per ep1-r2 F1). - let payload = self.ledger.load_rollback_row(provider, session_id)?; - RollbackRecord::from_stored_payload(payload) - } - fn delete_rollback(&self, provider: &str, session_id: &str) -> SinkWrite { - let ledger = self.ledger.clone(); - let (p, s) = (provider.to_string(), session_id.to_string()); - Box::pin(async move { - tokio::task::spawn_blocking(move || ledger.delete_rollback_row(&p, &s)) - .await - .map_err(std::io::Error::other)? - }) - } - - fn lookup_by_create_request_id( - &self, - provider: &str, - create_request_id: &str, - ) -> Option { - self.ledger - .lookup_by_create_request_id(provider, create_request_id) - .map(|row| row.session_id) - } - - // Retire-on-kill (delta-review round 5): the same real-ledger pass-through - // the LedgerIdentitySink this double mirrors now implements. - fn retire_closed(&self, provider: &str, session_id: &str) -> SinkCloseWrite { - let ledger = self.ledger.clone(); - let (p, s) = (provider.to_string(), session_id.to_string()); - Box::pin(async move { - let result = tokio::task::spawn_blocking(move || { - ledger.retire_closed(&p, &s, TestLedgerSink::now_ms()) - }) - .await - .map_err(|join| { - freshell_freshagent::identity_sink::SinkCloseError::Clean(std::io::Error::other( - join, - )) - })?; - result.map_err(|err| match err { - freshell_ws::pane_ledger::CloseEnvelopeError::Clean(e) => { - freshell_freshagent::identity_sink::SinkCloseError::Clean(e) - } - freshell_ws::pane_ledger::CloseEnvelopeError::Persisted(e) => { - freshell_freshagent::identity_sink::SinkCloseError::Persisted(e) - } - }) - }) - } - - /// Delta-r6-r3: the real-ledger delegation mirrors - /// `freshell-server/src/identity_sink.rs`'s `retire_closed_batch` - /// (the ledger envelope is the atomicity point; one spawn_blocking hop). - fn retire_closed_batch( - &self, - provider: &str, - session_ids: &[String], - pending_ids: &[String], - ) -> SinkCloseWrite { - let ledger = self.ledger.clone(); - let p = provider.to_string(); - let ids = session_ids.to_vec(); - let pendings = pending_ids.to_vec(); - Box::pin(async move { - let result = tokio::task::spawn_blocking(move || { - ledger.close_identities(&p, &ids, &pendings, TestLedgerSink::now_ms()) - }) - .await - .map_err(|join| { - freshell_freshagent::identity_sink::SinkCloseError::Clean(std::io::Error::other( - join, - )) - })?; - result.map_err(|err| match err { - freshell_ws::pane_ledger::CloseEnvelopeError::Clean(e) => { - freshell_freshagent::identity_sink::SinkCloseError::Clean(e) - } - freshell_ws::pane_ledger::CloseEnvelopeError::Persisted(e) => { - freshell_freshagent::identity_sink::SinkCloseError::Persisted(e) - } - }) - }) - } - fn delete_pending(&self, placeholder_id: &str) -> SinkWrite { - let ledger = self.ledger.clone(); - let p = placeholder_id.to_string(); - Box::pin(async move { - tokio::task::spawn_blocking(move || ledger.delete_pending(&p)) - .await - .map_err(std::io::Error::other)? - }) - } - - // Focused-ep5-r1 Finding 2: same real-ledger pass-through as `retire_closed`. - fn clear_kill_tombstone(&self, provider: &str, session_id: &str) -> SinkWrite { - let ledger = self.ledger.clone(); - let (p, s) = (provider.to_string(), session_id.to_string()); - Box::pin(async move { - tokio::task::spawn_blocking(move || ledger.clear_kill_tombstone(&p, &s)) - .await - .map_err(std::io::Error::other)? - }) - } - - // Focused-ep5-r3 Finding 1 (retire-on-kill round 4): the snapshot read is - // a memory-only inline pass-through (the LedgerIdentitySink discipline). - fn kill_tombstone_at_ms(&self, provider: &str, session_id: &str) -> Option { - self.ledger.kill_tombstone_at(provider, session_id) - } - - // Retire-on-kill round 5 (focused-ep5-r4 Finding 2): the alias-tombstone - // retention probe — memory-only inline pass-through like the snapshot read. - fn row_is_bound(&self, provider: &str, session_id: &str) -> bool { - self.ledger.row_is_bound(provider, session_id) - } - - // Focused-ep5-r3 Findings 1+3: the conditional single-transition claim - // commit — a faithful pass-through to the real ledger op. - fn commit_claim( - &self, - provider: &str, - session_id: &str, - expect_killed_at_ms: Option, - ) -> SinkCommitWrite { - self.commit_claim_aliased(provider, session_id, expect_killed_at_ms, &[]) - } - // Focused-ep5-r5 Finding 2 (retire-on-kill round 6): the aliased commit's - // faithful pass-through (the alias gate lives inside the ledger's - // guarded transition). - fn commit_claim_aliased( - &self, - provider: &str, - session_id: &str, - expect_killed_at_ms: Option, - fence_checked_aliases: &[String], - ) -> SinkCommitWrite { - let ledger = self.ledger.clone(); - let (p, s) = (provider.to_string(), session_id.to_string()); - let aliases: Vec = fence_checked_aliases.to_vec(); - Box::pin(async move { - let outcome = tokio::task::spawn_blocking(move || { - ledger.commit_claim_aliased( - &p, - &s, - expect_killed_at_ms, - &aliases, - TestLedgerSink::now_ms(), - ) - }) - .await - .map_err(std::io::Error::other)??; - Ok(match outcome { - freshell_ws::pane_ledger::ClaimCommitOutcome::Committed => ClaimCommit::Committed, - freshell_ws::pane_ledger::ClaimCommitOutcome::RefusedStale => { - ClaimCommit::RefusedStale - } - }) - }) - } - // Finding 2's alias-record pass-throughs (same disciplines as the rest of - // the test double). - fn record_alias_tombstone( - &self, - provider: &str, - placeholder: &str, - durable: &str, - at_ms: i64, - ) -> SinkWrite { - let ledger = self.ledger.clone(); - let (p, ph, d) = ( - provider.to_string(), - placeholder.to_string(), - durable.to_string(), - ); - Box::pin(async move { - tokio::task::spawn_blocking(move || ledger.record_alias_tombstone(&p, &ph, &d, at_ms)) - .await - .map_err(std::io::Error::other)? - }) - } - fn alias_tombstone_records(&self, provider: &str, placeholder: &str) -> Vec<(String, i64)> { - self.ledger.alias_tombstone_records(provider, placeholder) - } - fn clear_alias_tombstones_for_durable( - &self, - provider: &str, - durable: &str, - ) -> SinkAliasClearWrite { - let ledger = self.ledger.clone(); - let (p, d) = (provider.to_string(), durable.to_string()); - Box::pin(async move { - tokio::task::spawn_blocking(move || ledger.clear_alias_tombstones_for_durable(&p, &d)) - .await - .map_err(std::io::Error::other)? - }) - } + }); + eprintln!( + "{}", + json!({ "severity": "info", "event": "rollback_answered", "request": request_id, + "ms": started.elapsed().as_millis() as u64 }) + ); + answer["event"].clone() +} + +/// An undo or redo that must succeed: its ack, and the new session id it names. +async fn rollback_ok( + conn: &mut RigConnection, + kind: &str, + pane_id: &str, + durable: &str, + request_id: &str, +) -> (Value, String) { + request_rollback(conn, kind, pane_id, durable, request_id, None).await; + let ack = rollback_answer(conn, request_id).await; + let expected = if kind == "undo" { + "freshAgent.rolledBack" + } else { + "freshAgent.redone" + }; + assert_eq!(ack["type"], expected, "{ack}"); + let new_id = ack["newSessionId"] + .as_str() + .expect("the ack names the replacement session") + .to_string(); + (ack, new_id) } -fn test_settings_value() -> Value { - json!({ - "ai": {}, - "codingCli": { "enabledProviders": [], "mcpServer": true, "providers": {} }, - "editor": { "externalEditor": "auto" }, - "extensions": { "disabled": [] }, - "freshAgent": { "defaultPlugins": [], "enabled": true, "providers": {} }, - "logging": { "debug": false }, - "network": { "configured": true, "host": "127.0.0.1" }, - "panes": { "defaultNewPane": "ask" }, - "safety": { "autoKillIdleMinutes": 15 }, - "sidebar": { - "autoGenerateTitles": true, - "excludeFirstChatMustStart": false, - "excludeFirstChatSubstrings": [] - }, - "terminal": { "scrollback": 10000 } +/// The `freshAgent.session.materialized` frame that re-keyed `from`. +async fn materialized_from(conn: &mut RigConnection, from: &str) -> Value { + conn.wait_for(&format!("materialized {from}"), |v| { + v["type"] == "freshAgent.session.materialized" && v["previousSessionId"] == from }) + .await } -/// Real axum server (ephemeral loopback) with the ws route AND the fresh-agent -/// snapshot route merged (mirroring freshell-server's main.rs), the pane ledger -/// rooted under the rig's `.freshell/pane-ledger`, and fresh_claude's identity -/// sink wired to it. Returns (ws_url, http_addr, ledger). -async fn spawn_server_with_rollback_rig( - rig: &RollbackRig, -) -> (String, std::net::SocketAddr, Arc) { - let auth_token = Arc::new(AUTH_TOKEN.to_string()); - let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(64).0); - let settings = - Arc::new(serde_json::from_value(test_settings_value()).expect("valid settings fixture")); - let pane_ledger = Arc::new(PaneLedger::new(Some(rig.ledger_dir()))); - - let fresh_claude = freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)); - fresh_claude.set_identity_sink(Arc::new(TestLedgerSink { - ledger: Arc::clone(&pane_ledger), - })); - - let state = WsState { - pane_ledger: Arc::clone(&pane_ledger), - layout: Default::default(), - identity: freshell_ws::identity::TerminalIdentityRegistry::new(), - terminal_meta: Default::default(), - auth_token: Arc::clone(&auth_token), - server_instance_id: Arc::new("srv-test".to_string()), - boot_id: Arc::new("boot-test".to_string()), - settings, - handshake_settings: Arc::new(tokio::sync::RwLock::new( - serde_json::from_value(test_settings_value()).expect("valid settings fixture"), - )), - broadcast_tx: Arc::clone(&broadcast_tx), - auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, - auto_resume_cancels: Default::default(), - fresh_codex: freshell_freshagent::FreshCodexState::new( - Arc::clone(&auth_token), - Arc::clone(&broadcast_tx), - json!({ "freshAgent": { "enabled": true } }), - ), - fresh_claude: fresh_claude.clone(), - fresh_opencode: freshell_freshagent::FreshOpencodeState::new( - freshell_freshagent::FreshAgentState::new( - Arc::clone(&auth_token), - Arc::clone(&broadcast_tx), - ), - ), - registry: freshell_terminal::TerminalRegistry::new(), - tabs: freshell_ws::tabs::TabsRegistry::new(), - screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), - subagent_interest: Default::default(), - host_stats: Default::default(), - checkpoint_send: Default::default(), - terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), - sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), - cli_commands: Arc::new(Vec::new()), - shutdown: Arc::new(tokio::sync::Notify::new()), - ping_interval_ms: 30_000, - hello_timeout_ms: 5_000, - allowed_origins: Arc::new(freshell_ws::origin::default_allowed_origins()), - ws_max_payload_bytes: 16 * 1024 * 1024, - term09: freshell_ws::backpressure::Term09Config::default(), - create_protect: freshell_ws::create_limit::CreateProtectConfig::default(), - spawn_gate: std::sync::Arc::new(freshell_ws::spawn_gate::SpawnGate::new(4, 64)), - shutdown_started: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), - create_dedupe: std::sync::Arc::new(freshell_ws::create_dedupe::CreateDedupe::default()), - config_fallback: None, - opencode_locator: None, - codex_locator: None, - activity: None, - session_existence: std::sync::Arc::new(freshell_ws::existence::NoIndexProbe::default()), - reconcile_deferral_budget_ms: freshell_ws::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, - fresh_agent_respawn_counts: Default::default(), - ownership: None, - }; +/// The `query()` options the simulator recorded, in order. +fn queries(rig: &ClaudeRig) -> Vec { + rig.ledger_rows("query") + .into_iter() + .map(|row| row["options"].clone()) + .collect() +} - let fresh_agent = freshell_freshagent::FreshAgentState::new( - Arc::clone(&auth_token), - Arc::clone(&broadcast_tx), - ); - let snapshot_state = freshell_freshagent::SnapshotState::new( - Arc::clone(&auth_token), - state.fresh_codex.clone(), - fresh_agent, - state.fresh_claude.clone(), +/// The recorded `query()` options of the boot that ran under `session_id`. +fn boot_of(rig: &ClaudeRig, session_id: &str) -> Value { + let matching: Vec = queries(rig) + .into_iter() + .filter(|o| o["sessionId"] == session_id) + .collect(); + assert_eq!( + matching.len(), + 1, + "one boot under {session_id}: {:?}", + queries(rig) ); - let router = - freshell_ws::router(state).merge(freshell_freshagent::snapshot::router(snapshot_state)); - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("bind ephemeral loopback port"); - let addr = listener.local_addr().expect("local addr"); - tokio::spawn(async move { - let _ = axum::serve(listener, router).await; - }); - (format!("ws://{addr}/ws"), addr, pane_ledger) + matching.into_iter().next().unwrap() } -async fn send_json(ws: &mut TestWs, value: &Value) { - ws.send(WsMessage::Text(value.to_string())) - .await - .expect("send frame"); +/// The chain lines (those with a uuid) of a transcript, in file order. +fn chain_lines(path: &Path) -> Vec { + std::fs::read_to_string(path) + .expect("read the transcript") + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|line| line["uuid"].is_string()) + .collect() } -/// A capture bag with a CONSUME cursor: every awaited scan STARTS with the -/// bag's unconsumed tail (a prior await's drain may already have captured the -/// wanted frame — the undo ack's drain also captures the materialized broadcast -/// riding immediately behind it), and consumes exactly the matched frame. -#[derive(Default)] -struct FrameBag { - entries: Vec<(bool, Value)>, +fn uuid_where(lines: &[Value], what: &str, predicate: impl Fn(&Value) -> bool) -> String { + lines + .iter() + .find(|line| predicate(line)) + .and_then(|line| line["uuid"].as_str()) + .unwrap_or_else(|| panic!("no transcript line for {what}")) + .to_string() } -impl FrameBag { - fn collect(&mut self, value: Value) { - self.entries.push((false, value)); - } - fn take_matching(&mut self, pred: &dyn Fn(&Value) -> bool) -> Option { - let pos = self - .entries - .iter() - .position(|(consumed, v)| !*consumed && pred(v))?; - self.entries[pos].0 = true; - Some(self.entries[pos].1.clone()) - } - fn recent_types(&self) -> Vec { - self.entries - .iter() - .rev() - .take(12) - .map(|(_, f)| { - format!( - "{}/{}", - f["type"].as_str().unwrap_or("?"), - f["event"]["type"].as_str().unwrap_or("-") - ) - }) - .collect() - } + +fn prompt_uuid(lines: &[Value], text: &str) -> String { + uuid_where(lines, text, |line| { + line["type"] == "user" && line["message"]["content"][0]["text"] == text + }) } -/// Drain frames into `bag` until `pred` matches one (bounded); returns the match. -async fn await_frame_into( - ws: &mut TestWs, - bag: &mut FrameBag, - budget_secs: u64, - pred: impl Fn(&Value) -> bool, -) -> Value { - let deadline = tokio::time::Instant::now() + Duration::from_secs(budget_secs); - loop { - if let Some(value) = bag.take_matching(&pred) { - return value; - } - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); - let msg = tokio::time::timeout(remaining, ws.next()) - .await - .unwrap_or_else(|_| { - panic!( - "frame within budget (recent frames seen: {:?})", - bag.recent_types() - ) - }) - .expect("stream not ended") - .expect("no ws error"); - if let WsMessage::Text(text) = msg { - bag.collect(serde_json::from_str(&text).unwrap()); - } - } +/// The uuid of the last chain line before the line `uuid`. +fn uuid_before(lines: &[Value], uuid: &str) -> String { + let at = lines + .iter() + .position(|line| line["uuid"] == uuid) + .expect("the line is in the transcript"); + lines[at - 1]["uuid"].as_str().expect("a uuid").to_string() } -async fn http_get_json(addr: &std::net::SocketAddr, path: &str) -> Value { - let mut stream = tokio::net::TcpStream::connect(addr) - .await - .expect("http connect"); - let request = format!( - "GET {path} HTTP/1.1\r\nHost: 127.0.0.1\r\nx-auth-token: {AUTH_TOKEN}\r\nConnection: close\r\n\r\n" - ); - stream - .write_all(request.as_bytes()) +async fn snapshot_turn_ids(rig: &ClaudeRig, session_id: &str) -> Vec { + let snapshot = rig + .fresh_claude + .get_snapshot(SessionType::Freshclaude, session_id) .await - .expect("http write"); - let mut buf = Vec::new(); - stream.read_to_end(&mut buf).await.expect("http read"); - let text = String::from_utf8(buf).expect("utf8 response"); - let body = text.split("\r\n\r\n").nth(1).expect("http body present"); - serde_json::from_str(body.trim()).expect("json snapshot body") + .unwrap_or_else(|error| panic!("a snapshot of {session_id}: {error:?}")); + snapshot["turns"] + .as_array() + .expect("turns") + .iter() + .filter_map(|t| t["turnId"].as_str().map(str::to_string)) + .collect() } -/// Boot the rig and drive a freshclaude pane through `prompts` sends; returns -/// (ws, original durable cli id, every frame seen so far). -async fn drive_conversation( - rig: &RollbackRig, - prompts: &[&str], -) -> ( - TestWs, - std::net::SocketAddr, - Arc, - FrameBag, - String, -) { - let (url, addr, ledger) = spawn_server_with_rollback_rig(rig).await; - let (mut ws, _inventory) = connect_and_capture_inventory(&url).await; - let mut frames = FrameBag::default(); +fn transcript_of(rig: &ClaudeRig, session_id: &str) -> Option { + let name = format!("{session_id}.jsonl"); + rig.transcripts() + .into_iter() + .find(|path| path.file_name().and_then(|n| n.to_str()) == Some(name.as_str())) +} - send_json( - &mut ws, - &json!({ - "type": "freshAgent.create", - "requestId": "req-create-1", - "sessionType": "freshclaude", - "provider": "claude", - }), - ) - .await; - await_frame_into(&mut ws, &mut frames, 15, |v| { - v["type"] == json!("freshAgent.created") && v["requestId"] == json!("req-create-1") - }) - .await; - // The durable id arrives on the session.init broadcast; its consumer-side fold - // (cli_index insert) precedes the broadcast, so this settles the alias too. - let init = await_frame_into(&mut ws, &mut frames, 15, |v| { - v["type"] == json!("freshAgent.event") - && v["event"]["type"] == json!("freshAgent.session.init") - }) - .await; - let original = init["event"]["cliSessionId"] - .as_str() - .expect("cliSessionId on the init frame") - .to_string(); - // Drain the POST-CREATE idle (the fake emits one idle right after init): each - // per-send idle await below must pair with THAT send's emission, or the undo - // could observe a not-yet-appended transcript. - await_frame_into(&mut ws, &mut frames, 15, |v| { - v["type"] == json!("freshAgent.event") - && v["event"]["type"] == json!("freshAgent.status") - && v["event"]["status"] == json!("idle") - }) - .await; - for (i, prompt) in prompts.iter().enumerate() { - send_json( - &mut ws, - &json!({ - "type": "freshAgent.send", - "provider": "claude", - "sessionId": original, - "sessionType": "freshclaude", - "text": prompt, - "requestId": format!("send-{i}"), - }), - ) - .await; - await_frame_into(&mut ws, &mut frames, 15, |v| { - v["type"] == json!("freshAgent.send.accepted") - && v["requestId"] == json!(format!("send-{i}")) - }) - .await; - // The fake appends the u/a pair BEFORE answering with the result, - // so this waits out both the transcript append AND the busy-clear edge. - await_frame_into(&mut ws, &mut frames, 15, |v| { - v["type"] == json!("freshAgent.event") - && v["event"]["type"] == json!("freshAgent.result") - }) - .await; +/// Whether process `pid` is gone (or a zombie, which holds no pipes). +fn process_gone(pid: i64) -> bool { + match std::fs::read_to_string(format!("/proc/{pid}/stat")) { + Err(_) => true, + Ok(stat) => stat + .rsplit_once(')') + .map(|(_, rest)| rest.trim_start().starts_with('Z')) + .unwrap_or(false), } - (ws, addr, ledger, frames, original) } -fn rollback_frame(request_id: &str, kind: &str, session_id: &str, mode: &str) -> Value { - json!({ - "type": format!("freshAgent.{kind}"), - "provider": "claude", - "sessionId": session_id, - "sessionType": "freshclaude", - "requestId": request_id, - "mode": mode, - }) +/// The sidecar and stand-in CLI processes of the runtime that booted `session_id`. +fn runtime_pids(rig: &ClaudeRig, session_id: &str) -> Vec { + let boot = rig + .ledger_rows("boot") + .into_iter() + .find(|row| row["sessionId"] == session_id) + .unwrap_or_else(|| panic!("a boot row for {session_id}")); + vec![ + boot["pid"].as_i64().expect("the sidecar's pid"), + boot["cliPid"].as_i64().expect("the CLI's pid"), + ] } -/// The durable rollback row files under `rollback/claude/` — exactly the rows the -/// ledger holds for this provider. -fn rollback_rows(ledger_dir: &Path) -> Vec { - let dir = ledger_dir.join("rollback").join("claude"); - let Ok(entries) = std::fs::read_dir(&dir) else { - return Vec::new(); - }; - entries - .filter_map(|e| e.ok()) - .filter(|e| e.path().extension().is_some_and(|x| x == "json")) - .map(|e| serde_json::from_str(&std::fs::read_to_string(e.path()).unwrap()).unwrap()) - .collect() +async fn wait_until_gone(pids: &[i64], what: &str) { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while !pids.iter().all(|pid| process_gone(*pid)) { + assert!( + tokio::time::Instant::now() < deadline, + "{what} ({pids:?}) is still running" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } } -fn assert_never_completes(frames: &FrameBag) { - assert!( - frames.entries.iter().all(|(_, f)| { - f.get("event") - .and_then(|e| e.get("type")) - .and_then(Value::as_str) - != Some("freshAgent.turn.complete") - }), - "a rollback NEVER chimes — no freshAgent.turn.complete anywhere in the capture" +fn assert_never_chimes(conn: &RigConnection) { + assert_eq!( + conn.events_seen("freshAgent.turn.complete"), + conn.events_seen("freshAgent.result"), + "a rollback never rings: one edge per turn, none for the rollback" ); } -/// The canonical Task 4 flow: two sends → undo step → the ack/materalized pair → -/// every assertion the kata pins on the undo leg. Returns the state needed to -/// continue (redo test drives on from here). -async fn setup_and_undo_two_turn_conversation() -> ( - RollbackRig, - TestWs, - std::net::SocketAddr, - Arc, - FrameBag, - String, - String, -) { - let rig = RollbackRig::install(); - let (mut ws, addr, ledger, mut frames, original) = - drive_conversation(&rig, &["first prompt", "second prompt"]).await; - - send_json( - &mut ws, - &rollback_frame("rb-undo-1", "undo", &original, "step"), - ) - .await; - let ack = await_frame_into(&mut ws, &mut frames, 20, |v| { - v["type"] == json!("freshAgent.event") - && v["event"]["type"] == json!("freshAgent.rolledBack") - }) - .await; - assert_eq!(ack["event"]["requestId"], json!("rb-undo-1")); - assert_eq!(ack["event"]["mode"], json!("step")); - assert_eq!( - ack["event"]["removedPromptText"], - json!("second prompt"), - "the ack carries the removed prompt for the composer refill" - ); - assert_eq!(ack["event"]["canRedo"], json!(true)); - let new_id = ack["event"]["newSessionId"] - .as_str() - .expect("claude ack carries the adopted id") - .to_string(); - assert_ne!(new_id, original, "forkSession mints a fresh durable id"); - let removed: Vec<&str> = ack["event"]["removedTurnIds"] +fn is_uuid(id: &str) -> bool { + uuid::Uuid::parse_str(id).is_ok() +} + +/// A two-turn conversation on a new pane: (pane id, durable id A, A's transcript). +async fn two_turns(rig: &ClaudeRig, conn: &mut RigConnection) -> (String, String, PathBuf) { + let pane = conn.create("create-1").await; + converse(rig, conn, &pane, &["first prompt", "second prompt"]).await; + let durable = conn.init_session_id(&pane); + let path = rig.wait_for_transcript(&durable).await; + (pane, durable, path) +} + +/// G01: the undo answers in time. The pane is re-keyed to the new id C (a UUID +/// minted by the server), the fork of A through the first answer booted under C +/// with the drops-turn guard on the undone prompt, C's binding records how C came +/// to be and supersedes A, the rollback record followed the pane, C's snapshot is +/// the kept prefix, A's transcript is untouched and A's runtime was stopped. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_undo_answers_in_time_with_a_fork_materialized_under_a_new_id() { + let rig = rollback_rig().await; + let mut conn = rig.connect().await; + let (pane, a, a_path) = two_turns(&rig, &mut conn).await; + let a_before = std::fs::read_to_string(&a_path).expect("read A"); + let lines = chain_lines(&a_path); + let u2 = prompt_uuid(&lines, "second prompt"); + let keep = uuid_before(&lines, &u2); + let a_runtime = runtime_pids(&rig, &a); + + let (ack, c) = rollback_ok(&mut conn, "undo", &pane, &a, "undo-1").await; + assert_eq!(ack["removedPromptText"], "second prompt", "{ack}"); + assert_eq!(ack["canRedo"], true, "{ack}"); + assert!(is_uuid(&c) && c != a, "C is a new UUID: {c}"); + let removed: Vec<&str> = ack["removedTurnIds"] .as_array() .expect("removedTurnIds") .iter() .filter_map(Value::as_str) .collect(); - assert_eq!(removed, vec!["u2", "a2"], "{ack}"); + assert_eq!(removed.first().copied(), Some(u2.as_str()), "{ack}"); - // The materialized broadcast re-keyed the pane (existing repoint idiom). - let materialized = await_frame_into(&mut ws, &mut frames, 20, |v| { - v["type"] == json!("freshAgent.session.materialized") - }) - .await; - assert_eq!(materialized["previousSessionId"], json!(original)); - assert_eq!(materialized["sessionId"], json!(new_id)); + let materialized = materialized_from(&mut conn, &pane).await; + assert_eq!(materialized["sessionId"], c.as_str()); + assert_eq!( + materialized["sessionRef"], + json!({ "provider": "claude", "sessionId": c }) + ); - // The fake sidecar saw the fork-at-point create with EXACTLY the computed options. - let creates = rig.create_lines(); - let fork_create = creates - .iter() - .find(|l| l["boot"]["kind"] == json!("fork")) - .expect("fork create observed"); - assert_eq!(fork_create["boot"]["resume"], json!(original)); + let boot = boot_of(&rig, &c); + assert_eq!(boot["forkSession"], true, "{boot}"); + assert_eq!(boot["resume"], a.as_str(), "{boot}"); + assert_eq!(boot["resumeSessionAt"], keep.as_str(), "{boot}"); + assert_eq!(boot["resumeDropsTurn"], u2.as_str(), "{boot}"); + + let row = ledger(&rig) + .load_binding("claude", &c) + .expect("C's binding"); assert_eq!( - fork_create["boot"]["resumeSessionAt"], - json!("a1"), - "keep prefix through a1" + row.genesis, + Some(json!({ "kind": "fork", "parent": a, "at": keep, "dropsTurn": u2 })) ); + assert_eq!(row.cwd.as_deref(), rig.workspace.to_str()); + let superseded = ledger(&rig) + .load_binding("claude", &a) + .expect("A's binding"); assert_eq!( - fork_create["boot"]["dropsTurn"], - json!("u2"), - "the guard is the first-to-discard turn's prompt uuid" + superseded.superseded_by.map(|locator| locator.session_id), + Some(c.clone()), + "A's binding names C as its successor" + ); + assert!(ledger(&rig).load_rollback_row("claude", &a).is_none()); + let record = ledger(&rig) + .load_rollback_row("claude", &c) + .expect("the rollback record followed the pane to C"); + assert_eq!(record["originalSessionId"], a.as_str()); + + let c_turns = snapshot_turn_ids(&rig, &c).await; + let a_turns = snapshot_turn_ids(&rig, &a).await; + assert!( + !c_turns.is_empty() && a_turns.starts_with(&c_turns) && !c_turns.contains(&u2), + "C holds A's turns through the first answer: C {c_turns:?}, A {a_turns:?}" ); - - // REST snapshot by the NEW id is the prefix ("turns[] equals what the model sees next"). - let snapshot = http_get_json( - &addr, - &format!("/api/fresh-agent/threads/freshclaude/claude/{new_id}"), - ) - .await; - let turns = snapshot["turns"].as_array().expect("turns"); - assert_eq!(turns.len(), 2, "prefix only: u1+a1"); - assert_eq!(turns[0]["items"][0]["text"], json!("first prompt")); - assert_eq!(turns[1]["items"][0]["text"], json!("answer 1")); - - // The durable record: original_session_id + original_tip_uuid recorded for the - // redo contract, re-keyed to the adopted id (exactly ONE row survives the move). - let rows = rollback_rows(&rig.ledger_dir()); - assert_eq!(rows.len(), 1, "the rollback row MOVED old→new: {rows:?}"); - let record = &rows[0]; - assert_eq!(record["originalSessionId"], json!(original)); assert_eq!( - record["originalTipUuid"], - json!("a2"), - "undo stamps the original's raw-chain tip" + transcript_of(&rig, &c), + None, + "the fork writes nothing yet (T6)" ); - assert_eq!(record["canRedo"], json!(true)); - assert!(record["redoDestroyed"].is_boolean() && record["redoDestroyed"] == json!(false)); - let entry_ids: Vec<&str> = record["entries"] - .as_array() - .expect("entries") + assert_eq!(std::fs::read_to_string(&a_path).expect("read A"), a_before); + wait_until_gone(&a_runtime, "A's runtime").await; + assert!(rig.fresh_claude.has_live_session(&c).await); + assert_never_chimes(&conn); +} + +/// The fork materializes at its first prompt: the CLI reports C as the session +/// (init), writes C's transcript with A's prefix (uuids kept) and the new turn, +/// and the genesis is cleared once that transcript is on disk. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_first_send_after_an_undo_writes_the_fork_under_its_id() { + let rig = rollback_rig().await; + let mut conn = rig.connect().await; + let (pane, a, a_path) = two_turns(&rig, &mut conn).await; + let a_before = std::fs::read_to_string(&a_path).expect("read A"); + let (_, c) = rollback_ok(&mut conn, "undo", &pane, &a, "undo-1").await; + materialized_from(&mut conn, &pane).await; + let prefix = snapshot_turn_ids(&rig, &c).await; + + converse(&rig, &mut conn, &c, &["a different second prompt"]).await; + assert_eq!(conn.init_session_id(&c), c, "the CLI ran under C"); + let c_path = rig.wait_for_transcript(&c).await; + rig.wait_for_genesis_cleared(&c).await; + let c_lines = chain_lines(&c_path); + let a_lines = chain_lines(&a_path); + let kept = prefix.len(); + assert!(kept > 0); + let materialized = snapshot_turn_ids(&rig, &c).await; + assert!( + materialized.starts_with(&prefix) && materialized.len() > prefix.len(), + "the prefix leads C with the same turn ids: {prefix:?} then {materialized:?}" + ); + assert!(c_lines .iter() - .flat_map(|e| e["removedTurns"].as_array().expect("removedTurns").iter()) - .filter_map(|t| t["turnId"].as_str()) - .collect(); + .any(|line| line["message"]["content"][0]["text"] == "a different second prompt")); + assert!( + c_lines[0]["uuid"] == a_lines[0]["uuid"], + "A's lines keep their uuids" + ); + assert_eq!(std::fs::read_to_string(&a_path).expect("read A"), a_before); + assert_never_chimes(&conn); +} + +/// Redo re-forks the chain root (A) through the restored turn: the restored +/// session holds every turn again, and nothing beyond it is left to redo. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_redo_restores_the_undone_turn() { + let rig = rollback_rig().await; + let mut conn = rig.connect().await; + let (pane, a, a_path) = two_turns(&rig, &mut conn).await; + let all_turns = snapshot_turn_ids(&rig, &a).await; + let lines = chain_lines(&a_path); + let tip = lines.last().expect("a tip")["uuid"] + .as_str() + .unwrap() + .to_string(); + let (_, c) = rollback_ok(&mut conn, "undo", &pane, &a, "undo-1").await; + materialized_from(&mut conn, &pane).await; + + let (ack, d) = rollback_ok(&mut conn, "redo", &c, &c, "redo-1").await; + assert_eq!(ack["canRedo"], false, "{ack}"); + assert!(is_uuid(&d) && d != c && d != a, "{d}"); + materialized_from(&mut conn, &c).await; + let boot = boot_of(&rig, &d); + assert_eq!(boot["forkSession"], true, "{boot}"); assert_eq!( - entry_ids, - vec!["u2", "a2"], - "the marker bucket carries the removed slice (r3 union)" + boot["resume"], + a.as_str(), + "redo forks the chain root: {boot}" ); - - assert_never_completes(&frames); - (rig, ws, addr, ledger, frames, original, new_id) -} - -#[tokio::test] -async fn claude_undo_recreates_the_sidecar_with_resume_session_at_and_rekeys_the_pane() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let logs = common::log_capture::install(); - let (rig, _ws, _addr, _ledger, _frames, original, _new_id) = - setup_and_undo_two_turn_conversation().await; - // G14: the respawned sidecar's log is bound to the session the undo named. - let bound = logs.matching(|event| { - event.message == "freshagent.claude.sidecar_bound" - && event.field("session_id") == Some(original.as_str()) - }); - assert_eq!(bound.len(), 1, "{bound:?}"); - assert_eq!(bound[0].field("session_type"), Some("freshclaude")); - drop(rig); -} - -#[tokio::test] -async fn claude_redo_reforks_from_the_original_at_a_later_point() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let (rig, mut ws, addr, _ledger, mut frames, original, new_id) = - setup_and_undo_two_turn_conversation().await; - - send_json( - &mut ws, - &rollback_frame("rb-redo-1", "redo", &new_id, "step"), + assert_eq!(boot["resumeSessionAt"], tip.as_str(), "{boot}"); + assert_eq!( + boot.get("resumeDropsTurn"), + None, + "nothing is discarded, so nothing is guarded: {boot}" + ); + assert_eq!(snapshot_turn_ids(&rig, &d).await, all_turns); + + converse(&rig, &mut conn, &d, &["third prompt"]).await; + assert_eq!(conn.init_session_id(&d), d); + assert_never_chimes(&conn); +} + +/// §4.5 source collapse: a second undo before any send forks the ORIGINAL at the +/// earlier point (chains never nest), so it boots although the first fork has no +/// transcript; it discards two of A's turns, so no single-turn guard is armed. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_second_undo_before_any_send_forks_the_original_again() { + let rig = rollback_rig().await; + let mut conn = rig.connect().await; + let pane = conn.create("create-1").await; + converse( + &rig, + &mut conn, + &pane, + &["first prompt", "second prompt", "third prompt"], ) .await; - let ack = await_frame_into(&mut ws, &mut frames, 20, |v| { - v["type"] == json!("freshAgent.event") && v["event"]["type"] == json!("freshAgent.redone") - }) - .await; - assert_eq!(ack["event"]["requestId"], json!("rb-redo-1")); + let a = conn.init_session_id(&pane); + let a_path = rig.wait_for_transcript(&a).await; + let lines = chain_lines(&a_path); + let keep = uuid_before(&lines, &prompt_uuid(&lines, "second prompt")); + + let (_, c1) = rollback_ok(&mut conn, "undo", &pane, &a, "undo-1").await; + materialized_from(&mut conn, &pane).await; + let (ack, c2) = rollback_ok(&mut conn, "undo", &c1, &c1, "undo-2").await; + assert_eq!(ack["removedPromptText"], "second prompt", "{ack}"); + assert_eq!(ack["canRedo"], true, "{ack}"); + materialized_from(&mut conn, &c1).await; + + let boot = boot_of(&rig, &c2); assert_eq!( - ack["event"]["restoredThroughTurnId"], - json!("a2"), - "redo restores through the restored step's OWN last uuid (r3 boundary rule)" + boot["resume"], + a.as_str(), + "the fork of a fork forks A: {boot}" ); + assert_eq!(boot["resumeSessionAt"], keep.as_str(), "{boot}"); assert_eq!( - ack["event"]["canRedo"], - json!(false), - "nothing lies beyond the chain-root tip after restoring to it" + boot.get("resumeDropsTurn"), + None, + "two turns discarded: {boot}" ); - let redone_id = ack["event"]["newSessionId"] - .as_str() - .expect("claude ack carries the adopted id") - .to_string(); - assert_ne!(redone_id, new_id); - - // The observed create re-forks the ORIGINAL (the chain root), keeping through - // the restored step's group end — never the prefix's current tip + never the - // discarded fork. - let creates = rig.create_lines(); - let fork_creates: Vec<&Value> = creates - .iter() - .filter(|l| l["boot"]["kind"] == json!("fork")) - .collect(); - assert_eq!(fork_creates.len(), 2, "{creates:?}"); - let redo_create = fork_creates[1]; - assert_eq!(redo_create["boot"]["resume"], json!(original)); - assert_eq!(redo_create["boot"]["resumeSessionAt"], json!("a2")); - assert_eq!(redo_create["boot"]["kind"], json!("fork")); - assert!( - redo_create["boot"].get("dropsTurn").is_none(), - "the guard is omitted when the discard range is empty (redo to the tip)" + assert_eq!( + ledger(&rig) + .load_binding("claude", &c2) + .expect("C2's row") + .genesis, + Some(json!({ "kind": "fork", "parent": a, "at": keep })) ); - - // The new snapshot carries all four turns again. - let snapshot = http_get_json( - &addr, - &format!("/api/fresh-agent/threads/freshclaude/claude/{redone_id}"), - ) - .await; - let turns = snapshot["turns"].as_array().expect("turns"); + assert_eq!(transcript_of(&rig, &c1), None, "C1 never materialized"); + let c2_turns = snapshot_turn_ids(&rig, &c2).await; + assert_eq!(c2_turns.len(), 2, "the first turn only: {c2_turns:?}"); + + // Redo still reaches the root's later turns from the collapsed chain. + let (_, d) = rollback_ok(&mut conn, "redo", &c2, &c2, "redo-1").await; + materialized_from(&mut conn, &c2).await; + assert_eq!(snapshot_turn_ids(&rig, &d).await.len(), 4); + converse(&rig, &mut conn, &d, &["a new third prompt"]).await; + assert_eq!(conn.init_session_id(&d), d); +} + +/// First-turn undo (r2): the replacement is a FRESH conversation under C (no +/// resume, no fork); its snapshot is empty; a redo restores the turn from A. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn undoing_the_only_turn_starts_a_fresh_conversation_and_redo_restores_it() { + let rig = rollback_rig().await; + let mut conn = rig.connect().await; + let pane = conn.create("create-1").await; + converse(&rig, &mut conn, &pane, &["only prompt"]).await; + let a = conn.init_session_id(&pane); + let a_turns = snapshot_turn_ids(&rig, &a).await; + + let (ack, c) = rollback_ok(&mut conn, "undo", &pane, &a, "undo-1").await; + assert_eq!(ack["removedPromptText"], "only prompt"); + materialized_from(&mut conn, &pane).await; + let boot = boot_of(&rig, &c); + assert_eq!(boot.get("resume"), None, "{boot}"); + assert_eq!(boot.get("forkSession"), None, "{boot}"); assert_eq!( - turns.len(), - 4, - "the restored conversation is whole: {snapshot}" + ledger(&rig) + .load_binding("claude", &c) + .expect("C's row") + .genesis, + Some(json!({ "kind": "fresh" })) ); + assert!(snapshot_turn_ids(&rig, &c).await.is_empty()); + + let (_, d) = rollback_ok(&mut conn, "redo", &c, &c, "redo-1").await; + materialized_from(&mut conn, &c).await; + assert_eq!(snapshot_turn_ids(&rig, &d).await, a_turns); + converse(&rig, &mut conn, &d, &["second prompt"]).await; + assert_eq!(conn.init_session_id(&d), d); +} + +/// Decision 2: Claude folded another message into the turn being undone (here a +/// background agent's completion notice, written into the launching turn, T19), +/// so the fork's drops-turn guard is refused at boot. The undo answers +/// ROLLBACK_REFUSED with a plain reason, is never retried without the guard, +/// writes no rollback record, and the original stays live and takes a send. (A +/// send absorbed during a tool call is refused the same way by the CLI, pinned +/// by the `absorbed-undo` contract scenario; until the per-command ledger (C17) +/// such a session still reads busy, so its undo answers BUSY_TURN first, G08.) +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_undo_of_a_turn_claude_folded_a_message_into_is_refused_and_the_session_stays_live() { + let rig = rollback_rig().await; + let mut conn = rig.connect().await; + let pane = conn.create("create-1").await; + converse(&rig, &mut conn, &pane, &["first prompt"]).await; + let results = conn.events_seen("freshAgent.result"); + conn.send_text(&pane, "send-agent", "AGENT:async").await; + // The launching turn's result, then the turn the agent's completion starts. + wait_for_results(&mut conn, results + 2, "the background agent's turns").await; + let a = conn.init_session_id(&pane); + let a_path = rig.wait_for_transcript(&a).await; + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while !chain_lines(&a_path) + .iter() + .any(|line| line["origin"]["kind"] == "task-notification") + { + assert!( + tokio::time::Instant::now() < deadline, + "no notification line" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + let boots_before = queries(&rig).len(); - // The record followed the pane again; the restored turns left the marker bucket. - let rows = rollback_rows(&rig.ledger_dir()); - assert_eq!(rows.len(), 1, "{rows:?}"); - assert_eq!(rows[0]["canRedo"], json!(false)); - assert!( - rows[0]["entries"].as_array().expect("entries").is_empty(), - "the restored turns left the current-epoch marker portion: {}", - rows[0] + request_rollback(&mut conn, "undo", &pane, &a, "undo-1", None).await; + let refused = rollback_answer(&mut conn, "undo-1").await; + assert_eq!(refused["type"], "freshAgent.error", "{refused}"); + assert_eq!(refused["code"], "ROLLBACK_REFUSED", "{refused}"); + assert_eq!( + refused["message"], + "Claude folded another message into that turn, so it can't be undone to that point." + ); + let forks: Vec = queries(&rig).into_iter().skip(boots_before).collect(); + assert_eq!( + forks.len(), + 1, + "one guarded fork, never a guard-less retry: {forks:?}" + ); + assert!(forks[0]["resumeDropsTurn"].is_string(), "{:?}", forks[0]); + assert!(ledger(&rig).load_rollback_row("claude", &a).is_none()); + assert_eq!( + ledger(&rig) + .load_binding("claude", &a) + .expect("A's row") + .superseded_by, + None ); - assert_never_completes(&frames); - drop(rig); -} - -#[tokio::test] -async fn claude_undo_of_the_only_turn_empties_the_conversation_via_a_fresh_create() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let rig = RollbackRig::install(); - let (mut ws, addr, _ledger, mut frames, original) = - drive_conversation(&rig, &["first prompt"]).await; - - // r2: first-turn rollback is LEGAL. Undo of the only step → exactly ONE ack - // (removedPromptText == the only prompt, canRedo == true) and a FRESH create - // (NO resume/fork keys — the empty fresh transcript IS the rollback target). - send_json( - &mut ws, - &rollback_frame("rb-first-u", "undo", &original, "step"), - ) - .await; - let ack = await_frame_into(&mut ws, &mut frames, 20, |v| { - v["type"] == json!("freshAgent.event") - && v["event"]["type"] == json!("freshAgent.rolledBack") - }) - .await; - assert_eq!(ack["event"]["removedPromptText"], json!("first prompt")); - assert_eq!(ack["event"]["canRedo"], json!(true)); - let fresh_id = ack["event"]["newSessionId"] - .as_str() - .expect("adopted id") - .to_string(); - let materialized = await_frame_into(&mut ws, &mut frames, 20, |v| { - v["type"] == json!("freshAgent.session.materialized") - }) - .await; - assert_eq!(materialized["sessionId"], json!(fresh_id)); - assert_ne!(fresh_id, original); - let creates = rig.create_lines(); + let results = conn.events_seen("freshAgent.result"); + conn.send_text(&pane, "send-after", "still here?").await; + wait_for_results(&mut conn, results + 1, "the send after the refusal").await; assert_eq!( - creates.len(), - 2, - "the initial create + the rollback's FRESH create: {creates:?}" + conn.request_errors("send-after"), + Vec::::new(), + "A took the send" ); - let fresh_create = &creates[1]; +} + +/// The replacement fails to boot (here: A's transcript is moved away while the +/// undo is parked before the boot, so the CLI cannot find the conversation to +/// fork). The undo answers a retryable error, compensates its record, and A is +/// untouched: still live, and it takes a send. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_fork_that_cannot_boot_leaves_the_original_live() { + let rig = rollback_rig().await; + let mut conn = rig.connect().await; + let (pane, a, a_path) = two_turns(&rig, &mut conn).await; + let hold = rig.fresh_claude.hold_rollback_boot(); + request_rollback(&mut conn, "undo", &pane, &a, "undo-1", None).await; + hold.parked().await; + let aside = a_path.with_extension("moved"); + std::fs::rename(&a_path, &aside).expect("move A's transcript away"); + hold.release(); + + let failed = rollback_answer(&mut conn, "undo-1").await; + assert_eq!(failed["type"], "freshAgent.error", "{failed}"); + assert_eq!(failed["code"], "INTERNAL_ERROR", "{failed}"); + std::fs::rename(&aside, &a_path).expect("put A's transcript back"); + assert!(ledger(&rig).load_rollback_row("claude", &a).is_none()); + assert!(rig.fresh_claude.has_live_session(&a).await); assert_eq!( - fresh_create["boot"], - json!({ "kind": "fresh" }), - "the fresh-conversation leg boots a new conversation, with NO resume/fork keys: {fresh_create}" + conn.frames + .iter() + .filter(|v| v["type"] == "freshAgent.session.materialized") + .count(), + 0 ); - // The record retained the discarded session as the redo source. - let rows = rollback_rows(&rig.ledger_dir()); - assert_eq!(rows.len(), 1, "{rows:?}"); - assert_eq!(rows[0]["originalSessionId"], json!(original)); + converse(&rig, &mut conn, &pane, &["third prompt"]).await; assert_eq!( - rows[0]["originalTipUuid"], - json!("a1"), - "the discarded chain's tip" + conn.request_errors(&format!("send-{pane}-0")), + Vec::::new() ); - assert_eq!(rows[0]["canRedo"], json!(true)); +} - // The live conversation is genuinely EMPTY (a fresh durable id, empty transcript). - let snapshot = http_get_json( - &addr, - &format!("/api/fresh-agent/threads/freshclaude/claude/{fresh_id}"), - ) - .await; - assert_eq!(snapshot["turns"].as_array().expect("turns").len(), 0); +/// §4.5: a send that parked on the session's turn lock while the undo ran lands +/// on the replacement C, never on the stopped A and never on a revived A. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_send_parked_behind_an_undo_runs_in_the_fork() { + let logs = common::log_capture::install(); + let rig = rollback_rig().await; + let mut conn = rig.connect().await; + let (pane, a, a_path) = two_turns(&rig, &mut conn).await; + let hold = rig.fresh_claude.hold_rollback_boot(); + request_rollback(&mut conn, "undo", &pane, &a, "undo-1", None).await; + hold.parked().await; + // The pane's next message, sent before it saw the re-key. + let results = conn.events_seen("freshAgent.result"); + conn.send_text(&pane, "send-parked", "sent during the undo") + .await; + tokio::time::sleep(Duration::from_millis(200)).await; + hold.release(); - // A following redo re-forks the ORIGINAL at the LAST raw-chain uuid of the - // restored u1/a1 step's group — its assistant tail (the r3 boundary fix: - // resumeSessionAt keeps through-AND-including the named uuid, so the target - // is a1, NOT u1 — resuming at u1 would restore only the bare prompt). - send_json( - &mut ws, - &rollback_frame("rb-first-r", "redo", &fresh_id, "step"), - ) - .await; - let redone = await_frame_into(&mut ws, &mut frames, 20, |v| { - v["type"] == json!("freshAgent.event") && v["event"]["type"] == json!("freshAgent.redone") - }) - .await; - assert_eq!(redone["event"]["restoredThroughTurnId"], json!("a1")); - let restored_id = redone["event"]["newSessionId"] - .as_str() - .expect("adopted id") - .to_string(); - let creates = rig.create_lines(); - assert_eq!(creates.len(), 3, "{creates:?}"); - let redo_create = &creates[2]; - assert_eq!(redo_create["boot"]["kind"], json!("fork")); - assert_eq!(redo_create["boot"]["resume"], json!(original)); + let (ack, c) = { + let ack = rollback_answer(&mut conn, "undo-1").await; + let c = ack["newSessionId"].as_str().expect("C").to_string(); + (ack, c) + }; + assert_eq!(ack["type"], "freshAgent.rolledBack", "{ack}"); + wait_for_results(&mut conn, results + 1, "the parked send").await; + assert_eq!(conn.request_errors("send-parked"), Vec::::new()); + let prompt = rig + .ledger_rows("prompt") + .into_iter() + .find(|row| row["text"] == "sent during the undo") + .expect("the parked send ran"); assert_eq!( - redo_create["boot"]["resumeSessionAt"], - json!("a1"), - "the r3 boundary rule — resume at a1, NOT u1" + prompt["sessionId"], + c.as_str(), + "it ran in the fork: {prompt}" ); - let snapshot = http_get_json( - &addr, - &format!("/api/fresh-agent/threads/freshclaude/claude/{restored_id}"), - ) - .await; + let c_path = rig.wait_for_transcript(&c).await; + assert!(chain_lines(&c_path) + .iter() + .any(|line| line["message"]["content"][0]["text"] == "sent during the undo")); + assert!(!std::fs::read_to_string(&a_path) + .expect("read A") + .contains("sent during the undo")); + // It found C through the durable supersession chain from A, not a + // forward kept in memory. + let followed = logs.matching(|event| { + event.field("event") == Some("freshagent.claude.send_followed_replacement") + && event.field("replacement") == Some(c.as_str()) + }); + assert_eq!(followed.len(), 1, "{followed:?}"); + assert_eq!(followed[0].field("through_ledger_chain"), Some("true")); +} + +/// §4.3 "undo, then restart before sending": C's binding still holds its fork +/// genesis, so after a restart the pane's attach boots the same lazy fork under +/// C, its snapshot is the kept prefix, and its first send writes C. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_pending_fork_survives_a_restart_with_its_prefix() { + let mut rig = rollback_rig().await; + let mut conn = rig.connect().await; + let (pane, a, _) = two_turns(&rig, &mut conn).await; + let (_, c) = rollback_ok(&mut conn, "undo", &pane, &a, "undo-1").await; + materialized_from(&mut conn, &pane).await; + let prefix = snapshot_turn_ids(&rig, &c).await; + drop(conn); + + rig.restart().await; assert_eq!( - snapshot["turns"].as_array().expect("turns").len(), + snapshot_turn_ids(&rig, &c).await, + prefix, + "served from the genesis" + ); + let mut conn = rig.connect().await; + conn.attach(&c).await; + let boots: Vec = queries(&rig) + .into_iter() + .filter(|o| o["sessionId"] == c.as_str()) + .collect(); + assert_eq!( + boots.len(), 2, - "the whole u1/a1 step is restored" + "the undo's boot and the restart's: {boots:?}" + ); + assert_eq!(boots[1]["forkSession"], true, "{:?}", boots[1]); + assert_eq!(boots[1]["resume"], a.as_str(), "{:?}", boots[1]); + converse(&rig, &mut conn, &c, &["after the restart"]).await; + assert_eq!(conn.init_session_id(&c), c); + rig.wait_for_transcript(&c).await; + let turns = snapshot_turn_ids(&rig, &c).await; + assert!( + turns.starts_with(&prefix) && turns.len() > prefix.len(), + "{turns:?}" ); - assert_never_completes(&frames); - drop(rig); } -/// Makes a directory unreadable (mode 000) and restores it on drop, so the -/// rig's TempDir cleanup works even when an assertion panics. `None` when the -/// process bypasses permission bits (root), where EACCES cannot be produced. -#[cfg(unix)] +/// G14: the fork's sidecar log lines are bound to the replacement's id. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_forks_sidecar_log_is_bound_to_the_new_session() { + let logs = common::log_capture::install(); + let rig = rollback_rig().await; + let mut conn = rig.connect().await; + let (pane, a, _) = two_turns(&rig, &mut conn).await; + let (_, c) = rollback_ok(&mut conn, "undo", &pane, &a, "undo-1").await; + let bound = logs.matching(|event| { + event.message == "freshagent.claude.sidecar_bound" + && event.field("session_id") == Some(c.as_str()) + }); + assert_eq!(bound.len(), 1, "{bound:?}"); + assert_eq!(bound[0].field("session_type"), Some("freshclaude")); +} + +// ── G28: an unreadable transcript store never reads as an empty one ────────── + +/// Makes a directory unreadable and restores it on drop. `None` when the process +/// bypasses permission bits (root), where EACCES cannot be produced. struct UnreadableDir(PathBuf); -#[cfg(unix)] impl UnreadableDir { - fn make(dir: &Path) -> Option { - Self::with_mode(dir, 0o000) - } - /// Mode 111: listing `dir` fails with EACCES, but a path THROUGH it still - /// opens -- a store a scan cannot check while a known file stays readable. - fn list_denied(dir: &Path) -> Option { - Self::with_mode(dir, 0o111) - } fn with_mode(dir: &Path, mode: u32) -> Option { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(dir, std::fs::Permissions::from_mode(mode)).unwrap(); @@ -1237,7 +790,6 @@ impl UnreadableDir { Some(guard) } } -#[cfg(unix)] impl Drop for UnreadableDir { fn drop(&mut self) { use std::os::unix::fs::PermissionsExt; @@ -1245,202 +797,85 @@ impl Drop for UnreadableDir { } } -/// The rig's fake transcript store (`CLAUDE_CONFIG_DIR`) projects dir. -#[cfg(unix)] -fn rig_projects_dir(rig: &RollbackRig) -> PathBuf { - rig.home.path().join("claude-store").join("projects") -} - -/// The rollback refusal answered for `request_id` (the requesting sink's -/// `freshAgent.error{rollback:true}`). -#[cfg(unix)] -async fn await_rollback_error(ws: &mut TestWs, frames: &mut FrameBag, request_id: &str) -> Value { - let request_id = request_id.to_string(); - await_frame_into(ws, frames, 20, move |v| { - v["type"] == json!("freshAgent.event") - && v["event"]["type"] == json!("freshAgent.error") - && v["event"]["requestId"] == json!(request_id) - }) - .await -} - -/// G28: an undo while the transcript store cannot be READ (EACCES) is a -/// retryable `INTERNAL_ERROR` refusal -- never `NOTHING_TO_UNDO`, which tells -/// the user their conversation has nothing in it. Nothing is forked and no -/// rollback record is written. -#[cfg(unix)] -#[tokio::test] -async fn claude_undo_with_an_unreadable_store_is_internal_error_not_nothing_to_undo() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let rig = RollbackRig::install(); - let (mut ws, _addr, _ledger, mut frames, original) = - drive_conversation(&rig, &["first prompt", "second prompt"]).await; - let Some(_locked) = UnreadableDir::make(&rig_projects_dir(&rig)) else { +/// An undo while the store cannot be read is a retryable INTERNAL_ERROR, never +/// NOTHING_TO_UNDO; nothing is forked and no record is written. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_undo_with_an_unreadable_store_is_internal_error_not_nothing_to_undo() { + let rig = rollback_rig().await; + let mut conn = rig.connect().await; + let (pane, a, _) = two_turns(&rig, &mut conn).await; + let boots = queries(&rig).len(); + let Some(_locked) = UnreadableDir::with_mode(&rig.claude_config_dir.join("projects"), 0o000) + else { return; }; - - send_json( - &mut ws, - &rollback_frame("rb-undo-unreadable", "undo", &original, "step"), - ) - .await; - let refusal = await_rollback_error(&mut ws, &mut frames, "rb-undo-unreadable").await; - assert_eq!( - refusal["event"]["code"], - json!("INTERNAL_ERROR"), - "an unreadable store is a retryable failure, not an empty conversation: {refusal}" - ); - assert!( - rig.create_lines() - .iter() - .all(|l| l["forkSession"] != json!(true)), - "no fork may run against an unchecked store" - ); - assert!( - rollback_rows(&rig.ledger_dir()).is_empty(), - "a refused undo writes no rollback record" - ); - assert_never_completes(&frames); -} - -/// G28: a redo whose chain-root ORIGINAL cannot be read (EACCES) is a -/// retryable `INTERNAL_ERROR` -- never `REDO_UNAVAILABLE`, which tells the user -/// the removed history is permanently gone. The redo record stays intact. -#[cfg(unix)] -#[tokio::test] -async fn claude_redo_with_an_unreadable_original_is_internal_error_not_redo_unavailable() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let (rig, mut ws, _addr, _ledger, mut frames, _original, new_id) = - setup_and_undo_two_turn_conversation().await; - let creates_before = rig.create_lines().len(); - let Some(_locked) = UnreadableDir::make(&rig_projects_dir(&rig)) else { + request_rollback(&mut conn, "undo", &pane, &a, "undo-1", None).await; + let refused = rollback_answer(&mut conn, "undo-1").await; + assert_eq!(refused["code"], "INTERNAL_ERROR", "{refused}"); + assert_eq!(queries(&rig).len(), boots, "nothing was forked"); + assert!(ledger(&rig).load_rollback_row("claude", &a).is_none()); +} + +/// A redo whose original cannot be read is a retryable INTERNAL_ERROR, never +/// REDO_UNAVAILABLE; the record stays redoable. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_redo_with_an_unreadable_original_is_internal_error_not_redo_unavailable() { + let rig = rollback_rig().await; + let mut conn = rig.connect().await; + let (pane, a, _) = two_turns(&rig, &mut conn).await; + let (_, c) = rollback_ok(&mut conn, "undo", &pane, &a, "undo-1").await; + materialized_from(&mut conn, &pane).await; + let boots = queries(&rig).len(); + let Some(_locked) = UnreadableDir::with_mode(&rig.claude_config_dir.join("projects"), 0o000) + else { return; }; - - send_json( - &mut ws, - &rollback_frame("rb-redo-unreadable", "redo", &new_id, "step"), - ) - .await; - let refusal = await_rollback_error(&mut ws, &mut frames, "rb-redo-unreadable").await; - assert_eq!( - refusal["event"]["code"], - json!("INTERNAL_ERROR"), - "an unreadable original is a retryable failure, not lost redo history: {refusal}" - ); - assert_eq!( - rig.create_lines().len(), - creates_before, - "no fork may run against an unchecked store" - ); - let rows = rollback_rows(&rig.ledger_dir()); - assert_eq!(rows.len(), 1, "{rows:?}"); - assert_eq!( - rows[0]["canRedo"], - json!(true), - "the refused redo leaves the record redoable for a retry" - ); - assert_never_completes(&frames); -} - -/// Sets an env var for one test and removes it on drop (also on panic, while -/// the caller still holds `CLAUDE_ENV_LOCK`). -#[cfg(unix)] -struct EnvVarGuard(&'static str); -#[cfg(unix)] -impl EnvVarGuard { - fn set(key: &'static str, value: &str) -> Self { - std::env::set_var(key, value); - Self(key) - } -} -#[cfg(unix)] -impl Drop for EnvVarGuard { - fn drop(&mut self) { - std::env::remove_var(self.0); - } -} - -/// Polls `check` every 10 ms until it holds (bounded). -#[cfg(unix)] -async fn wait_until(budget_secs: u64, what: &str, check: impl Fn() -> bool) { - let deadline = tokio::time::Instant::now() + Duration::from_secs(budget_secs); - while !check() { - assert!( - tokio::time::Instant::now() < deadline, - "timed out waiting for {what}" - ); - tokio::time::sleep(Duration::from_millis(10)).await; - } -} - -/// G28: the fork resumes from the transcript the undo ALREADY located. If the -/// store stops listing between that lookup and the fork (here: the projects -/// dir turns list-denied while the handler is parked before teardown), a -/// second scan would miss, and the fork would resume by bare id with no cwd -/// after the old sidecar is gone. Using the located path keeps the -/// transcript's own cwd (the CLI's resume lookup is scoped to it). -#[cfg(unix)] -#[tokio::test] -async fn claude_undo_forks_from_the_transcript_it_located_even_if_the_store_stops_listing() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let rig = RollbackRig::install(); - let _park = EnvVarGuard::set("FRESHELL_TEST_CLAUDE_ROLLBACK_PRE_TEARDOWN_MS", "1500"); - let (mut ws, _addr, _ledger, mut frames, original) = - drive_conversation(&rig, &["first prompt", "second prompt"]).await; - - send_json( - &mut ws, - &rollback_frame("rb-undo-relocate", "undo", &original, "step"), - ) - .await; - // The durable pre-write lands AFTER the undo located the transcript and - // BEFORE the parked pre-teardown window. - let ledger_dir = rig.ledger_dir(); - wait_until(15, "the rollback pre-write", || { - !rollback_rows(&ledger_dir).is_empty() - }) - .await; - let Some(locked) = UnreadableDir::list_denied(&rig_projects_dir(&rig)) else { + request_rollback(&mut conn, "redo", &c, &c, "redo-1", None).await; + let refused = rollback_answer(&mut conn, "redo-1").await; + assert_eq!(refused["code"], "INTERNAL_ERROR", "{refused}"); + assert_eq!(queries(&rig).len(), boots, "nothing was forked"); + let record = ledger(&rig) + .load_rollback_row("claude", &c) + .expect("the record"); + assert_eq!(record["canRedo"], true, "{record}"); +} + +/// The fork resumes the transcript the undo already located, in that +/// transcript's own directory, even when the store stops listing between the +/// lookup and the boot (a second scan there would miss and lose the cwd). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_undo_forks_from_the_transcript_it_located_even_if_the_store_stops_listing() { + let rig = rollback_rig().await; + let mut conn = rig.connect().await; + let (pane, a, _) = two_turns(&rig, &mut conn).await; + let hold = rig.fresh_claude.hold_rollback_boot(); + request_rollback(&mut conn, "undo", &pane, &a, "undo-1", None).await; + hold.parked().await; + let Some(locked) = UnreadableDir::with_mode(&rig.claude_config_dir.join("projects"), 0o111) + else { + hold.release(); return; }; - wait_until(15, "the fork create", || { - rig.create_lines() - .iter() - .any(|l| l["boot"]["kind"] == json!("fork")) - }) - .await; + let boots = queries(&rig).len(); + hold.release(); + // The fork's query() is recorded before the simulated CLI boots (600 ms); + // the store lists again before the CLI looks for the parent. + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while queries(&rig).len() == boots { + assert!(tokio::time::Instant::now() < deadline, "no fork boot"); + tokio::time::sleep(Duration::from_millis(5)).await; + } drop(locked); - - let creates = rig.create_lines(); - let fork_create = creates - .iter() - .find(|l| l["boot"]["kind"] == json!("fork")) - .expect("fork create observed"); - assert_eq!(fork_create["boot"]["resume"], json!(original)); - assert_eq!(fork_create["boot"]["resumeSessionAt"], json!("a1")); + let fork = queries(&rig) + .into_iter() + .nth(boots) + .expect("the fork's boot"); + assert_eq!(fork["resume"], a.as_str(), "{fork}"); assert_eq!( - fork_create["cwd"], - json!("/tmp"), - "the fork runs in the located transcript's own cwd: {fork_create}" + fork["cwd"], + rig.workspace.to_str().expect("utf-8"), + "the fork runs in the located transcript's own cwd: {fork}" ); - let ack = await_frame_into(&mut ws, &mut frames, 20, |v| { - v["type"] == json!("freshAgent.event") - && v["event"]["type"] == json!("freshAgent.rolledBack") - }) - .await; - assert_eq!(ack["event"]["requestId"], json!("rb-undo-relocate")); - assert_never_completes(&frames); -} - -/// Sanity guard for the raw-HTTP helper's framing (keeps the ws::Write import -/// meaningful; exercises the same surface the snapshot GETs use). -#[test] -fn http_request_write_is_plain_bytes() { - let dir = std::env::temp_dir().join("freshell-rollback-http-sanity.bin"); - let mut f = std::fs::File::create(&dir).unwrap(); - let request = format!("GET /x HTTP/1.1\r\nx-auth-token: {AUTH_TOKEN}\r\n\r\n"); - f.write_all(request.as_bytes()).unwrap(); - assert!(std::fs::read_to_string(&dir).unwrap().contains("GET /x")); - let _ = std::fs::remove_file(&dir); + let ack = rollback_answer(&mut conn, "undo-1").await; + assert_eq!(ack["type"], "freshAgent.rolledBack", "{ack}"); } diff --git a/src/store/freshAgentSlice.ts b/src/store/freshAgentSlice.ts index 44302f5a7..f77cf36e3 100644 --- a/src/store/freshAgentSlice.ts +++ b/src/store/freshAgentSlice.ts @@ -5,6 +5,7 @@ import { type FreshAgentSessionType, } from '@shared/fresh-agent' import type { FreshAgentSnapshot } from '@shared/fresh-agent-contract' +import { isValidClaudeSessionId } from '@/lib/claude-session-id' import type { SessionRuntimeOwnerMessage } from '@shared/ws-protocol' import type { FreshAgentContentBlock, @@ -343,6 +344,12 @@ const freshAgentSlice = createSlice({ const previousSession = state.sessions[previousKey] const nextSession = state.sessions[nextKey] + // A claude re-key to a durable id (an undo or redo's replacement, core + // design §4.5) names the session's durable id: the replacement reports + // no init before its first prompt (T1), so the replaced session's + // `cliSessionId` must not survive the merge and point the pane back at it. + const durableRekey = action.payload.provider === 'claude' + && isValidClaudeSessionId(action.payload.sessionId) if (previousSession || nextSession) { state.sessions[nextKey] = { ...(previousSession ?? createSession(nextLocator, 'connected')), @@ -350,6 +357,9 @@ const freshAgentSlice = createSlice({ ...nextLocator, sessionKey: nextKey, threadId: action.payload.sessionId, + ...(durableRekey + ? { cliSessionId: action.payload.sessionId, historySessionId: action.payload.sessionId } + : {}), lost: false, restoreFailureCode: undefined, restoreFailureMessage: undefined, diff --git a/test/e2e-browser/specs/fresh-agent-rollback-rust.spec.ts b/test/e2e-browser/specs/fresh-agent-rollback-rust.spec.ts index c6d2133ce..d84b820ca 100644 --- a/test/e2e-browser/specs/fresh-agent-rollback-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-rollback-rust.spec.ts @@ -21,17 +21,22 @@ * typed /redo is answered by the pinned client-side codex notice * (decision 5 — the reserved-name interception; the wire refusal stays as * backstop). - * claude AND kilroy (fork-at-point emulation): /undo re-keys the pane via a - * fork-boot sidecar create that MINTS a fresh durable id (the s2rk - * correction), refills the composer, never touches checkpoints (decision - * 1 — zero /checkpoints/restore traffic), and /redo re-forks the retained - * original (the tip/LCP contract). + * claude AND kilroy (boot-verified fork runtime, core design §4.5, C13): the + * PRODUCTION sidecar over the CLI simulator (which boots like the pinned + * CLI: no init without a prompt, a lazy fork writes nothing until its + * first prompt). /undo re-keys the pane to a durable id the server MINTS, + * booted as a fork of the original, refills the composer, never touches + * checkpoints (decision 1 — zero /checkpoints/restore traffic), and /redo + * re-forks the retained original (the tip/LCP contract). Two undos with + * no send between them fork the original again (source collapse), and a + * server restart before the next send keeps the kept prefix (the fork + * boots again from its genesis). * mid-turn lockout (decisions 6/7): a typed /undo while a turn runs is * refused by the client busy gate with the pinned steer copy, NO rollback - * frames leave the client (the sidecar stdin audit shows no fork create), - * and the parked approval card is never silently resolved (cancel frames - * fire only on a SUCCESSFUL rollback; the sanity tail then proves /undo - * succeeds once the turn completes). + * reaches the CLI (the simulator's ledger shows no second boot), and the + * parked approval card is never silently resolved (cancel frames fire only + * on a SUCCESSFUL rollback; the sanity tail then proves /undo succeeds + * once the turn completes). * multi-client convergence (decision 10): a sibling raw-WS client observes * freshAgent.session.rolledBack{revokeAttention:true}; the REST snapshot * (another client's truth) reads identically; a page reload re-attaches @@ -70,7 +75,11 @@ import { WsCapture } from '../helpers/ws-capture.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const CLAUDE_SIDECAR_FIXTURE = path.resolve(__dirname, '../fixtures/providers/fake-claude-sdk-sidecar.mjs') +// The production claude sidecar and the CLI simulator it loads through its +// FRESHELL_CLAUDE_SDK_QUERY_MODULE seam (both in the cloud image's source tree; +// the simulator needs no CLI binary and no network). +const CLAUDE_SIDECAR = path.resolve(__dirname, '../../../crates/freshell-claude-sidecar/index.mjs') +const CLAUDE_CLI_SIMULATOR = path.resolve(__dirname, '../../fixtures/claude-cli-sim/query.mjs') const CODEX_APP_SERVER_FIXTURE = path.resolve( __dirname, '../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs', @@ -80,24 +89,19 @@ const OPENCODE_FIXTURE = path.resolve(__dirname, '../fixtures/fake-opencode.cjs' const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i -// ── Fixture program (claude/kilroy lane) ──────────────────────────────────── -// Plain sends take the canned success turn (u/a transcript lines + a positive -// completion); 'RAISE_PERMISSION' parks the turn behind an approval card -// until the Allow respond lands (the mid-turn lockout case). +// ── Simulator program (claude/kilroy lane) ────────────────────────────────── +// Plain prompts take the simulator's plain reply; 'RAISE_PERMISSION' asks +// canUseTool for a Bash call, which parks the turn behind an approval card +// until the Allow answer lands (the mid-turn lockout case). const ROLLBACK_CLAUDE_PROGRAM = { rules: [ { - on: 'msg:send', + on: 'msg:prompt', match: { text: 'RAISE_PERMISSION' }, emit: [ { kind: 'approval', data: { id: 'req-perm-1', tool: 'Bash', input: { command: 'ls' } } }, ], }, - { - on: 'msg:permission.respond', - match: { decision: { behavior: 'allow' } }, - emit: [{ kind: 'completion', data: { subtype: 'success' } }], - }, ], } @@ -188,38 +192,6 @@ function readJsonl(filePath: string): any[] { .filter((row) => row !== null) } -/** Read the sidecar raw-stdin audit, JSON-parsing each recorded frame. */ -function readStdinFrames(stdinLog: string): any[] { - return readJsonl(stdinLog) - .map((row) => { - try { - return JSON.parse(row.line) - } catch { - return null - } - }) - .filter((frame) => frame !== null) -} - -async function waitForStdinFrame( - stdinLog: string, - pred: (frame: any) => boolean, - what: string, - timeoutMs = 15_000, -): Promise { - let found: any = null - await expect - .poll( - () => { - found = readStdinFrames(stdinLog).find(pred) ?? null - return Boolean(found) - }, - { timeout: timeoutMs, message: `timed out waiting for ${what} in ${stdinLog}` }, - ) - .toBe(true) - return found -} - /** Direct REST snapshot read — the route every card renders from. */ async function fetchSnapshot( info: TestServerInfo, @@ -311,20 +283,35 @@ async function captureDurableId( function claudeLaneEnv( sharedRoot: string, - flavour: 'freshclaude' | 'kilroy', extraEnv: Record = {}, ): Record { return { - FRESHELL_CLAUDE_SIDECAR: CLAUDE_SIDECAR_FIXTURE, + FRESHELL_CLAUDE_SIDECAR: CLAUDE_SIDECAR, FRESHELL_CLAUDE_NODE: process.execPath, - FRESHELL_FAKE_PROVIDER: flavour, - FRESHELL_FAKE_PROGRAM: JSON.stringify(ROLLBACK_CLAUDE_PROGRAM), - FRESHELL_FAKE_STDIN: path.join(sharedRoot, 'sidecar-stdin.jsonl'), - FRESHELL_FAKE_EVENTS: path.join(sharedRoot, 'sidecar-events.jsonl'), + FRESHELL_CLAUDE_SDK_QUERY_MODULE: CLAUDE_CLI_SIMULATOR, + FRESHELL_CLAUDE_SIM_LEDGER: path.join(sharedRoot, 'claude-sim-ledger.jsonl'), + FRESHELL_CLAUDE_SIM_PROGRAM: JSON.stringify(ROLLBACK_CLAUDE_PROGRAM), ...extraEnv, } } +/** + * The `query()` options of every conversation the simulated CLI booted, in + * order (its ledger's `query` rows): the create, then each rollback's + * replacement (a fork carries `resume`, `resumeSessionAt`, `forkSession` and + * the minted `sessionId`; a first-turn undo boots fresh under `sessionId`). + */ +function simBoots(simLedger: string): any[] { + return readJsonl(simLedger) + .filter((row) => row.kind === 'query') + .map((row) => row.options ?? {}) +} + +/** The simulated CLI's boots of forks (rollback replacements of a conversation). */ +function simForks(simLedger: string): any[] { + return simBoots(simLedger).filter((options) => options.forkSession === true) +} + /** Boot the claude lane: create a freshclaude/kilroy pane on an owned server. */ async function bootClaudeLane( page: Page, @@ -336,8 +323,7 @@ async function bootClaudeLane( harness: TestHarness sharedRoot: string projectDir: string - stdinLog: string - eventsLog: string + simLedger: string tabId: string }> { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), `freshell-rollback-${flavour}-`)) @@ -345,7 +331,7 @@ async function bootClaudeLane( const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) const { server, info, harness } = await bootWall(page, { - env: claudeLaneEnv(sharedRoot, flavour, extraEnv), + env: claudeLaneEnv(sharedRoot, extraEnv), // Kilroy parity includes independence from Gemini-summary availability // (donor: the control spec's AGENT-24 strip — every Gemini-capable env // name is scrubbed from the spawned server's env, unit-pinned in @@ -370,8 +356,7 @@ async function bootClaudeLane( harness, sharedRoot, projectDir, - stdinLog: path.join(sharedRoot, 'sidecar-stdin.jsonl'), - eventsLog: path.join(sharedRoot, 'sidecar-events.jsonl'), + simLedger: path.join(sharedRoot, 'claude-sim-ledger.jsonl'), tabId, } } catch (error) { @@ -919,7 +904,8 @@ test.describe('fresh-agent /undo + /redo conversation rollback (rust, kata 1wxv) test('claude: /undo fork-at-point re-keys the pane and refills — and never touches checkpoints (decision 1)', async ({ page }) => { const lane = await bootClaudeLane(page, 'freshclaude') try { - await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + // A booted pane is `connected` until its first turn settles it `idle`. + await waitForPaneStatus(lane.harness, lane.tabId, ['idle', 'connected']) const checkpointRestores: string[] = [] page.on('request', (request) => { if (request.url().includes('/checkpoints/restore')) checkpointRestores.push(request.url()) @@ -938,8 +924,9 @@ test.describe('fresh-agent /undo + /redo conversation rollback (rust, kata 1wxv) await typeSlash(page, '/undo') - // The pane re-keys: fork-at-point adopts a MINTED-FRESH durable id (the - // s2rk correction) via the materialized-adoption leg. + // The pane re-keys to the durable id the server MINTED for the fork, + // through the materialized frame the undo answers with once the CLI + // booted the fork (it never waits for an init, G01). await expect .poll( async () => (await paneLeaf(lane.harness, lane.tabId))?.content?.sessionRef?.sessionId ?? '', @@ -952,20 +939,14 @@ test.describe('fresh-agent /undo + /redo conversation rollback (rust, kata 1wxv) .poll(() => composerLocator(page).inputValue(), { timeout: 15_000 }) .toBe('prompt two') - // Exactly ONE fork-at-point create: a fork boot of ORIG at a - // resumeSessionAt the raw-chain resume point (the sidecar's - // guard validated — no guard-refusal retry would land a second create). - const forks = readStdinFrames(lane.stdinLog) - .filter((f) => f?.type === 'create' && f?.boot?.kind === 'fork') + // Exactly ONE fork boot: ORIG at the raw-chain resume point, under the + // minted id, guarded by the undone prompt (no guard-less retry). + const forks = simForks(lane.simLedger) expect(forks).toHaveLength(1) - expect(forks[0].boot.resume).toBe(ORIG) - expect(typeof forks[0].boot.resumeSessionAt).toBe('string') - // The wire log's sdk.session.init for the fork carries the NEW id. - const wires = readJsonl(lane.eventsLog).filter((r) => r.kind === 'wire') - expect( - wires.some((r) => r.frame?.type === 'sdk.session.init' && r.frame?.cliSessionId === CHILD), - 'sdk.session.init carries the fresh-minted cliSessionId (never ORIG)', - ).toBe(true) + expect(forks[0].resume).toBe(ORIG) + expect(typeof forks[0].resumeSessionAt).toBe('string') + expect(forks[0].sessionId, 'the fork runs under the minted id (never ORIG)').toBe(CHILD) + expect(typeof forks[0].resumeDropsTurn).toBe('string') // The adopted snapshot shows the prefix + the marker bucket + redo. const childSnap = await fetchSnapshot(lane.info, 'freshclaude', 'claude', CHILD) @@ -990,10 +971,16 @@ test.describe('fresh-agent /undo + /redo conversation rollback (rust, kata 1wxv) await expect .poll(async () => userRows(await fetchSnapshot(lane.info, 'freshclaude', 'claude', REDONE)), { timeout: 15_000 }) .toBe(2) - const reforks = readStdinFrames(lane.stdinLog) - .filter((f) => f?.type === 'create' && f?.boot?.kind === 'fork') + const reforks = simForks(lane.simLedger) expect(reforks.length).toBe(2) - expect(reforks[1].boot.resume).toBe(ORIG) + expect(reforks[1].resume).toBe(ORIG) + expect(reforks[1].sessionId).toBe(REDONE) + + // The redone conversation takes the next prompt under its own id. + await sendComposerText(page, 'prompt three') + await expect + .poll(async () => userRows(await fetchSnapshot(lane.info, 'freshclaude', 'claude', REDONE)), { timeout: 15_000 }) + .toBe(3) // Rollback never touches files: ZERO checkpoint-restore traffic. expect(checkpointRestores).toEqual([]) @@ -1006,7 +993,8 @@ test.describe('fresh-agent /undo + /redo conversation rollback (rust, kata 1wxv) test('kilroy: typed /undo drives the claude lane — fork-at-point fork, pane re-key, refill, marker (r2 provider coverage)', async ({ page }) => { const lane = await bootClaudeLane(page, 'kilroy', { KILROY_ENABLED: '1' }) try { - await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + // A booted pane is `connected` until its first turn settles it `idle`. + await waitForPaneStatus(lane.harness, lane.tabId, ['idle', 'connected']) await sendComposerText(page, 'prompt one') const ORIG = await captureDurableId(lane.harness, lane.tabId, CANONICAL_UUID_RE) await expect @@ -1029,11 +1017,11 @@ test.describe('fresh-agent /undo + /redo conversation rollback (rust, kata 1wxv) .not.toBe(ORIG) const CHILD = (await paneLeaf(lane.harness, lane.tabId))?.content?.sessionRef?.sessionId as string expect(CHILD).toMatch(CANONICAL_UUID_RE) - const forks = readStdinFrames(lane.stdinLog) - .filter((f) => f?.type === 'create' && f?.boot?.kind === 'fork') + const forks = simForks(lane.simLedger) expect(forks).toHaveLength(1) - expect(forks[0].boot.resume).toBe(ORIG) - expect(typeof forks[0].boot.resumeSessionAt).toBe('string') + expect(forks[0].resume).toBe(ORIG) + expect(typeof forks[0].resumeSessionAt).toBe('string') + expect(forks[0].sessionId).toBe(CHILD) await expect .poll(() => composerLocator(page).inputValue(), { timeout: 15_000 }) .toBe('prompt two') @@ -1047,6 +1035,73 @@ test.describe('fresh-agent /undo + /redo conversation rollback (rust, kata 1wxv) } }) + test('claude: two /undo with no send between them, then a server restart: the pending fork keeps its prefix and takes the next prompt (core design §4.3, §4.5)', async ({ page }) => { + const lane = await bootClaudeLane(page, 'freshclaude') + try { + // A booted pane is `connected` until its first turn settles it `idle`. + await waitForPaneStatus(lane.harness, lane.tabId, ['idle', 'connected']) + await sendComposerText(page, 'prompt one') + const ORIG = await captureDurableId(lane.harness, lane.tabId, CANONICAL_UUID_RE) + for (const [n, text] of [[2, 'prompt two'], [3, 'prompt three']] as const) { + await expect + .poll(async () => userRows(await fetchSnapshot(lane.info, 'freshclaude', 'claude', ORIG)), { timeout: 15_000 }) + .toBe(n - 1) + await sendComposerText(page, text) + } + await expect + .poll(async () => userRows(await fetchSnapshot(lane.info, 'freshclaude', 'claude', ORIG)), { timeout: 15_000 }) + .toBe(3) + await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + await waitForRollbackCapability(page) + + const durable = async () => (await paneLeaf(lane.harness, lane.tabId))?.content?.sessionRef?.sessionId ?? '' + await typeSlash(page, '/undo') + await expect.poll(durable, { timeout: 30_000 }).not.toBe(ORIG) + const FIRST = await durable() + await expect.poll(() => composerLocator(page).inputValue(), { timeout: 15_000 }).toBe('prompt three') + await expect + .poll(async () => userRows(await fetchSnapshot(lane.info, 'freshclaude', 'claude', FIRST)), { timeout: 15_000 }) + .toBe(2) + await waitForRollbackCapability(page) + await typeSlash(page, '/undo') + await expect.poll(durable, { timeout: 30_000 }).not.toBe(FIRST) + const SECOND = await durable() + expect(SECOND).toMatch(CANONICAL_UUID_RE) + await expect.poll(() => composerLocator(page).inputValue(), { timeout: 15_000 }).toBe('prompt two') + // Source collapse: the second fork reads the ORIGINAL, never the first + // fork (which wrote no transcript: nothing was sent to it). + const forks = simForks(lane.simLedger) + expect(forks.map((f) => [f.resume, f.sessionId])).toEqual([[ORIG, FIRST], [ORIG, SECOND]]) + await expect + .poll(async () => userRows(await fetchSnapshot(lane.info, 'freshclaude', 'claude', SECOND)), { timeout: 15_000 }) + .toBe(1) + + // Restart before any send: the pane re-attaches and the pending fork + // boots again from its genesis (a fork of ORIG under SECOND). + await lane.server.restart() + await lane.harness.waitForConnection() + const paneRoot = page.locator('[data-context="fresh-agent"]').last() + await expect(paneRoot.locator('article[data-turn-role="user"]')).toHaveCount(1, { timeout: 30_000 }) + expect(userRows(await fetchSnapshot(lane.info, 'freshclaude', 'claude', SECOND))).toBe(1) + await expect + .poll(() => simForks(lane.simLedger).filter((f) => f.sessionId === SECOND).length, { timeout: 30_000 }) + .toBe(2) + const reboot = simForks(lane.simLedger).filter((f) => f.sessionId === SECOND)[1] + expect(reboot.resume).toBe(ORIG) + await waitForPaneStatus(lane.harness, lane.tabId, ['idle', 'connected']) + + await composerLocator(page).fill('') + await sendComposerText(page, 'prompt after the restart') + await expect + .poll(async () => userRows(await fetchSnapshot(lane.info, 'freshclaude', 'claude', SECOND)), { timeout: 15_000 }) + .toBe(2) + expect(await durable()).toBe(SECOND) + } finally { + await lane.server.stop().catch(() => {}) + await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) + } + }) + test('multi-client convergence: sibling raw-WS client + REST see the same post-rollback truth (decision 10)', async ({ page }) => { const lane = await bootOpencodeLane(page) try { @@ -1162,7 +1217,17 @@ test.describe('fresh-agent /undo + /redo conversation rollback (rust, kata 1wxv) test('mid-turn lockout: /undo is rejected while a turn runs; cards survive (decisions 6, 7)', async ({ page }) => { const lane = await bootClaudeLane(page, 'freshclaude') try { - await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + // A booted pane is `connected` until its first turn settles it `idle`. + await waitForPaneStatus(lane.harness, lane.tabId, ['idle', 'connected']) + // The CLI asks canUseTool only outside bypass mode: the turn runs in + // the default mode (the send carries the pane's permission mode). + const leafId = (await paneLeaf(lane.harness, lane.tabId))?.id as string + await page.evaluate(({ tabId, paneId }) => { + ;(window as any).__FRESHELL_TEST_HARNESS__?.dispatch({ + type: 'panes/mergePaneContent', + payload: { tabId, paneId, updates: { permissionMode: 'default' } }, + }) + }, { tabId: lane.tabId, paneId: leafId }) await sendComposerText(page, 'RAISE_PERMISSION') const card = page.getByRole('alert', { name: 'Permission request for Bash' }) await expect(card).toBeVisible({ timeout: 15_000 }) @@ -1184,13 +1249,12 @@ test.describe('fresh-agent /undo + /redo conversation rollback (rust, kata 1wxv) expect(userRows(await fetchSnapshot(lane.info, 'freshclaude', 'claude', ORIG))).toBe(1) await expect(card).toBeVisible() expect( - readStdinFrames(lane.stdinLog).filter((f) => f?.type === 'create' && f?.boot?.kind === 'fork'), - 'no fork-at-point create ever left the client for the refused /undo', - ).toEqual([]) + simBoots(lane.simLedger), + 'the refused /undo booted nothing: the only boot is the create', + ).toHaveLength(1) // Resolve the approval — the turn completes, then /undo succeeds. await card.getByRole('button', { name: 'Allow tool use' }).click() - await waitForStdinFrame(lane.stdinLog, (f) => f?.type === 'permission.respond', 'permission.respond') await expect(card).toBeHidden({ timeout: 15_000 }) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') await typeSlash(page, '/undo') @@ -1198,25 +1262,17 @@ test.describe('fresh-agent /undo + /redo conversation rollback (rust, kata 1wxv) page.getByText(/the removed prompt is back in the composer/), 'UNDO_REFILL_NOTICE: the post-turn /undo succeeds', ).toBeVisible({ timeout: 15_000 }) - // The tail /undo provably rolled back through EXACTLY ONE create event - // for the session: the busy window was proven window-silent above, so a - // frame leaked there plus the tail's legitimate rollback create would - // surface as TWO — as would a silent second create from the guard-retry - // or adoption seams (every attempt mints a fresh "rollback-" - // requestId and lands on the raw-stdin audit BEFORE its `created` - // response, so the row is on disk before the ack that refills the - // composer). NOTE: this tail is a FIRST-TURN rollback — the resume point - // resolves to None (claude_snapshot.rs), so the rollback create is a - // FRESH-conversation create (a fresh boot, no resume or fork); - // counting fork-boot frames here would count ZERO by design. - const isRollbackCreate = (f: any) => - f?.type === 'create' && typeof f?.requestId === 'string' && (f.requestId as string).startsWith('rollback-') - await waitForStdinFrame(lane.stdinLog, isRollbackCreate, 'the tail /undo rollback create') - const rollbackCreates = readStdinFrames(lane.stdinLog).filter(isRollbackCreate) - expect( - rollbackCreates, - 'exactly ONE rollback create for the session — no second silent create from any retry/adoption race', - ).toHaveLength(1) + // The tail /undo booted EXACTLY ONE replacement: the busy window was + // proven boot-silent above, so a leaked boot there plus the tail's + // legitimate one would surface as TWO, as would a silent retry. NOTE: + // this tail is a FIRST-TURN rollback (the resume point resolves to + // None), so the replacement boots a FRESH conversation under its minted + // id (no resume, no fork). + const boots = simBoots(lane.simLedger) + expect(boots, 'the create, then exactly ONE replacement boot').toHaveLength(2) + expect(boots[1].resume).toBeUndefined() + expect(boots[1].forkSession).toBeUndefined() + expect(boots[1].sessionId).toMatch(CANONICAL_UUID_RE) } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) diff --git a/test/unit/client/store/freshAgentSlice.test.ts b/test/unit/client/store/freshAgentSlice.test.ts index a25fd8f24..085cc80b3 100644 --- a/test/unit/client/store/freshAgentSlice.test.ts +++ b/test/unit/client/store/freshAgentSlice.test.ts @@ -15,6 +15,7 @@ import reducer, { sessionSnapshotReceived, setSessionStatus, } from '@/store/freshAgentSlice' +import { getCanonicalDurableSessionId } from '@/store/persistControl' describe('freshAgentSlice busy/streaming clearing', () => { const loc = { sessionId: 'abc', sessionType: 'freshclaude' as const, provider: 'claude' as const } @@ -213,6 +214,51 @@ describe('freshAgentSlice', () => { }) }) +describe('freshAgentSlice claude rollback re-key', () => { + // Core design §4.5 (C13): an undo or redo replaces the pane's session with a + // new durable session C, announced by `materialized` (placeholder → C). C + // reports no init until its first prompt (T1), so the durable id the pane + // resumes by must come from the re-key itself, never stay the replaced + // session's (which would point the pane back at the stopped original). + it('a claude re-key makes the materialized durable id the session\'s durable id', () => { + const A = '00000000-0000-4000-8000-0000000000aa' + const C = '00000000-0000-4000-8000-0000000000cc' + let state = reducer(undefined, sessionInit({ + sessionId: 'placeholder-1', + sessionType: 'freshclaude', + provider: 'claude', + cliSessionId: A, + })) + state = reducer(state, materializeSession({ + previousSessionId: 'placeholder-1', + sessionId: C, + sessionType: 'freshclaude', + provider: 'claude', + })) + const session = state.sessions[makeFreshAgentSessionKey({ sessionId: C, sessionType: 'freshclaude', provider: 'claude' })] + expect(session.cliSessionId).toBe(C) + expect(getCanonicalDurableSessionId(session)).toBe(C) + }) + + it('a re-key to a non-durable id keeps the session\'s durable id', () => { + const A = '00000000-0000-4000-8000-0000000000aa' + let state = reducer(undefined, sessionInit({ + sessionId: 'placeholder-1', + sessionType: 'freshclaude', + provider: 'claude', + cliSessionId: A, + })) + state = reducer(state, materializeSession({ + previousSessionId: 'placeholder-1', + sessionId: 'placeholder-2', + sessionType: 'freshclaude', + provider: 'claude', + })) + const session = state.sessions[makeFreshAgentSessionKey({ sessionId: 'placeholder-2', sessionType: 'freshclaude', provider: 'claude' })] + expect(session.cliSessionId).toBe(A) + }) +}) + describe('freshAgentSlice lost revocation on truth-bearing snapshot evidence', () => { // reconnect-revive Task 4: a snapshot answer is positive existence evidence // (the 404 FRESH_AGENT_LOST_SESSION path never dispatches these reducers), so From b6d01fde5e0976e2140988a6ec944c495c9e9b97 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 02:26:05 -0700 Subject: [PATCH 12/24] feat(freshclaude): fork a conversation (G09) A freshclaude or kilroy conversation can now be forked from any turn (or from its tip with /fork). The new claude/fork.rs mints the child's id, boots it as the SDK's lazy fork of the parent through the chosen turn (resume, resumeSessionAt, forkSession, sessionId) via establish_runtime while the parent stays live and untouched, records the child's binding row (Fork genesis, the parent's settings, the forking connection's provenance, no supersedes), commits the child's coordinator claim and answers freshAgent.forked on the requesting connection only. The pane switches to the child at once (decision 5) and closes the parent itself. - Keep point: the last chain line of the step holding input.atTurnId (a :row-N split id names its turn), or the tip (claude_snapshot::fork_point). A pending fork forks its own parent at the same point (source collapse). - Refusals, each a nested freshAgent.error on the requesting connection: BUSY_TURN while the parent runs a turn, FORK_REFUSED for an empty conversation, a turn the conversation does not hold or a point the CLI no longer has, FORK_IN_PROGRESS for a second fork of the same pane while the first boots, INVALID_FENCE for a half-sent owner pair, the lost frame for an unknown parent. A fork that fails tears down what it started. - The fork waits on the parent's turn lock and follows a replacement an undo made meanwhile (the pane re-keyed to it). - terminal.rs routes claude forks; the refusal table keeps amplifier only. The snapshot stamps capabilities.fork: true (goldens regenerated; only the stamp changed). - Session host: fork() routes claude to handle_fork. The claude transport's own id is the sidecar's, so the parent is accepted by its native id too, the transition is keyed by the native ids the actor holds, and the native identity points at the child at once (the bridge now announces from that watch, so the child's first turn announces nothing new). - New log events: freshagent.claude.fork_boot, fork_refused, fork_binding_write_failed, fork_commit_stale, fork_completed, fork_child_retire_failed. - Refactor: the effective-transcript reader moved from rollback.rs into claude/history.rs (shared by undo and fork); the boot refusal codes are claude_handshake constants. Deviations from the design: - A live runtime now remembers the genesis it was launched from (ClaudeSession.launch_genesis), and the snapshot and the effective transcript fall back to it when the ledger has none. The hosted session host has no ledger, so without this a pending child (and, before this, a hosted undo's replacement) had no snapshot and could not be forked again. - The pinned CLI appends a `last-prompt` line to a conversation's transcript when its CLI shuts down. The fork itself leaves the parent byte-identical; the tests check the parent before the pane closes it, and after that only that the child's turn writes nothing to it. Tests: freshagent_claude_fork.rs (5, RealSidecarSim); real-CLI lane item 6 on both rigs; session-host fork test; fork_point unit tests; the refusal-table test now pins dispatch; client capability test for freshclaude and kilroy; e2e freshclaude-fork-rust.spec.ts; the control spec's capability gate now expects /fork offered for claude. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 21 + .../src/claude/establish.rs | 19 +- crates/freshell-freshagent/src/claude/fork.rs | 529 ++++++++++++++++++ .../freshell-freshagent/src/claude/genesis.rs | 18 +- .../freshell-freshagent/src/claude/history.rs | 123 ++++ .../src/claude/rollback.rs | 119 +--- .../src/claude/teardown_tests.rs | 1 + .../src/claude_handshake.rs | 9 + .../src/claude_snapshot.rs | 112 +++- .../src/providers/fresh_agent.rs | 206 +++++-- crates/freshell-ws/src/terminal.rs | 30 +- .../tests/freshagent_claude_fork.rs | 480 ++++++++++++++++ .../tests/freshagent_claude_real_cli.rs | 98 ++++ .../tests/freshagent_control_reply.rs | 29 +- .../specs/fresh-agent-control-rust.spec.ts | 12 +- .../specs/freshclaude-fork-rust.spec.ts | 471 ++++++++++++++++ .../fresh-agent/claude-snapshot-golden.json | 2 +- .../agent-async/snapshot.golden.json | 2 +- .../agent-failed/snapshot.golden.json | 2 +- .../api-error/snapshot.golden.json | 2 +- .../compact-fail/snapshot.golden.json | 2 +- .../compact-ok/snapshot.golden.json | 2 +- .../image/snapshot.golden.json | 2 +- .../tools-sync/snapshot.golden.json | 2 +- .../fresh-agent/FreshAgentView.test.tsx | 106 ++++ 25 files changed, 2198 insertions(+), 201 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude/fork.rs create mode 100644 crates/freshell-freshagent/src/claude/history.rs create mode 100644 crates/freshell-ws/tests/freshagent_claude_fork.rs create mode 100644 test/e2e-browser/specs/freshclaude-fork-rust.spec.ts diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index aeb4abadc..b38103422 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -253,6 +253,11 @@ pub struct FreshClaudeState { /// polls its membership (task 4 review C1) to PARK a send that resolves /// inside the rollback's teardown→respawn window rather than refuse it. rollback_in_flight: crate::InFlightRegistry, + /// Fork-vs-fork single-flight (C14), keyed by the parent's sessions-map + /// key: a second Fork click on the same pane while the first one boots its + /// child is refused, never a second child the pane cannot adopt (the + /// codex `fork_in_flight` rule). + fork_in_flight: crate::InFlightRegistry, /// Focused-ep5-r1 Finding 1 (retire-on-kill round 2): retained ALIAS /// tombstones. When a session's `cli_index` aliases are evicted (consumer /// exit, kill, a lease-revoked/rollback teardown), the mapping is DEMOTED @@ -697,6 +702,12 @@ struct ClaudeSession { /// the hardcoded "idle" that used to wedge stale-busy/stale-idle panes. /// Starts "idle" — a fresh/just-resumed session has announced nothing else. last_status: Arc>, + /// The genesis this runtime was launched from when it had no transcript yet + /// (an undo's replacement, a fork's child; design §4.1), stamped with the + /// launch time. The snapshot serves it when the ledger records none: a lane + /// with no ledger (the hosted session host) still shows a pending session's + /// conversation. A transcript on disk always wins over it. + launch_genesis: Option, } /// The sidecar's signed answer to a `rollback.quiesce` probe (ep4-r3). All @@ -796,6 +807,7 @@ impl FreshClaudeState { ownership_stamps: Arc::new(std::sync::Mutex::new(HashMap::new())), terminal_liveness: Arc::new(|_, _| false), rollback_in_flight: crate::InFlightRegistry::new(), + fork_in_flight: crate::InFlightRegistry::new(), alias_tombstones: Arc::new(std::sync::Mutex::new(AliasTombstones::default())), close_pending: Arc::new(std::sync::Mutex::new(HashMap::new())), naming: crate::naming::NamingSink::default(), @@ -2448,6 +2460,7 @@ impl FreshClaudeState { partial_runtime_key: resume_sid.clone().or_else(|| fresh_claim_key.clone()), start_pid_slot: &start_pid_slot, inherit: None, + genesis: None, }, &mut lease_guard, &mut own_ticket, @@ -5789,6 +5802,7 @@ impl FreshClaudeState { partial_runtime_key: Some(durable.to_string()), start_pid_slot: &start_pid_slot, inherit: None, + genesis: None, }, lease_guard, own_ticket, @@ -8557,7 +8571,9 @@ mod create_outcome; pub use create_outcome::ClaudeCreateOutcome; mod establish; +mod fork; mod genesis; +mod history; mod rollback; use establish::{DurableClaim, EstablishLane, EstablishPlan, RegisterUnder}; @@ -8673,6 +8689,7 @@ pub(crate) mod tests { turn_lock: Arc::new(TokioMutex::new(())), rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), last_status: Arc::new(std::sync::Mutex::new("idle".to_string())), + launch_genesis: None, }, ); } @@ -17834,6 +17851,7 @@ rl.on('line', (line) => { turn_lock: Arc::new(TokioMutex::new(())), rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), last_status: Arc::new(std::sync::Mutex::new("idle".to_string())), + launch_genesis: None, }, ); } @@ -19703,6 +19721,7 @@ rl.on('line', (line) => { turn_lock, rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), last_status, + launch_genesis: None, }, ); st.cli_index @@ -19754,6 +19773,7 @@ rl.on('line', (line) => { turn_lock: Arc::new(TokioMutex::new(())), rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), last_status: Arc::new(std::sync::Mutex::new("idle".to_string())), + launch_genesis: None, }, ); st.cli_index @@ -19848,6 +19868,7 @@ rl.on('line', (line) => { turn_lock, rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), last_status, + launch_genesis: None, }, ); st.cli_index diff --git a/crates/freshell-freshagent/src/claude/establish.rs b/crates/freshell-freshagent/src/claude/establish.rs index b39fd053a..756a66391 100644 --- a/crates/freshell-freshagent/src/claude/establish.rs +++ b/crates/freshell-freshagent/src/claude/establish.rs @@ -18,7 +18,9 @@ //! An undo or redo establishes its replacement runtime here too (C13): it boots //! the replacement under the id the rollback minted, registered as already //! adopted, while the original is still live; the rollback then moves -//! ownership and stops the original (`claude/rollback.rs`). +//! ownership and stops the original (`claude/rollback.rs`). A fork establishes +//! its child the same way, beside the parent, which it never touches +//! (`claude/fork.rs`, C14). use super::*; @@ -32,6 +34,8 @@ pub(super) enum EstablishLane { AttachResume, /// An undo or redo's replacement runtime. Rollback, + /// A fork's child runtime. + Fork, } impl EstablishLane { @@ -40,6 +44,7 @@ impl EstablishLane { Self::Create => "create", Self::AttachResume => "attach-resume", Self::Rollback => "rollback", + Self::Fork => "fork", } } } @@ -105,6 +110,11 @@ pub(super) struct EstablishPlan<'a> { pub(super) start_pid_slot: &'a crate::ownership_lane::SidecarPidSlot, /// The state a replacement takes over (a rollback); `None` starts afresh. pub(super) inherit: Option, + /// The genesis a launch that mints a session with no transcript yet boots + /// (an undo's replacement, a fork's child): the session keeps it, so its + /// snapshot shows the conversation before the transcript exists, also on a + /// lane with no ledger. `None` for every other launch. + pub(super) genesis: Option, } /// A runtime that is registered and committed. @@ -384,6 +394,7 @@ impl FreshClaudeState { partial_runtime_key, start_pid_slot, inherit, + genesis, } = plan; let start = StartWatch { pid_slot: start_pid_slot, @@ -571,6 +582,10 @@ impl FreshClaudeState { turn_lock, rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), last_status, + launch_genesis: genesis.map(|genesis| crate::identity_sink::RecordedGenesis { + genesis, + updated_at_ms: crate::rollback_record::now_ms(), + }), }, ); // C09: a resumed session is live again, so it is no longer an ended @@ -678,7 +693,7 @@ impl FreshClaudeState { if let Err(outcome) = self.commit_lane_claim_at(own_ticket, &map_key, sidecar_pid) { let event = match lane { EstablishLane::Create => "freshagent.claude.create_commit_stale", - EstablishLane::AttachResume | EstablishLane::Rollback => { + EstablishLane::AttachResume | EstablishLane::Rollback | EstablishLane::Fork => { "freshagent.claude.attach_commit_stale" } }; diff --git a/crates/freshell-freshagent/src/claude/fork.rs b/crates/freshell-freshagent/src/claude/fork.rs new file mode 100644 index 000000000..4d67d38d7 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/fork.rs @@ -0,0 +1,529 @@ +//! Fork a claude/kilroy conversation (core-runtime design §4.5 `handle_fork`, +//! C14; audit G09). +//! +//! A fork gives the conversation a second, independent future: a child session +//! C, minted here, booted as the SDK's lazy fork of the parent's conversation +//! through a chosen turn (T6: `resume`, `resumeSessionAt`, `forkSession`, +//! `sessionId: C`). The child is boot-verified through `establish_runtime` while +//! the parent stays live and untouched (the fork only reads the parent's +//! transcript, and writes nothing until its own first prompt), registered under +//! C (map key = envelope stamp = durable id), and recorded in C's binding row +//! with its `Fork` genesis, so a restart before C's first send boots the same +//! fork again. The requester gets `freshAgent.forked` at once (decision 5): the +//! pane switches to the child and closes the parent itself. + +use super::*; + +use freshell_protocol::{FreshAgentFork, FreshAgentForked}; + +use crate::claude_handshake::RESUME_POINT_NOT_FOUND_CODE; +use crate::claude_launch::{ClaudeBoot, ClaudeGenesis, ClaudeLaunchSpec}; +use crate::claude_snapshot as snap; + +use super::establish::{EstablishError, EstablishLane, EstablishPlan, RegisterUnder}; +use super::history::EffectiveTranscriptError; + +/// A fork while the parent runs a turn (`BUSY_TURN`). +pub(crate) const FORK_BUSY_MESSAGE: &str = + "Claude is still working on this conversation. Fork it once the turn finishes."; + +/// A fork of a conversation with no messages yet (`FORK_REFUSED`). +pub(crate) const FORK_EMPTY_MESSAGE: &str = + "There is nothing to fork yet: this conversation has no messages."; + +/// A fork at a turn the conversation does not hold (`FORK_REFUSED`). +pub(crate) const FORK_TURN_MISSING_MESSAGE: &str = + "That turn is not in this conversation, so it can't be forked from."; + +/// The CLI no longer holds the point the fork starts from (`FORK_REFUSED`). +pub(crate) const FORK_POINT_GONE_MESSAGE: &str = + "That point is no longer in Claude's copy of the conversation, so it can't be forked from."; + +/// A second fork of the same pane while the first is booting its child. +pub(crate) const FORK_IN_PROGRESS_MESSAGE: &str = + "This conversation is already being forked. Wait for the new conversation to open."; + +/// The child's binding row could not be written. +pub(crate) const FORK_LEDGER_WRITE_MESSAGE: &str = + "Fork is unavailable right now: the new conversation could not be saved. Try again."; + +/// The nested `freshAgent.error` every fork failure answers with on the +/// requesting connection, keyed to the parent id the pane addressed (any code +/// but `INVALID_SESSION_ID` shows on the pane as an error banner). +fn fork_error_frame( + session_id: &str, + session_type: &str, + code: &str, + message: &str, +) -> ServerMessage { + ServerMessage::FreshAgentEvent(FreshAgentEvent { + event: json!({ + "type": "freshAgent.error", + "sessionId": session_id, + "code": code, + "message": message, + }), + provider: PROVIDER.to_string(), + session_id: session_id.to_string(), + session_type: session_type.to_string(), + }) +} + +/// The fork error a child that was not established answers with. +fn fork_failure(error: &EstablishError) -> (&'static str, String) { + match error { + EstablishError::Boot(failure) if failure.is_refusal(RESUME_POINT_NOT_FOUND_CODE) => { + ("FORK_REFUSED", FORK_POINT_GONE_MESSAGE.to_string()) + } + other => ("INTERNAL_ERROR", other.message(EstablishLane::Fork)), + } +} + +/// The parent a fork reads, as it stands once the fork holds its turn lock. +struct ForkParent { + /// The id the requester knows the parent by (the reply's `parentSessionId`). + reply_id: String, + /// The parent's durable id: whose effective transcript the fork reads. + durable: String, + in_turn: Arc, + /// The settings the parent runs with (model, effort, permission mode, + /// plugins, cwd): the child starts with the same. + settings: crate::identity_sink::FreshAgentSettings, +} + +impl FreshClaudeState { + /// Handle a `freshAgent.fork` for claude/kilroy (core design §4.5, C14). + /// + /// Ordering: fence shape → resolve the parent (unknown: the nested lost + /// frame) → fork single-flight on the parent → the parent's turn lock (a + /// fork never runs beside a send, an undo or another fork; it follows a + /// replacement an undo made while it waited) → busy gate (`BUSY_TURN`) → + /// the keep point on the parent's effective transcript (the last line of + /// the step holding `input.atTurnId`, or the tip; a pending fork's source + /// is its own parent, so chains never nest) → the child's coordinator claim + /// and lease → `establish_runtime(C)` beside the live parent → the child's + /// AWAITED binding row (its `Fork` genesis, the parent's settings, the + /// forking connection's provenance, no `supersedes`) → the coordinator + /// commit → `freshAgent.forked` on the requesting connection. Every failure + /// answers a nested `freshAgent.error` on the same connection, leaves the + /// parent untouched and tears down whatever the fork started. A fork never + /// rings. + pub async fn handle_fork( + &self, + msg: FreshAgentFork, + provenance: Option, + reply_sink: freshell_terminal::FrameSink, + ) { + let session_type = session_type_str(msg.session_type); + let refuse = |code: &str, message: &str| { + reply_sink(fork_error_frame( + &msg.session_id, + session_type, + code, + message, + )); + }; + // The delayed-request fence's shape (send/attach/rollback parity): a + // half-sent pair is the typed INVALID_FENCE refusal. A fork never touches + // the parent's runtime, so a whole pair has nothing further to guard. + if let Err(err) = + crate::ownership_lane::wire_fence(msg.observed_epoch, msg.observed_generation) + { + tracing::warn!(target: "freshell_freshagent::claude", + event = "freshagent.claude.fork_refused", + session_id = %msg.session_id, code = err.code(), + "the fork's observed fence is half-sent"); + refuse(err.code(), err.message()); + return; + } + let Some(map_key) = self.resolve_session_key(&msg.session_id).await else { + reply_sink(lost_session_frame(&msg.session_id, msg.session_type)); + return; + }; + let Some(_in_flight) = self.fork_in_flight.try_acquire(&map_key) else { + tracing::info!(target: "freshell_freshagent::claude", + event = "freshagent.claude.fork_refused", + session_id = %msg.session_id, code = "FORK_IN_PROGRESS", + "a fork of this session is already booting its child"); + refuse("FORK_IN_PROGRESS", FORK_IN_PROGRESS_MESSAGE); + return; + }; + let (turn_lock, durable_before) = { + let sessions = self.sessions.lock().await; + match sessions.get(&map_key) { + Some(session) => ( + Arc::clone(&session.turn_lock), + session + .cli_session_id + .clone() + .unwrap_or_else(|| map_key.clone()), + ), + None => { + drop(sessions); + reply_sink(lost_session_frame(&msg.session_id, msg.session_type)); + return; + } + } + }; + let _turn = turn_lock.lock().await; + let Some(parent) = self + .fork_parent(&map_key, &msg.session_id, &durable_before, &turn_lock) + .await + else { + reply_sink(lost_session_frame(&msg.session_id, msg.session_type)); + return; + }; + if parent.in_turn.load(std::sync::atomic::Ordering::SeqCst) { + tracing::info!(target: "freshell_freshagent::claude", + event = "freshagent.claude.fork_refused", + session_id = %parent.durable, code = "BUSY_TURN", + "a fork waits for the parent's turn to finish"); + refuse("BUSY_TURN", FORK_BUSY_MESSAGE); + return; + } + + // The keep point, on what the parent holds now (its transcript, or for + // a pending fork its parent's chain through the fork point). + let current = match self.effective_transcript(&parent.durable).await { + Ok(current) => current, + Err(EffectiveTranscriptError::Missing) => { + refuse("FORK_REFUSED", FORK_EMPTY_MESSAGE); + return; + } + Err(EffectiveTranscriptError::Unreadable(error)) => { + snap::log_transcript_unreadable("fork", &parent.durable, &error); + refuse( + "INTERNAL_ERROR", + &format!("claude transcript store unreadable: {}", error.kind_label()), + ); + return; + } + Err(EffectiveTranscriptError::Read(message)) => { + refuse("INTERNAL_ERROR", &message); + return; + } + }; + let at_turn_id = msg + .input + .as_ref() + .and_then(|input| input.get("atTurnId")) + .and_then(Value::as_str); + let at = match snap::fork_point(¤t.text, at_turn_id) { + Ok(at) => at, + Err(snap::ForkPointError::Empty) => { + refuse("FORK_REFUSED", FORK_EMPTY_MESSAGE); + return; + } + Err(snap::ForkPointError::TurnNotFound) => { + refuse("FORK_REFUSED", FORK_TURN_MISSING_MESSAGE); + return; + } + }; + // A fork discards nothing of its source that the pane shows as its own + // conversation, so no drops-turn guard is armed (that guard is undo's). + let genesis = ClaudeGenesis::Fork { + parent: current.source.clone(), + at: Some(at.clone()), + drops_turn: None, + }; + + // The child's launch: the parent's settings and the lane's hosted MCP + // selection, resuming the source by id under its ORIGINAL cwd while that + // cwd exists (ledger A15), else by the transcript path the read above + // located (G28: never a second scan). + let located = current + .source_path + .as_deref() + .map(|path| crate::claude_launch::resume_target(¤t.source, path)); + let spawn_cwd = located + .as_ref() + .and_then(|(_, cwd)| cwd.clone()) + .or_else(|| msg.cwd.clone()) + .or_else(|| parent.settings.cwd.clone()); + let boot = ClaudeBoot::Fork { + resume: located + .map(|(resume, _)| resume) + .unwrap_or_else(|| current.source.clone()), + at: Some(at.clone()), + drops_turn: None, + }; + let mut launch_settings = parent.settings.clone(); + if spawn_cwd.is_some() { + launch_settings.cwd = spawn_cwd.clone(); + } + // D8: the child's attribution comes from the FORKING connection, then + // the parent's row (a hollow connection identity never overrides). + let fork_provenance = provenance.filter(|p| p.is_meaningful()).or_else(|| { + self.identity_sink() + .and_then(|sink| sink.load_provenance(PROVIDER, &parent.durable)) + }); + + let child = uuid::Uuid::new_v4().to_string(); + // The child's id is minted, never observed: its coordinator claim needs + // no fence, and it makes the child cross-kind-authoritative from birth. + let fork_operation = format!("fork-child-{}", uuid::Uuid::new_v4()); + let mut own_ticket = match self.begin_lane_claim_at( + &child, + &fork_operation, + None, + &Self::initiator_for(fork_provenance.as_ref(), "freshclaude/fork"), + ) { + crate::ownership_lane::LaneClaim::Granted(ticket) => Some(ticket), + crate::ownership_lane::LaneClaim::Unwired => None, + crate::ownership_lane::LaneClaim::Adopt => { + tracing::warn!(target: "freshell_freshagent::claude", + event = "freshagent.claude.fork_refused", + session_id = %parent.durable, child = %child, code = "SESSION_RESERVED", + "the ownership coordinator found a live owner under the minted fork child id"); + refuse( + "SESSION_RESERVED", + "Another resume for this session is in flight", + ); + return; + } + crate::ownership_lane::LaneClaim::Refused(outcome) => { + tracing::warn!(target: "freshell_freshagent::claude", + event = "freshagent.claude.fork_refused", + session_id = %parent.durable, child = %child, code = "SESSION_RESERVED", + outcome = ?outcome, + "the ownership coordinator refused the fork child's claim"); + refuse( + "SESSION_RESERVED", + "Another resume for this session is in flight", + ); + return; + } + }; + let start_pid_slot = crate::ownership_lane::sidecar_pid_cancel_slot(); + let _start_cancellation = crate::ownership_lane::register_start_cancellation_for_ticket( + &self.ownership, + PROVIDER, + &child, + &own_ticket, + crate::ownership_lane::pid_slot_cancellation(&start_pid_slot), + ); + if own_ticket.is_some() { + crate::ownership_lane::register_partial_fresh_runtime( + &self.ownership, + PROVIDER, + &child, + &own_ticket, + &child, + None, + ); + } + // The child's lease binds its id to the live session once established + // (one live writer per durable transcript). + let lease_id = format!("fork-{}", uuid::Uuid::new_v4()); + let mut lease_guard = match self.leases.claim( + PROVIDER, + &child, + &lease_id, + crate::session_lease::now_epoch_ms(), + ) { + crate::session_lease::FreshSessionClaim::Acquired => { + Some(crate::FreshSessionLeaseGuard::armed( + Arc::clone(&self.leases), + PROVIDER, + &child, + &lease_id, + )) + } + other => { + tracing::error!(target: "invariant", + event = "freshagent.claude.fork_refused", + child = %child, claim = ?other, + "a freshly minted fork child id already holds a lease"); + refuse( + "SESSION_RESERVED", + "Another resume for this session is in flight", + ); + return; + } + }; + + tracing::info!(target: "freshell_freshagent::claude", + event = "freshagent.claude.fork_boot", + session_id = %parent.durable, + child = %child, + source = %current.source, + at = %at, + at_turn_id = ?at_turn_id, + "the fork boots its child beside the live parent" + ); + let spec = ClaudeLaunchSpec::new( + format!("fork-{}", uuid::Uuid::new_v4()), + session_type, + boot, + spawn_cwd, + &launch_settings, + self.hosted_mcp, + ) + .with_session_id(Some(child.clone())); + let established = match self + .establish_runtime( + EstablishPlan { + lane: EstablishLane::Fork, + spec, + register_under: RegisterUnder::Adopted(child.clone()), + claim: None, + rekey_provisional_claim: false, + recorded_settings: Some(launch_settings.clone()), + owning_operation: own_ticket.as_ref().map(|t| t.operation_id().to_string()), + owning_generation: None, + provenance: fork_provenance.clone(), + naming: None, + partial_runtime_key: own_ticket.as_ref().map(|_| child.clone()), + start_pid_slot: &start_pid_slot, + inherit: None, + genesis: Some(genesis.clone()), + }, + &mut lease_guard, + &mut own_ticket, + ) + .await + { + Ok(established) => established, + Err(error) => { + let (code, message) = fork_failure(&error); + tracing::warn!(target: "freshell_freshagent::claude", + event = "freshagent.claude.fork_refused", + session_id = %parent.durable, + child = %child, + code, + failure = %error.message(EstablishLane::Fork), + "the fork's child was not established; the parent is untouched" + ); + refuse(code, &message); + return; + } + }; + + // The child's binding row, AWAITED before the answer (durable before + // answer): how it came to be (its genesis, so a restart before its first + // send boots the same fork), the parent's settings and the forking + // connection's attribution. No `supersedes`: the parent lives on. + if let Some(sink) = self.identity_sink() { + let fenced = own_ticket.as_ref().map(|ticket| ticket.generation()); + if let Err(error) = sink + .record_binding(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Set(genesis.clone()), + provider: PROVIDER.into(), + session_id: child.clone(), + mode: session_type.to_string(), + create_request_id: None, + resolves_pending: None, + supersedes: None, + name_transition: None, + provenance: fork_provenance.clone().into(), + observed_epoch: fenced.and(self.ownership.as_ref().map(|r| r.boot_epoch())), + observed_generation: fenced, + authoritative: false, + settings: launch_settings.clone(), + }) + .await + { + tracing::error!(target: "freshell_freshagent::claude", + event = "freshagent.claude.fork_binding_write_failed", + session_id = %parent.durable, child = %child, error = %error, + "the fork child's binding could not be written; the child is torn down" + ); + self.unwind_fork_child(&child, false).await; + refuse("INTERNAL_ERROR", FORK_LEDGER_WRITE_MESSAGE); + return; + } + } + + // The child is live: commit `Live{FreshAgent}` under its id (the owner + // record is broadcast, so the client holds the child's fence pair). + if let Err(outcome) = + self.commit_lane_claim_at(&mut own_ticket, &child, established.sidecar_pid) + { + tracing::error!(target: "invariant", + event = "freshagent.claude.fork_commit_stale", + provider = PROVIDER, session_id = %parent.durable, child = %child, + outcome = ?outcome, + "the coordinator moved on while the fork child registered; the child is torn down" + ); + self.unwind_fork_child(&child, true).await; + refuse( + "INTERNAL_ERROR", + "session ownership changed during fork; the new conversation was torn down", + ); + return; + } + + tracing::info!(target: "freshell_freshagent::claude", + event = "freshagent.claude.fork_completed", + session_id = %parent.durable, + child = %child, + session_type, + "the fork's child is live beside its parent" + ); + reply_sink(ServerMessage::FreshAgentForked(FreshAgentForked { + request_id: msg.request_id.clone(), + parent_session_id: parent.reply_id, + session_id: child.clone(), + session_type: session_type.to_string(), + provider: PROVIDER.to_string(), + runtime_provider: PROVIDER.to_string(), + parent_retired_by_runtime: None, + session_ref: Some(claude_session_ref(&child)), + })); + } + + /// The parent a fork reads once it holds `turn_lock`: the session `map_key` + /// names while it is still registered; else the runtime an undo or redo + /// replaced it with while the fork waited (it took this very lock over), so + /// the fork reads what the pane now shows. `None` when the parent is gone. + async fn fork_parent( + &self, + map_key: &str, + wire_id: &str, + durable: &str, + turn_lock: &Arc>, + ) -> Option { + let live_key = self + .live_key_after_turn_wait(map_key, wire_id, Some(durable), turn_lock) + .await?; + let sessions = self.sessions.lock().await; + let session = sessions.get(&live_key)?; + Some(ForkParent { + // The pane follows a replacement through its `materialized` re-key, + // so it then knows the parent by the replacement's id. + reply_id: if live_key == map_key { + wire_id.to_string() + } else { + live_key.clone() + }, + durable: session + .cli_session_id + .clone() + .unwrap_or_else(|| live_key.clone()), + in_turn: Arc::clone(&session.in_turn), + settings: session.configuration.settings.clone(), + }) + } + + /// Tear down a fork child that registered but cannot be handed to the + /// pane; the parent is untouched. Once the child's binding row was written + /// (`row_written`), it is retired so nothing offers to restore a + /// conversation no pane ever showed. + async fn unwind_fork_child(&self, child: &str, row_written: bool) { + if let Some(session) = self.sessions.lock().await.remove(child) { + let _teardown: LoggedTeardown = self.teardown_removed_session_logged(session).await; + } + self.evict_cli_index_aliases(child).await; + self.leases.clear_binding(PROVIDER, child); + if row_written { + if let Some(sink) = self.identity_sink() { + if let Err(error) = sink.retire_closed(PROVIDER, child).await { + tracing::warn!(target: "freshell_freshagent::claude", + event = "freshagent.claude.fork_child_retire_failed", + child = %child, error = ?error, + "the torn-down fork child's binding row could not be retired"); + } + } + } + } +} diff --git a/crates/freshell-freshagent/src/claude/genesis.rs b/crates/freshell-freshagent/src/claude/genesis.rs index 3e02390f0..555280019 100644 --- a/crates/freshell-freshagent/src/claude/genesis.rs +++ b/crates/freshell-freshagent/src/claude/genesis.rs @@ -124,12 +124,24 @@ impl FreshClaudeState { } } - /// The genesis the binding row of the session `any_id` names records, if - /// any (memory-only). + /// The genesis the session `any_id` names came from, if any (memory-only): + /// what its binding row records, else what its live runtime was launched + /// from (a lane with no ledger, such as the hosted session host, records + /// nothing). pub(crate) async fn load_genesis(&self, any_id: &str) -> Option { let durable = self.durable_session_id(any_id).await; - self.identity_sink() + if let Some(recorded) = self + .identity_sink() .and_then(|sink| sink.load_genesis(PROVIDER, &durable)) + { + 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()) } /// The session's snapshot from disk, read from this lane's transcript store: diff --git a/crates/freshell-freshagent/src/claude/history.rs b/crates/freshell-freshagent/src/claude/history.rs new file mode 100644 index 000000000..11d918b7a --- /dev/null +++ b/crates/freshell-freshagent/src/claude/history.rs @@ -0,0 +1,123 @@ +//! A claude session's history as undo, redo and fork read it (core-runtime +//! design §4.5): the conversation the session holds, and the real transcript a +//! fork of it reads. Before the CLI writes a session's transcript, its genesis +//! says what it holds: a pending fork holds its parent's chain through the fork +//! point, a fresh session nothing. + +use super::*; + +use crate::claude_launch::ClaudeGenesis; +use crate::claude_snapshot as snap; + +/// A session's effective transcript (design §4.5): what it holds now, and the +/// real transcript a fork of it reads. +pub(super) struct EffectiveTranscript { + /// The conversation as the session holds it. + pub(super) text: String, + /// The session a fork of it reads: itself, or, for a pending fork, its + /// parent (source collapse). + pub(super) source: String, + /// That source's own transcript text and path (empty / `None` for a + /// `Fresh` session, which has none). + pub(super) source_text: String, + pub(super) source_path: Option, +} + +/// Why a session's effective transcript could not be read. +pub(super) enum EffectiveTranscriptError { + /// No transcript and no genesis: nothing the store knows. + Missing, + /// The store could not be checked (G28: never "empty"). + Unreadable(snap::LocateError), + /// A located transcript (or a pending fork's parent) could not be read. + Read(String), +} + +impl FreshClaudeState { + /// The session's effective transcript (design §4.5): its transcript when + /// the CLI wrote one; before that, what its genesis holds (a pending fork + /// holds its parent's chain through the fork point, a fresh session + /// nothing). The genesis is the binding row's, or the live runtime's own + /// on a lane with no ledger. + pub(super) async fn effective_transcript( + &self, + session_id: &str, + ) -> Result { + match self.locate_transcript(session_id) { + snap::TranscriptLocation::Found(path) => { + let text = std::fs::read_to_string(&path) + .map_err(|e| EffectiveTranscriptError::Read(e.to_string()))?; + Ok(EffectiveTranscript { + source_text: text.clone(), + text, + source: session_id.to_string(), + source_path: Some(path), + }) + } + snap::TranscriptLocation::Unreadable(error) => { + Err(EffectiveTranscriptError::Unreadable(error)) + } + snap::TranscriptLocation::Absent => { + // The binding row's genesis, else the one the live runtime was + // launched from (a lane with no ledger records none). + let genesis = self + .load_genesis(session_id) + .await + .map(|recorded| recorded.genesis); + match genesis { + None => Err(EffectiveTranscriptError::Missing), + Some(ClaudeGenesis::Fresh) => Ok(EffectiveTranscript { + text: String::new(), + source: session_id.to_string(), + source_text: String::new(), + source_path: None, + }), + Some(ClaudeGenesis::Fork { parent, at, .. }) => { + let path = match self.locate_transcript(&parent) { + snap::TranscriptLocation::Found(path) => path, + snap::TranscriptLocation::Absent => { + return Err(EffectiveTranscriptError::Read(format!( + "the conversation this one was forked from ({parent}) is gone" + ))) + } + snap::TranscriptLocation::Unreadable(error) => { + return Err(EffectiveTranscriptError::Unreadable(error)) + } + }; + let parent_text = std::fs::read_to_string(&path) + .map_err(|e| EffectiveTranscriptError::Read(e.to_string()))?; + let text = snap::fork_prefix_transcript(&parent_text, at.as_deref()) + .ok_or_else(|| { + EffectiveTranscriptError::Read(format!( + "the point this conversation was forked at is no longer in {parent}" + )) + })?; + Ok(EffectiveTranscript { + text, + source: parent, + source_text: parent_text, + source_path: Some(path), + }) + } + } + } + } + } + + /// `session_id`'s transcript text, when the store has one (`Ok(None)` + /// when it has none). + pub(super) fn read_located_transcript( + &self, + session_id: &str, + ) -> Result, EffectiveTranscriptError> { + match self.locate_transcript(session_id) { + snap::TranscriptLocation::Found(path) => std::fs::read_to_string(&path) + .map(Some) + .map_err(|e| EffectiveTranscriptError::Read(e.to_string())), + snap::TranscriptLocation::Absent => Ok(None), + snap::TranscriptLocation::Unreadable(error) => { + Err(EffectiveTranscriptError::Unreadable(error)) + } + } + } +} diff --git a/crates/freshell-freshagent/src/claude/rollback.rs b/crates/freshell-freshagent/src/claude/rollback.rs index 1fc814d3a..70160f4f6 100644 --- a/crates/freshell-freshagent/src/claude/rollback.rs +++ b/crates/freshell-freshagent/src/claude/rollback.rs @@ -9,6 +9,7 @@ use super::*; +use crate::claude_handshake::{FORK_GUARD_REFUSED_CODE, RESUME_POINT_NOT_FOUND_CODE}; use crate::claude_launch::{ClaudeBoot, ClaudeGenesis, ClaudeLaunchSpec}; use crate::claude_snapshot as snap; use crate::rollback_record::*; @@ -16,6 +17,7 @@ use crate::rollback_record::*; use super::establish::{ EstablishError, EstablishLane, EstablishPlan, InheritedRuntime, RegisterUnder, }; +use super::history::EffectiveTranscriptError; /// Decision 2: the CLI refused the fork's drops-turn guard because the turn /// being undone absorbed another message (a send made during a tool call, a @@ -27,10 +29,6 @@ pub(crate) const ROLLBACK_FOLDED_TURN_MESSAGE: &str = pub(crate) const ROLLBACK_POINT_GONE_MESSAGE: &str = "That point is no longer in Claude's copy of the conversation, so it can't be undone to."; -/// The `create.failed` codes of a fork the CLI refused (`claude-sidecar/boot.mjs`). -const FORK_GUARD_REFUSED_CODE: &str = "FORK_GUARD_REFUSED"; -const RESUME_POINT_NOT_FOUND_CODE: &str = "RESUME_POINT_NOT_FOUND"; - /// What a rollback replaces the session's conversation with, from the reads. struct RollbackPlan { /// The display turns the undo removes (or the redo restores). @@ -48,30 +46,6 @@ struct RollbackPlan { source_path: Option, } -/// A session's effective transcript (design §4.5): what it holds now, and the -/// real transcript a fork of it reads. -struct EffectiveTranscript { - /// The conversation as the session holds it. - text: String, - /// The session a fork of it reads: itself, or, for a pending fork, its - /// parent (source collapse). - source: String, - /// That source's own transcript text and path (empty / `None` for a - /// `Fresh` session, which has none). - source_text: String, - source_path: Option, -} - -/// Why a session's effective transcript could not be read. -enum EffectiveTranscriptError { - /// No transcript and no genesis: nothing the store knows. - Missing, - /// The store could not be checked (G28: never "empty"). - Unreadable(snap::LocateError), - /// A located transcript (or a pending fork's parent) could not be read. - Read(String), -} - impl EffectiveTranscriptError { /// The rollback refusal for this (`Missing` is the caller's to word). fn refusal(&self, op: &RollbackRequest, session_id: &str) -> freshell_protocol::ServerMessage { @@ -589,7 +563,7 @@ impl FreshClaudeState { let plan = match op.direction { RollbackDirection::Undo => { - let current = match self.effective_transcript(&durable_id) { + let current = match self.effective_transcript(&durable_id).await { Ok(current) => current, Err(EffectiveTranscriptError::Missing) => { reply_sink(rollback_error_frame( @@ -738,7 +712,7 @@ impl FreshClaudeState { // transcript: empty after a first-turn undo, the kept prefix of // a pending fork. A real read error is a loud INTERNAL_ERROR, // never a silent empty chain yielding a false `redone` ack. - let current_text = match self.effective_transcript(&durable_id) { + let current_text = match self.effective_transcript(&durable_id).await { Ok(current) => current.text, Err(EffectiveTranscriptError::Missing) => { reply_sink(rollback_error_frame( @@ -1169,6 +1143,7 @@ impl FreshClaudeState { turn_tracker: turn_tracker.clone(), last_synthesized_complete_at: Arc::clone(&last_synthesized_complete_at), }), + genesis: Some(genesis.clone()), }, &mut lease_guard, &mut own_ticket, @@ -1330,90 +1305,6 @@ impl FreshClaudeState { )); } - /// The session's effective transcript (design §4.5): its transcript when - /// the CLI wrote one; before that, what its genesis holds (a pending fork - /// holds its parent's chain through the fork point, a fresh session - /// nothing). - fn effective_transcript( - &self, - session_id: &str, - ) -> Result { - match self.locate_transcript(session_id) { - snap::TranscriptLocation::Found(path) => { - let text = std::fs::read_to_string(&path) - .map_err(|e| EffectiveTranscriptError::Read(e.to_string()))?; - Ok(EffectiveTranscript { - source_text: text.clone(), - text, - source: session_id.to_string(), - source_path: Some(path), - }) - } - snap::TranscriptLocation::Unreadable(error) => { - Err(EffectiveTranscriptError::Unreadable(error)) - } - snap::TranscriptLocation::Absent => { - let genesis = self - .identity_sink() - .and_then(|sink| sink.load_genesis(PROVIDER, session_id)) - .map(|recorded| recorded.genesis); - match genesis { - None => Err(EffectiveTranscriptError::Missing), - Some(ClaudeGenesis::Fresh) => Ok(EffectiveTranscript { - text: String::new(), - source: session_id.to_string(), - source_text: String::new(), - source_path: None, - }), - Some(ClaudeGenesis::Fork { parent, at, .. }) => { - let path = match self.locate_transcript(&parent) { - snap::TranscriptLocation::Found(path) => path, - snap::TranscriptLocation::Absent => { - return Err(EffectiveTranscriptError::Read(format!( - "the conversation this one was forked from ({parent}) is gone" - ))) - } - snap::TranscriptLocation::Unreadable(error) => { - return Err(EffectiveTranscriptError::Unreadable(error)) - } - }; - let parent_text = std::fs::read_to_string(&path) - .map_err(|e| EffectiveTranscriptError::Read(e.to_string()))?; - let text = snap::fork_prefix_transcript(&parent_text, at.as_deref()) - .ok_or_else(|| { - EffectiveTranscriptError::Read(format!( - "the point this conversation was forked at is no longer in {parent}" - )) - })?; - Ok(EffectiveTranscript { - text, - source: parent, - source_text: parent_text, - source_path: Some(path), - }) - } - } - } - } - } - - /// `session_id`'s transcript text, when the store has one (`Ok(None)` - /// when it has none). - fn read_located_transcript( - &self, - session_id: &str, - ) -> Result, EffectiveTranscriptError> { - match self.locate_transcript(session_id) { - snap::TranscriptLocation::Found(path) => std::fs::read_to_string(&path) - .map(Some) - .map_err(|e| EffectiveTranscriptError::Read(e.to_string())), - snap::TranscriptLocation::Absent => Ok(None), - snap::TranscriptLocation::Unreadable(error) => { - Err(EffectiveTranscriptError::Unreadable(error)) - } - } - } - /// The replacement takes the original's place (design §4.6, the rollback /// re-key): the original's record leaves the sessions map, and every id /// that resolved to it (its map key, its durable id, their aliases) diff --git a/crates/freshell-freshagent/src/claude/teardown_tests.rs b/crates/freshell-freshagent/src/claude/teardown_tests.rs index 84e9493fe..0f7e9cc3a 100644 --- a/crates/freshell-freshagent/src/claude/teardown_tests.rs +++ b/crates/freshell-freshagent/src/claude/teardown_tests.rs @@ -117,6 +117,7 @@ async fn fake_session( turn_lock: Arc::new(TokioMutex::new(())), rollback_probe_slot: Arc::new(std::sync::Mutex::new(None)), last_status: Arc::new(std::sync::Mutex::new("idle".to_string())), + launch_genesis: None, }; (session, ready) } diff --git a/crates/freshell-freshagent/src/claude_handshake.rs b/crates/freshell-freshagent/src/claude_handshake.rs index aa76e3082..b2f4c30d4 100644 --- a/crates/freshell-freshagent/src/claude_handshake.rs +++ b/crates/freshell-freshagent/src/claude_handshake.rs @@ -28,6 +28,15 @@ pub(crate) const CREATE_FAILED_CODE: &str = "CLAUDE_CREATE_FAILED"; /// The code of a `created` that speaks another protocol. pub(crate) const PROTOCOL_MISMATCH_CODE: &str = "SIDECAR_PROTOCOL_MISMATCH"; +/// The `create.failed` code of a fork whose point the parent conversation does +/// not hold (`claude-sidecar/boot.mjs`, T5). +pub(crate) const RESUME_POINT_NOT_FOUND_CODE: &str = "RESUME_POINT_NOT_FOUND"; + +/// The `create.failed` code of a fork the CLI refused because the turn its +/// drops-turn guard names absorbed another message (`claude-sidecar/boot.mjs`, +/// T5, decision 2). +pub(crate) const FORK_GUARD_REFUSED_CODE: &str = "FORK_GUARD_REFUSED"; + /// The sidecar's `created`: the CLI booted. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct SidecarCreated { diff --git a/crates/freshell-freshagent/src/claude_snapshot.rs b/crates/freshell-freshagent/src/claude_snapshot.rs index 71d761ec1..b9adf9e94 100644 --- a/crates/freshell-freshagent/src/claude_snapshot.rs +++ b/crates/freshell-freshagent/src/claude_snapshot.rs @@ -1554,6 +1554,60 @@ pub(crate) fn resolve_resume_point( }) } +/// Why a fork of a conversation has no point to keep through. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum ForkPointError { + /// The conversation holds no chain lines: there is nothing to fork. + Empty, + /// The turn the fork was asked to start from is not in the conversation. + TurnNotFound, +} + +/// Where a fork of `transcript` keeps through (core design §4.5 `handle_fork`): +/// the last raw-chain entry of the step holding `at_turn_id`, so the fork keeps +/// that whole turn (its answer and tool round trips), or the chain's tip when no +/// turn is named. `at_turn_id` is a snapshot turn id (a line uuid); a client +/// split-row id (`:row-`) names its turn. A line before the first step +/// keeps the lines before that step. +pub(crate) fn fork_point( + transcript: &str, + at_turn_id: Option<&str>, +) -> Result { + let chain = raw_chain(transcript); + let Some(tip) = chain.last() else { + return Err(ForkPointError::Empty); + }; + let Some(at) = at_turn_id else { + return Ok(tip.uuid.clone()); + }; + let at = strip_split_row_suffix(at); + let pos = chain + .iter() + .position(|line| line.uuid == at) + .ok_or(ForkPointError::TurnNotFound)?; + let anchors = rollback_anchor_positions(&chain); + let end = match anchors.iter().rposition(|&anchor| anchor <= pos) { + Some(step) => step_group_end(chain.len(), &anchors, step), + None => anchors + .first() + .map(|first| first.saturating_sub(1)) + .unwrap_or(chain.len() - 1), + }; + Ok(chain[end].uuid.clone()) +} + +/// A turn id without the client's split-row suffix (`:row-`). +fn strip_split_row_suffix(turn_id: &str) -> &str { + match turn_id.rsplit_once(":row-") { + Some((turn, digits)) + if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) => + { + turn + } + _ => turn_id, + } +} + /// The restored slice's display turns + the composer-refill prompt: the display /// projection of the original's chain range strictly-after-the-current-prefix /// through `resume_at` (inclusive), with the prompt of the first restored step. @@ -1754,7 +1808,9 @@ pub(crate) fn build_claude_snapshot_from_projection( "interrupt": true, "approvals": false, "questions": false, - "fork": false, + // C14 (G09): a conversation forks (`FreshClaudeState::handle_fork`). + // The server refuses a fork while a turn runs (BUSY_TURN). + "fork": true, // The changed-files panel shows when the transcript edited files. "diffs": !diffs.is_empty(), // Kata 1wxv Task 5: STATIC stamps (freshclaude AND kilroy — no SDK @@ -2018,7 +2074,8 @@ pub(crate) mod tests { assert_eq!(snapshot["sessionType"], session_type); assert_eq!(snapshot["capabilities"]["send"], true); assert_eq!(snapshot["capabilities"]["interrupt"], true); - assert_eq!(snapshot["capabilities"]["fork"], false); + // C14 (G09): a claude conversation forks (`FreshClaudeState::handle_fork`). + assert_eq!(snapshot["capabilities"]["fork"], true); assert_eq!(snapshot["capabilities"]["undo"], true); assert_eq!(snapshot["capabilities"]["redo"], true); } @@ -2774,7 +2831,7 @@ pub(crate) mod tests { // Untouched gates and fields. assert_eq!(built["capabilities"]["send"], json!(true)); assert_eq!(built["capabilities"]["interrupt"], json!(true)); - assert_eq!(built["capabilities"]["fork"], json!(false)); + assert_eq!(built["capabilities"]["fork"], json!(true)); assert_eq!(built["sessionType"], json!("freshclaude")); assert_eq!(built["revision"], json!(7)); } @@ -3177,6 +3234,55 @@ pub(crate) mod tests { ); } + /// A fork keeps through the last chain entry of the step holding the turn it + /// was asked to fork at: a prompt, an answer, or a line inside the step (a + /// tool-result carrier) all keep the whole step. + #[test] + fn a_fork_at_a_turn_keeps_through_the_end_of_that_turns_step() { + let transcript = uuid_transcript_with_carriers(); + for at in ["u1", "c1", "a1", "tr1"] { + assert_eq!( + fork_point(&transcript, Some(at)).as_deref(), + Ok("tr1"), + "fork at {at}" + ); + } + assert_eq!(fork_point(&transcript, Some("u2")).as_deref(), Ok("a2")); + assert_eq!(fork_point(&transcript, Some("a2")).as_deref(), Ok("a2")); + } + + /// With no turn named, a fork keeps the whole conversation (its chain tip). + #[test] + fn a_fork_with_no_turn_keeps_through_the_tip() { + assert_eq!( + fork_point(&uuid_transcript_with_carriers(), None).as_deref(), + Ok("a2") + ); + } + + /// The client's split-row ids (`:row-`) name their turn. + #[test] + fn a_fork_at_a_split_row_id_forks_at_its_turn() { + assert_eq!( + fork_point(&uuid_transcript_with_carriers(), Some("u1:row-1")).as_deref(), + Ok("tr1") + ); + } + + #[test] + fn a_fork_at_a_turn_the_conversation_does_not_hold_is_refused() { + assert_eq!( + fork_point(&uuid_transcript(), Some("gone")), + Err(ForkPointError::TurnNotFound) + ); + } + + #[test] + fn an_empty_conversation_has_nothing_to_fork() { + assert_eq!(fork_point("", None), Err(ForkPointError::Empty)); + assert_eq!(fork_point("", Some("u1")), Err(ForkPointError::Empty)); + } + #[test] fn resolve_resume_point_unknown_target_is_not_found() { assert!(resolve_resume_point( diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index 281fc968c..4c33d834d 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -66,6 +66,11 @@ struct HostedTransport { session_id: Mutex>, created_rx: Mutex>>>, native_rx: Mutex>>, + /// The native identity's writer, shared with the bridge: a claude fork + /// points it at the child the moment the transition is known (the bridge + /// announces only an id it has not recorded, so the child's first turn + /// announces nothing new). + native_tx: watch::Sender>, send_outcomes: broadcast::Sender<(String, bool)>, kill_outcomes: broadcast::Sender<(String, bool)>, suppressed_kills: Arc>>, @@ -239,7 +244,7 @@ impl HostedTransport { broadcast_rx, BroadcastBridgeOutputs { created: created_tx, - native: native_tx, + native: native_tx.clone(), events: event_tx.clone(), send_outcomes: send_outcomes.clone(), kill_outcomes: kill_outcomes.clone(), @@ -254,6 +259,7 @@ impl HostedTransport { session_id: Mutex::new(None), created_rx: Mutex::new(created_rx), native_rx: Mutex::new(native_rx), + native_tx, send_outcomes, kill_outcomes, suppressed_kills, @@ -604,18 +610,19 @@ impl FreshAgentTransport for HostedTransport { message: "fresh-agent session is not started".into(), acceptance_ambiguous: false, })?; - if current != parent_session_id { + // The actor names the parent by its native id. A claude session's own + // id here is the sidecar's (`created.sessionId`), so the parent is the + // owned session when either names it. + let claude = matches!(self.provider, FreshProvider::Claude | FreshProvider::Kilroy); + let owned = current == parent_session_id + || (claude + && self.current_native_id().await.ok().as_deref() == Some(parent_session_id)); + if !owned { return Err(DispatchFailure { message: "provider-native fork parent no longer matches the owned session".into(), acceptance_ambiguous: false, }); } - if matches!(self.provider, FreshProvider::Claude | FreshProvider::Kilroy) { - return Err(DispatchFailure { - message: "provider-native conversation fork is unsupported".into(), - acceptance_ambiguous: false, - }); - } let message = FreshAgentFork { observed_epoch: None, observed_generation: None, @@ -641,11 +648,11 @@ impl FreshAgentTransport for HostedTransport { } }); match &self.state { + ProviderState::Claude(state) => state.handle_fork(message, None, sink).await, ProviderState::Codex(state) => state.handle_fork(message, None, sink).await, ProviderState::Opencode { runtime, .. } => { runtime.handle_fork(message, None, sink).await } - ProviderState::Claude(_) => unreachable!("unsupported providers returned above"), } let forked = match tokio::time::timeout(FORK_TIMEOUT, receiver).await { Ok(Ok(ServerMessage::FreshAgentForked(forked))) => forked, @@ -699,8 +706,13 @@ impl FreshAgentTransport for HostedTransport { }); } *self.session_id.lock().await = Some(child.clone()); + if claude { + // The child is the native session from here: snapshots, captures and + // a later fork address it before its first turn names it. + let _ = self.native_tx.send(Some(child.clone())); + } Ok(ForkTransition { - parent_session_id: current, + parent_session_id: parent_session_id.to_string(), child_session_id: child, parent_retired_by_runtime: true, }) @@ -1155,9 +1167,9 @@ fn spawn_broadcast_bridge( // (design §7.3). `freshAgent.created` names a resumed conversation (the CLI // booted it before the sidecar answered); a fresh one is named by its first // turn's init or by `materialized`. Every later turn's init repeats the same - // id and announces nothing. + // id and announces nothing. The identity watch records the id last + // announced (a fork's transport records its child there, C14). let claude = matches!(provider, FreshProvider::Claude | FreshProvider::Kilroy); - let mut announced: Option = None; tokio::spawn(async move { let mut dropped = 0u64; loop { @@ -1197,7 +1209,7 @@ fn spawn_broadcast_bridge( if let Some(session_id) = value.get("sessionId").and_then(Value::as_str) { if claude { if let Some(native_id) = claude_session_ref_id(&value) { - if !announce_native(&mut announced, native_id, &native, &events).await { + if !announce_native(native_id, &native, &events).await { break; } } @@ -1215,7 +1227,7 @@ fn spawn_broadcast_bridge( { if let Some(session_id) = value.get("sessionId").and_then(Value::as_str) { if claude { - if !announce_native(&mut announced, session_id, &native, &events).await { + if !announce_native(session_id, &native, &events).await { break; } } else { @@ -1245,8 +1257,7 @@ fn spawn_broadcast_bridge( .and_then(Value::as_str) { if claude { - if !announce_native(&mut announced, session_id, &native, &events).await - { + if !announce_native(session_id, &native, &events).await { break; } } else { @@ -1320,15 +1331,13 @@ fn claude_session_ref_id(created: &Value) -> Option<&str> { /// `AgentEvent::Started` for it, unless it is the id last announced. False once /// the event receiver is gone. async fn announce_native( - announced: &mut Option, native_id: &str, native: &watch::Sender>, events: &mpsc::Sender, ) -> bool { - if announced.as_deref() == Some(native_id) { + if native.borrow().as_deref() == Some(native_id) { return true; } - *announced = Some(native_id.to_string()); let _ = native.send(Some(native_id.to_string())); events .send(AgentEvent::Started { @@ -1813,6 +1822,126 @@ lines.on('line', line=>{ Arc::clone(&claude.transport).stop().await.unwrap(); } + /// C14 (G09): a hosted claude fork routes to `FreshClaudeState::handle_fork`. The + /// transition arrives once the CLI booted the child as a lazy fork of the parent + /// (no prompt), keyed by the native ids the actor holds; the runtime retires the + /// parent; from then on the transport addresses the child: its snapshot is the + /// child's, and the next turn runs in the child under the child's id with no + /// second identity announcement (the actor records the child at the fork). + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_claude_fork_boots_the_child_retires_the_parent_and_runs_the_next_turn_there() { + let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; + let conversation = "6d1f0a2b-3c4d-4e5f-8a6b-7c8d9e0f1a2b"; + let claude = SimulatedClaude::start(&[conversation]).await; + let mut events = claude.transport.take_event_stream().expect("event stream"); + let profile = claude.profile(Some(conversation)); + tokio::time::timeout(Duration::from_secs(20), claude.transport.start(&profile)) + .await + .expect("the resume start answers") + .expect("the resume start succeeds"); + + let transition = tokio::time::timeout( + Duration::from_secs(20), + claude + .transport + .fork(&RequestId::parse("fork-1").unwrap(), conversation, None), + ) + .await + .expect("a claude fork must not wait for a prompt") + .expect("the fork succeeds"); + assert_eq!(transition.parent_session_id, conversation); + assert!(transition.parent_retired_by_runtime); + let child = transition.child_session_id.clone(); + assert!( + uuid::Uuid::parse_str(&child).is_ok() && child != conversation, + "{child}" + ); + let boot = claude + .ledger_rows("query") + .into_iter() + .map(|row| row["options"].clone()) + .find(|options| options["sessionId"] == child.as_str()) + .expect("the CLI booted the child"); + assert_eq!(boot["forkSession"], true, "{boot}"); + assert_eq!(boot["resume"], conversation, "{boot}"); + assert!( + claude.ledger_rows("prompt").is_empty(), + "no prompt was needed" + ); + assert!( + !claude + .transport + .provider_session_is_live(conversation) + .await + ); + assert!(claude.transport.provider_session_is_live(&child).await); + let snapshot = claude.transport.snapshot().await.expect("a snapshot"); + assert_eq!(snapshot["sessionId"], child.as_str(), "{snapshot}"); + + // The child has no transcript yet; a fork of it forks the parent at the + // same point (source collapse), with no ledger to record the child's + // genesis on this lane. + let second = tokio::time::timeout( + Duration::from_secs(20), + claude + .transport + .fork(&RequestId::parse("fork-2").unwrap(), &child, None), + ) + .await + .expect("the second fork answers") + .expect("a pending child forks"); + assert_eq!(second.parent_session_id, child); + let grandchild = second.child_session_id.clone(); + let boot = claude + .ledger_rows("query") + .into_iter() + .map(|row| row["options"].clone()) + .find(|options| options["sessionId"] == grandchild.as_str()) + .expect("the CLI booted the second child"); + assert_eq!(boot["resume"], conversation, "{boot}"); + let child = grandchild; + + let mut child_profile = profile.clone(); + child_profile.native_session_id = Some(child.clone()); + claude + .transport + .dispatch( + &RequestId::parse("fork-turn").unwrap(), + "after the fork", + &child_profile, + ) + .await + .expect("the send is accepted"); + 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 child's turn completes"); + assert_eq!( + announced, + vec![conversation.to_string()], + "only the resumed parent is announced by the bridge" + ); + let prompt = claude + .ledger_rows("prompt") + .into_iter() + .find(|row| row["text"] == "after the fork") + .expect("the turn ran"); + assert_eq!(prompt["sessionId"], child.as_str(), "{prompt}"); + Arc::clone(&claude.transport).stop().await.unwrap(); + } + /// A hosted claude resume of a conversation the CLI does not have fails at start: /// the boot is refused (`create.failed`), so no session is reported started. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -2642,8 +2771,7 @@ lines.on('line', line=>{{ fn unsupported(&self, operation: &str) -> bool { matches!( (self.provider.clone(), operation), - (FreshProvider::Claude, "fork") - | (FreshProvider::Opencode, "approval" | "question") + (FreshProvider::Opencode, "approval" | "question") ) } } @@ -2770,18 +2898,7 @@ lines.on('line', line=>{{ parent_session_id: &str, input: Option, ) -> Result { - let unsupported = self.unsupported("fork"); - if unsupported { - self.trace.record( - "fork", - json!({"requestId":request_id.as_str(),"parentSessionId":parent_session_id,"input":input}), - "unsupported", - ); - return Err(DispatchFailure { - message: "fixture provider-native fork is unsupported".into(), - acceptance_ambiguous: false, - }); - } + // Every provider forks natively (claude since C14). let child = format!("{parent_session_id}-fork"); self.trace.record( "fork", @@ -3225,10 +3342,11 @@ lines.on('line', line=>{{ Some(json!({"atTurnId":"turn-1"})), ) .await; + let transition = fork.expect("every provider forks natively (claude since C14)"); assert_eq!( - fork.is_err(), - fixture.provider == FreshProvider::Claude, - "Claude keeps its provider-native unsupported fork result" + actor.profile().await.native_session_id.as_deref(), + Some(transition.child_session_id.as_str()), + "the actor follows the fork to its child" ); let resumed_profile = actor.profile().await; let expected_native = resumed_profile.native_session_id.clone(); @@ -3366,17 +3484,11 @@ lines.on('line', line=>{{ create_rows[0].input["nativeSessionId"], format!("native-{}", provider.as_str()) ); - let resumed_native = if provider == FreshProvider::Claude { - format!("native-{}", provider.as_str()) - } else { - format!("native-{}-fork", provider.as_str()) - }; - assert_eq!(create_rows[1].input["nativeSessionId"], resumed_native); - if provider == FreshProvider::Claude { - assert!(direct_rows - .iter() - .any(|row| row.operation == "fork" && row.result == "unsupported")); - } + let resumed_native = format!("native-{}-fork", provider.as_str()); + assert_eq!( + create_rows[1].input["nativeSessionId"], resumed_native, + "the restart resumes the fork's child" + ); if provider == FreshProvider::Opencode { assert!(direct_rows .iter() diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index fe247e4dc..c67ec8791 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -2213,8 +2213,8 @@ async fn handle_client_text( // `freshAgent.forked` (or a failure `freshAgent.error`, e.g. // INVALID_SESSION_ID / INTERNAL_ERROR) ON THE REQUESTING CONNECTION // (`conn_sink`, same shape as `create_gate.rs`'s `CreateOutput::Channel(&sink)`), - // so a Fork click never dies silently. The refusal table already answered the - // remaining unsupported cells (claude permanently, amplifier unconditionally). + // so a Fork click never dies silently. C14 added the claude arm; the refusal + // table already answered the one remaining unsupported cell (amplifier). // Detached task, same shape as the FreshAgentCompact arm (the fork RPC chain // never blocks the select loop). // @@ -2251,6 +2251,20 @@ async fn handle_client_text( } .instrument(tracing::Span::current()), ); + } else if fork.provider == freshell_protocol::AgentProvider::Claude { + // C14 (G09): claude x fork (freshclaude AND kilroy) boots a + // verified lazy-fork child beside the live parent + // (`FreshClaudeState::handle_fork`); same request/response shape. + let fresh_claude = state.fresh_claude.clone(); + let conn_sink = conn_sink.clone(); + tokio::spawn( + async move { + fresh_claude + .handle_fork(fork, Some(provenance), conn_sink) + .await + } + .instrument(tracing::Span::current()), + ); } true } @@ -9525,8 +9539,8 @@ fn session_type_wire(session_type: SessionType) -> &'static str { /// the genuinely unsupported provider x op cells, with the legacy `runtime-manager.ts` /// wording (`"Approvals are not supported for "`, `"Questions are …"`, /// `"Fork is …"`, `"Compact is …"`): approvals/questions support Claude and Codex, fork is -/// refused for claude (permanently — the claude path has no fork) and amplifier -/// (always); the opencode fork arm landed in Task 5 and the codex arm in Task 6. +/// refused for amplifier only (the opencode fork arm landed in Task 5, the codex arm in +/// Task 6 and the claude arm in C14). /// Compact is refused ONLY for amplifier (every other provider has a real /// arm since Task 4). There is no amplifier /// FRESH-AGENT runtime at all, so the amplifier x op cells are unconditional @@ -9550,11 +9564,11 @@ pub(crate) fn fresh_agent_control_refusal(message: &ClientMessage) -> Option ( - matches!(m.provider, AgentProvider::Claude | AgentProvider::Amplifier), + m.provider == AgentProvider::Amplifier, "Fork is", m.provider, m.session_id.as_str(), diff --git a/crates/freshell-ws/tests/freshagent_claude_fork.rs b/crates/freshell-ws/tests/freshagent_claude_fork.rs new file mode 100644 index 000000000..4f6c491e4 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_fork.rs @@ -0,0 +1,480 @@ +//! Fork a freshclaude conversation (core design §4.5 `handle_fork`, C14; audit +//! G09). +//! +//! Every test runs the REAL claude sidecar over the CLI simulator +//! (RealSidecarSim), with a real pane ledger and the ownership coordinator wired +//! the way main.rs wires them. The simulator boots like the pinned CLI: a lazy +//! fork (`resume`, `resumeSessionAt`, `forkSession`, `sessionId`) boots with no +//! prompt and writes nothing until its first prompt, which writes the child's +//! transcript with the parent's prefix (line uuids kept) and leaves the parent's +//! untouched (T6). +//! +//! A fork mints the child's id (C), boots the fork of the parent's (A's) +//! effective transcript under C while A stays live, records C's binding (genesis +//! `Fork{A, at}`, no `supersedes`), and answers `freshAgent.forked` on the +//! requesting connection only. The child is live at once (decision 5): the pane +//! switches to it and closes A itself. + +mod common; + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use serde_json::{json, Value}; + +use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions, RIG_FRAME_BUDGET}; +use freshell_protocol::SessionType; + +/// A fork answers within this budget: the child's boot takes about a second. +const FORK_BUDGET: Duration = Duration::from_secs(20); + +async fn fork_rig() -> ClaudeRig { + ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await +} + +fn ledger(rig: &ClaudeRig) -> &freshell_ws::pane_ledger::PaneLedger { + rig.pane_ledger.as_deref().expect("the rig has a ledger") +} + +/// One finished turn per prompt (each `freshAgent.result`), each on disk before +/// the next: the CLI flushes a transcript line 95-200 ms after its SDK frame +/// (T3), and a fork reads the transcript. +async fn converse(rig: &ClaudeRig, conn: &mut RigConnection, session_id: &str, prompts: &[&str]) { + for (n, prompt) in prompts.iter().enumerate() { + let results_before = conn.events_seen("freshAgent.result"); + conn.send_text(session_id, &format!("send-{session_id}-{n}"), prompt) + .await; + wait_for_results(conn, results_before + 1, prompt).await; + let path = rig + .wait_for_transcript(&conn.init_session_id(session_id)) + .await; + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + let lines = chain_lines(&path); + let at = lines.iter().rposition(|line| { + line["type"] == "user" && line["message"]["content"][0]["text"] == *prompt + }); + if at.is_some_and(|at| lines[at..].iter().any(|line| line["type"] == "assistant")) { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "the turn for {prompt:?} never reached disk" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + } +} + +async fn wait_for_results(conn: &mut RigConnection, count: usize, what: &str) { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while conn.events_seen("freshAgent.result") < count { + assert!( + tokio::time::Instant::now() < deadline, + "the turn for {what:?} never finished; frames: {:?}", + conn.frames + ); + conn.read_for(Duration::from_millis(50)).await; + } +} + +/// `freshAgent.fork` from the pane `pane_id` the way `FreshAgentView` sends it: +/// the pane's create request id, its tab, the turn to fork at (none: the tip), +/// and the owner pair the client holds for the session's durable id. +async fn request_fork( + conn: &mut RigConnection, + pane_id: &str, + durable: &str, + request_id: &str, + at_turn_id: Option<&str>, +) { + let mut frame = json!({ + "type": "freshAgent.fork", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": pane_id, + "requestId": request_id, + "tabId": "tab-fork", + }); + if let Some((epoch, generation)) = conn.owner_pair(durable) { + frame["observedEpoch"] = json!(epoch); + frame["observedGeneration"] = json!(generation); + } + if let Some(turn_id) = at_turn_id { + frame["input"] = json!({ "atTurnId": turn_id }); + } + conn.send(frame).await; +} + +/// The requester's answer to a fork of `pane_id`: `freshAgent.forked` for +/// `request_id`, or the nested `freshAgent.error` naming the pane, among the +/// frames read after `seen` (the request's own answer, never an older one), +/// within [`FORK_BUDGET`]. +async fn fork_answer( + conn: &mut RigConnection, + pane_id: &str, + request_id: &str, + seen: usize, +) -> Value { + let started = tokio::time::Instant::now(); + let deadline = started + FORK_BUDGET; + loop { + if let Some(answer) = conn.frames[seen..].iter().find(|v| { + (v["type"] == "freshAgent.forked" && v["requestId"] == request_id) + || (v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.error" + && v["event"]["sessionId"] == pane_id) + }) { + eprintln!( + "{}", + json!({ "severity": "info", "event": "fork_answered", "request": request_id, + "ms": started.elapsed().as_millis() as u64 }) + ); + return answer.clone(); + } + assert!( + tokio::time::Instant::now() < deadline, + "fork {request_id} was not answered within {FORK_BUDGET:?}; frames: {:?}", + conn.frames + ); + conn.read_for(Duration::from_millis(50)).await; + } +} + +/// A fork that must succeed: the `forked` frame and the child id it names. +async fn fork_ok( + conn: &mut RigConnection, + pane_id: &str, + durable: &str, + request_id: &str, + at_turn_id: Option<&str>, +) -> (Value, String) { + let seen = conn.frames.len(); + request_fork(conn, pane_id, durable, request_id, at_turn_id).await; + let forked = fork_answer(conn, pane_id, request_id, seen).await; + assert_eq!(forked["type"], "freshAgent.forked", "{forked}"); + let child = forked["sessionId"] + .as_str() + .expect("forked names the child") + .to_string(); + (forked, child) +} + +/// A fork that must be refused: the nested error's code and message. +async fn fork_refused( + conn: &mut RigConnection, + pane_id: &str, + durable: &str, + request_id: &str, + at_turn_id: Option<&str>, +) -> Value { + let seen = conn.frames.len(); + request_fork(conn, pane_id, durable, request_id, at_turn_id).await; + let answer = fork_answer(conn, pane_id, request_id, seen).await; + assert_eq!(answer["type"], "freshAgent.event", "{answer}"); + answer["event"].clone() +} + +/// The `query()` options the simulator recorded, in order. +fn queries(rig: &ClaudeRig) -> Vec { + rig.ledger_rows("query") + .into_iter() + .map(|row| row["options"].clone()) + .collect() +} + +/// The recorded `query()` options of the boot that ran under `session_id`. +fn boot_of(rig: &ClaudeRig, session_id: &str) -> Value { + let matching: Vec = queries(rig) + .into_iter() + .filter(|o| o["sessionId"] == session_id) + .collect(); + assert_eq!( + matching.len(), + 1, + "one boot under {session_id}: {:?}", + queries(rig) + ); + matching.into_iter().next().unwrap() +} + +/// The chain lines (those with a uuid) of a transcript, in file order. +fn chain_lines(path: &Path) -> Vec { + std::fs::read_to_string(path) + .expect("read the transcript") + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|line| line["uuid"].is_string()) + .collect() +} + +fn prompt_uuid(lines: &[Value], text: &str) -> String { + lines + .iter() + .find(|line| line["type"] == "user" && line["message"]["content"][0]["text"] == text) + .and_then(|line| line["uuid"].as_str()) + .unwrap_or_else(|| panic!("no transcript line for {text}")) + .to_string() +} + +/// The uuid of the last chain line before the line `uuid`. +fn uuid_before(lines: &[Value], uuid: &str) -> String { + let at = lines + .iter() + .position(|line| line["uuid"] == uuid) + .expect("the line is in the transcript"); + lines[at - 1]["uuid"].as_str().expect("a uuid").to_string() +} + +async fn snapshot_turn_ids(rig: &ClaudeRig, session_id: &str) -> Vec { + let snapshot = rig + .fresh_claude + .get_snapshot(SessionType::Freshclaude, session_id) + .await + .unwrap_or_else(|error| panic!("a snapshot of {session_id}: {error:?}")); + snapshot["turns"] + .as_array() + .expect("turns") + .iter() + .filter_map(|t| t["turnId"].as_str().map(str::to_string)) + .collect() +} + +fn transcript_of(rig: &ClaudeRig, session_id: &str) -> Option { + let name = format!("{session_id}.jsonl"); + rig.transcripts() + .into_iter() + .find(|path| path.file_name().and_then(|n| n.to_str()) == Some(name.as_str())) +} + +fn is_uuid(id: &str) -> bool { + uuid::Uuid::parse_str(id).is_ok() +} + +/// A two-turn conversation on a new pane: (pane id, durable id A, A's transcript). +async fn two_turns(rig: &ClaudeRig, conn: &mut RigConnection) -> (String, String, PathBuf) { + let pane = conn.create("create-1").await; + converse(rig, conn, &pane, &["first prompt", "second prompt"]).await; + let durable = conn.init_session_id(&pane); + let path = rig.wait_for_transcript(&durable).await; + (pane, durable, path) +} + +/// G09: a fork at the first turn answers `freshAgent.forked` on the requesting +/// connection only, naming the parent the pane addressed and a new UUID child C +/// that is live at once (decision 5). C boots as the lazy fork of A through the +/// first turn's last line, with no drops-turn guard (a fork discards nothing of +/// A); C's binding records that genesis and does not supersede A. C's snapshot +/// is A's prefix with the same turn ids, and A is untouched: its transcript, its +/// binding and its runtime. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_fork_answers_the_requester_with_a_live_child_holding_the_parents_prefix() { + let rig = fork_rig().await; + let mut conn = rig.connect().await; + let mut other = rig.connect().await; + let (pane, a, a_path) = two_turns(&rig, &mut conn).await; + let a_before = std::fs::read_to_string(&a_path).expect("read A"); + let lines = chain_lines(&a_path); + let u1 = prompt_uuid(&lines, "first prompt"); + let keep = uuid_before(&lines, &prompt_uuid(&lines, "second prompt")); + let a_turns = snapshot_turn_ids(&rig, &a).await; + + let (forked, c) = fork_ok(&mut conn, &pane, &a, "fork-1", Some(&u1)).await; + assert_eq!(forked["requestId"], "fork-1", "{forked}"); + assert_eq!(forked["parentSessionId"], pane.as_str(), "{forked}"); + assert_eq!(forked["provider"], "claude", "{forked}"); + assert_eq!(forked["runtimeProvider"], "claude", "{forked}"); + assert_eq!(forked["sessionType"], "freshclaude", "{forked}"); + assert_eq!( + forked["sessionRef"], + json!({ "provider": "claude", "sessionId": c }) + ); + assert_eq!( + forked.get("parentRetiredByRuntime"), + None, + "the pane closes the parent itself: {forked}" + ); + assert!(is_uuid(&c) && c != a, "C is a new UUID: {c}"); + + other.read_for(Duration::from_millis(500)).await; + assert!( + !other + .frames + .iter() + .any(|v| v["type"] == "freshAgent.forked"), + "forked goes to the requesting connection only" + ); + + let boot = boot_of(&rig, &c); + assert_eq!(boot["forkSession"], true, "{boot}"); + assert_eq!(boot["resume"], a.as_str(), "{boot}"); + assert_eq!(boot["resumeSessionAt"], keep.as_str(), "{boot}"); + assert_eq!(boot.get("resumeDropsTurn"), None, "{boot}"); + + let row = ledger(&rig).load_binding("claude", &c).expect("C's row"); + assert_eq!( + row.genesis, + Some(json!({ "kind": "fork", "parent": a, "at": keep })) + ); + assert_eq!(row.mode, "freshclaude"); + assert_eq!(row.cwd.as_deref(), rig.workspace.to_str()); + assert_eq!( + ledger(&rig) + .load_binding("claude", &a) + .expect("A's row") + .superseded_by, + None, + "a fork never supersedes its parent" + ); + + let c_turns = snapshot_turn_ids(&rig, &c).await; + assert!( + !c_turns.is_empty() && c_turns.len() < a_turns.len() && a_turns.starts_with(&c_turns), + "C holds A's first turn with its ids: C {c_turns:?}, A {a_turns:?}" + ); + assert_eq!(transcript_of(&rig, &c), None, "the fork writes nothing yet"); + assert_eq!(std::fs::read_to_string(&a_path).expect("read A"), a_before); + assert!(rig.fresh_claude.has_live_session(&c).await, "C is live"); + assert!(rig.fresh_claude.has_live_session(&a).await, "A is live"); + assert_eq!( + conn.events_seen("freshAgent.turn.complete"), + conn.events_seen("freshAgent.result"), + "a fork never rings" + ); +} + +/// The pane switches to C and closes A (the client's own kill); C's first send +/// runs under C (the CLI reports C), writes C's transcript (A's prefix lines +/// with their uuids, then the new turn) and clears the genesis, with no re-key: +/// the id the pane holds is the id the conversation lives under. C's turn +/// writes nothing to A's transcript. (A closing CLI may record its own +/// bookkeeping there: the pinned CLI appends a `last-prompt` line at shutdown.) +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_childs_first_send_materializes_under_the_same_id() { + let rig = fork_rig().await; + let mut conn = rig.connect().await; + let (pane, a, a_path) = two_turns(&rig, &mut conn).await; + let u1 = prompt_uuid(&chain_lines(&a_path), "first prompt"); + let (_, c) = fork_ok(&mut conn, &pane, &a, "fork-1", Some(&u1)).await; + let prefix = snapshot_turn_ids(&rig, &c).await; + conn.kill(&pane).await; + let a_before = std::fs::read_to_string(&a_path).expect("read A"); + + converse(&rig, &mut conn, &c, &["a different second prompt"]).await; + assert_eq!(conn.init_session_id(&c), c, "the CLI ran under C"); + let c_path = rig.wait_for_transcript(&c).await; + rig.wait_for_genesis_cleared(&c).await; + assert!( + !conn + .frames + .iter() + .any(|v| v["type"] == "freshAgent.session.materialized"), + "no re-key: {:?}", + conn.frames + ); + let c_lines = chain_lines(&c_path); + let a_lines = chain_lines(&a_path); + assert_eq!( + c_lines[0]["uuid"], a_lines[0]["uuid"], + "A's lines keep their uuids" + ); + assert!(c_lines + .iter() + .any(|line| line["message"]["content"][0]["text"] == "a different second prompt")); + assert!(!c_lines + .iter() + .any(|line| line["message"]["content"][0]["text"] == "second prompt")); + let materialized = snapshot_turn_ids(&rig, &c).await; + assert!( + materialized.starts_with(&prefix) && materialized.len() > prefix.len(), + "the prefix leads C with the same turn ids: {prefix:?} then {materialized:?}" + ); + assert_eq!(std::fs::read_to_string(&a_path).expect("read A"), a_before); +} + +/// A fork while the parent runs a turn is refused BUSY_TURN and boots nothing; +/// once the turn finished, the same fork succeeds. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_fork_while_the_parent_is_busy_is_refused_busy_turn() { + let rig = fork_rig().await; + let mut conn = rig.connect().await; + let pane = conn.create("create-1").await; + converse(&rig, &mut conn, &pane, &["first prompt"]).await; + let a = conn.init_session_id(&pane); + let results = conn.events_seen("freshAgent.result"); + conn.send_text(&pane, "send-busy", "TOOL:sleep=2 then answer") + .await; + conn.wait_for("the busy send's acceptance", |v| { + v["type"] == "freshAgent.send.accepted" && v["requestId"] == "send-busy" + }) + .await; + let boots = queries(&rig).len(); + + let refused = fork_refused(&mut conn, &pane, &a, "fork-busy", None).await; + assert_eq!(refused["code"], "BUSY_TURN", "{refused}"); + assert_eq!(refused["sessionId"], pane.as_str(), "{refused}"); + assert_eq!(queries(&rig).len(), boots, "nothing was booted"); + + wait_for_results(&mut conn, results + 1, "the busy turn").await; + let (_, c) = fork_ok(&mut conn, &pane, &a, "fork-idle", None).await; + assert!(is_uuid(&c) && c != a, "{c}"); +} + +/// §4.5 source collapse: a fork of a fork that has not been sent to yet forks +/// the original at the same point (chains never nest), so it boots although +/// the first child has no transcript. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_fork_of_a_pending_fork_forks_its_parent() { + let rig = fork_rig().await; + let mut conn = rig.connect().await; + let (pane, a, a_path) = two_turns(&rig, &mut conn).await; + let lines = chain_lines(&a_path); + let u1 = prompt_uuid(&lines, "first prompt"); + let keep = uuid_before(&lines, &prompt_uuid(&lines, "second prompt")); + let (_, c1) = fork_ok(&mut conn, &pane, &a, "fork-1", Some(&u1)).await; + + let (forked, c2) = fork_ok(&mut conn, &c1, &c1, "fork-2", None).await; + assert_eq!(forked["parentSessionId"], c1.as_str(), "{forked}"); + assert!(is_uuid(&c2) && c2 != c1 && c2 != a, "{c2}"); + let boot = boot_of(&rig, &c2); + assert_eq!( + boot["resume"], + a.as_str(), + "the fork of a fork forks A: {boot}" + ); + assert_eq!(boot["resumeSessionAt"], keep.as_str(), "{boot}"); + assert_eq!( + snapshot_turn_ids(&rig, &c2).await, + snapshot_turn_ids(&rig, &c1).await + ); + converse(&rig, &mut conn, &c2, &["after two forks"]).await; + assert_eq!(conn.init_session_id(&c2), c2); +} + +/// A fork at a turn the conversation does not hold is refused FORK_REFUSED and +/// boots nothing; the parent stays live. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_fork_at_a_turn_the_conversation_does_not_hold_is_refused() { + let rig = fork_rig().await; + let mut conn = rig.connect().await; + let (pane, a, _) = two_turns(&rig, &mut conn).await; + let boots = queries(&rig).len(); + let refused = fork_refused( + &mut conn, + &pane, + &a, + "fork-missing", + Some("00000000-0000-4000-8000-000000000000"), + ) + .await; + assert_eq!(refused["code"], "FORK_REFUSED", "{refused}"); + assert_eq!(queries(&rig).len(), boots, "nothing was booted"); + assert!(rig.fresh_claude.has_live_session(&a).await); +} diff --git a/crates/freshell-ws/tests/freshagent_claude_real_cli.rs b/crates/freshell-ws/tests/freshagent_claude_real_cli.rs index ce48c5f19..281784af9 100644 --- a/crates/freshell-ws/tests/freshagent_claude_real_cli.rs +++ b/crates/freshell-ws/tests/freshagent_claude_real_cli.rs @@ -416,9 +416,102 @@ async fn undo_send_redo_and_two_undos_without_a_send(kind: RigKind) { conn.kill(&f2).await; } +/// The snapshot turn id of the user turn `text` starts. +async fn prompt_turn_id(rig: &ClaudeRig, session_id: &str, text: &str) -> String { + let snapshot = rig + .fresh_claude + .get_snapshot(freshell_protocol::SessionType::Freshclaude, session_id) + .await + .unwrap_or_else(|error| panic!("a snapshot of {session_id}: {error:?}")); + snapshot["turns"] + .as_array() + .expect("turns") + .iter() + .find(|turn| turn["role"] == "user" && turn["items"][0]["text"] == text) + .and_then(|turn| turn["turnId"].as_str()) + .unwrap_or_else(|| panic!("no turn for {text:?}: {snapshot}")) + .to_string() +} + +/// Design §11.5 item 6 (C14, G09): fork at a turn, then send. The fork answers +/// once the CLI booted the child (no init is awaited), on the requesting +/// connection, naming a new child that holds the parent's conversation through +/// that turn. After the pane closes the parent (as the client does), the child's +/// first send runs under the child's own id (the CLI reports it) and the parent's +/// transcript is untouched. +async fn fork_at_a_turn_then_send(kind: RigKind) { + let rig = ClaudeRig::start_with_options( + kind, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let pane = conn.create("create-1").await; + turn(&rig, &mut conn, &pane, "send-1", "first prompt").await; + turn(&rig, &mut conn, &pane, "send-2", "second prompt").await; + let a = conn.init_session_id(&pane); + let a_path = rig.wait_for_transcript(&a).await; + let a_before = std::fs::read_to_string(&a_path).expect("read the parent's transcript"); + let at = prompt_turn_id(&rig, &a, "first prompt").await; + + let (epoch, generation) = conn.owner_pair(&a).expect("an owner record"); + conn.send(json!({ + "type": "freshAgent.fork", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": pane, + "requestId": "fork-1", + "input": { "atTurnId": at }, + "observedEpoch": epoch, + "observedGeneration": generation, + })) + .await; + let forked = conn + .wait_for("the answer to fork-1", |v| { + (v["type"] == "freshAgent.forked" && v["requestId"] == "fork-1") + || (v["type"] == "freshAgent.event" && v["event"]["type"] == "freshAgent.error") + }) + .await; + assert_eq!(forked["type"], "freshAgent.forked", "{forked}"); + assert_eq!(forked["parentSessionId"], pane.as_str(), "{forked}"); + let c = forked["sessionId"].as_str().expect("the child").to_string(); + assert_ne!(c, a); + assert_eq!(prompts_of(&rig, &c).await, ["first prompt"]); + assert_eq!( + std::fs::read_to_string(&a_path).expect("read the parent's transcript"), + a_before, + "the fork wrote nothing to the parent's transcript" + ); + + // The pane closes the parent. The real CLI then records its own `last-prompt` + // bookkeeping line in the parent's transcript as it shuts down, so the + // parent's file is compared from here on. + conn.kill(&pane).await; + let a_closed = std::fs::read_to_string(&a_path).expect("read the parent's transcript"); + turn(&rig, &mut conn, &c, "send-3", "a different second prompt").await; + assert_eq!(conn.init_session_id(&c), c, "the send ran under the child"); + assert_eq!( + prompts_of(&rig, &c).await, + ["first prompt", "a different second prompt"] + ); + assert_eq!( + std::fs::read_to_string(&a_path).expect("read the parent's transcript"), + a_closed, + "the child's turn wrote nothing to the parent's transcript" + ); +} + mod sim { use super::*; + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn fork_at_a_turn_then_send() { + super::fork_at_a_turn_then_send(RigKind::Sim).await; + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn undo_send_redo_and_two_undos_without_a_send() { super::undo_send_redo_and_two_undos_without_a_send(RigKind::Sim).await; @@ -492,6 +585,11 @@ mod sim { mod cli { use super::*; + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn fork_at_a_turn_then_send() { + super::fork_at_a_turn_then_send(RigKind::Cli).await; + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn undo_send_redo_and_two_undos_without_a_send() { super::undo_send_redo_and_two_undos_without_a_send(RigKind::Cli).await; diff --git a/crates/freshell-ws/tests/freshagent_control_reply.rs b/crates/freshell-ws/tests/freshagent_control_reply.rs index c56694414..98ed6fcc5 100644 --- a/crates/freshell-ws/tests/freshagent_control_reply.rs +++ b/crates/freshell-ws/tests/freshagent_control_reply.rs @@ -9,8 +9,8 @@ //! `"Fork is …"`, `"Compact is …"`) under code `UNSUPPORTED_CAPABILITY`; every handled //! cell routes to a real dispatch arm. Task 4 landed the codex/opencode compact arms //! (their cells dropped from the table) and pins the unconditional amplifier x op -//! refusal cells; Task 5 landed the opencode fork arm, Task 6 the codex fork arm (both -//! cells dropped — the remaining fork refusals are claude [permanent] and amplifier). +//! refusal cells; Task 5 landed the opencode fork arm, Task 6 the codex fork arm and C14 +//! the claude fork arm (all three cells dropped — the remaining fork refusal is amplifier). //! The whole-branch-review follow-up completed the matrix (question.respond refusals //! for codex/opencode, approval.respond refusal for opencode, claude question.respond //! dispatch reachability) and flipped BOTH compact unknown-session legs to the nested @@ -171,9 +171,14 @@ async fn opencode_question_respond_is_refused_with_the_parity_capability_message ); } -/// Refusal-matrix cell: claude has no fork — refused with the parity capability text. +/// Dispatch-matrix cell (C14): claude fork is NO LONGER refused — it reaches +/// `FreshClaudeState::handle_fork`, which answers an unknown parent session with the +/// nested `freshAgent.error{INVALID_SESSION_ID}` lost-session shape ON THE REQUESTING +/// CONNECTION (never UNSUPPORTED_CAPABILITY, never silence). This pin began life as +/// `claude_fork_is_refused_with_the_parity_capability_message` and flipped when C14 +/// added the claude fork arm (G09). #[tokio::test] -async fn claude_fork_is_refused_with_the_parity_capability_message() { +async fn claude_fork_reaches_the_claude_dispatch_arm() { let (url, _registry) = spawn_server().await; let (mut ws, _inventory) = connect_and_capture_inventory(&url).await; @@ -191,12 +196,16 @@ async fn claude_fork_is_refused_with_the_parity_capability_message() { .await; let frame = next_frame_of_type(&mut ws, "freshAgent.event").await; - assert_capability_refusal( - &frame, - "ses-fork-1", - "claude", - "freshclaude", - "Fork is not supported for freshclaude", + assert_eq!(frame["provider"], serde_json::json!("claude")); + assert_eq!(frame["sessionType"], serde_json::json!("freshclaude")); + assert_eq!( + frame["event"]["type"], + serde_json::json!("freshAgent.error") + ); + assert_eq!( + frame["event"]["code"], + serde_json::json!("INVALID_SESSION_ID"), + "the frame reached the claude handler (a refusal-table answer would be UNSUPPORTED_CAPABILITY): {frame}" ); } diff --git a/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts b/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts index adfb27282..fb0d32aa1 100644 --- a/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts @@ -27,9 +27,9 @@ * Resume with no reload), a second crash recovered by a page reload, and * restart recovery, all on the SAME durable id, never a fabricated * completion. - * Plus the capability-gate pin: /fork is NOT offered for claude/kilroy - * (snapshot capabilities.fork=false) — so AGENT-05/06 surfaces never - * expose a fork affordance that would hit the refusal table. + * Plus the capability-gate pin: /fork IS offered for claude (snapshot + * capabilities.fork=true since C14; the fork itself is pinned end to end + * by freshclaude-fork-rust.spec.ts). * * RESIDUAL HONESTY (also recorded in the checklist note): these fakes pin the * CONTRACT SHAPE + server/client wiring. Real-provider semantic fidelity of @@ -1060,7 +1060,7 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { } }) - test('capability gate: /fork is not offered for claude', async ({ page }) => { + test('capability gate: /fork is offered for claude', async ({ page }) => { const lane = await bootClaudeLane(page, 'freshclaude') try { await waitForPaneStatus(lane.harness, lane.tabId, 'idle') @@ -1075,8 +1075,8 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { await expect(menu.getByRole('menuitem', { name: /^\/compact/ })).toBeVisible() await expect( menu.getByRole('menuitem', { name: /^\/fork/ }), - 'claude capabilities.fork=false hides /fork from the menu', - ).toHaveCount(0) + 'claude capabilities.fork=true offers /fork in the menu (C14)', + ).toBeVisible() await page.keyboard.press('Escape') } finally { await lane.server.stop().catch(() => {}) diff --git a/test/e2e-browser/specs/freshclaude-fork-rust.spec.ts b/test/e2e-browser/specs/freshclaude-fork-rust.spec.ts new file mode 100644 index 000000000..ff3b44eb6 --- /dev/null +++ b/test/e2e-browser/specs/freshclaude-fork-rust.spec.ts @@ -0,0 +1,471 @@ +/** + * FRESHCLAUDE / KILROY FORK — PW-RUST e2e (core design §4.5 `handle_fork`, C14; + * audit G09). + * + * Drives the REAL Rust server + real browser UI against the PRODUCTION claude + * sidecar over the CLI simulator (FRESHELL_CLAUDE_SDK_QUERY_MODULE), which boots + * like the pinned CLI: a lazy fork (`resume`, `resumeSessionAt`, `forkSession`, + * `sessionId`) boots with no prompt and writes nothing until its first prompt, + * which writes the child's transcript with the parent's prefix (line uuids + * kept) and leaves the parent's untouched (T6). + * + * freshclaude, per-turn fork: "Fork conversation from here" on the first turn + * forks the conversation through that turn. The pane switches to the child + * the server minted AT ONCE (decision 5: before anything is sent there) and + * closes the parent; the simulated CLI booted exactly one fork of the parent + * under the child's id; the child shows the parent's first turn only; the + * parent's conversation on disk is untouched. A server restart before the + * child's first send keeps the child (it boots again from its genesis), and + * the child's first send runs under the same id (no re-key). + * kilroy, typed /fork: forks the whole conversation (its tip); the child holds + * every turn and takes the next prompt under its own id. + * + * Every test owns its RustServer. Per-test wall budget: 120s (the cloud e2e + * budget): the sidecar and the simulator are in-repo, so the spec is + * cloud-runnable and appears in neither CLOUD_SKIP_SPECS nor CLOUD_SKIP_TITLES. + * + * Donors (helpers copied, not imported, per this suite's per-spec-ownership + * convention): fresh-agent-rollback-rust.spec.ts (the claude lane boot, pane + * bootstrap, snapshot and simulator-ledger readers, the turn-action hover + * idiom). + */ +import fs from 'node:fs/promises' +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Page } from '@playwright/test' +import { test, expect } from '../helpers/fixtures.js' +import { + RustServer, + GEMINI_STRIP_ENV_PREFIXES, +} from '../helpers/rust-server.js' +import type { E2eServerInfo as TestServerInfo } from '../helpers/server-fixture-support.js' +import { TestHarness } from '../helpers/test-harness.js' +import { openPanePicker } from '../helpers/pane-picker.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// The production claude sidecar and the CLI simulator it loads through its +// FRESHELL_CLAUDE_SDK_QUERY_MODULE seam (both in the cloud image's source tree; +// the simulator needs no CLI binary and no network). +const CLAUDE_SIDECAR = path.resolve(__dirname, '../../../crates/freshell-claude-sidecar/index.mjs') +const CLAUDE_CLI_SIMULATOR = path.resolve(__dirname, '../../fixtures/claude-cli-sim/query.mjs') + +const CANONICAL_UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +// ── Copied helpers (donor: fresh-agent-rollback-rust.spec.ts) ─────────────── + +async function selectShellIfPickerShowing(page: Page): Promise { + const picker = page.getByRole('toolbar', { name: /pane type picker/i }).last() + if (!(await picker.isVisible().catch(() => false))) return + for (const name of ['Shell', 'WSL', 'CMD', 'PowerShell', 'Bash']) { + const option = picker.getByRole('button', { name: new RegExp(`^${name}$`, 'i') }) + if (await option.isVisible().catch(() => false)) { + await option.click({ force: true }) + return + } + } +} + +function seedWallConfig(): (homeDir: string) => Promise { + return async (homeDir: string) => { + const freshellDir = path.join(homeDir, '.freshell') + await fs.mkdir(freshellDir, { recursive: true }) + await fs.writeFile( + path.join(freshellDir, 'config.json'), + JSON.stringify( + { + version: 1, + settings: { + codingCli: { enabledProviders: ['claude'] }, + freshAgent: { enabled: true }, + }, + }, + null, + 2, + ), + ) + } +} + +function findFreshAgentLeaf(node: any): any { + if (!node) return null + if (node.type === 'leaf' && node.content?.kind === 'fresh-agent') return node + if (node.type === 'split') { + for (const child of node.children ?? []) { + const found = findFreshAgentLeaf(child) + if (found) return found + } + } + return null +} + +function readJsonl(filePath: string): any[] { + if (!existsSync(filePath)) return [] + return readFileSync(filePath, 'utf8') + .split('\n') + .filter(Boolean) + .map((line) => { + try { + return JSON.parse(line) + } catch { + return null + } + }) + .filter((row) => row !== null) +} + +/** Direct REST snapshot read — the route every card renders from. */ +async function fetchSnapshot( + info: TestServerInfo, + sessionType: string, + threadId: string, +): Promise { + const res = await fetch( + `${info.baseUrl}/api/fresh-agent/threads/${sessionType}/claude/${encodeURIComponent(threadId)}`, + { headers: { 'x-auth-token': info.token } }, + ) + if (res.status === 404) return null + if (!res.ok) throw new Error(`snapshot fetch failed: ${res.status} ${await res.text()}`) + return res.json() +} + +async function enableClaude(page: Page): Promise { + await page.evaluate(() => { + ;(window as any).__FRESHELL_TEST_HARNESS__?.dispatch({ + type: 'connection/setAvailableClis', + payload: { claude: true }, + }) + }) +} + +async function createFreshAgentPane(page: Page, name: RegExp, label: string, cwd: string): Promise { + const picker = await openPanePicker(page) + await picker.getByRole('button', { name }).click({ force: true }) + const directoryInput = page.getByLabel(new RegExp(`^Starting directory for ${label}$`, 'i')) + await expect(directoryInput).toBeVisible({ timeout: 15_000 }) + await directoryInput.fill(cwd) + await directoryInput.press('Enter') + await expect(page.locator('[data-context="fresh-agent"]').last()).toBeVisible({ + timeout: 15_000, + }) +} + +async function paneLeaf(harness: TestHarness, tabId: string): Promise { + return findFreshAgentLeaf(await harness.getPaneLayout(tabId)) +} + +async function waitForPaneStatus( + harness: TestHarness, + tabId: string, + wanted: string | string[], + timeoutMs = 30_000, +): Promise { + const wantedSet = Array.isArray(wanted) ? wanted : [wanted] + await expect + .poll( + async () => { + const status = (await paneLeaf(harness, tabId))?.content?.status ?? null + return typeof status === 'string' && wantedSet.includes(status) + }, + { timeout: timeoutMs }, + ) + .toBe(true) +} + +function paneRoot(page: Page) { + return page.locator('[data-context="fresh-agent"]').last() +} + +function composerLocator(page: Page) { + return paneRoot(page).getByRole('textbox', { name: 'Chat message input' }) +} + +/** Fill the visible fresh-agent composer and click Send. */ +async function sendComposerText(page: Page, text: string): Promise { + await composerLocator(page).fill(text) + await paneRoot(page).getByRole('button', { name: 'Send' }).click() +} + +/** Type a slash command into the composer and commit it with Enter. */ +async function typeSlash(page: Page, text: string): Promise { + const composer = composerLocator(page) + await composer.fill(text) + await composer.press('Enter') +} + +/** Capture the pane's durable sessionRef id once it exists. */ +async function captureDurableId(harness: TestHarness, tabId: string): Promise { + let captured = '' + await expect + .poll( + async () => { + captured = (await paneLeaf(harness, tabId))?.content?.sessionRef?.sessionId ?? '' + return captured + }, + { timeout: 15_000 }, + ) + .toMatch(CANONICAL_UUID_RE) + return captured +} + +/** Count of USER-role rows in a fresh-agent snapshot (null snapshot ⇒ 0). */ +function userRows(snapshot: any | null): number { + return ((snapshot?.turns ?? []) as any[]).filter((turn) => turn?.role === 'user').length +} + +/** + * The `query()` options of every conversation the simulated CLI booted, in + * order (its ledger's `query` rows); a fork carries `resume`, + * `resumeSessionAt`, `forkSession` and the minted `sessionId`. + */ +function simForks(simLedger: string): any[] { + return readJsonl(simLedger) + .filter((row) => row.kind === 'query') + .map((row) => row.options ?? {}) + .filter((options) => options.forkSession === true) +} + +/** The prompts the simulated CLI received, in order. */ +function simPrompts(simLedger: string): any[] { + return readJsonl(simLedger).filter((row) => row.kind === 'prompt') +} + +// ── Lane-specific helpers ──────────────────────────────────────────────────── + +/** `.jsonl` under the server home's `.claude/projects` tree, once written. */ +function findTranscript(homeDir: string, sessionId: string): string | null { + const projects = path.join(homeDir, '.claude', 'projects') + if (!existsSync(projects)) return null + for (const project of readdirSync(projects)) { + const candidate = path.join(projects, project, `${sessionId}.jsonl`) + if (existsSync(candidate)) return candidate + } + return null +} + +/** The conversation lines (those with a uuid) of a transcript, as JSON text. */ +function chainLines(file: string): string[] { + return readJsonl(file) + .filter((line) => typeof line.uuid === 'string') + .map((line) => JSON.stringify(line)) +} + +/** + * Wait until the CLIENT's snapshot carries the fork capability: the per-turn + * "Fork conversation from here" action renders exactly when + * `capabilities.fork === true`, the same truth the /fork menu entry consults. + */ +async function waitForForkCapability(page: Page): Promise { + const article = paneRoot(page).locator('article[data-turn-role="user"]').first() + await article.hover() + await expect( + article.getByRole('button', { name: 'Fork conversation from here' }), + 'the client-side fork capability stamp landed (fork action renders)', + ).toBeVisible({ timeout: 15_000 }) + await page.mouse.move(0, 0) +} + +/** Boot the claude lane: create a freshclaude/kilroy pane on an owned server. */ +async function bootClaudeLane( + page: Page, + flavour: 'freshclaude' | 'kilroy', + extraEnv: Record = {}, +): Promise<{ + server: RustServer + info: TestServerInfo + harness: TestHarness + sharedRoot: string + simLedger: string + tabId: string +}> { + const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), `freshell-fork-${flavour}-`)) + try { + const projectDir = path.join(sharedRoot, 'project') + await fs.mkdir(projectDir, { recursive: true }) + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') + const server = new RustServer({ + env: { + FRESHELL_CLAUDE_SIDECAR: CLAUDE_SIDECAR, + FRESHELL_CLAUDE_NODE: process.execPath, + FRESHELL_CLAUDE_SDK_QUERY_MODULE: CLAUDE_CLI_SIMULATOR, + FRESHELL_CLAUDE_SIM_LEDGER: simLedger, + ...extraEnv, + }, + // Kilroy parity includes independence from Gemini-summary availability. + stripEnvPrefixes: flavour === 'kilroy' ? [...GEMINI_STRIP_ENV_PREFIXES] : undefined, + setupHome: seedWallConfig(), + }) + const info = await server.start() + await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) + const harness = new TestHarness(page) + await harness.waitForHarness() + await harness.waitForConnection() + await selectShellIfPickerShowing(page) + await expect(page.locator('.xterm').first()).toBeVisible({ timeout: 30_000 }) + await enableClaude(page) + const tabId = (await harness.getActiveTabId())! + expect(tabId).toBeTruthy() + await createFreshAgentPane( + page, + flavour === 'kilroy' ? /^Kilroy$/ : /^Freshclaude$/, + flavour === 'kilroy' ? 'Kilroy' : 'Freshclaude', + projectDir, + ) + return { server, info, harness, sharedRoot, simLedger, tabId } + } catch (error) { + await fs.rm(sharedRoot, { recursive: true, force: true }).catch(() => {}) + throw error + } +} + +/** Two finished turns on the lane's pane: the parent's durable id. */ +async function twoTurns( + page: Page, + lane: Awaited>, + sessionType: 'freshclaude' | 'kilroy', +): Promise { + // A booted pane is `connected` until its first turn settles it `idle`. + await waitForPaneStatus(lane.harness, lane.tabId, ['idle', 'connected']) + await sendComposerText(page, 'prompt one') + const parent = await captureDurableId(lane.harness, lane.tabId) + await expect + .poll(async () => userRows(await fetchSnapshot(lane.info, sessionType, parent)), { timeout: 15_000 }) + .toBe(1) + await sendComposerText(page, 'prompt two') + await expect + .poll(async () => userRows(await fetchSnapshot(lane.info, sessionType, parent)), { timeout: 15_000 }) + .toBe(2) + await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + // The CLI writes each transcript line a moment after its SDK frame: wait for + // the second answer on disk, so the parent's conversation is complete. + await expect + .poll(() => { + const file = findTranscript(lane.info.homeDir, parent) + if (!file) return false + const lines = readJsonl(file).filter((line) => typeof line.uuid === 'string') + const prompt = lines.findIndex((line) => line.type === 'user' + && JSON.stringify(line.message ?? '').includes('prompt two')) + return prompt >= 0 && lines.slice(prompt).some((line) => line.type === 'assistant') + }, { timeout: 15_000, message: 'the second turn reached the parent transcript' }) + .toBe(true) + await waitForForkCapability(page) + return parent +} + +test.describe('freshclaude/kilroy fork (core design §4.5, C14)', () => { + test('freshclaude: fork from a turn opens the child at once, survives a restart, and its first send keeps its id', async ({ page }) => { + const lane = await bootClaudeLane(page, 'freshclaude') + try { + const parent = await twoTurns(page, lane, 'freshclaude') + const parentPaneId = (await paneLeaf(lane.harness, lane.tabId))?.content?.sessionId as string + const parentFile = findTranscript(lane.info.homeDir, parent) + expect(parentFile, 'the parent transcript is on disk').toBeTruthy() + const parentBefore = chainLines(parentFile!) + await lane.harness.clearSentWsMessages() + + // The first turn's real hover affordance. + const firstTurn = paneRoot(page).locator('article[data-turn-role="user"]').first() + await firstTurn.hover() + await firstTurn.getByRole('button', { name: 'Fork conversation from here' }).click() + + // Decision 5: the pane switches to the child the server minted as soon as + // the fork answers, before anything is sent there. + await expect + .poll(async () => (await paneLeaf(lane.harness, lane.tabId))?.content?.sessionRef?.sessionId ?? '', { timeout: 30_000 }) + .not.toBe(parent) + const child = (await paneLeaf(lane.harness, lane.tabId))?.content?.sessionRef?.sessionId as string + expect(child).toMatch(CANONICAL_UUID_RE) + expect((await paneLeaf(lane.harness, lane.tabId))?.content?.sessionId).toBe(child) + expect(simPrompts(lane.simLedger), 'nothing was sent to the child yet').toHaveLength(2) + + // Exactly one fork boot: the parent through its first turn, under the + // child's id, with no drops-turn guard (a fork discards nothing). + const forks = simForks(lane.simLedger) + expect(forks).toHaveLength(1) + expect(forks[0].resume).toBe(parent) + expect(typeof forks[0].resumeSessionAt).toBe('string') + expect(forks[0].sessionId).toBe(child) + expect(forks[0].resumeDropsTurn).toBeUndefined() + + // The child shows the parent's first turn only, in the pane and over REST. + await expect(paneRoot(page).locator('article[data-turn-role="user"]')).toHaveCount(1, { timeout: 15_000 }) + expect(userRows(await fetchSnapshot(lane.info, 'freshclaude', child))).toBe(1) + + // The pane closed the parent itself; the fork wrote nothing to it. + await expect + .poll(async () => ((await lane.harness.getSentWsMessages()) as any[]) + .filter((m) => m?.type === 'freshAgent.kill' && m?.sessionId === parentPaneId).length, + { timeout: 15_000, message: 'the client closed the parent after switching to the child' }) + .toBe(1) + expect(chainLines(parentFile!)).toEqual(parentBefore) + + // A restart before the child's first send keeps the child: it boots again + // from its genesis (a second fork boot under the child's id). + await lane.server.restart() + await lane.harness.waitForConnection() + await expect(paneRoot(page).locator('article[data-turn-role="user"]')).toHaveCount(1, { timeout: 30_000 }) + await expect + .poll(() => simForks(lane.simLedger).filter((f) => f.sessionId === child).length, { timeout: 30_000 }) + .toBe(2) + expect(simForks(lane.simLedger).filter((f) => f.sessionId === child)[1].resume).toBe(parent) + await waitForPaneStatus(lane.harness, lane.tabId, ['idle', 'connected']) + + // The child's first send runs under the same id: no re-key. + await sendComposerText(page, 'prompt after the fork') + await expect + .poll(async () => userRows(await fetchSnapshot(lane.info, 'freshclaude', child)), { timeout: 15_000 }) + .toBe(2) + expect((await paneLeaf(lane.harness, lane.tabId))?.content?.sessionRef?.sessionId).toBe(child) + const childPrompt = simPrompts(lane.simLedger).find((row) => row.text === 'prompt after the fork') + expect(childPrompt?.sessionId).toBe(child) + await expect.poll(() => findTranscript(lane.info.homeDir, child), { timeout: 15_000 }).toBeTruthy() + const childLines = chainLines(findTranscript(lane.info.homeDir, child)!) + expect(JSON.parse(childLines[0]).uuid, 'the child keeps the parent line uuids').toBe(JSON.parse(parentBefore[0]).uuid) + expect(chainLines(parentFile!), 'the child turn wrote nothing to the parent').toEqual(parentBefore) + } finally { + await lane.server.stop().catch(() => {}) + await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) + } + }) + + test('kilroy: typed /fork forks the whole conversation and the child takes the next prompt under its own id', async ({ page }) => { + const lane = await bootClaudeLane(page, 'kilroy', { KILROY_ENABLED: '1' }) + try { + const parent = await twoTurns(page, lane, 'kilroy') + const parentFile = findTranscript(lane.info.homeDir, parent) + expect(parentFile, 'the parent transcript is on disk').toBeTruthy() + const parentBefore = chainLines(parentFile!) + const parentUuids = parentBefore.map((line) => JSON.parse(line).uuid) + + await typeSlash(page, '/fork') + + await expect + .poll(async () => (await paneLeaf(lane.harness, lane.tabId))?.content?.sessionRef?.sessionId ?? '', { timeout: 30_000 }) + .not.toBe(parent) + const child = (await paneLeaf(lane.harness, lane.tabId))?.content?.sessionRef?.sessionId as string + expect(child).toMatch(CANONICAL_UUID_RE) + const forks = simForks(lane.simLedger) + expect(forks).toHaveLength(1) + expect(forks[0].resume).toBe(parent) + expect(parentUuids, 'the fork point is a line of the parent').toContain(forks[0].resumeSessionAt) + expect(forks[0].sessionId).toBe(child) + expect(userRows(await fetchSnapshot(lane.info, 'kilroy', child)), 'a tip fork keeps the whole conversation').toBe(2) + await expect(paneRoot(page).locator('article[data-turn-role="user"]')).toHaveCount(2, { timeout: 15_000 }) + + await waitForPaneStatus(lane.harness, lane.tabId, ['idle', 'connected']) + await sendComposerText(page, 'kilroy after the fork') + await expect + .poll(async () => userRows(await fetchSnapshot(lane.info, 'kilroy', child)), { timeout: 15_000 }) + .toBe(3) + expect((await paneLeaf(lane.harness, lane.tabId))?.content?.sessionRef?.sessionId).toBe(child) + expect(chainLines(parentFile!), 'the parent conversation is untouched').toEqual(parentBefore) + } finally { + await lane.server.stop().catch(() => {}) + await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) + } + }) +}) diff --git a/test/fixtures/fresh-agent/claude-snapshot-golden.json b/test/fixtures/fresh-agent/claude-snapshot-golden.json index 9b65658ff..088650b9c 100644 --- a/test/fixtures/fresh-agent/claude-snapshot-golden.json +++ b/test/fixtures/fresh-agent/claude-snapshot-golden.json @@ -6,7 +6,7 @@ "revision": 1753437600000, "latestTurnId": "44444444-4444-4444-8444-444444444444:5", "status": "idle", - "capabilities": { "send": true, "interrupt": true, "approvals": false, "questions": false, "fork": false, "diffs": false, "undo": true, "redo": true, "settingScopes": { "model": "per-send", "effort": "per-send", "permissionMode": "per-send", "sandbox": "unsupported" } }, + "capabilities": { "send": true, "interrupt": true, "approvals": false, "questions": false, "fork": true, "diffs": false, "undo": true, "redo": true, "settingScopes": { "model": "per-send", "effort": "per-send", "permissionMode": "per-send", "sandbox": "unsupported" } }, "tokenUsage": { "inputTokens": 0, "outputTokens": 0, "totalTokens": 0 }, "pendingApprovals": [], "pendingQuestions": [], diff --git a/test/fixtures/fresh-agent/claude/real-cli-2.1.237/agent-async/snapshot.golden.json b/test/fixtures/fresh-agent/claude/real-cli-2.1.237/agent-async/snapshot.golden.json index f3904b981..ebe79211a 100644 --- a/test/fixtures/fresh-agent/claude/real-cli-2.1.237/agent-async/snapshot.golden.json +++ b/test/fixtures/fresh-agent/claude/real-cli-2.1.237/agent-async/snapshot.golden.json @@ -11,7 +11,7 @@ "interrupt": true, "approvals": false, "questions": false, - "fork": false, + "fork": true, "diffs": true, "undo": true, "redo": true, diff --git a/test/fixtures/fresh-agent/claude/real-cli-2.1.237/agent-failed/snapshot.golden.json b/test/fixtures/fresh-agent/claude/real-cli-2.1.237/agent-failed/snapshot.golden.json index 716058f50..f9a7024cf 100644 --- a/test/fixtures/fresh-agent/claude/real-cli-2.1.237/agent-failed/snapshot.golden.json +++ b/test/fixtures/fresh-agent/claude/real-cli-2.1.237/agent-failed/snapshot.golden.json @@ -11,7 +11,7 @@ "interrupt": true, "approvals": false, "questions": false, - "fork": false, + "fork": true, "diffs": false, "undo": true, "redo": true, diff --git a/test/fixtures/fresh-agent/claude/real-cli-2.1.237/api-error/snapshot.golden.json b/test/fixtures/fresh-agent/claude/real-cli-2.1.237/api-error/snapshot.golden.json index d4837a630..96e8d5ebe 100644 --- a/test/fixtures/fresh-agent/claude/real-cli-2.1.237/api-error/snapshot.golden.json +++ b/test/fixtures/fresh-agent/claude/real-cli-2.1.237/api-error/snapshot.golden.json @@ -11,7 +11,7 @@ "interrupt": true, "approvals": false, "questions": false, - "fork": false, + "fork": true, "diffs": false, "undo": true, "redo": true, diff --git a/test/fixtures/fresh-agent/claude/real-cli-2.1.237/compact-fail/snapshot.golden.json b/test/fixtures/fresh-agent/claude/real-cli-2.1.237/compact-fail/snapshot.golden.json index b37975341..63057a2fc 100644 --- a/test/fixtures/fresh-agent/claude/real-cli-2.1.237/compact-fail/snapshot.golden.json +++ b/test/fixtures/fresh-agent/claude/real-cli-2.1.237/compact-fail/snapshot.golden.json @@ -11,7 +11,7 @@ "interrupt": true, "approvals": false, "questions": false, - "fork": false, + "fork": true, "diffs": false, "undo": true, "redo": true, diff --git a/test/fixtures/fresh-agent/claude/real-cli-2.1.237/compact-ok/snapshot.golden.json b/test/fixtures/fresh-agent/claude/real-cli-2.1.237/compact-ok/snapshot.golden.json index 7f6821bbb..23d0dd392 100644 --- a/test/fixtures/fresh-agent/claude/real-cli-2.1.237/compact-ok/snapshot.golden.json +++ b/test/fixtures/fresh-agent/claude/real-cli-2.1.237/compact-ok/snapshot.golden.json @@ -11,7 +11,7 @@ "interrupt": true, "approvals": false, "questions": false, - "fork": false, + "fork": true, "diffs": false, "undo": true, "redo": true, diff --git a/test/fixtures/fresh-agent/claude/real-cli-2.1.237/image/snapshot.golden.json b/test/fixtures/fresh-agent/claude/real-cli-2.1.237/image/snapshot.golden.json index 829db1a00..b2fe1bc97 100644 --- a/test/fixtures/fresh-agent/claude/real-cli-2.1.237/image/snapshot.golden.json +++ b/test/fixtures/fresh-agent/claude/real-cli-2.1.237/image/snapshot.golden.json @@ -11,7 +11,7 @@ "interrupt": true, "approvals": false, "questions": false, - "fork": false, + "fork": true, "diffs": false, "undo": true, "redo": true, diff --git a/test/fixtures/fresh-agent/claude/real-cli-2.1.237/tools-sync/snapshot.golden.json b/test/fixtures/fresh-agent/claude/real-cli-2.1.237/tools-sync/snapshot.golden.json index e200d32b8..bf49f37f9 100644 --- a/test/fixtures/fresh-agent/claude/real-cli-2.1.237/tools-sync/snapshot.golden.json +++ b/test/fixtures/fresh-agent/claude/real-cli-2.1.237/tools-sync/snapshot.golden.json @@ -11,7 +11,7 @@ "interrupt": true, "approvals": false, "questions": false, - "fork": false, + "fork": true, "diffs": true, "undo": true, "redo": true, diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index 0e11434bf..71023700c 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -8225,6 +8225,112 @@ describe('FreshAgentView', () => { }) }) + // C14 (G09): a claude conversation forks (the server stamps capabilities.fork). + // Decision 5: the pane opens the forked child as soon as the server reports it, + // before anything is sent there, and closes the parent. + it.each(['freshclaude', 'kilroy'] as const)('offers fork for a %s conversation and opens the forked child at once', async (sessionType) => { + const store = createStore() + let onMessage: ((message: Record) => void) | undefined + wsMock.onMessage.mockImplementation((handler) => { + onMessage = handler + return () => {} + }) + const parent = CLAUDE_THREAD_ID + const child = '7c1d0e2f-3a4b-4c5d-8e6f-0a1b2c3d4e5f' + apiMock.getFreshAgentThreadSnapshot.mockResolvedValue({ + status: 'idle', + capabilities: { send: true, interrupt: true, fork: true, undo: true, redo: true }, + turns: [ + { + id: 'turn-claude-user-1', + turnId: 'turn-claude-user-1', + role: 'user', + summary: 'first prompt', + items: [{ id: 'item-claude-user-1', kind: 'text', text: 'first prompt' }], + }, + { + id: 'turn-claude-answer-1', + turnId: 'turn-claude-answer-1', + role: 'assistant', + summary: 'first answer', + items: [{ id: 'item-claude-answer-1', kind: 'text', text: 'first answer' }], + }, + ], + }) + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType, + provider: 'claude', + createRequestId: 'req-claude-fork', + sessionId: parent, + sessionRef: { provider: 'claude', sessionId: parent }, + status: 'idle', + }, + })) + + render( + + + , + ) + + // Both fork affordances are offered: /fork in the slash menu and the + // per-turn "Fork conversation from here" action. + await screen.findByText('first answer') + fireEvent.click(screen.getByRole('button', { name: 'Slash commands' })) + const menu = await screen.findByRole('menu', { name: 'Slash commands' }) + expect(within(menu).getByRole('menuitem', { name: /\/fork/ })).toBeInTheDocument() + fireEvent.keyDown(menu, { key: 'Escape' }) + const forkActions = await screen.findAllByRole('button', { name: 'Fork conversation from here' }) + wsMock.send.mockClear() + fireEvent.click(forkActions[0]) + expect(wsMock.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'freshAgent.fork', + requestId: 'req-claude-fork', + sessionId: parent, + sessionType, + provider: 'claude', + tabId: 'tab-1', + input: { atTurnId: 'turn-claude-user-1' }, + })) + + await waitFor(() => expect(onMessage).toBeTypeOf('function')) + apiMock.getFreshAgentThreadSnapshot.mockClear() + act(() => { + onMessage?.({ + type: 'freshAgent.forked', + requestId: 'req-claude-fork', + parentSessionId: parent, + sessionId: child, + sessionType, + provider: 'claude', + runtimeProvider: 'claude', + sessionRef: { provider: 'claude', sessionId: child }, + }) + }) + + await waitFor(() => { + const layout = store.getState().panes.layouts['tab-1'] + if (layout?.type !== 'leaf' || layout.content.kind !== 'fresh-agent') throw new Error('expected fresh-agent pane') + expect(layout.content.sessionId).toBe(child) + expect(layout.content.sessionRef).toEqual({ provider: 'claude', sessionId: child }) + expect(layout.content.createRequestId).not.toBe('req-claude-fork') + }) + expect(wsMock.send).toHaveBeenCalledWith(expect.objectContaining({ + type: 'freshAgent.kill', + sessionId: parent, + sessionType, + provider: 'claude', + })) + expect(wsMock.send).not.toHaveBeenCalledWith(expect.objectContaining({ type: 'freshAgent.send' })) + await waitFor(() => { + expect(apiMock.getFreshAgentThreadSnapshot).toHaveBeenCalledWith(sessionType, 'claude', child, expect.any(Object)) + }) + }) + it('does not stop the containing soul when the managed runtime already retired the fork parent', async () => { const store = createStore() let onMessage: ((message: Record) => void) | undefined From 25d757317c7a8277ae706e6fb87fe601888a6c87 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:01:54 -0700 Subject: [PATCH 13/24] docs(freshclaude): the genesis round-trip doc sits on its own test (F9) The genesis round-trip doc was stacked on top of the supersession-chain test, leaving genesis_writes_through_reads_back_and_clears undocumented. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-server/src/identity_sink.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/freshell-server/src/identity_sink.rs b/crates/freshell-server/src/identity_sink.rs index 953d680c0..759b09b28 100644 --- a/crates/freshell-server/src/identity_sink.rs +++ b/crates/freshell-server/src/identity_sink.rs @@ -889,9 +889,6 @@ mod tests { ); } - /// Claude core design §4.1: the genesis rides the binding write, survives - /// a restart, is kept by a later settings write, and is dropped by the - /// lazy clear once the transcript is on disk. /// Claude core design §4.5 (C13): a send that waited behind an undo /// finds the replacement through the durable supersession chain. The /// chain follows every hop to the live end, survives a restart (it is @@ -942,6 +939,9 @@ mod tests { ); } + /// Claude core design §4.1: the genesis rides the binding write, survives + /// a restart, is kept by a later settings write, and is dropped by the + /// lazy clear once the transcript is on disk. #[tokio::test(flavor = "multi_thread")] async fn genesis_writes_through_reads_back_and_clears() { let tmp = tempfile::tempdir().unwrap(); From 4680d8484f34c387b1ba5eb12937db5491a8ed8a Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:09:50 -0700 Subject: [PATCH 14/24] fix(freshclaude): a create that resumes a pending session boots its genesis (PRC-1, F1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C13/C14 create sessions that have no transcript until their first send: an undo's replacement (genesis `Fork{A, at}`, or `Fresh` for a first-turn undo) and a fork's child. When such a session's sidecar died before that send, the pane's Resume (`triggerRecovery`, a create carrying `sessionRef: C`) always booted `Resume{C}`, which the CLI refuses (T5), so the pane got `create.failed CONVERSATION_NOT_FOUND` and the undo or fork was lost until a reload happened to go through attach. The create's resume lane now decides its boot like every other launch (design §4.2, §4.3 crash row): when C's binding row records a genesis, `decide_boot` over the lane's store picks the genesis boot under C while C has no transcript (with the fork parent's recorded cwd), and a resume once it has one (clearing the genesis). A genesis with nothing left to boot (a fork whose parent is gone) answers CONVERSATION_NOT_FOUND without spawning; an unreadable store answers a retryable CLAUDE_CREATE_FAILED, never lost. The booted genesis also rides the runtime (`launch_genesis`). Deviation from F1's suggestion: a session with no recorded genesis still boots the plain resume without a store check. Its only possible boot is that resume, and the CLI, which reads the store from its own environment, answers a missing conversation with the same typed CONVERSATION_NOT_FOUND; checking first would add a way for a server whose view of the store differs from the CLI's (no root found, an IO error) to refuse a resume the CLI would run. decide_attach_boot and the create share one `decide_recorded_boot`; its lines name the lane, and a lost decision is logged (`freshagent.claude.boot_lost`). Red first (RealSidecarSim, real ledger): - freshagent_claude_rollback: an_undos_replacement_whose_sidecar_died_ resumes_through_the_create, a_fresh_replacement_whose_sidecar_died_ resumes_through_the_create (both answered create.failed CONVERSATION_NOT_FOUND before the fix) - freshagent_claude_fork: a_child_whose_sidecar_died_before_its_first_send_ resumes_through_the_create (same) - freshagent_claude_genesis: a_create_resuming_a_pending_fork_of_a_gone_ parent_is_refused_without_a_boot (booted a doomed resume before the fix) Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 52 +++++++- .../freshell-freshagent/src/claude/genesis.rs | 83 ++++++++++++- .../tests/freshagent_claude_fork.rs | 59 +++++++++ .../tests/freshagent_claude_genesis.rs | 27 ++++ .../tests/freshagent_claude_rollback.rs | 115 ++++++++++++++++++ 5 files changed, 329 insertions(+), 7 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 563b1a203..87f6b811b 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -2416,6 +2416,51 @@ impl FreshClaudeState { .as_deref() .and_then(|sid| self.claim_dead_state_snapshot(sid)); + // Which conversation the resume boots (design §4.2, §4.3): a session + // that has no transcript yet boots from the genesis its binding row + // records (an undo's replacement or a fork's child whose sidecar died + // before its first send), never as a resume the CLI refuses (T5). + let genesis_boot = match resume_sid.as_deref() { + Some(sid) => match self.decide_create_resume_boot(sid) { + Ok(decided) => decided, + Err(ResumeClaudeError::NotFound) => { + return self.fail_create( + &request_id, + "CONVERSATION_NOT_FOUND", + &format!("No conversation found with session ID: {sid}"), + ); + } + Err(ResumeClaudeError::Transient(detail)) => { + return self.answer_create_failure( + &request_id, + ClaudeCreateOutcome::Failed { + code: crate::claude_handshake::CREATE_FAILED_CODE.to_string(), + message: format!( + "The Claude transcript store could not be read: {detail}" + ), + retryable: Some(true), + owner: None, + }, + ); + } + }, + None => None, + }; + let (boot, boot_cwd, boot_session_id, boot_genesis) = match genesis_boot { + Some(decided) => ( + decided.boot, + decided.cwd.or_else(|| msg.cwd.clone()), + decided.session_id, + decided.genesis, + ), + None => ( + ClaudeBoot::resume_or_fresh(resume_sid.as_deref()), + msg.cwd.clone(), + None, + None, + ), + }; + // P1.13: the FULL settings snapshot the session records at // `sdk.session.init` (claude has no sandbox concept: always `None`), and // the launch is built from the same values (G15: the plugins too). @@ -2431,11 +2476,12 @@ impl FreshClaudeState { let spec = ClaudeLaunchSpec::new( request_id.clone(), session_type, - ClaudeBoot::resume_or_fresh(resume_sid.as_deref()), - msg.cwd.clone(), + boot, + boot_cwd, &settings, self.hosted_mcp, ) + .with_session_id(boot_session_id) .with_model_selection(serde_json::to_value(&msg.model_selection).ok()); // b8ke focused episode-2 round-2 F2: the create's claim owns this // runtime's coordinator transition (the session-init adoption's Blocked @@ -2460,7 +2506,7 @@ impl FreshClaudeState { partial_runtime_key: resume_sid.clone().or_else(|| fresh_claim_key.clone()), start_pid_slot: &start_pid_slot, inherit: None, - genesis: None, + genesis: boot_genesis, }, &mut lease_guard, &mut own_ticket, diff --git a/crates/freshell-freshagent/src/claude/genesis.rs b/crates/freshell-freshagent/src/claude/genesis.rs index 555280019..b65ae72f0 100644 --- a/crates/freshell-freshagent/src/claude/genesis.rs +++ b/crates/freshell-freshagent/src/claude/genesis.rs @@ -40,6 +40,21 @@ pub(crate) enum GenesisClear { Failed(String), } +/// A boot decided from a session's recorded genesis +/// ([`FreshClaudeState::decide_create_resume_boot`]). +#[derive(Debug)] +pub(super) struct GenesisAwareBoot { + pub(super) boot: ClaudeBoot, + /// The directory the conversation (or a fork's parent) was recorded in, + /// when it still exists. + pub(super) cwd: Option, + /// The id a fresh or forked boot writes under (the session's own). + pub(super) session_id: Option, + /// The genesis the boot follows; `None` when the transcript is on disk and + /// the boot resumes it. + pub(super) genesis: Option, +} + /// Clear `session_id`'s genesis once its transcript is on disk in `roots`: /// look up to `attempts` times, `interval` apart, and clear at the first find. /// A store that cannot be read is not a find. @@ -176,12 +191,61 @@ impl FreshClaudeState { sink: Option<&crate::identity_sink::SharedPaneIdentitySink>, ) -> Result<(ClaudeBoot, Option, Option), ResumeClaudeError> { let recorded = sink.and_then(|sink| sink.load_genesis(PROVIDER, durable)); + self.decide_recorded_boot("attach_resume", durable, sink, recorded.as_ref()) + } + + /// Which conversation a create that resumes `durable` boots (design §4.2, + /// the boot decision every launch makes; §4.3's crash row). A session whose + /// binding row records a genesis (a zero-turn session, an undo's replacement + /// or a fork's child before its first send) is decided exactly like an + /// attach: from its genesis while it has no transcript, as a resume once it + /// has one. So the pane's Resume after such a session's sidecar died boots + /// the same conversation again instead of a resume the CLI refuses (T5). + /// + /// `None` for a session with no genesis: the create resumes it as it always + /// did. Its only possible boot is that resume, and the CLI, which reads the + /// store from its own environment, refuses a conversation it does not hold + /// with the typed `CONVERSATION_NOT_FOUND` (T5), the same answer a lost + /// decision gives. + pub(super) fn decide_create_resume_boot( + &self, + durable: &str, + ) -> Result, ResumeClaudeError> { + let sink = self.identity_sink(); + let Some(recorded) = sink + .as_ref() + .and_then(|sink| sink.load_genesis(PROVIDER, durable)) + else { + return Ok(None); + }; + let (boot, cwd, session_id) = + self.decide_recorded_boot("create_resume", durable, sink.as_ref(), Some(&recorded))?; + // A boot under the session's own id is a boot from its genesis; a + // resume (the transcript is on disk) boots nothing from it. + let genesis = session_id.is_some().then_some(recorded.genesis); + Ok(Some(GenesisAwareBoot { + boot, + cwd, + session_id, + genesis, + })) + } + + /// [`decide_boot`] for `durable`, whose binding row records `recorded`, on + /// behalf of the launch `lane` (it names the log lines). + fn decide_recorded_boot( + &self, + lane: &'static str, + durable: &str, + sink: Option<&crate::identity_sink::SharedPaneIdentitySink>, + recorded: Option<&RecordedGenesis>, + ) -> Result<(ClaudeBoot, Option, Option), ResumeClaudeError> { let location = self.locate_transcript(durable); let found = matches!(location, TranscriptLocation::Found(_)); let decision = decide_boot( durable, location, - recorded.as_ref().map(|recorded| &recorded.genesis), + recorded.map(|recorded| &recorded.genesis), |id| self.locate_transcript(id), ); match decision { @@ -190,7 +254,7 @@ impl FreshClaudeState { cwd, session_id, } => { - if let (Some(recorded), Some(sink)) = (recorded.as_ref(), sink) { + if let (Some(recorded), Some(sink)) = (recorded, sink) { if found { self.clear_found_genesis(sink, durable, recorded); } else { @@ -198,6 +262,7 @@ impl FreshClaudeState { component = "freshagent.claude", event = "freshagent.claude.genesis_boot", session_id = %durable, + lane, genesis = recorded.genesis.kind(), boot = boot.kind(), "the session has no transcript yet; it boots from its genesis" @@ -206,9 +271,19 @@ impl FreshClaudeState { } Ok((boot, cwd, session_id)) } - BootDecision::Lost => Err(ResumeClaudeError::NotFound), + BootDecision::Lost => { + tracing::info!( + component = "freshagent.claude", + event = "freshagent.claude.boot_lost", + session_id = %durable, + lane, + genesis = recorded.map(|recorded| recorded.genesis.kind()), + "the session has no transcript and nothing to boot it from; it is gone" + ); + Err(ResumeClaudeError::NotFound) + } BootDecision::Transient(error) => { - crate::claude_snapshot::log_transcript_unreadable("attach_resume", durable, &error); + crate::claude_snapshot::log_transcript_unreadable(lane, durable, &error); Err(ResumeClaudeError::Transient(error.to_string())) } } diff --git a/crates/freshell-ws/tests/freshagent_claude_fork.rs b/crates/freshell-ws/tests/freshagent_claude_fork.rs index 4f6c491e4..99832adc9 100644 --- a/crates/freshell-ws/tests/freshagent_claude_fork.rs +++ b/crates/freshell-ws/tests/freshagent_claude_fork.rs @@ -478,3 +478,62 @@ async fn a_fork_at_a_turn_the_conversation_does_not_hold_is_refused() { assert_eq!(queries(&rig).len(), boots, "nothing was booted"); assert!(rig.fresh_claude.has_live_session(&a).await); } + +/// §4.3: a fork's child has no transcript until its first send. When its +/// sidecar dies before then, the pane's Resume (a create carrying the child's +/// `sessionRef`, `FreshAgentView.triggerRecovery`) boots the child from the +/// genesis its binding row records, the same lazy fork of the parent under C +/// (a plain resume of C is refused by the CLI, T5), and the next send writes C +/// with the parent's prefix. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_child_whose_sidecar_died_before_its_first_send_resumes_through_the_create() { + let rig = fork_rig().await; + let mut conn = rig.connect().await; + let (pane, a, a_path) = two_turns(&rig, &mut conn).await; + let u1 = prompt_uuid(&chain_lines(&a_path), "first prompt"); + let (_, c) = fork_ok(&mut conn, &pane, &a, "fork-1", Some(&u1)).await; + conn.kill(&pane).await; + let fork = boot_of(&rig, &c); + let sidecar = rig + .ledger_rows("boot") + .into_iter() + .find(|row| row["sessionId"] == c.as_str()) + .and_then(|row| row["pid"].as_i64()) + .expect("the child's sidecar pid"); + // SAFETY: a plain signal to a sidecar process this rig's server spawned. + assert_eq!(unsafe { libc::kill(sidecar as i32, libc::SIGKILL) }, 0); + conn.wait_for("the child's sidecar exit", |v| { + v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.error" + && v["event"]["code"] == "SIDECAR_EXITED" + }) + .await; + + let created = conn + .request_create( + "resume-child", + json!({ "sessionRef": { "provider": "claude", "sessionId": c } }), + ) + .await; + assert_eq!(created["type"], "freshAgent.created", "{created}"); + let boots: Vec = queries(&rig) + .into_iter() + .filter(|o| o["sessionId"] == c.as_str()) + .collect(); + assert_eq!(boots.len(), 2, "the fork booted again under C: {boots:?}"); + for key in ["resume", "forkSession", "resumeSessionAt"] { + assert_eq!(boots[1][key], fork[key], "{key}: {:?}", boots[1]); + } + + let resumed = created["sessionId"] + .as_str() + .expect("a session id") + .to_string(); + converse(&rig, &mut conn, &resumed, &["a different second prompt"]).await; + assert_eq!(conn.init_session_id(&resumed), c, "the CLI ran under C"); + let c_lines = chain_lines(&rig.wait_for_transcript(&c).await); + assert_eq!(c_lines[0]["uuid"], chain_lines(&a_path)[0]["uuid"]); + assert!(!c_lines + .iter() + .any(|line| line["message"]["content"][0]["text"] == "second prompt")); +} diff --git a/crates/freshell-ws/tests/freshagent_claude_genesis.rs b/crates/freshell-ws/tests/freshagent_claude_genesis.rs index 69817a9fe..e81b0a141 100644 --- a/crates/freshell-ws/tests/freshagent_claude_genesis.rs +++ b/crates/freshell-ws/tests/freshagent_claude_genesis.rs @@ -195,3 +195,30 @@ async fn a_pending_fork_reboots_the_same_fork_and_keeps_its_turn_ids() { ); rig.wait_for_genesis_cleared(&child).await; } + +/// §4.2 on the create lane: a create resuming a pending fork whose parent's +/// transcript is gone has nothing to boot. It is refused +/// `CONVERSATION_NOT_FOUND`, the CLI's own answer for a missing conversation, +/// and no sidecar is spawned for it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_create_resuming_a_pending_fork_of_a_gone_parent_is_refused_without_a_boot() { + let (rig, _ledger) = ledger_rig().await; + let id = uuid::Uuid::new_v4().to_string(); + let parent = uuid::Uuid::new_v4().to_string(); + rig.seed_pending_session(&id, &json!({ "kind": "fork", "parent": parent })); + let mut conn = rig.connect().await; + + let refused = conn + .request_create( + "resume-1", + json!({ "sessionRef": { "provider": "claude", "sessionId": id } }), + ) + .await; + assert_eq!(refused["type"], "freshAgent.create.failed", "{refused}"); + assert_eq!(refused["code"], "CONVERSATION_NOT_FOUND", "{refused}"); + assert!( + rig.ledger_rows("query").is_empty(), + "nothing was booted: {:?}", + rig.ledger_rows("query") + ); +} diff --git a/crates/freshell-ws/tests/freshagent_claude_rollback.rs b/crates/freshell-ws/tests/freshagent_claude_rollback.rs index c0aa1dd9f..da5171309 100644 --- a/crates/freshell-ws/tests/freshagent_claude_rollback.rs +++ b/crates/freshell-ws/tests/freshagent_claude_rollback.rs @@ -879,3 +879,118 @@ async fn an_undo_forks_from_the_transcript_it_located_even_if_the_store_stops_li let ack = rollback_answer(&mut conn, "undo-1").await; assert_eq!(ack["type"], "freshAgent.rolledBack", "{ack}"); } + +// ── §4.2/§4.3: a dead pending session resumes through the create lane ───────── + +/// SIGKILL the sidecar of the runtime that booted `session_id`, then wait for +/// the pane's `SIDECAR_EXITED` (the pane then shows "Resume session"). +async fn kill_sidecar_of(rig: &ClaudeRig, conn: &mut RigConnection, session_id: &str) { + let sidecar = runtime_pids(rig, session_id)[0]; + // SAFETY: a plain signal to a sidecar process this rig's server spawned. + assert_eq!(unsafe { libc::kill(sidecar as i32, libc::SIGKILL) }, 0); + conn.wait_for("the sidecar's exit", |v| { + v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.error" + && v["event"]["code"] == "SIDECAR_EXITED" + }) + .await; +} + +/// The create the pane's Resume sends for a dead claude session +/// (`FreshAgentView.triggerRecovery`, then `buildCreateMessage`): a new request +/// carrying the session as its `sessionRef`. Returns the `freshAgent.created` +/// frame. +async fn resume_through_create(conn: &mut RigConnection, session_id: &str) -> Value { + let created = conn + .request_create( + &format!("resume-{session_id}"), + json!({ "sessionRef": { "provider": "claude", "sessionId": session_id } }), + ) + .await; + assert_eq!(created["type"], "freshAgent.created", "{created}"); + assert_eq!( + created["sessionRef"], + json!({ "provider": "claude", "sessionId": session_id }), + "{created}" + ); + created +} + +/// Every recorded `query()` boot under `session_id`, in order. +fn boots_under(rig: &ClaudeRig, session_id: &str) -> Vec { + queries(rig) + .into_iter() + .filter(|o| o["sessionId"] == session_id) + .collect() +} + +/// §4.3 (sidecar crash row): the replacement an undo booted has no transcript +/// until its first send. When its sidecar dies before then, the pane's Resume +/// (a create resuming C) boots C from the genesis its binding row records, the +/// same guarded fork of A under C (a plain resume of C is refused by the CLI, +/// T5), and the next send writes C with A's kept prefix. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_undos_replacement_whose_sidecar_died_resumes_through_the_create() { + let rig = rollback_rig().await; + let mut conn = rig.connect().await; + let (pane, a, _) = two_turns(&rig, &mut conn).await; + let (_, c) = rollback_ok(&mut conn, "undo", &pane, &a, "undo-1").await; + materialized_from(&mut conn, &pane).await; + let fork = boot_of(&rig, &c); + kill_sidecar_of(&rig, &mut conn, &c).await; + + let created = resume_through_create(&mut conn, &c).await; + let boots = boots_under(&rig, &c); + assert_eq!(boots.len(), 2, "the fork booted again under C: {boots:?}"); + for key in [ + "resume", + "forkSession", + "resumeSessionAt", + "resumeDropsTurn", + ] { + assert_eq!(boots[1][key], fork[key], "{key}: {:?}", boots[1]); + } + assert_eq!(boots[1]["resume"], a.as_str()); + + let resumed = created["sessionId"] + .as_str() + .expect("a session id") + .to_string(); + converse(&rig, &mut conn, &resumed, &["after the crash"]).await; + assert_eq!(conn.init_session_id(&resumed), c, "the CLI ran under C"); + let c_lines = chain_lines(&rig.wait_for_transcript(&c).await); + let prompts: Vec<&str> = c_lines + .iter() + .filter(|line| line["type"] == "user") + .filter_map(|line| line["message"]["content"][0]["text"].as_str()) + .collect(); + assert_eq!(prompts, ["first prompt", "after the crash"]); +} + +/// §4.3: a first-turn undo's replacement (genesis `Fresh`) whose sidecar died +/// before its first send comes back through the create as a fresh boot under +/// C, never a resume of a conversation that was never written. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_fresh_replacement_whose_sidecar_died_resumes_through_the_create() { + let rig = rollback_rig().await; + let mut conn = rig.connect().await; + let pane = conn.create("create-1").await; + converse(&rig, &mut conn, &pane, &["only prompt"]).await; + let a = conn.init_session_id(&pane); + let (_, c) = rollback_ok(&mut conn, "undo", &pane, &a, "undo-1").await; + materialized_from(&mut conn, &pane).await; + kill_sidecar_of(&rig, &mut conn, &c).await; + + let created = resume_through_create(&mut conn, &c).await; + let boots = boots_under(&rig, &c); + assert_eq!(boots.len(), 2, "C booted fresh again: {boots:?}"); + assert_eq!(boots[1].get("resume"), None, "{:?}", boots[1]); + assert_eq!(boots[1].get("forkSession"), None, "{:?}", boots[1]); + + let resumed = created["sessionId"] + .as_str() + .expect("a session id") + .to_string(); + converse(&rig, &mut conn, &resumed, &["a new first prompt"]).await; + assert_eq!(conn.init_session_id(&resumed), c, "the CLI ran under C"); +} From 3af3fc30d61b72dea935d8ff37ea7cc65e374e9a Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:13:10 -0700 Subject: [PATCH 15/24] fix(freshclaude): an undo's replacement launches with the session's live settings (PRC-2) An undo or redo built the replacement's launch only from the ledger (`identity_sink().load_settings`). The hosted session host keeps no pane ledger, so a hosted undo booted the replacement with no plugins and no model, effort or permission mode. Later sends re-apply model, effort and permission mode through configure, but plugins are boot-only, so the replacement and every later turn ran without the pane's plugins (G15 on the hosted lane). The rollback now reads the settings the live session runs with (`configuration.settings`, kept current by create, attach and configure) when it reads the session at entry, the same source a fork's child launches from, and uses them for the replacement's launch, its recorded settings and its binding row. A rollback always has a live record (it refuses an id with none), so no ledger fallback is needed. Red first: freshell-session-host a_hosted_claude_undo_keeps_the_panes_plugins_and_model (real index.mjs over the simulator): the replacement's query() had `plugins` absent and `model: null`. The finding's redo leg does not apply to this lane: redo needs the durable rollback record, which only a lane with a ledger keeps, so a hosted redo is REDO_UNAVAILABLE before it boots anything. Co-Authored-By: Claude Opus 5.5 --- .../src/claude/rollback.rs | 27 +++++----- .../src/providers/fresh_agent.rs | 51 +++++++++++++++++++ 2 files changed, 66 insertions(+), 12 deletions(-) diff --git a/crates/freshell-freshagent/src/claude/rollback.rs b/crates/freshell-freshagent/src/claude/rollback.rs index 70160f4f6..bbeaca749 100644 --- a/crates/freshell-freshagent/src/claude/rollback.rs +++ b/crates/freshell-freshagent/src/claude/rollback.rs @@ -212,6 +212,7 @@ impl FreshClaudeState { turn_lock, session_type, last_synthesized_complete_at, + live_settings, ) = { let guard = self.sessions.lock().await; match guard.get(&map_key) { @@ -226,6 +227,9 @@ impl FreshClaudeState { // the same logical session lifetime, so a death-mint // after the fork can never regress past one before it). s.last_synthesized_complete_at.clone(), + // The settings the session runs with (G15): the + // replacement launches with them, on every lane. + s.configuration.settings.clone(), ), None => { reply_sink(rollback_error_frame( @@ -1060,16 +1064,15 @@ impl FreshClaudeState { self.emit_pending_cancellations(&map_key, &op.session_id, session_type) .await; - // The replacement's launch: the original's recorded settings (plugins - // included, G15) and the lane's hosted MCP selection; a fork resumes - // its source by id under the source's ORIGINAL cwd while that cwd - // exists (ledger A15 slug scoping), else by the transcript PATH (the - // verified cli.js escape hatch). The path is the one the reads above - // located (G28: never a second scan that a store turning unreadable - // could miss). - let recovered = self - .identity_sink() - .and_then(|s| s.load_settings(PROVIDER, &durable_id)); + // The replacement's launch: the settings the original runs with + // (model, effort, permission mode and the plugins, G15; the same source + // a fork's child launches from, and the only one a lane with no ledger + // has, such as the hosted session host) and the lane's hosted MCP + // selection; a fork resumes its source by id under the source's + // ORIGINAL cwd while that cwd exists (ledger A15 slug scoping), else by + // the transcript PATH (the verified cli.js escape hatch). The path is + // the one the reads above located (G28: never a second scan that a + // store turning unreadable could miss). let located = source_path .as_deref() .map(|path| crate::claude_launch::resume_target(&source, path)); @@ -1077,7 +1080,7 @@ impl FreshClaudeState { .as_ref() .and_then(|(_, cwd)| cwd.clone()) .or_else(|| op.cwd.clone()) - .or_else(|| recovered.as_ref().and_then(|r| r.cwd.clone())); + .or_else(|| live_settings.cwd.clone()); let boot = match &genesis { ClaudeGenesis::Fresh => ClaudeBoot::Fresh, ClaudeGenesis::Fork { at, drops_turn, .. } => ClaudeBoot::Fork { @@ -1089,7 +1092,7 @@ impl FreshClaudeState { drops_turn: drops_turn.clone(), }, }; - let mut launch_settings = recovered.clone().unwrap_or_default(); + let mut launch_settings = live_settings; if spawn_cwd.is_some() { launch_settings.cwd = spawn_cwd.clone(); } diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index 4c33d834d..fee32b87f 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -1822,6 +1822,57 @@ lines.on('line', line=>{ 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 + /// (plugins, model, effort), not the ledger's (nothing), or the replacement + /// and every later turn run without the pane's plugins (a boot-only + /// option). (Redo needs the durable rollback record, which only a lane with + /// a ledger keeps.) + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_hosted_claude_undo_keeps_the_panes_plugins_and_model() { + let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; + let conversation = "6d1f0a2b-3c4d-4e5f-8a6b-7c8d9e0f1a2b"; + let claude = SimulatedClaude::start(&[conversation]).await; + let plugin = claude.root.path().join("workspace/.claude/plugins/review"); + std::fs::create_dir_all(&plugin).unwrap(); + let plugin = plugin.to_string_lossy().to_string(); + let mut profile = claude.profile(Some(conversation)); + profile.plugins = Some(vec![plugin.clone()]); + profile.model = Some("opus".into()); + profile.effort = Some("high".into()); + tokio::time::timeout(Duration::from_secs(20), claude.transport.start(&profile)) + .await + .expect("the resume start answers") + .expect("the resume start succeeds"); + let plugins = json!([{ "type": "local", "path": plugin }]); + let start = claude.ledger_rows("query")[0]["options"].clone(); + assert_eq!(start["plugins"], plugins, "{start}"); + + tokio::time::timeout( + Duration::from_secs(20), + claude.transport.dispatch_operation( + &RequestId::parse("undo-1").unwrap(), + &FreshAgentOperation::Rollback { + direction: FreshAgentRollbackDirection::Undo, + mode: FreshAgentRollbackMode::Step, + turn_id: None, + cwd: None, + }, + ), + ) + .await + .expect("the undo answers") + .expect("the undo succeeds"); + let rows = claude.ledger_rows("query"); + assert_eq!(rows.len(), 2, "the undo booted one replacement"); + let replacement = rows[1]["options"].clone(); + assert_eq!(replacement["plugins"], plugins, "{replacement}"); + assert_eq!(replacement["model"], "opus", "{replacement}"); + assert_eq!(replacement["effort"], "high", "{replacement}"); + Arc::clone(&claude.transport).stop().await.unwrap(); + } + /// C14 (G09): a hosted claude fork routes to `FreshClaudeState::handle_fork`. The /// transition arrives once the CLI booted the child as a lazy fork of the parent /// (no prompt), keyed by the native ids the actor holds; the runtime retires the From 041f475651e3856760e7e7c9cd3dbb03ab4b322d Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:17:09 -0700 Subject: [PATCH 16/24] fix(freshclaude): a fork whose pane closed the parent while the child booted hands out no child (PRC-3) A fork re-checked nothing after its child booted (about a second on the real CLI, decision 4). When the user closed the pane in that window, the kill of the parent succeeded (it takes neither the fork's single-flight nor the turn lock, and the coordinator grants it: the fork claims only the child's key), and the fork still wrote the child's Bound binding row, committed it Live and answered `forked` to a pane that no longer existed. Nothing ever closed that child: its sidecar and CLI ran until shutdown and its row could be offered for restore. The fork now checks whether the pane closed the parent twice: once its child is established, and again once the child's row is written (from there to the answer nothing awaits, so a close lands before the check or after the pane was answered). Closed means a kill of the parent is in flight (its close is pending) or done (its runtime left the live map without a dead-session record). A parent whose sidecar merely died is still the pane's, so its fork goes on. A closed parent's child is torn down through `unwind_fork_child` (retiring its row once written) and the fork answers FORK_REFUSED; logged as `freshagent.claude.fork_parent_closed`. Test seam: `hold_fork_child` parks a fork once its child is established, before the row write, so the close lands in the window every time. Red first: freshagent_claude_fork closing_the_pane_while_the_child_boots_leaves_no_child_behind (answered `freshAgent.forked` with a live child before the fix). Guard test: a_parent_that_dies_while_the_child_boots_still_gets_its_fork. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 8 ++ crates/freshell-freshagent/src/claude/fork.rs | 73 +++++++++++ .../src/claude_test_holds.rs | 25 ++++ .../tests/freshagent_claude_fork.rs | 118 ++++++++++++++++++ 4 files changed, 224 insertions(+) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 87f6b811b..16745cb9a 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -875,6 +875,14 @@ impl FreshClaudeState { self.test_holds.hold_rollback_boot() } + /// Test seam: park every later fork once its child runtime is established, + /// before it records the child's binding row, until the returned hold is + /// released. + #[cfg(any(test, feature = "test-support"))] + pub fn hold_fork_child(&self) -> Arc { + self.test_holds.hold_fork_child() + } + /// Spawn one claude sidecar process (create, attach-resume and rollback all /// come through here) for a session of `session_type`. async fn spawn_sidecar_process(&self, session_type: &str) -> Result { diff --git a/crates/freshell-freshagent/src/claude/fork.rs b/crates/freshell-freshagent/src/claude/fork.rs index 4d67d38d7..3d2c8caa1 100644 --- a/crates/freshell-freshagent/src/claude/fork.rs +++ b/crates/freshell-freshagent/src/claude/fork.rs @@ -43,6 +43,11 @@ pub(crate) const FORK_POINT_GONE_MESSAGE: &str = pub(crate) const FORK_IN_PROGRESS_MESSAGE: &str = "This conversation is already being forked. Wait for the new conversation to open."; +/// The pane closed the conversation while its fork was booting the child +/// (`FORK_REFUSED`; nothing shows it, since the pane is gone). +pub(crate) const FORK_PARENT_CLOSED_MESSAGE: &str = + "The conversation was closed while it was being forked, so the fork was discarded."; + /// The child's binding row could not be written. pub(crate) const FORK_LEDGER_WRITE_MESSAGE: &str = "Fork is unavailable right now: the new conversation could not be saved. Try again."; @@ -81,6 +86,8 @@ fn fork_failure(error: &EstablishError) -> (&'static str, String) { /// The parent a fork reads, as it stands once the fork holds its turn lock. struct ForkParent { + /// The parent's sessions-map key. + live_key: String, /// The id the requester knows the parent by (the reply's `parentSessionId`). reply_id: String, /// The parent's durable id: whose effective transcript the fork reads. @@ -399,6 +406,18 @@ impl FreshClaudeState { } }; + self.test_holds.fork_child().await; + // The pane may have closed the parent while the child booted (about a + // second, decision 4): then nothing would ever show or close the + // child, so it is torn down instead of handed out. + if self + .refuse_fork_of_closed_parent(&parent, &child, false) + .await + { + refuse("FORK_REFUSED", FORK_PARENT_CLOSED_MESSAGE); + return; + } + // The child's binding row, AWAITED before the answer (durable before // answer): how it came to be (its genesis, so a restart before its first // send boots the same fork), the parent's settings and the forking @@ -434,6 +453,17 @@ impl FreshClaudeState { } } + // Again once the row is written: from here to the answer nothing + // awaits, so a close of the parent lands either before this check or + // after the pane was answered. + if self + .refuse_fork_of_closed_parent(&parent, &child, true) + .await + { + refuse("FORK_REFUSED", FORK_PARENT_CLOSED_MESSAGE); + return; + } + // The child is live: commit `Live{FreshAgent}` under its id (the owner // record is broadcast, so the client holds the child's fence pair). if let Err(outcome) = @@ -489,6 +519,7 @@ impl FreshClaudeState { let sessions = self.sessions.lock().await; let session = sessions.get(&live_key)?; Some(ForkParent { + live_key: live_key.clone(), // The pane follows a replacement through its `materialized` re-key, // so it then knows the parent by the replacement's id. reply_id: if live_key == map_key { @@ -505,6 +536,48 @@ impl FreshClaudeState { }) } + /// Whether the pane closed the fork's parent: a kill of it is in flight + /// (its close is pending) or done (its runtime left the live map without + /// ending on its own, which leaves a dead-session record instead). A parent + /// whose sidecar merely died is still the pane's, so its fork goes on. + async fn fork_parent_closed(&self, parent_key: &str) -> bool { + let closing = self + .close_pending + .lock() + .expect("close-pending lock") + .get(parent_key) + .is_some_and(|pending| *pending > 0); + if closing { + return true; + } + if self.sessions.lock().await.contains_key(parent_key) { + return false; + } + self.dead_sessions.find(parent_key).is_none() + } + + /// When the pane closed the fork's parent, tear the child down (retiring + /// its binding row once written) and say so; the caller refuses the fork. + async fn refuse_fork_of_closed_parent( + &self, + parent: &ForkParent, + child: &str, + row_written: bool, + ) -> bool { + if !self.fork_parent_closed(&parent.live_key).await { + return false; + } + tracing::info!(target: "freshell_freshagent::claude", + event = "freshagent.claude.fork_parent_closed", + session_id = %parent.durable, + child = %child, + row_written, + "the pane closed the parent while its fork booted the child; the child is torn down" + ); + self.unwind_fork_child(child, row_written).await; + true + } + /// Tear down a fork child that registered but cannot be handed to the /// pane; the parent is untouched. Once the child's binding row was written /// (`row_written`), it is retired so nothing offers to restore a diff --git a/crates/freshell-freshagent/src/claude_test_holds.rs b/crates/freshell-freshagent/src/claude_test_holds.rs index 747cef837..2666e660c 100644 --- a/crates/freshell-freshagent/src/claude_test_holds.rs +++ b/crates/freshell-freshagent/src/claude_test_holds.rs @@ -13,6 +13,9 @@ //! parks after it wrote its durable record, holding the session's turn lock, //! before it boots the replacement runtime, so a test can park a send behind //! it or change the transcript store under it. +//! - Fork child ([`ClaudeTestHolds::hold_fork_child`]): a fork parks once its +//! child runtime is established, before it records the child's binding row, +//! so a test can close the parent while the child is still booting. use std::sync::{Arc, Mutex}; use std::time::Duration; @@ -25,6 +28,7 @@ pub(crate) struct ClaudeTestHolds { registration: Mutex>, exit_report: Mutex>>, rollback_boot: Mutex>>, + fork_child: Mutex>>, } /// One parked step: [`Self::parked`] returns once the step reached the hold, @@ -84,6 +88,15 @@ impl ClaudeTestHolds { hold } + /// Park every later fork once its child runtime is established, before it + /// records the child's binding row, until the returned hold is released. + #[cfg(any(test, feature = "test-support"))] + pub(crate) fn hold_fork_child(&self) -> Arc { + let hold = Arc::new(StepHold::default()); + *self.fork_child.lock().expect("fork child hold lock") = Some(Arc::clone(&hold)); + hold + } + /// The registration hold, if a test set one. pub(crate) async fn registration(&self) { let hold = *self.registration.lock().expect("registration hold lock"); @@ -115,4 +128,16 @@ impl ClaudeTestHolds { hold.park().await; } } + + /// The fork-child hold, if a test set one. + pub(crate) async fn fork_child(&self) { + let hold = self + .fork_child + .lock() + .expect("fork child hold lock") + .clone(); + if let Some(hold) = hold { + hold.park().await; + } + } } diff --git a/crates/freshell-ws/tests/freshagent_claude_fork.rs b/crates/freshell-ws/tests/freshagent_claude_fork.rs index 99832adc9..2502a75eb 100644 --- a/crates/freshell-ws/tests/freshagent_claude_fork.rs +++ b/crates/freshell-ws/tests/freshagent_claude_fork.rs @@ -537,3 +537,121 @@ async fn a_child_whose_sidecar_died_before_its_first_send_resumes_through_the_cr .iter() .any(|line| line["message"]["content"][0]["text"] == "second prompt")); } + +/// Whether process `pid` is gone (or a zombie, which holds no pipes). +fn process_gone(pid: i64) -> bool { + match std::fs::read_to_string(format!("/proc/{pid}/stat")) { + Err(_) => true, + Ok(stat) => stat + .rsplit_once(')') + .map(|(_, rest)| rest.trim_start().starts_with('Z')) + .unwrap_or(false), + } +} + +/// The user closes the pane (the client kills the parent) while the fork's +/// child is still booting. Nothing would ever show or close that child, so the +/// fork must not hand it out: it answers a refusal instead of `forked`, the +/// child's runtime is torn down (its sidecar and CLI are gone) and no binding +/// row offers it for restore. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn closing_the_pane_while_the_child_boots_leaves_no_child_behind() { + let rig = fork_rig().await; + let mut conn = rig.connect().await; + let pane = conn.create("create-1").await; + converse(&rig, &mut conn, &pane, &["first prompt"]).await; + let a = conn.init_session_id(&pane); + let hold = rig.fresh_claude.hold_fork_child(); + let seen = conn.frames.len(); + request_fork(&mut conn, &pane, &a, "fork-1", None).await; + hold.parked().await; + conn.kill(&pane).await; + hold.release(); + + let answer = fork_answer(&mut conn, &pane, "fork-1", seen).await; + assert_eq!( + answer["type"], "freshAgent.event", + "no child is handed out: {answer}" + ); + assert_eq!(answer["event"]["code"], "FORK_REFUSED", "{answer}"); + let boot = queries(&rig) + .into_iter() + .find(|o| o["forkSession"] == true) + .expect("the child booted"); + let child = boot["sessionId"] + .as_str() + .expect("the child's id") + .to_string(); + assert!( + !rig.fresh_claude.has_live_session(&child).await, + "the child is torn down" + ); + let pids: Vec = rig + .ledger_rows("boot") + .into_iter() + .filter(|row| row["sessionId"] == child.as_str()) + .flat_map(|row| [row["pid"].as_i64(), row["cliPid"].as_i64()]) + .flatten() + .collect(); + assert_eq!(pids.len(), 2, "the child's sidecar and CLI pids"); + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while !pids.iter().all(|pid| process_gone(*pid)) { + assert!( + tokio::time::Instant::now() < deadline, + "the child's processes ({pids:?}) are still running" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + assert!( + ledger(&rig) + .load_binding("claude", &child) + .is_none_or(|row| row.state != freshell_ws::pane_ledger::RowState::Bound), + "nothing offers the child for restore" + ); +} + +/// A parent whose sidecar died on its own while the fork's child booted is +/// still the pane's (it shows the ended card, not a closed pane), so the fork +/// goes on and hands the pane its live child. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_parent_that_dies_while_the_child_boots_still_gets_its_fork() { + let rig = fork_rig().await; + let mut conn = rig.connect().await; + let pane = conn.create("create-1").await; + converse(&rig, &mut conn, &pane, &["first prompt"]).await; + let a = conn.init_session_id(&pane); + let parent_sidecar = rig + .ledger_rows("boot") + .into_iter() + .next() + .and_then(|row| row["pid"].as_i64()) + .expect("the parent's sidecar pid"); + let hold = rig.fresh_claude.hold_fork_child(); + let seen = conn.frames.len(); + request_fork(&mut conn, &pane, &a, "fork-1", None).await; + hold.parked().await; + // SAFETY: a plain signal to a sidecar process this rig's server spawned. + assert_eq!( + unsafe { libc::kill(parent_sidecar as i32, libc::SIGKILL) }, + 0 + ); + conn.wait_for("the parent's sidecar exit", |v| { + v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.error" + && v["event"]["code"] == "SIDECAR_EXITED" + }) + .await; + hold.release(); + + let answer = conn + .wait_for("the fork's answer", |v| { + v["type"] == "freshAgent.forked" && v["requestId"] == "fork-1" + }) + .await; + assert!(conn.frames.len() > seen); + let child = answer["sessionId"].as_str().expect("the child").to_string(); + assert!( + rig.fresh_claude.has_live_session(&child).await, + "the child is live" + ); +} From 969b6656aafb691fbbc4fbfd21daa4e6f780f722 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:21:45 -0700 Subject: [PATCH 17/24] fix(freshclaude): a configure or /compact parked behind an undo follows the replacement (F2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C13 moves the session to a new map key (the replacement C) while handle_rollback holds the session's turn lock. Only the send and the fork re-resolved their session after waiting on that lock; a configure or a /compact queued behind an undo (from this or another device) still used the key it resolved before the wait, the retired A. The configure answered CLAUDE_SETTINGS_FAILED "Claude session is no longer available." and the compact a lost-session frame (INVALID_SESSION_ID), so the setting or the compaction was dropped. Before C13 the replacement re-registered under the same key, so both landed on the post-rollback session. Both now re-resolve after the lock through `live_key_after_turn_wait` (the durable ledger chain, then the same-lock alias), like the send and the fork (design §3.3: the turn lock serializes send, configure, compact, rollback and fork). A request that followed a replacement answers under the replacement's id, the id the pane holds after its `materialized` re-key (the fork already answers that way). The helper takes the request kind (`TurnLockedRequest`) so each follow is logged under its own event (`freshagent.claude.{send,configure,compact,fork}_followed_replacement`; the send's name is unchanged). Red first (RealSidecarSim, the undo held by hold_rollback_boot): - a_configure_parked_behind_an_undo_applies_to_the_replacement (answered CLAUDE_SETTINGS_FAILED "Claude session is no longer available.") - a_compact_parked_behind_an_undo_runs_on_the_replacement (answered INVALID_SESSION_ID) Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 103 ++++++++++-- crates/freshell-freshagent/src/claude/fork.rs | 8 +- .../tests/freshagent_claude_rollback.rs | 155 ++++++++++++++++++ 3 files changed, 252 insertions(+), 14 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 16745cb9a..81b503b53 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -4184,15 +4184,18 @@ impl FreshClaudeState { // ── freshAgent.send (WS) ───────────────────────────────────────────────────────── - /// The live session a send resolved before it waited on `turn_lock`, - /// after the wait (design §4.5 and §4.8): the same map key while it is - /// still registered; else, when an undo or redo replaced that runtime - /// while the send waited, the live end of the durable ledger chain from - /// the session's durable id (`durable`), then the wire id's own - /// resolution. Only a session that took this very lock over counts: a - /// send never lands on a runtime it did not serialize behind. - async fn live_key_after_turn_wait( + /// The live session a request (`op`) resolved before it waited on + /// `turn_lock`, after the wait (design §3.3, §4.5 and §4.8): the same map + /// key while it is still registered; else, when an undo or redo replaced + /// that runtime while the request waited, the live end of the durable + /// ledger chain from the session's durable id (`durable`), then the wire + /// id's own resolution. Only a session that took this very lock over + /// counts: a request never lands on a runtime it did not serialize behind. + /// Every request the turn lock serializes (send, configure, compact, fork) + /// re-resolves through here. + pub(super) async fn live_key_after_turn_wait( &self, + op: TurnLockedRequest, map_key: &str, wire_id: &str, durable: Option<&str>, @@ -4218,12 +4221,12 @@ impl FreshClaudeState { .is_some_and(|session| Arc::ptr_eq(&session.turn_lock, turn_lock)); if same_lock { tracing::info!(target: "freshell_freshagent::claude", - event = "freshagent.claude.send_followed_replacement", + event = op.followed_replacement_event(), session_id = %wire_id, replaced = %map_key, replacement = %key, through_ledger_chain = terminus.as_deref() == Some(candidate), - "a send that waited behind a rollback goes to the replacement runtime" + "a request that waited behind a rollback goes to the replacement runtime" ); return Some(key); } @@ -4309,6 +4312,7 @@ impl FreshClaudeState { // original and never to a revived one. let Some(map_key) = self .live_key_after_turn_wait( + TurnLockedRequest::Send, &map_key, &session_id, durable_before_wait.as_deref(), @@ -4693,11 +4697,13 @@ impl FreshClaudeState { let session_type = session_type_str(msg.session_type); let request_id = msg.request_id.clone(); - let (map_key, turn_lock) = loop { + let (map_key, turn_lock, durable_before_wait) = loop { let handles = match self.resolve_session_key(&session_id).await { Some(key) => { let guard = self.sessions.lock().await; - guard.get(&key).map(|s| (key, s.turn_lock.clone())) + guard + .get(&key) + .map(|s| (key, s.turn_lock.clone(), s.cli_session_id.clone())) } None => None, }; @@ -4711,6 +4717,30 @@ impl FreshClaudeState { tokio::time::sleep(MID_ROLLBACK_PARK_TICK).await; }; let _turn = turn_lock.lock().await; + // An undo or redo that held this lock may have replaced the runtime + // (C13): the setting applies to the replacement the pane now shows, + // never to the retired original. + let Some(live_key) = self + .live_key_after_turn_wait( + TurnLockedRequest::Configure, + &map_key, + &session_id, + durable_before_wait.as_deref(), + &turn_lock, + ) + .await + else { + self.send_error(&request_id, "SESSION_NOT_FOUND", "claude session not found"); + return; + }; + // The pane follows a replacement through its `materialized` re-key, so + // it then knows the session by the replacement's id. + let session_id = if live_key == map_key { + session_id + } else { + live_key.clone() + }; + 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. @@ -4964,7 +4994,7 @@ impl FreshClaudeState { // membership and re-resolve (the compact then serializes BEHIND the rollback // on the carried-over turn lock). This handler never ACQUIRES // `rollback_in_flight` (no circular wait exists): it only polls membership. - let (map_key, turn_lock, in_turn, turn_tracker) = loop { + let (map_key, turn_lock, in_turn, turn_tracker, durable_before_wait) = loop { let handles = match self.resolve_session_key(&session_id).await { Some(key) => { let guard = self.sessions.lock().await; @@ -4974,6 +5004,7 @@ impl FreshClaudeState { s.turn_lock.clone(), s.in_turn.clone(), s.turn_tracker.clone(), + s.cli_session_id.clone(), ) }) } @@ -4992,6 +5023,28 @@ impl FreshClaudeState { // serialization discipline) — a rollback holding this lock observes either // no-compact or a fully-marked in-flight compact turn. let _turn = turn_lock.lock().await; + // An undo or redo that held this lock may have replaced the runtime + // (C13): the compact runs on the replacement the pane now shows, which + // took over this lock and the busy truth (`in_turn`, `turn_tracker`). + let Some(live_key) = self + .live_key_after_turn_wait( + TurnLockedRequest::Compact, + &map_key, + &session_id, + durable_before_wait.as_deref(), + &turn_lock, + ) + .await + else { + self.broadcast(&lost_session_frame(&session_id, msg.session_type)); + return; + }; + let session_id = if live_key == map_key { + session_id + } else { + live_key.clone() + }; + let map_key = live_key; // ep1-r3 F3 (the arm/await race): with a prior turn active this compact is // QUEUED — arm the tracker NOW, BEFORE the sidecar write await. The stdout // consumer's terminal-edge fold NEVER takes the turn lock, so an arm @@ -8038,6 +8091,30 @@ enum ResumeClaudeError { Transient(String), } +/// A request the session's turn lock serializes (design §3.3). Each one that +/// waited on the lock re-resolves its session afterwards +/// ([`FreshClaudeState::live_key_after_turn_wait`]): an undo or redo holding +/// the lock may have replaced the runtime. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum TurnLockedRequest { + Send, + Configure, + Compact, + Fork, +} + +impl TurnLockedRequest { + /// The event logged when the request follows a replacement. + fn followed_replacement_event(self) -> &'static str { + match self { + Self::Send => "freshagent.claude.send_followed_replacement", + Self::Configure => "freshagent.claude.configure_followed_replacement", + Self::Compact => "freshagent.claude.compact_followed_replacement", + Self::Fork => "freshagent.claude.fork_followed_replacement", + } + } +} + fn lost_session_frame(session_id: &str, session_type: SessionType) -> ServerMessage { ServerMessage::FreshAgentEvent(FreshAgentEvent { event: json!({ diff --git a/crates/freshell-freshagent/src/claude/fork.rs b/crates/freshell-freshagent/src/claude/fork.rs index 3d2c8caa1..09bebe166 100644 --- a/crates/freshell-freshagent/src/claude/fork.rs +++ b/crates/freshell-freshagent/src/claude/fork.rs @@ -514,7 +514,13 @@ impl FreshClaudeState { turn_lock: &Arc>, ) -> Option { let live_key = self - .live_key_after_turn_wait(map_key, wire_id, Some(durable), turn_lock) + .live_key_after_turn_wait( + TurnLockedRequest::Fork, + map_key, + wire_id, + Some(durable), + turn_lock, + ) .await?; let sessions = self.sessions.lock().await; let session = sessions.get(&live_key)?; diff --git a/crates/freshell-ws/tests/freshagent_claude_rollback.rs b/crates/freshell-ws/tests/freshagent_claude_rollback.rs index da5171309..1d69ee736 100644 --- a/crates/freshell-ws/tests/freshagent_claude_rollback.rs +++ b/crates/freshell-ws/tests/freshagent_claude_rollback.rs @@ -994,3 +994,158 @@ async fn a_fresh_replacement_whose_sidecar_died_resumes_through_the_create() { converse(&rig, &mut conn, &resumed, &["a new first prompt"]).await; assert_eq!(conn.init_session_id(&resumed), c, "the CLI ran under C"); } + +// ── §3.3: configure and /compact serialize behind an undo on its turn lock ─── + +/// A frame sent while an undo holds the session's turn lock (the replacement's +/// boot is parked), so its handler is parked on that lock when the undo +/// replaces the runtime. Returns the replacement's id and how many frames the +/// connection had read before the parked frame was sent. +async fn park_behind_an_undo( + rig: &ClaudeRig, + conn: &mut RigConnection, + pane: &str, + a: &str, + frame: Value, +) -> (String, usize) { + let hold = rig.fresh_claude.hold_rollback_boot(); + request_rollback(conn, "undo", pane, a, "undo-1", None).await; + hold.parked().await; + let seen = conn.frames.len(); + conn.send(frame).await; + // The handler resolves the session and waits on the undo's turn lock. + conn.read_for(Duration::from_millis(300)).await; + hold.release(); + let ack = rollback_answer(conn, "undo-1").await; + assert_eq!(ack["type"], "freshAgent.rolledBack", "{ack}"); + let c = ack["newSessionId"] + .as_str() + .expect("the replacement") + .to_string(); + (c, seen) +} + +/// The first frame matching `predicate` among those read after the first +/// `seen` (an answer to a request sent then, never an older frame). +async fn frame_after( + conn: &mut RigConnection, + seen: usize, + what: &str, + predicate: impl Fn(&Value) -> bool, +) -> Value { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + if let Some(hit) = conn.frames[seen..].iter().find(|v| predicate(v)) { + return hit.clone(); + } + assert!( + tokio::time::Instant::now() < deadline, + "no {what}; frames: {:?}", + &conn.frames[seen..] + ); + conn.read_for(Duration::from_millis(50)).await; + } +} + +fn lost_frames(conn: &RigConnection) -> Vec { + conn.frames + .iter() + .filter(|v| { + v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.error" + && matches!( + v["event"]["code"].as_str(), + Some("INVALID_SESSION_ID" | "CLAUDE_SETTINGS_FAILED") + ) + }) + .cloned() + .collect() +} + +/// A model change sent while an undo boots its replacement waits on the +/// session's turn lock (design §3.3), then applies to the replacement C the +/// pane now shows: the CLI behind C takes the model, C's binding records it +/// and the pane gets C's metadata. It is never dropped against the retired A. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_configure_parked_behind_an_undo_applies_to_the_replacement() { + let rig = rollback_rig().await; + let mut conn = rig.connect().await; + let (pane, a, _) = two_turns(&rig, &mut conn).await; + let configure = json!({ + "type": "freshAgent.configure", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": pane, + "requestId": "configure-1", + "settings": { "model": "claude-opus-parked" }, + }); + let (c, seen) = park_behind_an_undo(&rig, &mut conn, &pane, &a, configure).await; + + let answer = frame_after( + &mut conn, + seen, + "the configure's metadata or its failure", + |v| { + v["type"] == "freshAgent.event" + && ((v["event"]["type"] == "freshAgent.session.metadata" + && v["event"]["model"] == "claude-opus-parked") + || v["event"]["type"] == "freshAgent.error") + }, + ) + .await; + assert_eq!( + answer["event"]["type"], "freshAgent.session.metadata", + "{answer}" + ); + assert_eq!(answer["event"]["sessionId"], c.as_str(), "{answer}"); + assert!(lost_frames(&conn).is_empty(), "{:?}", lost_frames(&conn)); + assert_eq!( + ledger(&rig) + .load_binding("claude", &c) + .expect("C's row") + .model + .as_deref(), + Some("claude-opus-parked") + ); +} + +/// A /compact sent while an undo boots its replacement waits on the session's +/// turn lock, then compacts the replacement C the pane now shows (its turn +/// runs under C), never answered with a lost-session frame for the retired A. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_compact_parked_behind_an_undo_runs_on_the_replacement() { + let rig = rollback_rig().await; + let mut conn = rig.connect().await; + let (pane, a, _) = two_turns(&rig, &mut conn).await; + let results = conn.events_seen("freshAgent.result"); + let compact = json!({ + "type": "freshAgent.compact", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": pane, + "requestId": "compact-1", + }); + let (c, seen) = park_behind_an_undo(&rig, &mut conn, &pane, &a, compact).await; + + assert_eq!( + conn.events_seen("freshAgent.result"), + results, + "nothing ran while the undo held the lock" + ); + let answer = frame_after( + &mut conn, + seen, + "the compact's result or its failure", + |v| { + v["type"] == "freshAgent.event" + && matches!( + v["event"]["type"].as_str(), + Some("freshAgent.result" | "freshAgent.error") + ) + }, + ) + .await; + assert_eq!(answer["event"]["type"], "freshAgent.result", "{answer}"); + assert_eq!(answer["sessionId"], c.as_str(), "{answer}"); + assert!(lost_frames(&conn).is_empty(), "{:?}", lost_frames(&conn)); +} From 5e23c1a5c6a643b5fd2dac0c322dadd4daa577ad Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:36:00 -0700 Subject: [PATCH 18/24] feat(freshclaude): the typed boot retries of the boot decision (F3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design §4.2 (and §4.9: establish_runtime does "typed retries per §4.2") retries two boot refusals once; C12 deferred them and C13 did not add them, so a genesis boot whose transcript was flushed between the store check and the spawn surfaced as a transient attach failure. `claude_launch::retry_refused_boot` is the pure rule, decided again from a fresh look at the store: - SESSION_ID_IN_USE of a boot under an id (T4): the transcript appeared between the check and the spawn, so resume that conversation (where it was recorded, when the look finds it). - CONVERSATION_NOT_FOUND of a resume, with a genesis (T5): the transcript the check found is gone, so boot from the genesis (`decide_boot` on Absent). - Anything else stands: a resume with no genesis is lost, and a fork's own refusals (FORK_GUARD_REFUSED, RESUME_POINT_NOT_FOUND) are the caller's typed answers, never retried (decision 2). `establish_runtime` applies it once, re-arming the lease's kill handle and the start's cancellation on the retried sidecar, and logs `freshagent.claude.boot_retry` (lane, code, refused and retried boot kinds). The plan carries the retry context (`BootRetry`: the durable id and its recorded genesis) for the launches that boot a durable session from its recorded state, the attach-resume (the handoff resume is one) and the create's resume lane; an undo's replacement and a fork's child boot under ids they minted and carry none. Red first: - claude_launch unit tests, one per rule: a_boot_refused_in_use_is_retried_ as_a_resume_of_that_conversation, a_resume_refused_not_found_is_retried_ from_the_genesis (both failed against a stub), plus the guard other_refusals_are_never_retried. - freshagent_claude_genesis (RealSidecarSim, the store changed between the query() and the simulated CLI's boot): a_genesis_boot_refused_in_use_is_ retried_once_as_a_resume (the attach answered CLAUDE_ATTACH_RESUME_FAILED: SESSION_ID_IN_USE before), a_resume_refused_not_found_is_retried_once_ from_the_genesis (the create answered create.failed before). Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 19 +- .../src/claude/establish.rs | 74 +++++- crates/freshell-freshagent/src/claude/fork.rs | 1 + .../freshell-freshagent/src/claude/genesis.rs | 6 +- .../src/claude/rollback.rs | 1 + .../src/claude_handshake.rs | 8 + .../freshell-freshagent/src/claude_launch.rs | 235 ++++++++++++++++++ .../tests/freshagent_claude_genesis.rs | 163 ++++++++++++ 8 files changed, 499 insertions(+), 8 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 81b503b53..719f68b66 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -2454,18 +2454,20 @@ impl FreshClaudeState { }, None => None, }; - let (boot, boot_cwd, boot_session_id, boot_genesis) = match genesis_boot { + let (boot, boot_cwd, boot_session_id, boot_genesis, recorded_genesis) = match genesis_boot { Some(decided) => ( decided.boot, decided.cwd.or_else(|| msg.cwd.clone()), decided.session_id, decided.genesis, + Some(decided.recorded), ), None => ( ClaudeBoot::resume_or_fresh(resume_sid.as_deref()), msg.cwd.clone(), None, None, + None, ), }; @@ -2515,6 +2517,10 @@ impl FreshClaudeState { start_pid_slot: &start_pid_slot, inherit: None, genesis: boot_genesis, + retry: resume_sid.clone().map(|durable| BootRetry { + durable, + genesis: recorded_genesis, + }), }, &mut lease_guard, &mut own_ticket, @@ -5832,6 +5838,11 @@ impl FreshClaudeState { // so it must not deny. 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)) + .map(|recorded| recorded.genesis); // 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 @@ -5910,6 +5921,10 @@ impl FreshClaudeState { start_pid_slot: &start_pid_slot, inherit: None, genesis: None, + retry: Some(BootRetry { + durable: durable.to_string(), + genesis: recorded_genesis, + }), }, lease_guard, own_ticket, @@ -8784,7 +8799,7 @@ mod fork; mod genesis; mod history; mod rollback; -use establish::{DurableClaim, EstablishLane, EstablishPlan, RegisterUnder}; +use establish::{BootRetry, DurableClaim, EstablishLane, EstablishPlan, RegisterUnder}; #[cfg(test)] mod teardown_tests; diff --git a/crates/freshell-freshagent/src/claude/establish.rs b/crates/freshell-freshagent/src/claude/establish.rs index 756a66391..e1936a248 100644 --- a/crates/freshell-freshagent/src/claude/establish.rs +++ b/crates/freshell-freshagent/src/claude/establish.rs @@ -25,7 +25,7 @@ use super::*; use crate::claude_handshake::{CreateFailure, SidecarCreated}; -use crate::claude_launch::ClaudeLaunchSpec; +use crate::claude_launch::{retry_refused_boot, ClaudeLaunchSpec}; /// The launch establishing a runtime: it names the failures and the log events. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -72,6 +72,14 @@ pub(super) struct InheritedRuntime { pub(super) last_synthesized_complete_at: Arc>>, } +/// What a launch that boots a durable session from its recorded state needs +/// for its one typed retry (design §4.2, [`retry_refused_boot`]): the session, +/// and the genesis its binding row records. +pub(super) struct BootRetry { + pub(super) durable: String, + pub(super) genesis: Option, +} + /// The durable session a launch claims, with the identity's dead state as it /// stood when the claim started (before the spawn), so a close that lands while /// the sidecar boots refuses the commit instead of being undone by it. @@ -115,6 +123,11 @@ pub(super) struct EstablishPlan<'a> { /// snapshot shows the conversation before the transcript exists, also on a /// lane with no ledger. `None` for every other launch. pub(super) genesis: Option, + /// A launch that boots a durable session from its recorded state (a + /// resume, a boot from its genesis) retries a typed refusal once (design + /// §4.2). `None` for a launch under an id it minted (an undo, a fork): + /// its refusals are its own typed answers. + pub(super) retry: Option, } /// A runtime that is registered and committed. @@ -333,6 +346,41 @@ impl FreshClaudeState { } } + /// The launch to retry after the typed boot refusal `failure` of `spec` + /// ([`retry_refused_boot`]), logged; `None` when the refusal stands. + fn retry_spec( + &self, + lane: EstablishLane, + spec: &ClaudeLaunchSpec, + failure: &CreateFailure, + retry: &BootRetry, + ) -> Option { + let planned = retry_refused_boot( + failure.code(), + &spec.boot, + spec.session_id.as_deref(), + &retry.durable, + retry.genesis.as_ref(), + |id| self.locate_transcript(id), + )?; + tracing::info!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.boot_retry", + lane = lane.name(), + request_id = %spec.request_id, + session_id = %retry.durable, + code = failure.code(), + refused = spec.boot.kind(), + retry = planned.boot.kind(), + "the CLI refused the boot for a reason the store explains; it is retried once" + ); + let mut next = spec.clone(); + next.boot = planned.boot; + next.cwd = planned.cwd.or_else(|| spec.cwd.clone()); + next.session_id = planned.session_id; + Some(next) + } + /// Boot a sidecar for `plan` and commit it as the session's live runtime /// (design §4.9). Every failure tears down what was spawned and releases the /// lease; `own_ticket` is committed on success and otherwise left for the @@ -382,7 +430,7 @@ impl FreshClaudeState { ) -> Result { let EstablishPlan { lane, - spec, + mut spec, register_under, claim, rekey_provisional_claim, @@ -395,16 +443,32 @@ impl FreshClaudeState { start_pid_slot, inherit, genesis, + retry, } = plan; let start = StartWatch { pid_slot: start_pid_slot, partial_runtime_key: partial_runtime_key.as_deref(), ticket: own_ticket, }; - let booted = match self + let mut booted = self .boot_sidecar(&spec, lease_guard.as_mut(), Some(start)) - .await - { + .await; + // Design §4.2: a typed refusal the store can explain is retried once, + // decided again from a fresh look at the store. + if let (Err(EstablishError::Boot(failure)), Some(retry)) = (&booted, retry.as_ref()) { + if let Some(retried) = self.retry_spec(lane, &spec, failure, retry) { + spec = retried; + let start = StartWatch { + pid_slot: start_pid_slot, + partial_runtime_key: partial_runtime_key.as_deref(), + ticket: own_ticket, + }; + booted = self + .boot_sidecar(&spec, lease_guard.as_mut(), Some(start)) + .await; + } + } + let booted = match booted { Ok(booted) => booted, Err(error) => { // The spawned tree is torn down: releasing the lease is safe. diff --git a/crates/freshell-freshagent/src/claude/fork.rs b/crates/freshell-freshagent/src/claude/fork.rs index 09bebe166..d99808c99 100644 --- a/crates/freshell-freshagent/src/claude/fork.rs +++ b/crates/freshell-freshagent/src/claude/fork.rs @@ -384,6 +384,7 @@ impl FreshClaudeState { start_pid_slot: &start_pid_slot, inherit: None, genesis: Some(genesis.clone()), + retry: None, }, &mut lease_guard, &mut own_ticket, diff --git a/crates/freshell-freshagent/src/claude/genesis.rs b/crates/freshell-freshagent/src/claude/genesis.rs index b65ae72f0..093469491 100644 --- a/crates/freshell-freshagent/src/claude/genesis.rs +++ b/crates/freshell-freshagent/src/claude/genesis.rs @@ -53,6 +53,9 @@ pub(super) struct GenesisAwareBoot { /// The genesis the boot follows; `None` when the transcript is on disk and /// the boot resumes it. pub(super) genesis: Option, + /// The genesis the session's binding row records (the boot's one typed + /// retry follows it, design §4.2). + pub(super) recorded: crate::claude_launch::ClaudeGenesis, } /// Clear `session_id`'s genesis once its transcript is on disk in `roots`: @@ -222,12 +225,13 @@ impl FreshClaudeState { self.decide_recorded_boot("create_resume", durable, sink.as_ref(), Some(&recorded))?; // A boot under the session's own id is a boot from its genesis; a // resume (the transcript is on disk) boots nothing from it. - let genesis = session_id.is_some().then_some(recorded.genesis); + let genesis = session_id.is_some().then(|| recorded.genesis.clone()); Ok(Some(GenesisAwareBoot { boot, cwd, session_id, genesis, + recorded: recorded.genesis, })) } diff --git a/crates/freshell-freshagent/src/claude/rollback.rs b/crates/freshell-freshagent/src/claude/rollback.rs index bbeaca749..105ef2771 100644 --- a/crates/freshell-freshagent/src/claude/rollback.rs +++ b/crates/freshell-freshagent/src/claude/rollback.rs @@ -1147,6 +1147,7 @@ impl FreshClaudeState { last_synthesized_complete_at: Arc::clone(&last_synthesized_complete_at), }), genesis: Some(genesis.clone()), + retry: None, }, &mut lease_guard, &mut own_ticket, diff --git a/crates/freshell-freshagent/src/claude_handshake.rs b/crates/freshell-freshagent/src/claude_handshake.rs index b2f4c30d4..b57e85861 100644 --- a/crates/freshell-freshagent/src/claude_handshake.rs +++ b/crates/freshell-freshagent/src/claude_handshake.rs @@ -28,6 +28,14 @@ pub(crate) const CREATE_FAILED_CODE: &str = "CLAUDE_CREATE_FAILED"; /// The code of a `created` that speaks another protocol. pub(crate) const PROTOCOL_MISMATCH_CODE: &str = "SIDECAR_PROTOCOL_MISMATCH"; +/// The `create.failed` code of a boot under an id whose transcript already +/// exists (`claude-sidecar/boot.mjs`, T4). +pub(crate) const SESSION_ID_IN_USE_CODE: &str = "SESSION_ID_IN_USE"; + +/// The `create.failed` code of a resume of a conversation the CLI does not +/// hold (`claude-sidecar/boot.mjs`, T5). +pub(crate) const CONVERSATION_NOT_FOUND_CODE: &str = "CONVERSATION_NOT_FOUND"; + /// The `create.failed` code of a fork whose point the parent conversation does /// not hold (`claude-sidecar/boot.mjs`, T5). pub(crate) const RESUME_POINT_NOT_FOUND_CODE: &str = "RESUME_POINT_NOT_FOUND"; diff --git a/crates/freshell-freshagent/src/claude_launch.rs b/crates/freshell-freshagent/src/claude_launch.rs index 067cc6e93..2d59850a2 100644 --- a/crates/freshell-freshagent/src/claude_launch.rs +++ b/crates/freshell-freshagent/src/claude_launch.rs @@ -315,6 +315,70 @@ pub(crate) fn decide_boot( } } +/// A boot to launch: what [`retry_refused_boot`] plans. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct PlannedBoot { + pub(crate) boot: ClaudeBoot, + /// The directory the conversation was recorded in, when it still exists. + pub(crate) cwd: Option, + /// The id a fresh or forked boot writes under. + pub(crate) session_id: Option, +} + +/// The one retry a typed boot refusal gets (design §4.2), decided again from a +/// fresh look at the store. `failed` is the refused boot and `booted_under` the +/// id it wrote under (its `sessionId`); `durable` is the session the launch +/// boots and `genesis` what its binding row records. +/// +/// | refusal | retry | +/// |---|---| +/// | `SESSION_ID_IN_USE` of a boot under an id | the transcript appeared between the check and the spawn (T4): resume it | +/// | `CONVERSATION_NOT_FOUND` of a resume, with a genesis | the transcript the check found is gone (T5): boot from the genesis | +/// | anything else | none: the caller's typed failure stands (a resume with no genesis is lost) | +pub(crate) fn retry_refused_boot( + code: &str, + failed: &ClaudeBoot, + booted_under: Option<&str>, + durable: &str, + genesis: Option<&ClaudeGenesis>, + locate: impl Fn(&str) -> TranscriptLocation, +) -> Option { + match code { + crate::claude_handshake::SESSION_ID_IN_USE_CODE => { + let id = booted_under?; + let (resume, cwd) = match locate(id) { + TranscriptLocation::Found(path) => resume_target(id, &path), + // The CLI saw the transcript even if this look does not. + TranscriptLocation::Absent | TranscriptLocation::Unreadable(_) => { + (id.to_string(), None) + } + }; + Some(PlannedBoot { + boot: ClaudeBoot::Resume { resume }, + cwd, + session_id: None, + }) + } + crate::claude_handshake::CONVERSATION_NOT_FOUND_CODE + if matches!(failed, ClaudeBoot::Resume { .. }) => + { + match decide_boot(durable, TranscriptLocation::Absent, genesis, locate) { + BootDecision::Boot { + boot, + cwd, + session_id, + } => Some(PlannedBoot { + boot, + cwd, + session_id, + }), + BootDecision::Lost | BootDecision::Transient(_) => None, + } + } + _ => None, + } +} + /// How to name the conversation `session_id`, whose transcript is at `path`, to /// a resume (or a fork of it), and the directory to run that CLI in: the id and /// the recorded directory while that directory exists. Once it is gone, the @@ -787,6 +851,177 @@ mod tests { ); } + // ── §4.2 typed retries ────────────────────────────────────────────────── + + const IN_USE: &str = crate::claude_handshake::SESSION_ID_IN_USE_CODE; + const NOT_FOUND: &str = crate::claude_handshake::CONVERSATION_NOT_FOUND_CODE; + + /// §4.2 SESSION_ID_IN_USE: a boot under the session's own id (fresh, or a + /// fork) found its transcript written between the check and the spawn + /// (T4). It is retried once as a resume of that conversation, run where it + /// was recorded. + #[test] + fn a_boot_refused_in_use_is_retried_as_a_resume_of_that_conversation() { + let store = tempfile::tempdir().unwrap(); + let recorded = tempfile::tempdir().unwrap(); + let cwd = recorded.path().to_str().unwrap().to_string(); + let path = transcript( + store.path(), + "S", + &format!("{{\"type\":\"user\",\"cwd\":{}}}\n", json!(cwd)), + ); + let fork = ClaudeBoot::Fork { + resume: "P".into(), + at: Some("a1".into()), + drops_turn: None, + }; + for failed in [ClaudeBoot::Fresh, fork] { + assert_eq!( + retry_refused_boot(IN_USE, &failed, Some("S"), "S", None, |id: &str| { + assert_eq!(id, "S"); + TranscriptLocation::Found(path.clone()) + }), + Some(PlannedBoot { + boot: ClaudeBoot::Resume { resume: "S".into() }, + cwd: Some(cwd.clone()), + session_id: None, + }), + "{failed:?}" + ); + } + // The CLI saw the transcript even if this look does not: still a resume. + assert_eq!( + retry_refused_boot( + IN_USE, + &ClaudeBoot::Fresh, + Some("S"), + "S", + Some(&ClaudeGenesis::Fresh), + |_: &str| TranscriptLocation::Absent + ), + Some(PlannedBoot { + boot: ClaudeBoot::Resume { resume: "S".into() }, + cwd: None, + session_id: None, + }) + ); + // A boot that wrote under no id cannot collide: nothing to retry. + assert_eq!( + retry_refused_boot(IN_USE, &ClaudeBoot::Fresh, None, "S", None, no_parent), + None + ); + } + + /// §4.2 CONVERSATION_NOT_FOUND with a genesis: the resume's transcript + /// was found by the check but is gone at the spawn (T5). It is retried once + /// from the genesis, under the session's own id. + #[test] + fn a_resume_refused_not_found_is_retried_from_the_genesis() { + let resume = ClaudeBoot::Resume { resume: "S".into() }; + assert_eq!( + retry_refused_boot( + NOT_FOUND, + &resume, + None, + "S", + Some(&ClaudeGenesis::Fresh), + no_parent + ), + Some(PlannedBoot { + boot: ClaudeBoot::Fresh, + cwd: None, + session_id: Some("S".into()), + }) + ); + let store = tempfile::tempdir().unwrap(); + let parent = transcript(store.path(), "P", ""); + assert_eq!( + retry_refused_boot( + NOT_FOUND, + &resume, + None, + "S", + Some(&fork_genesis("P")), + |id: &str| { + assert_eq!(id, "P", "only the parent is looked up"); + TranscriptLocation::Found(parent.clone()) + } + ), + Some(PlannedBoot { + boot: ClaudeBoot::Fork { + resume: "P".into(), + at: Some("a1".into()), + drops_turn: Some("u2".into()), + }, + cwd: None, + session_id: Some("S".into()), + }) + ); + } + + /// No genesis, nothing to boot from, or a refusal no retry can change: + /// the typed failure stands (a resume with no genesis is lost; a fork's + /// own refusals are the caller's typed answer). + #[test] + fn other_refusals_are_never_retried() { + let resume = ClaudeBoot::Resume { resume: "S".into() }; + let fork = ClaudeBoot::Fork { + resume: "P".into(), + at: Some("a1".into()), + drops_turn: Some("u2".into()), + }; + assert_eq!( + retry_refused_boot(NOT_FOUND, &resume, None, "S", None, no_parent), + None, + "a resume with no genesis is lost" + ); + assert_eq!( + retry_refused_boot( + NOT_FOUND, + &resume, + None, + "S", + Some(&fork_genesis("P")), + |_: &str| TranscriptLocation::Absent + ), + None, + "a fork genesis whose parent is gone" + ); + assert_eq!( + retry_refused_boot( + NOT_FOUND, + &fork, + Some("S"), + "S", + Some(&fork_genesis("P")), + no_parent + ), + None, + "a fork whose parent the CLI does not hold" + ); + for code in [ + crate::claude_handshake::FORK_GUARD_REFUSED_CODE, + crate::claude_handshake::RESUME_POINT_NOT_FOUND_CODE, + crate::claude_handshake::CREATE_FAILED_CODE, + crate::claude_handshake::PROTOCOL_MISMATCH_CODE, + ] { + for failed in [&resume, &fork, &ClaudeBoot::Fresh] { + assert_eq!( + retry_refused_boot( + code, + failed, + Some("S"), + "S", + Some(&ClaudeGenesis::Fresh), + no_parent + ), + None, + "{code} of {failed:?}" + ); + } + } + } + #[test] fn a_resume_id_makes_a_resume_boot_and_none_a_fresh_one() { assert_eq!(ClaudeBoot::resume_or_fresh(None), ClaudeBoot::Fresh); diff --git a/crates/freshell-ws/tests/freshagent_claude_genesis.rs b/crates/freshell-ws/tests/freshagent_claude_genesis.rs index e81b0a141..2f4c16f0b 100644 --- a/crates/freshell-ws/tests/freshagent_claude_genesis.rs +++ b/crates/freshell-ws/tests/freshagent_claude_genesis.rs @@ -222,3 +222,166 @@ async fn a_create_resuming_a_pending_fork_of_a_gone_parent_is_refused_without_a_ rig.ledger_rows("query") ); } + +// ── §4.2 typed boot retries ────────────────────────────────────────────────── + +/// A finished one-turn conversation on its own pane, its transcript on disk with +/// the answer: (its durable id, its transcript path, its text). +async fn donor_conversation( + rig: &ClaudeRig, + conn: &mut RigConnection, +) -> (String, std::path::PathBuf, String) { + let pane = conn.create("create-donor").await; + converse(conn, &pane, &["the donor's prompt"]).await; + let donor = conn.init_session_id(&pane); + let path = rig.wait_for_transcript(&donor).await; + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + let text = std::fs::read_to_string(&path).expect("read the donor"); + if text.contains("\"assistant\"") { + return (donor, path, text); + } + assert!( + tokio::time::Instant::now() < deadline, + "the donor's answer never reached disk" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +/// The user prompts in the transcript of `session_id`, in order. +fn prompts_in(rig: &ClaudeRig, session_id: &str) -> Vec { + let name = format!("{session_id}.jsonl"); + let path = rig + .transcripts() + .into_iter() + .find(|path| path.file_name().and_then(|n| n.to_str()) == Some(name.as_str())) + .expect("the transcript"); + std::fs::read_to_string(path) + .expect("read the transcript") + .lines() + .filter_map(|line| serde_json::from_str::(line).ok()) + .filter(|line| line["type"] == "user" && line["uuid"].is_string()) + .filter_map(|line| { + line["message"]["content"][0]["text"] + .as_str() + .map(str::to_string) + }) + .collect() +} + +/// §4.2 SESSION_ID_IN_USE: a zero-turn session's fresh boot under its own id +/// finds its transcript written between the server's check and the CLI's boot +/// (T4: the CLI refuses the id as already in use). The boot is retried once as +/// a resume of that conversation, so the attach succeeds and the next send +/// continues it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_genesis_boot_refused_in_use_is_retried_once_as_a_resume() { + let (rig, _) = ledger_rig().await; + let mut conn = rig.connect().await; + let (donor, donor_path, donor_text) = donor_conversation(&rig, &mut conn).await; + let id = uuid::Uuid::new_v4().to_string(); + rig.seed_pending_session(&id, &json!({ "kind": "fresh" })); + + conn.send(json!({ + "type": "freshAgent.attach", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": id, + "sessionRef": { "provider": "claude", "sessionId": id }, + })) + .await; + // The fresh boot's query() is recorded before the simulated CLI boots; the + // transcript lands before the CLI looks for the id. + wait_for_query(&rig, "the fresh boot", |o| { + o["sessionId"] == id.as_str() && o.get("resume").is_none() + }) + .await; + std::fs::write( + donor_path.with_file_name(format!("{id}.jsonl")), + donor_text.replace(&donor, &id), + ) + .expect("write the transcript under the session's id"); + let answer = conn + .wait_for("the attach's answer", |v| { + v["type"] == "error" + || (v["type"] == "freshAgent.event" + && matches!( + v["event"]["type"].as_str(), + Some("freshAgent.session.snapshot" | "freshAgent.error") + ) + && v["event"]["sessionId"] == id.as_str()) + }) + .await; + assert_eq!( + answer["event"]["type"], "freshAgent.session.snapshot", + "{answer}" + ); + wait_for_query(&rig, "the retried resume", |o| o["resume"] == id.as_str()).await; + + converse(&mut conn, &id, &["after the retry"]).await; + assert_eq!( + conn.init_session_id(&id), + id, + "the CLI ran under the session's id" + ); + assert_eq!( + prompts_in(&rig, &id), + ["the donor's prompt", "after the retry"], + "the retry resumed the conversation it collided with" + ); +} + +/// §4.2 CONVERSATION_NOT_FOUND with a genesis: a pending fork whose transcript +/// the server found is gone when the CLI boots its resume (T5). The boot is +/// retried once from the genesis, so the pane's Resume (a create resuming the +/// session) still opens the same fork under its id. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_resume_refused_not_found_is_retried_once_from_the_genesis() { + let (rig, _) = ledger_rig().await; + let mut conn = rig.connect().await; + let (donor, donor_path, donor_text) = donor_conversation(&rig, &mut conn).await; + let id = uuid::Uuid::new_v4().to_string(); + rig.seed_pending_session(&id, &json!({ "kind": "fork", "parent": donor })); + let stale = donor_path.with_file_name(format!("{id}.jsonl")); + std::fs::write(&stale, donor_text.replace(&donor, &id)).expect("a transcript to find"); + + conn.send(json!({ + "type": "freshAgent.create", + "requestId": "resume-1", + "provider": "claude", + "sessionType": "freshclaude", + "cwd": rig.workspace.to_str().expect("utf-8"), + "sessionRef": { "provider": "claude", "sessionId": id }, + })) + .await; + wait_for_query(&rig, "the resume", |o| o["resume"] == id.as_str()).await; + std::fs::remove_file(&stale).expect("the transcript goes away before the CLI boots"); + let created = conn + .wait_for("the create's answer", |v| { + v["requestId"] == "resume-1" + && matches!( + v["type"].as_str(), + Some("freshAgent.created" | "freshAgent.create.failed") + ) + }) + .await; + assert_eq!(created["type"], "freshAgent.created", "{created}"); + let fork = wait_for_query(&rig, "the retried fork", |o| { + o["forkSession"] == true && o["sessionId"] == id.as_str() + }) + .await; + assert_eq!(fork["resume"], json!(donor), "{fork}"); + + let pane = created["sessionId"].as_str().expect("the pane").to_string(); + converse(&mut conn, &pane, &["after the retry"]).await; + assert_eq!( + conn.init_session_id(&pane), + id, + "the fork ran under the session's id" + ); + assert_eq!( + prompts_in(&rig, &id), + ["the donor's prompt", "after the retry"] + ); +} From be4eaecba34b3c805c7e6ee6dae24f4742fd6f4a Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:39:42 -0700 Subject: [PATCH 19/24] test(freshclaude): one ledger-backed identity sink for the ws tests (F6) C11 added the shared real-ledger sink (`tests/common/ledger_sink.rs`) but left two copies, in cross_kind_liveness.rs and freshagent_claude_kill_after_restart.rs, and C12 had to add the genesis and plugins plumbing to each. Both now use the shared sink; the copies are gone (about 750 lines). The cross-kind copy also ran the production bridge's authoritative-write ownership consult, which the shared sink omitted. The consult moves into the shared sink (`authoritative_write_refusal`, run inside the write as in `freshell-server/src/identity_sink.rs`), with the coordinator given by `LedgerSink::with_ownership`; an authoritative write to a sink with none is refused, as in production. The claude rig now hands its sink the one coordinator it wires into the lane, so its handoff lanes run the same consult production does. Behavior the two suites now get from the shared sink that their copies stubbed: the tri-state provenance policy, provenance and create-request read-backs, the supersession chain and the observed pair on binding writes (all as production has them). Results: cross_kind_liveness 51/51 (run in the sandbox, per its header), freshagent_claude_kill_after_restart 2/2, the claude rig suites (launch_parity, rollback, fork, genesis, lifecycle) all green. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-ws/tests/common/claude_rig.rs | 7 +- .../freshell-ws/tests/common/ledger_sink.rs | 78 ++- .../freshell-ws/tests/cross_kind_liveness.rs | 506 +----------------- .../freshagent_claude_kill_after_restart.rs | 336 +----------- 4 files changed, 101 insertions(+), 826 deletions(-) diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index adc40ee91..65b3d0497 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -816,9 +816,10 @@ async fn spawn_rig_server( .with_hosted_mcp(options.hosted_claude); fresh_claude.set_ownership(Arc::clone(&ownership)); if let Some(ledger) = pane_ledger.as_ref() { - fresh_claude.set_identity_sink(Arc::new(super::ledger_sink::LedgerSink::new(Arc::clone( - ledger, - )))); + fresh_claude.set_identity_sink(Arc::new(super::ledger_sink::LedgerSink::with_ownership( + Arc::clone(ledger), + Some(Arc::clone(&ownership)), + ))); } let lane = fresh_claude.clone(); let fresh_agent_state = freshell_freshagent::FreshAgentState::new( diff --git a/crates/freshell-ws/tests/common/ledger_sink.rs b/crates/freshell-ws/tests/common/ledger_sink.rs index f07d1ace0..9c411434f 100644 --- a/crates/freshell-ws/tests/common/ledger_sink.rs +++ b/crates/freshell-ws/tests/common/ledger_sink.rs @@ -5,10 +5,10 @@ //! (the production bridge; the ws-test crate cannot depend on freshell-server, the //! dependency edge runs the other way): awaited `spawn_blocking` writes, inline //! memory reads, the same settings gates, the tri-state provenance policy and the -//! claude rollback-row re-key that rides the binding batch. The one omission is the -//! authoritative-write ownership consult (only the handoff runner's own target -//! bindings set `authoritative`, and the production consult's refusal is pinned in -//! freshell-server's own tests). +//! claude rollback-row re-key that rides the binding batch, and the authoritative +//! write's ownership consult (the handoff runner's target bindings set +//! `authoritative`; the consult reads the coordinator the sink was given, and an +//! authoritative write to a sink with none is refused, as in production). use std::sync::Arc; @@ -25,14 +25,78 @@ use freshell_ws::pane_ledger::{ pub struct LedgerSink { pub ledger: Arc, + /// The ownership coordinator an authoritative write is re-validated + /// against (the one the lanes writing through this sink are wired to). + ownership: Option>, } impl LedgerSink { + /// A sink over `ledger` with no ownership coordinator. pub fn new(ledger: Arc) -> Self { - Self { ledger } + Self { + ledger, + ownership: None, + } + } + + /// A sink over `ledger` whose authoritative writes are re-validated against + /// `ownership`. + pub fn with_ownership( + ledger: Arc, + ownership: Option>, + ) -> Self { + Self { ledger, ownership } } } +/// The production bridge's authoritative-write consult +/// (`freshell-server/src/identity_sink.rs`), run at execution time inside the +/// write: an authoritative write must carry its observed pair, and the handoff +/// that minted it must still own the key's transition at that pair. +fn authoritative_write_refusal( + ownership: Option<&freshell_ownership::RuntimeOwnershipRegistry>, + upsert: &FreshAgentBindingUpsert, +) -> Option { + if !upsert.authoritative { + return None; + } + let (Some(epoch), Some(generation)) = (upsert.observed_epoch, upsert.observed_generation) + else { + return Some(std::io::Error::other( + "STALE_TRANSITION_BINDING: an authoritative write must carry its observed \ + (epoch, generation) pair", + )); + }; + let Some(registry) = ownership else { + return Some(std::io::Error::other( + "STALE_TRANSITION_BINDING: an authoritative write arrived at a bridge with no \ + ownership coordinator wired", + )); + }; + let snap = registry.observe(&upsert.provider, &upsert.session_id); + let transition_owned = snap.epoch == epoch + && match &snap.state { + freshell_ownership::OwnershipState::Handoff { + generation: owned, .. + } => *owned == generation, + freshell_ownership::OwnershipState::Live { + owner, + generation: owned, + .. + } => { + *owned == generation + && owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent + } + _ => false, + }; + (!transition_owned).then(|| { + std::io::Error::other( + "STALE_TRANSITION_BINDING: the authoritative write's handoff operation no \ + longer owns the key's transition at its generation", + ) + }) +} + fn now_ms() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -75,8 +139,12 @@ impl PaneIdentitySink for LedgerSink { fn record_binding(&self, upsert: FreshAgentBindingUpsert) -> SinkWrite { let ledger = self.ledger.clone(); + let ownership = self.ownership.clone(); let now = now_ms(); blocking(move || { + if let Some(refusal) = authoritative_write_refusal(ownership.as_deref(), &upsert) { + return Err(refusal); + } let provenance = match &upsert.provenance { ProvenanceUpdate::Replace(stamps) => ProvenancePolicy::Replace(ProvenanceStamps { client_instance_id: stamps.client_instance_id.as_deref(), diff --git a/crates/freshell-ws/tests/cross_kind_liveness.rs b/crates/freshell-ws/tests/cross_kind_liveness.rs index 7eaac66ed..e64cb699d 100644 --- a/crates/freshell-ws/tests/cross_kind_liveness.rs +++ b/crates/freshell-ws/tests/cross_kind_liveness.rs @@ -25,6 +25,8 @@ //! unconfirmable-prior arm SIGKILLs its own fake sidecar, the lease-suite //! class). +mod common; + use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; @@ -34,13 +36,12 @@ use serde_json::{json, Value}; use tokio::net::TcpListener; use tokio_tungstenite::tungstenite::Message as WsMessage; -use freshell_freshagent::{ - ClaimCommit, FreshAgentBindingUpsert, FreshAgentSettings, PaneIdentitySink, RollbackRecord, - SinkAliasClearWrite, SinkCloseWrite, SinkCommitWrite, SinkWrite, -}; -use freshell_ws::pane_ledger::{FreshAgentBindingWrite, PaneLedger}; +use freshell_freshagent::{FreshAgentBindingUpsert, PaneIdentitySink}; +use freshell_ws::pane_ledger::PaneLedger; use freshell_ws::WsState; +use common::ledger_sink::LedgerSink; + const AUTH_TOKEN: &str = "s3cr3t-token-abcdef"; /// Serializes the tests in this file: they mutate process-global env vars @@ -707,473 +708,6 @@ async fn spawn_merged_server_with_hooks( } } -// ── b8ke focused ep5 r2 F1: the ledger-backed identity sink for the real-ledger -// merged harness (copied from freshagent_claude_rollback.rs — the ws-test -// crate cannot depend on freshell-server: the dep edge runs the other way) ── - -/// The rollback-record-provisioning identity sink, backed by the REAL pane -/// ledger — mirrors `freshell-server/src/identity_sink.rs`'s LedgerIdentitySink -/// (the ws-test crate cannot depend on freshell-server: the dep edge runs the -/// other way), INCLUDING the claude-adoption rollback-row re-key that rides the -/// awaited binding batch. -struct TestLedgerSink { - ledger: Arc, - /// b8ke focused ep5 r3 F1: the merged harness's ONE ownership - /// coordinator — the authoritative write's ownership re-validation - /// consults it (mirrors the production bridge's consult verbatim). - ownership: Option>, -} -impl TestLedgerSink { - fn now_ms() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) - } -} -impl PaneIdentitySink for TestLedgerSink { - fn record_pending(&self, placeholder_id: &str, mode: &str, cwd: Option<&str>) -> SinkWrite { - let ledger = self.ledger.clone(); - let (p, m, c) = ( - placeholder_id.to_string(), - mode.to_string(), - cwd.map(str::to_string), - ); - let now = Self::now_ms(); - Box::pin(async move { - tokio::task::spawn_blocking(move || { - ledger.record_pending( - &p, - &m, - c.as_deref(), - None, - freshell_ws::pane_ledger::ProvenanceStamps::default(), - now, - ) - }) - .await - .map_err(std::io::Error::other)? - }) - } - fn record_binding(&self, upsert: FreshAgentBindingUpsert) -> SinkWrite { - let ledger = self.ledger.clone(); - let ownership = self.ownership.clone(); - let now = Self::now_ms(); - Box::pin(async move { - tokio::task::spawn_blocking(move || { - // b8ke focused ep5 r3 F1: the authoritative write's - // ownership re-validation — mirrors the production bridge - // (crates/freshell-server/src/identity_sink.rs) verbatim: - // the consult runs INSIDE the spawn_blocking closure, at - // execution time, and refuses typed when the write's - // handoff no longer owns the key's transition at its - // (epoch, generation) pair. - if upsert.authoritative { - let (Some(w_epoch), Some(w_generation)) = - (upsert.observed_epoch, upsert.observed_generation) - else { - return Err(std::io::Error::other( - "STALE_TRANSITION_BINDING: an authoritative write must \ - carry its observed (epoch, generation) pair — the \ - ownership re-validation cannot run (kata b8ke focused \ - ep5 r3 F1)", - )); - }; - let Some(registry) = ownership.as_ref() else { - return Err(std::io::Error::other( - "STALE_TRANSITION_BINDING: an authoritative write \ - arrived at a bridge with no ownership coordinator \ - wired — authoritative markers are minted only by \ - coordinator-driven handoff lanes (kata b8ke focused \ - ep5 r3 F1)", - )); - }; - let snap = registry.observe(&upsert.provider, &upsert.session_id); - let transition_owned = snap.epoch == w_epoch - && match &snap.state { - freshell_ownership::OwnershipState::Handoff { - generation, - .. - } => *generation == w_generation, - freshell_ownership::OwnershipState::Live { - owner, - generation, - .. - } => { - *generation == w_generation - && owner.kind - == freshell_ownership::RuntimeOwnerKind::FreshAgent - } - _ => false, - }; - if !transition_owned { - return Err(std::io::Error::other( - "STALE_TRANSITION_BINDING: the authoritative write's \ - handoff operation no longer owns the key's transition \ - at its generation (canceled, superseded, restored, \ - vacated, or a new boot epoch) — the write is refused \ - (kata b8ke focused ep5 r3 F1)", - )); - } - } - let stored_genesis = upsert.genesis.stored_value(); - let w = FreshAgentBindingWrite { - genesis: freshell_ws::pane_ledger::GenesisWrite::from_update( - stored_genesis.as_ref(), - upsert.genesis.is_clear(), - ), - provider: &upsert.provider, - session_id: &upsert.session_id, - mode: &upsert.mode, - cwd: upsert.settings.cwd.as_deref(), - create_request_id: upsert.create_request_id.as_deref(), - model: upsert.settings.model.as_deref(), - sandbox: upsert.settings.sandbox.as_deref(), - permission_mode: upsert.settings.permission_mode.as_deref(), - effort: upsert.settings.effort.as_deref(), - supersedes: upsert.supersedes.as_deref(), - provenance: freshell_ws::pane_ledger::ProvenancePolicy::Inherit, - observed_epoch: upsert.observed_epoch, - observed_generation: upsert.observed_generation, - authoritative: upsert.authoritative, - now_ms: now, - plugins: upsert.settings.plugins.as_deref(), - }; - ledger.record_fresh_agent_binding(&w)?; - // kata 1wxv Task 4 (claude adoption): the rollback row re-keys - // old→new in the SAME awaited batch as the binding write. - if upsert.provider == "claude" { - if let Some(old_id) = upsert.supersedes.as_deref() { - if old_id != upsert.session_id { - if let Some(payload) = - ledger.load_rollback_row(&upsert.provider, old_id) - { - ledger.record_rollback_row( - &upsert.provider, - &upsert.session_id, - &payload, - now, - )?; - if let Err(e) = ledger.delete_rollback_row(&upsert.provider, old_id) { - tracing::warn!(error = %e, session = %old_id, "rollback row re-key: old row delete failed (cosmetic)"); - } - } - } - } - } - if let Some(p) = upsert.resolves_pending.as_deref() { - let _ = ledger.delete_pending(p); - } - Ok(()) - }) - .await - .map_err(std::io::Error::other)? - }) - } - fn load_settings(&self, provider: &str, session_id: &str) -> Option { - // b8ke focused ep5 r5 F1: mirror the PRODUCTION bridge's read - // verbatim (the rollback-focused `None` stub this copy inherited - // silently disabled the ordinary cold resume's RECOVERED path — - // `recovered` never answered Some, so the recovered-session - // binding write never ran in this harness). Memory-only against - // the real ledger, the same fresh-agent gate + settings-bearing - // gate as `LedgerIdentitySink::load_settings`. - let row = self.ledger.load_binding(provider, session_id)?; - if row.pane_kind.as_deref() != Some("fresh-agent") { - return None; - } - let s = FreshAgentSettings { - model: row.model, - sandbox: row.sandbox, - permission_mode: row.permission_mode, - effort: row.effort, - cwd: row.cwd, - plugins: row.plugins, - }; - if s == FreshAgentSettings::default() { - return None; - } - Some(s) - } - fn load_provenance( - &self, - _provider: &str, - _session_id: &str, - ) -> Option { - // Rollback-focused double (like `load_settings` above): these - // rollback tests never cold-attach a codex/opencode session, so the - // provenance read-back is out of their contract. - None - } - fn load_genesis( - &self, - provider: &str, - session_id: &str, - ) -> Option { - let row = self.ledger.load_binding(provider, session_id)?; - if row.pane_kind.as_deref() != Some("fresh-agent") { - return None; - } - freshell_freshagent::RecordedGenesis::from_row( - provider, - session_id, - row.genesis.as_ref(), - row.updated_at, - ) - } - - fn clear_genesis(&self, provider: &str, session_id: &str) -> SinkWrite { - let ledger = self.ledger.clone(); - let (p, s) = (provider.to_string(), session_id.to_string()); - let now = Self::now_ms(); - Box::pin(async move { - tokio::task::spawn_blocking(move || { - ledger.clear_binding_genesis(&p, &s, now).map(|_| ()) - }) - .await - .map_err(std::io::Error::other)? - }) - } - - fn was_recorded(&self, provider: &str, session_id: &str) -> bool { - self.ledger.load_binding(provider, session_id).is_some() - } - fn record_rollback( - &self, - provider: &str, - session_id: &str, - record: RollbackRecord, - ) -> SinkWrite { - let ledger = self.ledger.clone(); - let (p, s) = (provider.to_string(), session_id.to_string()); - Box::pin(async move { - tokio::task::spawn_blocking(move || { - let payload = serde_json::to_value(&record).map_err(std::io::Error::other)?; - ledger.record_rollback_row(&p, &s, &payload, TestLedgerSink::now_ms()) - }) - .await - .map_err(std::io::Error::other)? - }) - } - fn repair_failed_transition( - &self, - provider: &str, - session_id: &str, - epoch: u64, - generation: u64, - ) -> SinkWrite { - let ledger = self.ledger.clone(); - let (p, s) = (provider.to_string(), session_id.to_string()); - let now = Self::now_ms(); - Box::pin(async move { - tokio::task::spawn_blocking(move || { - ledger.repair_failed_transition_binding(&p, &s, epoch, generation, now) - }) - .await - .map_err(std::io::Error::other)? - }) - } - fn load_rollback(&self, provider: &str, session_id: &str) -> Option { - // Mirror of freshell-server's LedgerIdentitySink: the shared migrating - // reader owns the version gate + the legacy epochless-union migration - // (focused ep1-r1 F3, absence-keyed per ep1-r2 F1). - let payload = self.ledger.load_rollback_row(provider, session_id)?; - RollbackRecord::from_stored_payload(payload) - } - fn delete_rollback(&self, provider: &str, session_id: &str) -> SinkWrite { - let ledger = self.ledger.clone(); - let (p, s) = (provider.to_string(), session_id.to_string()); - Box::pin(async move { - tokio::task::spawn_blocking(move || ledger.delete_rollback_row(&p, &s)) - .await - .map_err(std::io::Error::other)? - }) - } - - fn lookup_by_create_request_id( - &self, - provider: &str, - create_request_id: &str, - ) -> Option { - self.ledger - .lookup_by_create_request_id(provider, create_request_id) - .map(|row| row.session_id) - } - - // Retire-on-kill (delta-review round 5): the same real-ledger pass-through - // the LedgerIdentitySink this double mirrors now implements. - fn retire_closed(&self, provider: &str, session_id: &str) -> SinkCloseWrite { - let ledger = self.ledger.clone(); - let (p, s) = (provider.to_string(), session_id.to_string()); - Box::pin(async move { - let result = tokio::task::spawn_blocking(move || { - ledger.retire_closed(&p, &s, TestLedgerSink::now_ms()) - }) - .await - .map_err(|join| { - freshell_freshagent::identity_sink::SinkCloseError::Clean(std::io::Error::other( - join, - )) - })?; - result.map_err(|err| match err { - freshell_ws::pane_ledger::CloseEnvelopeError::Clean(e) => { - freshell_freshagent::identity_sink::SinkCloseError::Clean(e) - } - freshell_ws::pane_ledger::CloseEnvelopeError::Persisted(e) => { - freshell_freshagent::identity_sink::SinkCloseError::Persisted(e) - } - }) - }) - } - - /// Delta-r6-r3: the real-ledger delegation mirrors - /// `freshell-server/src/identity_sink.rs`'s `retire_closed_batch` - /// (the ledger envelope is the atomicity point; one spawn_blocking hop). - fn retire_closed_batch( - &self, - provider: &str, - session_ids: &[String], - pending_ids: &[String], - ) -> SinkCloseWrite { - let ledger = self.ledger.clone(); - let p = provider.to_string(); - let ids = session_ids.to_vec(); - let pendings = pending_ids.to_vec(); - Box::pin(async move { - let result = tokio::task::spawn_blocking(move || { - ledger.close_identities(&p, &ids, &pendings, TestLedgerSink::now_ms()) - }) - .await - .map_err(|join| { - freshell_freshagent::identity_sink::SinkCloseError::Clean(std::io::Error::other( - join, - )) - })?; - result.map_err(|err| match err { - freshell_ws::pane_ledger::CloseEnvelopeError::Clean(e) => { - freshell_freshagent::identity_sink::SinkCloseError::Clean(e) - } - freshell_ws::pane_ledger::CloseEnvelopeError::Persisted(e) => { - freshell_freshagent::identity_sink::SinkCloseError::Persisted(e) - } - }) - }) - } - fn delete_pending(&self, placeholder_id: &str) -> SinkWrite { - let ledger = self.ledger.clone(); - let p = placeholder_id.to_string(); - Box::pin(async move { - tokio::task::spawn_blocking(move || ledger.delete_pending(&p)) - .await - .map_err(std::io::Error::other)? - }) - } - - // Focused-ep5-r1 Finding 2: same real-ledger pass-through as `retire_closed`. - fn clear_kill_tombstone(&self, provider: &str, session_id: &str) -> SinkWrite { - let ledger = self.ledger.clone(); - let (p, s) = (provider.to_string(), session_id.to_string()); - Box::pin(async move { - tokio::task::spawn_blocking(move || ledger.clear_kill_tombstone(&p, &s)) - .await - .map_err(std::io::Error::other)? - }) - } - - // Focused-ep5-r3 Finding 1 (retire-on-kill round 4): the snapshot read is - // a memory-only inline pass-through (the LedgerIdentitySink discipline). - fn kill_tombstone_at_ms(&self, provider: &str, session_id: &str) -> Option { - self.ledger.kill_tombstone_at(provider, session_id) - } - - // Retire-on-kill round 5 (focused-ep5-r4 Finding 2): the alias-tombstone - // retention probe — memory-only inline pass-through like the snapshot read. - fn row_is_bound(&self, provider: &str, session_id: &str) -> bool { - self.ledger.row_is_bound(provider, session_id) - } - - // Focused-ep5-r3 Findings 1+3: the conditional single-transition claim - // commit — a faithful pass-through to the real ledger op. - fn commit_claim( - &self, - provider: &str, - session_id: &str, - expect_killed_at_ms: Option, - ) -> SinkCommitWrite { - self.commit_claim_aliased(provider, session_id, expect_killed_at_ms, &[]) - } - // Focused-ep5-r5 Finding 2 (retire-on-kill round 6): the aliased commit's - // faithful pass-through (the alias gate lives inside the ledger's - // guarded transition). - fn commit_claim_aliased( - &self, - provider: &str, - session_id: &str, - expect_killed_at_ms: Option, - fence_checked_aliases: &[String], - ) -> SinkCommitWrite { - let ledger = self.ledger.clone(); - let (p, s) = (provider.to_string(), session_id.to_string()); - let aliases: Vec = fence_checked_aliases.to_vec(); - Box::pin(async move { - let outcome = tokio::task::spawn_blocking(move || { - ledger.commit_claim_aliased( - &p, - &s, - expect_killed_at_ms, - &aliases, - TestLedgerSink::now_ms(), - ) - }) - .await - .map_err(std::io::Error::other)??; - Ok(match outcome { - freshell_ws::pane_ledger::ClaimCommitOutcome::Committed => ClaimCommit::Committed, - freshell_ws::pane_ledger::ClaimCommitOutcome::RefusedStale => { - ClaimCommit::RefusedStale - } - }) - }) - } - // Finding 2's alias-record pass-throughs (same disciplines as the rest of - // the test double). - fn record_alias_tombstone( - &self, - provider: &str, - placeholder: &str, - durable: &str, - at_ms: i64, - ) -> SinkWrite { - let ledger = self.ledger.clone(); - let (p, ph, d) = ( - provider.to_string(), - placeholder.to_string(), - durable.to_string(), - ); - Box::pin(async move { - tokio::task::spawn_blocking(move || ledger.record_alias_tombstone(&p, &ph, &d, at_ms)) - .await - .map_err(std::io::Error::other)? - }) - } - fn alias_tombstone_records(&self, provider: &str, placeholder: &str) -> Vec<(String, i64)> { - self.ledger.alias_tombstone_records(provider, placeholder) - } - fn clear_alias_tombstones_for_durable( - &self, - provider: &str, - durable: &str, - ) -> SinkAliasClearWrite { - let ledger = self.ledger.clone(); - let (p, d) = (provider.to_string(), durable.to_string()); - Box::pin(async move { - tokio::task::spawn_blocking(move || ledger.clear_alias_tombstones_for_durable(&p, &d)) - .await - .map_err(std::io::Error::other)? - }) - } -} - /// b8ke focused ep5 r2 F1: [`spawn_merged_server`] over a REAL pane ledger /// (a tempdir root) with the ledger-backed identity sink wired into the /// fresh states — the fresh-agent lanes' binding rows actually LAND (the @@ -1188,20 +722,20 @@ async fn spawn_merged_server_with_ledger() -> (MergedHarness, Arc) { // The REAL ledger (tempdir-rooted) replaces the disabled one BEFORE any // write can flow; the fresh states write through the same ledger via the - // TestLedgerSink. + // shared `common::ledger_sink::LedgerSink`. let ledger_root = std::env::temp_dir().join(format!("freshell-cross-kind-ledger-{}", uuid_like_suffix())); std::fs::create_dir_all(&ledger_root).expect("create the ledger root"); let pane_ledger = Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( ledger_root.clone(), ))); - let sink: std::sync::Arc = Arc::new(TestLedgerSink { - ledger: Arc::clone(&pane_ledger), - // b8ke focused ep5 r3 F1: the same ONE coordinator the merged - // harness wired into the lanes — the sink's authoritative-write - // consult reads the same transitions the runner drives. - ownership: state.ownership.clone(), - }); + // b8ke focused ep5 r3 F1: the same ONE coordinator the merged harness + // wired into the lanes — the sink's authoritative-write consult reads the + // same transitions the runner drives. + let sink: std::sync::Arc = Arc::new(LedgerSink::with_ownership( + Arc::clone(&pane_ledger), + state.ownership.clone(), + )); state.fresh_claude.set_identity_sink(sink.clone()); state.fresh_codex.set_identity_sink(sink.clone()); state.fresh_opencode.set_identity_sink(sink.clone()); @@ -3751,10 +3285,7 @@ async fn a_superseded_handoffs_stale_authoritative_write_is_refused_end_to_end() let row_before = pane_ledger .load_binding("claude", &sid) .expect("the row exists before the stale write"); - let sink = TestLedgerSink { - ledger: Arc::clone(&pane_ledger), - ownership: h.ws_state.ownership.clone(), - }; + let sink = LedgerSink::with_ownership(Arc::clone(&pane_ledger), h.ws_state.ownership.clone()); let err = sink .record_binding(FreshAgentBindingUpsert { genesis: freshell_freshagent::GenesisUpdate::Keep, @@ -3908,10 +3439,7 @@ async fn the_failed_transition_repair_fences_and_reverts_end_to_end() { // (epoch, generation) fails and its cleanup fires the repair — // the row the target's own write produced retires typed // FailedTransition, and the durable fence is fed. - let sink = TestLedgerSink { - ledger: Arc::clone(&pane_ledger), - ownership: h.ws_state.ownership.clone(), - }; + let sink = LedgerSink::with_ownership(Arc::clone(&pane_ledger), h.ws_state.ownership.clone()); sink.repair_failed_transition("claude", &sid, epoch, generation) .await .expect("the cleanup's repair lands"); @@ -3987,7 +3515,7 @@ async fn the_failed_transition_repair_fences_and_reverts_end_to_end() { /// b8ke focused ep5 r5 F1 (e2e): an ORDINARY codex cold resume with /// RECOVERED settings succeeds end-to-end over the PRODUCTION-shaped -/// consulting sink (the merged harness's TestLedgerSink mirrors the +/// consulting sink (the merged harness's shared LedgerSink mirrors the /// production bridge's ownership consult verbatim, over the real /// ledger). The recovered-session binding write is a LANE write — never /// the authoritative marker without its pair — so the thread registers, 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 a5bbf9ccf..2863afb0a 100644 --- a/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs +++ b/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs @@ -28,18 +28,17 @@ //! `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. `TestLedgerSink` mirrors -//! `freshell-server/src/identity_sink.rs`'s adapter (the ws test crate cannot -//! depend on freshell-server). +//! 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). + +mod common; use std::sync::Arc; use serde_json::Value; -use freshell_freshagent::{ - ClaimCommit, FreshAgentBindingUpsert, FreshAgentSettings, PaneIdentitySink, RollbackRecord, - SinkAliasClearWrite, SinkCloseWrite, SinkCommitWrite, SinkWrite, -}; +use common::ledger_sink::LedgerSink; use freshell_protocol::{ AgentProvider, FreshAgentAttach, FreshAgentCreate, FreshAgentKill, SessionLocator, SessionType, }; @@ -122,325 +121,6 @@ impl Drop for Rig { } } -/// The real-ledger identity-sink test double — mirrors -/// `freshell-server/src/identity_sink.rs`'s `LedgerIdentitySink` -/// (spawn_blocking awaited writes, inline memory reads), including the round-6 -/// alias-tombstone lanes. -struct TestLedgerSink { - ledger: Arc, -} -impl TestLedgerSink { - fn now_ms() -> i64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as i64) - .unwrap_or(0) - } -} -impl PaneIdentitySink for TestLedgerSink { - fn record_pending(&self, placeholder_id: &str, mode: &str, cwd: Option<&str>) -> SinkWrite { - let ledger = self.ledger.clone(); - let (p, m, c) = ( - placeholder_id.to_string(), - mode.to_string(), - cwd.map(str::to_string), - ); - let now = Self::now_ms(); - Box::pin(async move { - tokio::task::spawn_blocking(move || { - ledger.record_pending(&p, &m, c.as_deref(), None, Default::default(), now) - }) - .await - .map_err(std::io::Error::other)? - }) - } - fn record_binding(&self, upsert: FreshAgentBindingUpsert) -> SinkWrite { - let ledger = self.ledger.clone(); - let now = Self::now_ms(); - Box::pin(async move { - tokio::task::spawn_blocking(move || { - let stored_genesis = upsert.genesis.stored_value(); - let w = freshell_ws::pane_ledger::FreshAgentBindingWrite { - genesis: freshell_ws::pane_ledger::GenesisWrite::from_update( - stored_genesis.as_ref(), - upsert.genesis.is_clear(), - ), - provider: &upsert.provider, - session_id: &upsert.session_id, - mode: &upsert.mode, - cwd: upsert.settings.cwd.as_deref(), - create_request_id: upsert.create_request_id.as_deref(), - model: upsert.settings.model.as_deref(), - sandbox: upsert.settings.sandbox.as_deref(), - permission_mode: upsert.settings.permission_mode.as_deref(), - effort: upsert.settings.effort.as_deref(), - supersedes: upsert.supersedes.as_deref(), - provenance: freshell_ws::pane_ledger::ProvenancePolicy::Inherit, - observed_epoch: None, - observed_generation: None, - authoritative: upsert.authoritative, - now_ms: now, - plugins: upsert.settings.plugins.as_deref(), - }; - ledger.record_fresh_agent_binding(&w)?; - if let Some(p) = upsert.resolves_pending.as_deref() { - let _ = ledger.delete_pending(p); - } - Ok(()) - }) - .await - .map_err(std::io::Error::other)? - }) - } - fn load_settings(&self, provider: &str, session_id: &str) -> Option { - let row = self.ledger.load_binding(provider, session_id)?; - (row.pane_kind.as_deref() == Some("fresh-agent")).then_some(FreshAgentSettings { - model: row.model, - sandbox: row.sandbox, - permission_mode: row.permission_mode, - effort: row.effort, - cwd: row.cwd, - plugins: row.plugins, - }) - } - fn load_genesis( - &self, - provider: &str, - session_id: &str, - ) -> Option { - let row = self.ledger.load_binding(provider, session_id)?; - if row.pane_kind.as_deref() != Some("fresh-agent") { - return None; - } - freshell_freshagent::RecordedGenesis::from_row( - provider, - session_id, - row.genesis.as_ref(), - row.updated_at, - ) - } - - fn clear_genesis(&self, provider: &str, session_id: &str) -> SinkWrite { - let ledger = self.ledger.clone(); - let (p, s) = (provider.to_string(), session_id.to_string()); - let now = Self::now_ms(); - Box::pin(async move { - tokio::task::spawn_blocking(move || { - ledger.clear_binding_genesis(&p, &s, now).map(|_| ()) - }) - .await - .map_err(std::io::Error::other)? - }) - } - - fn was_recorded(&self, provider: &str, session_id: &str) -> bool { - self.ledger.load_binding(provider, session_id).is_some() - } - fn load_provenance(&self, _: &str, _: &str) -> Option { - None - } - fn record_rollback(&self, p: &str, s: &str, record: RollbackRecord) -> SinkWrite { - let ledger = self.ledger.clone(); - let (p, s) = (p.to_string(), s.to_string()); - Box::pin(async move { - tokio::task::spawn_blocking(move || { - let payload = serde_json::to_value(&record).map_err(std::io::Error::other)?; - ledger.record_rollback_row(&p, &s, &payload, TestLedgerSink::now_ms()) - }) - .await - .map_err(std::io::Error::other)? - }) - } - fn repair_failed_transition( - &self, - provider: &str, - session_id: &str, - epoch: u64, - generation: u64, - ) -> SinkWrite { - let ledger = self.ledger.clone(); - let (p, s) = (provider.to_string(), session_id.to_string()); - let now = Self::now_ms(); - Box::pin(async move { - tokio::task::spawn_blocking(move || { - ledger.repair_failed_transition_binding(&p, &s, epoch, generation, now) - }) - .await - .map_err(std::io::Error::other)? - }) - } - fn load_rollback(&self, provider: &str, session_id: &str) -> Option { - let payload = self.ledger.load_rollback_row(provider, session_id)?; - RollbackRecord::from_stored_payload(payload) - } - fn delete_rollback(&self, p: &str, s: &str) -> SinkWrite { - let ledger = self.ledger.clone(); - let (p, s) = (p.to_string(), s.to_string()); - Box::pin(async move { - tokio::task::spawn_blocking(move || ledger.delete_rollback_row(&p, &s)) - .await - .map_err(std::io::Error::other)? - }) - } - fn lookup_by_create_request_id(&self, _: &str, _: &str) -> Option { - None - } - fn retire_closed(&self, provider: &str, session_id: &str) -> SinkCloseWrite { - let ledger = self.ledger.clone(); - let (p, s) = (provider.to_string(), session_id.to_string()); - Box::pin(async move { - let result = tokio::task::spawn_blocking(move || { - ledger.retire_closed(&p, &s, TestLedgerSink::now_ms()) - }) - .await - .map_err(|join| { - freshell_freshagent::identity_sink::SinkCloseError::Clean(std::io::Error::other( - join, - )) - })?; - result.map_err(|err| match err { - freshell_ws::pane_ledger::CloseEnvelopeError::Clean(e) => { - freshell_freshagent::identity_sink::SinkCloseError::Clean(e) - } - freshell_ws::pane_ledger::CloseEnvelopeError::Persisted(e) => { - freshell_freshagent::identity_sink::SinkCloseError::Persisted(e) - } - }) - }) - } - - /// Delta-r6-r3: the real-ledger delegation mirrors - /// `freshell-server/src/identity_sink.rs`'s `retire_closed_batch` - /// (the ledger envelope is the atomicity point; one spawn_blocking hop). - fn retire_closed_batch( - &self, - provider: &str, - session_ids: &[String], - pending_ids: &[String], - ) -> SinkCloseWrite { - let ledger = self.ledger.clone(); - let p = provider.to_string(); - let ids = session_ids.to_vec(); - let pendings = pending_ids.to_vec(); - Box::pin(async move { - let result = tokio::task::spawn_blocking(move || { - ledger.close_identities(&p, &ids, &pendings, TestLedgerSink::now_ms()) - }) - .await - .map_err(|join| { - freshell_freshagent::identity_sink::SinkCloseError::Clean(std::io::Error::other( - join, - )) - })?; - result.map_err(|err| match err { - freshell_ws::pane_ledger::CloseEnvelopeError::Clean(e) => { - freshell_freshagent::identity_sink::SinkCloseError::Clean(e) - } - freshell_ws::pane_ledger::CloseEnvelopeError::Persisted(e) => { - freshell_freshagent::identity_sink::SinkCloseError::Persisted(e) - } - }) - }) - } - fn delete_pending(&self, placeholder_id: &str) -> SinkWrite { - let ledger = self.ledger.clone(); - let p = placeholder_id.to_string(); - Box::pin(async move { - tokio::task::spawn_blocking(move || ledger.delete_pending(&p)) - .await - .map_err(std::io::Error::other)? - }) - } - fn clear_kill_tombstone(&self, provider: &str, session_id: &str) -> SinkWrite { - let ledger = self.ledger.clone(); - let (p, s) = (provider.to_string(), session_id.to_string()); - Box::pin(async move { - tokio::task::spawn_blocking(move || ledger.clear_kill_tombstone(&p, &s)) - .await - .map_err(std::io::Error::other)? - }) - } - fn kill_tombstone_at_ms(&self, provider: &str, session_id: &str) -> Option { - self.ledger.kill_tombstone_at(provider, session_id) - } - fn row_is_bound(&self, provider: &str, session_id: &str) -> bool { - self.ledger.row_is_bound(provider, session_id) - } - fn commit_claim( - &self, - provider: &str, - session_id: &str, - expect_killed_at_ms: Option, - ) -> SinkCommitWrite { - self.commit_claim_aliased(provider, session_id, expect_killed_at_ms, &[]) - } - fn commit_claim_aliased( - &self, - provider: &str, - session_id: &str, - expect_killed_at_ms: Option, - fence_checked_aliases: &[String], - ) -> SinkCommitWrite { - let ledger = self.ledger.clone(); - let (p, s) = (provider.to_string(), session_id.to_string()); - let aliases: Vec = fence_checked_aliases.to_vec(); - Box::pin(async move { - let outcome = tokio::task::spawn_blocking(move || { - ledger.commit_claim_aliased( - &p, - &s, - expect_killed_at_ms, - &aliases, - TestLedgerSink::now_ms(), - ) - }) - .await - .map_err(std::io::Error::other)??; - Ok(match outcome { - freshell_ws::pane_ledger::ClaimCommitOutcome::Committed => ClaimCommit::Committed, - freshell_ws::pane_ledger::ClaimCommitOutcome::RefusedStale => { - ClaimCommit::RefusedStale - } - }) - }) - } - fn record_alias_tombstone( - &self, - provider: &str, - placeholder: &str, - durable: &str, - at_ms: i64, - ) -> SinkWrite { - let ledger = self.ledger.clone(); - let (p, ph, d) = ( - provider.to_string(), - placeholder.to_string(), - durable.to_string(), - ); - Box::pin(async move { - tokio::task::spawn_blocking(move || ledger.record_alias_tombstone(&p, &ph, &d, at_ms)) - .await - .map_err(std::io::Error::other)? - }) - } - fn alias_tombstone_records(&self, provider: &str, placeholder: &str) -> Vec<(String, i64)> { - self.ledger.alias_tombstone_records(provider, placeholder) - } - fn clear_alias_tombstones_for_durable( - &self, - provider: &str, - durable: &str, - ) -> SinkAliasClearWrite { - let ledger = self.ledger.clone(); - let (p, d) = (provider.to_string(), durable.to_string()); - Box::pin(async move { - tokio::task::spawn_blocking(move || ledger.clear_alias_tombstones_for_durable(&p, &d)) - .await - .map_err(std::io::Error::other)? - }) - } -} - /// 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( @@ -453,9 +133,7 @@ fn new_generation( 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(TestLedgerSink { - ledger: ledger.clone(), - })); + st.set_identity_sink(Arc::new(LedgerSink::new(ledger.clone()))); (st, ledger, rx) } From db5268dbb32c23f346cfc6a49b43614f79849854 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:49:49 -0700 Subject: [PATCH 20/24] refactor(freshclaude): drop the pre-v2 rollback's adoption and park machinery (F4) C13's rollback registers its replacement already adopted and swaps the original's map entry and ids onto it in one step under the map locks (`replace_runtime`), so two pieces of the deleted teardown/respawn/init pre-read flow had nothing left to serve: - The adoption's `supersedes` parameter: every production caller passed None. It is removed from adopt_session_init, its deferral, its test-only completion variant and the test driver, together with the "preseed" fork-rekey branch, the supersession edge on the adoption's binding write and the InternalContinuation naming arm it selected. The in-stream remint gate (an init naming a durable id other than the one the session carries publishes the re-key bookkeeping only, never ownership) stays: C24's conversation reset needs it. - The MID_ROLLBACK_PARK_TICK loops in send, configure and compact (and InFlightRegistry::contains, which only they used): they parked a request whose session the old rollback had removed before re-inserting it. A resolution miss now means the session is gone. The three handlers share one resolver (`turn_locked_handles`). Tests: the_deferred_fork_adoption_carries_supersedes_and_never_claims_the_ new_key pinned only the removed pre-registration preseed path and is deleted. a_rollback_fork_rekey_never_adopts_ownership_under_the_new_id, a_failed_fork_rekey_publication_fails_the_adoption_through_the_completion and the handoff runner's re-key test drove the same assertions through the supersedes argument; they now drive them through the in-stream remint arm, which applies to the same sessions (they already carry another durable id), and their docs say so. The InFlightRegistry test now pins single-flight through try_acquire. Comments describing the removed window are corrected. freshell-freshagent lib: 1479/1479; claude rollback, fork and control-reply ws suites green. Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 451 +++++------------- crates/freshell-freshagent/src/lib.rs | 32 +- .../src/session_handoff/tests.rs | 25 +- 3 files changed, 139 insertions(+), 369 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 719f68b66..eb541f04f 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -108,11 +108,6 @@ const PENDING_CLAUDE_CREATE_PREFIX: &str = "pending-create-"; const CLAUDE_SIDECAR_OWNERSHIP_ENV: &str = crate::claude_spawn::CLAUDE_SIDECAR_OWNERSHIP_ENV; /// Cold-boot budget for the sidecar to answer the `create` request (`created`). const SIDECAR_CREATE_BUDGET: Duration = Duration::from_secs(45); -/// kata 1wxv task 4 review (C1): `handle_send`'s re-resolve cadence while a -/// rollback holds the session's teardown→respawn window open. The loop is -/// bounded by the rollback's own lifetime (`rollback_in_flight` membership -/// clears when the handler ends, on EVERY terminal path). -const MID_ROLLBACK_PARK_TICK: Duration = Duration::from_millis(10); /// Shared, cheaply-cloneable freshclaude WS state (mergeable into the server app + WsState). #[derive(Clone)] @@ -249,9 +244,7 @@ pub struct FreshClaudeState { /// Rollback-vs-rollback single-flight (kata 1wxv Task 4), keyed by the /// CURRENT durable id. [`Self::handle_rollback`] acquires it BEFORE the /// session's `turn_lock` (lock order: rollback_in_flight FIRST, then the - /// turn lock — never the reverse); `handle_send` never ACQUIRES it but - /// polls its membership (task 4 review C1) to PARK a send that resolves - /// inside the rollback's teardown→respawn window rather than refuse it. + /// turn lock — never the reverse); nothing else acquires it. rollback_in_flight: crate::InFlightRegistry, /// Fork-vs-fork single-flight (C14), keyed by the parent's sessions-map /// key: a second Fork click on the same pane while the first one boots its @@ -677,10 +670,9 @@ struct ClaudeSession { /// lock. `handle_rollback` holds it across the WHOLE handler (busy-check → /// reads → record pre-write → pending-cancel → kill+spawn+adoption → reply); /// `handle_send` waits on it, then proceeds and destroys redo — and never - /// ACQUIRES `rollback_in_flight` (task 4 review C1: it only polls membership - /// to park through the teardown→respawn window — no circular wait exists). - /// Carried across the rollback's kill+respawn so a send that resolved its - /// handle (or parked through the window) serializes identically. + /// ACQUIRES `rollback_in_flight` (no circular wait exists). The replacement + /// an undo or redo registers takes this lock over, so a request that waited + /// on it follows the replacement (`live_key_after_turn_wait`). turn_lock: Arc>, /// Focused ep4-r2/ep4-r3 (probe protocol): rollback's pre-teardown quiesce /// probe registers its probeId + a oneshot here before writing the @@ -904,26 +896,18 @@ impl FreshClaudeState { } /// b8ke focused episode-2 round-3 F3: crate-visible adoption driver for - /// in-crate cross-module tests (the handoff-runner tests simulate the - /// rollback's fork re-key publication). + /// in-crate cross-module tests (the handoff-runner tests drive an + /// in-stream re-key: an init naming a durable id other than the one the + /// session already carries). #[cfg(test)] pub(crate) async fn test_adopt_session_init( &self, cli_id: &str, session_id: &str, session_type: &str, - supersedes: Option<&str>, ) { - self.adopt_session_init( - cli_id, - session_id, - session_type, - None, - supersedes, - None, - None, - ) - .await; + self.adopt_session_init(cli_id, session_id, session_type, None, None, None) + .await; } /// b8ke focused episode-2 round-2 F3 test seam installer. @@ -4190,6 +4174,32 @@ impl FreshClaudeState { // ── freshAgent.send (WS) ───────────────────────────────────────────────────────── + /// The session `wire_id` names, with what a turn-locked request needs from + /// it: its map key, turn lock, busy truth (`in_turn`, `turn_tracker`) and + /// durable id. `None` when no live session answers to `wire_id`. + #[allow(clippy::type_complexity)] // the handles every turn-locked request reads + async fn turn_locked_handles( + &self, + wire_id: &str, + ) -> Option<( + String, + Arc>, + Arc, + Arc>, + Option, + )> { + let key = self.resolve_session_key(wire_id).await?; + let sessions = self.sessions.lock().await; + let session = sessions.get(&key)?; + Some(( + key, + session.turn_lock.clone(), + session.in_turn.clone(), + session.turn_tracker.clone(), + session.cli_session_id.clone(), + )) + } + /// The live session a request (`op`) resolved before it waited on /// `turn_lock`, after the wait (design §3.3, §4.5 and §4.8): the same map /// key while it is still registered; else, when an undo or redo replaced @@ -4265,51 +4275,19 @@ impl FreshClaudeState { // 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. - // - // kata 1wxv task 4 review (C1): a resolve/handles MISS while - // `rollback_in_flight` names this id means the rollback's teardown→respawn - // window swallowed the map entry (removed at teardown, re-inserted - // post-spawn under the SAME key with the turn lock carried across). PARK - // on the registry's membership and re-resolve instead of dying with - // SESSION_NOT_FOUND — the send then serializes BEHIND the rollback on the - // carried-over turn lock and lands on the POST-rollback (adopted) session - // («send waits, rollback wins, then destroys»). `handle_send` still never - // ACQUIRES `rollback_in_flight` (no circular wait exists): it only polls - // membership. A miss persisting after the rollback exits means the session - // is genuinely gone (a provably-rejected fork is torn down) — the honest - // SESSION_NOT_FOUND leg. - let (map_key, turn_lock, in_turn, turn_tracker, durable_before_wait) = loop { - let handles = match self.resolve_session_key(&session_id).await { - Some(key) => { - let guard = self.sessions.lock().await; - guard.get(&key).map(|s| { - ( - key, - s.turn_lock.clone(), - s.in_turn.clone(), - s.turn_tracker.clone(), - s.cli_session_id.clone(), - ) - }) - } - None => None, - }; - if let Some(handles) = handles { - break handles; - } - if !self.rollback_in_flight.contains(&session_id) { - self.refuse_send_to_missing_session(&request_id, &session_id, msg.session_type); - return; - } - tokio::time::sleep(MID_ROLLBACK_PARK_TICK).await; + // 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 Some((map_key, turn_lock, in_turn, turn_tracker, durable_before_wait)) = + self.turn_locked_handles(&session_id).await + else { + self.refuse_send_to_missing_session(&request_id, &session_id, msg.session_type); + return; }; // r2 serialization discipline: hold the session turn lock across the // busy-set AND the sidecar write — the check-then-set window vs // `handle_rollback`'s busy gate is closed (a rollback holding this lock - // observes either no-send or a fully-marked in-flight turn). The lock - // handle is carried across the rollback's kill+respawn, so a send that - // resolved before the teardown window serializes identically to one that - // parked through it. + // observes either no-send or a fully-marked in-flight turn). let _turn = turn_lock.lock().await; // An undo or redo that held this lock may have replaced the runtime // the send resolved (C13: the replacement registers under its own id @@ -4686,7 +4664,6 @@ impl FreshClaudeState { map_key, session_type, Some(&effective), - None, self.identity_sink(), // Settings-apply is conn-less from the pane's perspective: not a // browser assertion — the ledger merge keeps the row's prior @@ -4703,24 +4680,11 @@ impl FreshClaudeState { let session_type = session_type_str(msg.session_type); let request_id = msg.request_id.clone(); - let (map_key, turn_lock, durable_before_wait) = loop { - let handles = match self.resolve_session_key(&session_id).await { - Some(key) => { - let guard = self.sessions.lock().await; - guard - .get(&key) - .map(|s| (key, s.turn_lock.clone(), s.cli_session_id.clone())) - } - None => None, - }; - if let Some(handles) = handles { - break handles; - } - if !self.rollback_in_flight.contains(&session_id) { - self.send_error(&request_id, "SESSION_NOT_FOUND", "claude session not found"); - return; - } - tokio::time::sleep(MID_ROLLBACK_PARK_TICK).await; + let Some((map_key, turn_lock, _, _, durable_before_wait)) = + self.turn_locked_handles(&session_id).await + else { + self.send_error(&request_id, "SESSION_NOT_FOUND", "claude session not found"); + return; }; let _turn = turn_lock.lock().await; // An undo or redo that held this lock may have replaced the runtime @@ -4969,10 +4933,10 @@ impl FreshClaudeState { /// normal `sdk.*` stream). /// /// Delta-r1 F1 (busy discipline): a compact IS a turn — the handler mirrors - /// [`Self::handle_send`] exactly: park through a mid-rollback teardown window - /// (poll `rollback_in_flight` membership; never acquire it), take the session - /// `turn_lock` (carried across the rollback kill+respawn, so the check-then-set - /// window against the rollback busy gate is closed), and set the busy truth + /// [`Self::handle_send`] exactly: take the session `turn_lock` (an undo's + /// replacement takes it over, and the compact follows the replacement, so + /// the check-then-set window against the rollback busy gate is closed), and + /// set the busy truth /// (`in_turn`) UNDER the lock BEFORE the sidecar write. A compact turn ends at /// the SDK's `sdk.result` frame (any subtype) — the existing four-edge clear /// set terminates it (plus `sdk.status:idle`, sidecar EOF, or a completed @@ -4994,36 +4958,12 @@ impl FreshClaudeState { let session_id = msg.session_id.clone(); let session_type = session_type_str(msg.session_type); - // The `handle_send` resolve discipline (task 4 review C1): a resolve/handles - // MISS while `rollback_in_flight` names this id means the rollback's - // teardown→respawn window swallowed the map entry — PARK on the registry's - // membership and re-resolve (the compact then serializes BEHIND the rollback - // on the carried-over turn lock). This handler never ACQUIRES - // `rollback_in_flight` (no circular wait exists): it only polls membership. - let (map_key, turn_lock, in_turn, turn_tracker, durable_before_wait) = loop { - let handles = match self.resolve_session_key(&session_id).await { - Some(key) => { - let guard = self.sessions.lock().await; - guard.get(&key).map(|s| { - ( - key, - s.turn_lock.clone(), - s.in_turn.clone(), - s.turn_tracker.clone(), - s.cli_session_id.clone(), - ) - }) - } - None => None, - }; - if let Some(handles) = handles { - break handles; - } - if !self.rollback_in_flight.contains(&session_id) { - self.broadcast(&lost_session_frame(&session_id, msg.session_type)); - return; - } - tokio::time::sleep(MID_ROLLBACK_PARK_TICK).await; + // The `handle_send` resolve discipline: a miss is a session that is gone. + let Some((map_key, turn_lock, in_turn, turn_tracker, durable_before_wait)) = + self.turn_locked_handles(&session_id).await + else { + self.broadcast(&lost_session_frame(&session_id, msg.session_type)); + return; }; // Held across the busy-set AND the sidecar write (the handle_send r2 // serialization discipline) — a rollback holding this lock observes either @@ -5983,29 +5923,21 @@ impl FreshClaudeState { })); } - /// The sdk.session.init adoption block (the consumer's in-stream arm; a - /// rollback no longer adopts through it, C13: its replacement registers - /// already adopted): record the durable Claude UUID in cli_index + the - /// session record, then AWAIT-write the identity binding row (V8/A11). A - /// `supersedes` caller's row carries `supersedes: old durable id`, and the - /// rollback-row re-key old→new rides the SAME awaited batch (server-side - /// sink). A failed write is surfaced user-visibly, never - /// warn-and-drop, then the identity event proceeds. No-laundering guard - /// (V7/A10): never persist an all-blank snapshot UNLESS a supersession edge - /// is being written (a supersession write always goes through — it is the - /// only record of the old→new linkage, G3). `provenance` (D8): the creating - /// connection's stamps, when this adoption flows from a connection-scoped - /// create; `None` on conn-less lanes — the ledger's keep-when-None merge - /// then keeps (or, under `supersedes`, inherits the parent's) prior stamps. - #[allow(clippy::too_many_arguments)] // Session-init wiring; the spawn_consumer precedent. - #[allow(clippy::too_many_arguments)] // the adoption field set (the e2r3 completion rides it) + /// The sdk.session.init adoption block (the consumer's in-stream arm, and + /// a settings apply; a rollback or a fork never adopts through it, C13/C14: + /// its runtime registers already adopted): record the durable Claude UUID + /// in cli_index + the session record, then AWAIT-write the identity binding + /// row (V8/A11). A failed write is surfaced user-visibly, never + /// warn-and-drop, then the identity event proceeds. `provenance` (D8): the + /// creating connection's stamps, when this adoption flows from a + /// connection-scoped create; `None` on conn-less lanes — the ledger's + /// keep-when-None merge then keeps the prior stamps. async fn adopt_session_init( &self, cli_id: &str, session_id: &str, session_type: &str, settings: Option<&crate::identity_sink::FreshAgentSettings>, - supersedes: Option<&str>, identity_sink: Option, provenance: Option<&crate::BindProvenance>, ) -> SessionInitAdoptionOutcome { @@ -6014,7 +5946,6 @@ impl FreshClaudeState { session_id, session_type, settings, - supersedes, identity_sink, provenance, false, @@ -6035,7 +5966,6 @@ impl FreshClaudeState { session_id: &str, session_type: &str, settings: Option<&crate::identity_sink::FreshAgentSettings>, - supersedes: Option<&str>, identity_sink: Option, provenance: Option<&crate::BindProvenance>, on_settled: Option>>, @@ -6053,7 +5983,6 @@ impl FreshClaudeState { identity_sink, provenance, on_settled, - supersedes, ); return SessionInitAdoptionOutcome::Deferred; } @@ -6063,7 +5992,6 @@ impl FreshClaudeState { session_id, session_type, settings, - supersedes, identity_sink, provenance, false, @@ -6105,7 +6033,6 @@ impl FreshClaudeState { identity_sink: Option, provenance: Option<&crate::BindProvenance>, on_settled: Option>>, - supersedes: Option<&str>, ) { let state = self.clone(); let cli_id = cli_id.to_string(); @@ -6113,17 +6040,6 @@ impl FreshClaudeState { let session_type = session_type.to_string(); let settings = settings.cloned(); let provenance = provenance.cloned(); - // b8ke focused episode-2 post-cap F2: the ROLLBACK/FORK CONTEXT - // rides the deferral. The normal rollback path NECESSARILY defers - // (the replacement session is inserted after the consumer starts); - // dropping `supersedes` here made the deferred adoption treat the - // rollback as an ordinary create — it claimed and occupied the NEW - // key, and the outer rekey then refused the occupied target and - // tore the replacement down. Carried through, the deferred - // adoption coordinates WITH the outer rekey: the fork gate keeps - // it publication-only (no claim on the new key), exactly like the - // synchronous fork path. - let supersedes = supersedes.map(str::to_string); tokio::spawn(async move { let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { @@ -6134,7 +6050,6 @@ impl FreshClaudeState { &session_id, &session_type, settings.as_ref(), - supersedes.as_deref(), identity_sink.clone(), provenance.as_ref(), true, @@ -6284,13 +6199,12 @@ impl FreshClaudeState { session_id: &str, session_type: &str, settings: Option<&crate::identity_sink::FreshAgentSettings>, - supersedes: Option<&str>, identity_sink: Option, provenance: Option<&crate::BindProvenance>, deferred_reentry: bool, ) -> SessionInitAdoptionOutcome { // Phase 1 — the mint-gate READ scope (never publishes): the gate - // flag, the session's existing durable id (the rollback-fork remint + // flag, the session's existing durable id (the in-stream remint // test), the registration presence (the F2 pre-registration test), // and the sidecar pid. // b8ke ext r27 F2: the session's recorded handoff generation reads @@ -6335,28 +6249,17 @@ impl FreshClaudeState { .store(true, std::sync::atomic::Ordering::SeqCst); let _ = pause.notify.notified().await; } - // b8ke focused episode-2 round-1 F4: the ROLLBACK-FORK gate. A - // rollback's `sdk.session.init` deliberately MINTS A NEW durable - // id (the fork re-key) while the rollback path owns and commits - // the runtime under the OLD durable id — adopting under the NEW - // id here would commit a second Live{FreshAgent} record + stamp - // beside the old key's (contradictory snapshots; the old key - // left falsely Live after the runtime moves). Fork re-keys (a - // supersedes-carrying preseed, or an in-stream remint of a - // session that already carries a DIFFERENT durable id) publish - // the re-key bookkeeping ONLY — ownership stays wherever the - // rollback committed it. - let fork_rekey = supersedes.is_some() - || existing_cli_id - .as_deref() - .is_some_and(|existing| existing != cli_id); - // b8ke focused episode-2 round-3 F2: the registration gate applies - // to FORK REKEYS exactly like ordinary creates — the rollback - // spawns its consumer BEFORE inserting the replacement session, so - // the preseeded init can reach this point pre-registration; the - // publication then has no session to name and MUST NOT continue - // unaliased. The deferral settles post-registration and the - // completion hook answers the rollback. + // b8ke focused episode-2 round-1 F4: the in-stream REMINT gate. An + // init that names a durable id other than the one the session + // already carries (the CLI moved the conversation to a new id) + // must not adopt ownership under the new id: the runtime is owned + // and committed under the id it already carries, and a second + // Live{FreshAgent} record + stamp beside that key would leave + // contradictory snapshots. Such a re-key publishes the bookkeeping + // ONLY — ownership stays where it was committed. + let fork_rekey = existing_cli_id + .as_deref() + .is_some_and(|existing| existing != cli_id); if !registered { // b8ke focused episode-2 round-1 F2: the pre-registration // window must not expose a Live owner that cannot be killed — @@ -6387,7 +6290,6 @@ impl FreshClaudeState { identity_sink, provenance, None, - supersedes, ); return SessionInitAdoptionOutcome::Deferred; } @@ -6569,14 +6471,13 @@ impl FreshClaudeState { // zero-turn init whose transcript has not materialized yet retains // the handle and records only the acquired location (InitialRecovery // semantics — the name authority never moves on unverified - // evidence). A rollback fork classifies as InternalContinuation (the - // child is seeded once with the source preserved). Deliberately NOT + // evidence). Deliberately NOT // a lane blocker: a naming failure never fails the identity event // (the handle is retained; the main.rs naming tick retries the // transition visibly), and the bind targets the DURABLE session — // its verified transcript outlives even this runtime's teardown. let declared_transition = self - .bind_naming_handle_at_init(cli_id, session_id, session_type, supersedes) + .bind_naming_handle_at_init(cli_id, session_id, session_type) .await; if let Some(sink) = &identity_sink { if let Err(e) = sink @@ -6587,12 +6488,11 @@ impl FreshClaudeState { mode: session_type.to_string(), create_request_id: None, resolves_pending: None, - supersedes: supersedes.map(str::to_string), + supersedes: None, // Unified agent names (Task 4, T2-M5 wired): the init lane's // DECLARED classification — the same transition the direct - // naming fold above consumed (InternalContinuation for a - // rollback fork, InitialMaterialization otherwise), so the - // identity event carries the naming fact alongside the + // naming fold above consumed (InitialMaterialization), so + // the identity event carries the naming fact alongside the // ledger write. name_transition: declared_transition, provenance: provenance.cloned().into(), @@ -6719,7 +6619,6 @@ impl FreshClaudeState { cli_id: &str, placeholder: &str, session_type: &str, - supersedes: Option<&str>, ) -> Option { if session_type == "kilroy" { return None; @@ -6767,18 +6666,13 @@ impl FreshClaudeState { provider: freshell_protocol::session_names::NamedProvider::Claude, session_id: cli_id.to_string(), }; - // Transition classification: a rollback fork is an - // InternalContinuation (the child is seeded once with the source - // preserved — the store's collision rules keep a manual winner); - // every other init is the initial materialization. The shared + // Transition classification: an init is the initial + // materialization (a rollback's or a fork's runtime registers + // already adopted and never reaches here, C13/C14). The shared // classification-driven fold (plan Task 4) applies the policy: // verified persistence binds the handle, prospective evidence // RETAINS it (zero-turn rule — only the routing evidence moves). - let reason = if supersedes.is_some() { - crate::naming::NameTransitionReason::InternalContinuation - } else { - crate::naming::NameTransitionReason::InitialMaterialization - }; + let reason = crate::naming::NameTransitionReason::InitialMaterialization; let transition = crate::naming::NameTransition { reason, acquisition, @@ -7183,7 +7077,6 @@ impl FreshClaudeState { &session_id, &session_type, current_settings.as_ref(), - None, identity_sink.clone(), provenance.as_ref(), ) @@ -13608,7 +13501,6 @@ rl.on('line', (line) => { model: Some("gate-model".into()), ..crate::identity_sink::FreshAgentSettings::default() }), - None, Some(fake.clone()), None, ) @@ -13755,7 +13647,6 @@ rl.on('line', (line) => { None, None, None, - None, ) .await; @@ -13874,12 +13765,13 @@ rl.on('line', (line) => { assert!(saw_lost, "the teardown engages the lost-session recovery"); } - /// b8ke focused episode-2 round-1 F4: a ROLLBACK-FORK re-key never - /// adopts ownership under the NEW durable id — the rollback owns and - /// commits the runtime under the OLD durable id, so a second - /// Live{FreshAgent} record under the fork id would leave contradictory - /// snapshots (the old key falsely Live after the runtime moves). - /// The re-key bookkeeping (the alias) still lands. + /// b8ke focused episode-2 round-1 F4: an in-stream RE-KEY (an init + /// naming a durable id other than the one the session already carries) + /// never adopts ownership under the NEW durable id — the runtime is + /// owned and committed under the OLD durable id, so a second + /// Live{FreshAgent} record under the new id would leave contradictory + /// snapshots (the old key falsely Live after the runtime moves). The + /// re-key bookkeeping (the alias) still lands. #[tokio::test(flavor = "multi_thread")] async fn a_rollback_fork_rekey_never_adopts_ownership_under_the_new_id() { let _guard = CLAUDE_ENV_LOCK.lock().await; @@ -13911,15 +13803,14 @@ rl.on('line', (line) => { tokio::time::sleep(std::time::Duration::from_millis(25)).await; } - // The ROLLBACK FORK: the respawned sidecar's sdk.session.init mints - // a NEW durable id (the supersedes-carrying preseed shape). The - // re-key bookkeeping lands — but NO ownership under the new id. + // The RE-KEY: the session's next sdk.session.init names a NEW durable + // id. The re-key bookkeeping lands — but NO ownership under the new + // id. st.adopt_session_init( "new-fork-id-e2r4", &placeholder, "freshclaude", None, - Some("old-durable-id"), None, None, ) @@ -14851,7 +14742,6 @@ rl.on('line', (line) => { stale_map_key, "freshclaude", Some(&settings), - None, sink2, None, Some(tx), @@ -14910,101 +14800,10 @@ rl.on('line', (line) => { ); } - /// b8ke focused episode-2 post-cap F2: the ROLLBACK/FORK CONTEXT - /// (`supersedes`) rides the registration-gated deferral. The normal - /// rollback path NECESSARILY defers (the replacement session is - /// inserted after the consumer starts); pre-fix the deferred call - /// passed `None`, `fork_rekey` became false, and the deferred - /// adoption claimed + occupied the NEW key as an ordinary create — - /// the outer rollback rekey then refused the occupied target and tore - /// the replacement down. Carried through, the deferred adoption stays - /// publication-only: it NEVER claims the new key, so the outer rekey - /// owns the move. - #[tokio::test(flavor = "multi_thread")] - async fn the_deferred_fork_adoption_carries_supersedes_and_never_claims_the_new_key() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let _env = FakeClaudeSidecarEnv::install(); - let (mut st, mut rx) = state_with_bus(); - let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); - st.set_ownership(registry.clone()); - - st.handle_create(dedup_create_msg("req-postcap-f2"), None) - .await; - let created = await_claude_created(&mut rx, "req-postcap-f2").await; - let _created_session = created["sessionId"].as_str().unwrap().to_string(); - // The first adoption publishes. - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - while st - .cli_index - .lock() - .await - .get(FRESH_CREATE_DURABLE_ID) - .is_none() - { - assert!( - tokio::time::Instant::now() < deadline, - "the first create's adoption never published" - ); - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - - // The fork rekey's preseed BEFORE registration → Deferred (the - // rollback's real ordering: the replacement session's map key is - // inserted AFTER the consumer starts), with the completion - // channel. The fork target is the NEW session's map key. - let fork_dur = "abcdeabc-abcd-4abc-8abc-abcdeabcdeab"; - let fork_map_key = "fork-map-key-postcap-f2"; - let (tx, mut rx_done) = tokio::sync::oneshot::channel::>(); - let outcome = st - .adopt_session_init_with_completion( - fork_dur, - fork_map_key, - "freshclaude", - None, - Some(FRESH_CREATE_DURABLE_ID), - None, - None, - Some(tx), - ) - .await; - assert!( - matches!(outcome, SessionInitAdoptionOutcome::Deferred), - "the unregistered fork preseed defers (the rollback's insert window)" - ); - // The registration lands (the handler's insert); the deferred - // adoption settles with the fork context. - insert_fake_claude_session(&st, fork_map_key).await; - assert!( - registry - .observe("claude", fork_dur) - .state - .eq(&freshell_ownership::OwnershipState::Vacant), - "the NEW key is NEVER claimed by the deferred fork adoption — \ - the outer rekey owns the move (pre-fix: the deferred adoption \ - claimed it as an ordinary create and the outer rekey refused \ - the occupied target)" - ); - let answer = tokio::time::timeout(std::time::Duration::from_secs(15), &mut rx_done) - .await - .expect("the deferred adoption settles") - .expect("the completion channel survived"); - assert_eq!(answer.as_deref(), Some(fork_dur)); - // Still never claimed — publication-only bookkeeping. - assert!(matches!( - registry.observe("claude", fork_dur).state, - freshell_ownership::OwnershipState::Vacant - )); - assert_eq!( - st.cli_index.lock().await.get(fork_dur).cloned(), - Some(fork_map_key.to_string()), - "the fork publication landed (the alias bookkeeping)" - ); - } - - /// b8ke focused episode-2 round-3 F2: a FAILED preseed publication - /// FAILS the adoption — the completion hook answers `None` so the - /// rollback handler tears the replacement down instead of continuing - /// with a live runtime unreachable by its advertised id. The + /// b8ke focused episode-2 round-3 F2: a FAILED re-key publication (an + /// in-stream init naming a durable id other than the one the session + /// carries) FAILS the adoption — the completion hook answers `None`, + /// never a live runtime unreachable by its advertised id. The /// deterministic failure shape: the mint gate armed (a kill's /// enumeration in flight) → the publication abandons → `Abandoned` /// + `on_settled` receives None. @@ -15050,7 +14849,6 @@ rl.on('line', (line) => { &placeholder, "freshclaude", None, - Some(FRESH_CREATE_DURABLE_ID), None, None, Some(tx), @@ -15142,16 +14940,8 @@ rl.on('line', (line) => { .get_mut(&placeholder_a) .expect("session") .cli_session_id = None; - st.adopt_session_init( - foreign_dur, - &placeholder_a, - "freshclaude", - None, - None, - None, - None, - ) - .await; + st.adopt_session_init(foreign_dur, &placeholder_a, "freshclaude", None, None, None) + .await; // The teardown is DETACHED (the adoption's caller is the session's // own consumer — the standard teardown aborts it, so the sequence // runs in a spawned task): bounded-poll for the fail-closed result. @@ -15207,16 +14997,8 @@ rl.on('line', (line) => { session.owning_operation = Some("own-runner-op".to_string()); session.cli_session_id = None; } - st.adopt_session_init( - own_dur, - &placeholder_b, - "freshclaude", - None, - None, - None, - None, - ) - .await; + st.adopt_session_init(own_dur, &placeholder_b, "freshclaude", None, None, None) + .await; assert!( st.sessions.lock().await.contains_key(&placeholder_b), "the runtime's OWN operation proceeds — no teardown" @@ -15277,7 +15059,7 @@ rl.on('line', (line) => { let st2 = st.clone(); let ph = placeholder.clone(); let mut adoption = tokio::spawn(async move { - st2.adopt_session_init(killed_dur, &ph, "freshclaude", None, None, None, None) + st2.adopt_session_init(killed_dur, &ph, "freshclaude", None, None, None) .await; }); // b8ke focused episode-2 round-3 F10: the test must PROVE the @@ -15441,7 +15223,6 @@ rl.on('line', (line) => { &placeholder, "freshclaude", None, - None, Some(fake.clone()), None, ) @@ -15559,7 +15340,6 @@ rl.on('line', (line) => { &placeholder, "freshclaude", None, - None, Some(fake.clone()), None, ) @@ -23177,17 +22957,16 @@ rl.on('line', (line) => { } /// The r2 lock discipline on the claude lane: a send fired mid-rollback waits on - /// the per-session turn lock (handle_send NEVER acquires rollback_in_flight — it - /// only PARKS on the registry's membership while the rollback's teardown→respawn - /// window hides the map entry), then proceeds against the POST-rollback session - /// and destroys redo. + /// the per-session turn lock (handle_send NEVER acquires rollback_in_flight), + /// then proceeds against the POST-rollback session (the replacement took the + /// lock over) and destroys redo. /// /// DETERMINISM (task 4 review C1): the fake sidecar's create ANSWER is deferred /// 750ms — the spawn-log gate observes the create request receipt while the - /// rollback is PARKED inside `read_created`, so the send provably arrives in the - /// teardown→respawn window (the map entry is already removed). Pre-fix the send - /// died with SESSION_NOT_FOUND on a resolve-before-lock; post-fix it parks, - /// serializes behind the rollback, and lands on the adopted session. + /// rollback is PARKED inside `read_created` (the replacement's boot, the + /// original still registered), so the send provably arrives while the rollback + /// holds the lock; it serializes behind the rollback and lands on the + /// replacement. #[tokio::test] async fn concurrent_send_plus_undo_serializes_on_the_turn_lock_without_deadlock() { let _guard = CLAUDE_ENV_LOCK.lock().await; diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index 396fb8209..804862ee1 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -7256,17 +7256,6 @@ impl InFlightRegistry { held, }) } - - /// Snapshot membership check — never an acquisition. Claude's `handle_send` - /// parks while a rollback names this durable id (kata 1wxv task 4 review C1): - /// a session-map resolve miss inside the rollback's teardown→respawn window - /// must WAIT for the re-insert, never refuse with SESSION_NOT_FOUND. - pub(crate) fn contains(&self, key: &str) -> bool { - self.keys - .lock() - .expect("in-flight registry lock") - .contains(key) - } } /// RAII release for an [`InFlightRegistry`] acquisition: removes its held key(s) @@ -7290,17 +7279,22 @@ mod in_flight_registry_tests { use super::*; #[test] - fn contains_tracks_acquire_and_drop_without_acquiring() { + fn an_acquisition_is_single_flight_until_its_guard_drops() { let registry = InFlightRegistry::new(); - assert!(!registry.contains("dur-1")); let guard = registry.try_acquire("dur-1").expect("first acquire"); - assert!(registry.contains("dur-1")); - // contains is a snapshot, NOT an acquisition: a second try_acquire still - // refuses (single-flight) while contains never blocks. - assert!(registry.try_acquire("dur-1").is_none()); - assert!(registry.contains("dur-1")); + assert!( + registry.try_acquire("dur-1").is_none(), + "a second acquisition of a held key refuses" + ); + assert!( + registry.try_acquire("dur-2").is_some(), + "another key is independent" + ); drop(guard); - assert!(!registry.contains("dur-1")); + assert!( + registry.try_acquire("dur-1").is_some(), + "the key is free once the guard drops" + ); } } diff --git a/crates/freshell-freshagent/src/session_handoff/tests.rs b/crates/freshell-freshagent/src/session_handoff/tests.rs index ca479bf08..573c19565 100644 --- a/crates/freshell-freshagent/src/session_handoff/tests.rs +++ b/crates/freshell-freshagent/src/session_handoff/tests.rs @@ -7000,20 +7000,17 @@ async fn a_handoff_on_a_superseded_rekeyed_id_resolves_the_canonical_owner() { let sidecar_pid = env.sidecar_pid_for(&superseded); assert!(sidecar_pid.is_some(), "the live owner's sidecar pid"); - // The rollback's re-key in the FULL production sequence: (1) the fork - // adoption publishes the re-key bookkeeping at the LANE level (the - // preseed publication: cli_index[new] = map key + the session's - // cli_session_id = new — the same write adopt_session_init performs - // for the supersedes-carrying preseed), then (2) the registry's own - // rekey_live performs the atomic old→new coordinator move (the same - // step the rollback's Adopt-path commit performs), leaving - // Aliased{to: canonical} at the superseded key — the COORDINATOR as - // the single source of truth (e2r3 F3). Pre-fix this id pair was a + // A re-key in the FULL production sequence: (1) an in-stream init + // naming the NEW durable id publishes the re-key bookkeeping at the LANE + // level (cli_index[new] = map key + the session's cli_session_id = new; + // the session already carries the old id, so the adoption claims + // nothing under the new one), then (2) the registry's own rekey_live + // performs the atomic old→new coordinator move, leaving + // Aliased{to: canonical} at the superseded key — the COORDINATOR as the + // single source of truth (e2r3 F3). Pre-fix this id pair was a // lane-local HashMap nobody hydrated. - // (1) The fork adoption's publication — the REAL lane-level re-key - // bookkeeping (cli_index[new] = map key + the session's - // cli_session_id = new), driven through the adoption itself exactly - // like the rollback's preseed arm. + // (1) The re-key's publication — the REAL lane-level bookkeeping, + // driven through the adoption itself. { let map_key = rig .fresh_claude @@ -7021,7 +7018,7 @@ async fn a_handoff_on_a_superseded_rekeyed_id_resolves_the_canonical_owner() { .await .expect("the live session's map key"); rig.fresh_claude - .test_adopt_session_init(&canonical, &map_key, "freshclaude", Some(&superseded)) + .test_adopt_session_init(&canonical, &map_key, "freshclaude") .await; } // (2) The registry's own rekey_live performs the atomic old→new From d249fddb38b77b66ca6984f8a890466163c42ba2 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:56:35 -0700 Subject: [PATCH 21/24] refactor(freshclaude): one lease claim and one genesis binding write for every launch (F5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design §4.9 runs every launch through "claim → lease(id) → decide_boot → spawn … → binding write". establish_runtime held only the middle, so each lane hand-rolled the rest: four copies of the provider-lease claim with its expired-holder kill-and-retry (create-resume, attach, handoff resume, rollback) and two copies of the genesis binding upsert (rollback, fork). F1 was a concrete cost: the create lane missed the boot decision. establish.rs now holds both: - `claim_launch_lease(durable, request_id) -> LaunchLease` runs the claim and the one expired-holder kill (tree confirmed dead by ownership tag, then one retry), logging the unconfirmed-kill invariant itself; each lane keeps only its own answer to Acquired / BoundLive / Held / ExpiredUnconfirmed (adopt, rebind, refuse), unchanged. - `record_genesis_binding(GenesisBinding)` writes a minted session's row (genesis, launch settings, supersedes, provenance, fenced with the launch's ticket), awaited; a lane with no ledger records nothing. The boot decision itself already runs in every lane that boots a durable id (decide_attach_boot, decide_create_resume_boot, and the rollback's and fork's own effective-transcript reads), and the typed retries in establish_runtime (F3). No behavior change. Green: freshell-freshagent claude + session_handoff unit tests (514), the claude ws suites (rollback, fork, genesis, launch parity, attach, kill after restart), and in the sandbox freshagent_session_lease (9/9, its expired-holder kill path) and cross_kind_liveness (51/51). Co-Authored-By: Claude Opus 5.5 --- crates/freshell-freshagent/src/claude.rs | 209 ++++-------------- .../src/claude/establish.rs | 112 ++++++++++ crates/freshell-freshagent/src/claude/fork.rs | 53 ++--- .../src/claude/rollback.rs | 142 ++++-------- 4 files changed, 227 insertions(+), 289 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index eb541f04f..5b51770cf 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -1419,51 +1419,24 @@ impl FreshClaudeState { // D8 same-kind lease claim (the provider backstop — the coordinator // claim is the runner's, under-ticket). let resume_request_id = format!("handoff-resume-{}", uuid::Uuid::new_v4()); - let mut lease_guard: Option = None; - for round in 0..2u8 { - match self.leases.claim( - PROVIDER, - session_id, - &resume_request_id, - crate::session_lease::now_epoch_ms(), - ) { - crate::session_lease::FreshSessionClaim::Acquired => { - lease_guard = Some(crate::FreshSessionLeaseGuard::armed( - Arc::clone(&self.leases), - PROVIDER, - session_id, - &resume_request_id, - )); - break; - } - crate::session_lease::FreshSessionClaim::BoundLive { .. } - | crate::session_lease::FreshSessionClaim::Held { .. } => { - return Err(( - "another lifecycle operation holds this session's lease".to_string(), - session_id.to_string(), - )); - } - crate::session_lease::FreshSessionClaim::ExpiredNeedsKill { pid, ownership_id } => { - if round == 0 - && crate::session_lease::kill_and_confirm_tree_dead( - pid, - CLAUDE_SIDECAR_OWNERSHIP_ENV, - &ownership_id, - ) - .await - { - self.leases - .force_release_after_confirmed_kill(PROVIDER, session_id); - continue; - } - return Err(( - "the prior lease holder expired and could not be confirmed dead" - .to_string(), - session_id.to_string(), - )); - } + let mut lease_guard = match self + .claim_launch_lease(session_id, &resume_request_id) + .await + { + LaunchLease::Acquired(guard) => Some(guard), + LaunchLease::BoundLive { .. } | LaunchLease::Held => { + return Err(( + "another lifecycle operation holds this session's lease".to_string(), + session_id.to_string(), + )); } - } + LaunchLease::ExpiredUnconfirmed => { + return Err(( + "the prior lease holder expired and could not be confirmed dead".to_string(), + session_id.to_string(), + )); + } + }; let msg = FreshAgentAttach { provider: freshell_protocol::AgentProvider::Claude, session_id: session_id.to_string(), @@ -2270,50 +2243,15 @@ impl FreshClaudeState { ); } } - for round in 0..2u8 { - match self.leases.claim( - PROVIDER, - sid, - &request_id, - crate::session_lease::now_epoch_ms(), - ) { - crate::session_lease::FreshSessionClaim::Acquired => { - lease_guard = Some(crate::FreshSessionLeaseGuard::armed( - Arc::clone(&self.leases), - PROVIDER, - sid, - &request_id, - )); - break; - } - crate::session_lease::FreshSessionClaim::BoundLive { .. } => { - // Under-lock ADOPT: the winner completed between our pre-check - // and the claim (the V5 TOCTOU window). - return self.adopt_live_create(&request_id, sid, session_type).await; - } - crate::session_lease::FreshSessionClaim::Held { .. } => { - return self.fail_create_session_reserved(&request_id); - } - crate::session_lease::FreshSessionClaim::ExpiredNeedsKill { - pid, - ownership_id, - } => { - if round == 0 - && crate::session_lease::kill_and_confirm_tree_dead( - pid, - CLAUDE_SIDECAR_OWNERSHIP_ENV, - &ownership_id, - ) - .await - { - self.leases - .force_release_after_confirmed_kill(PROVIDER, sid); - continue; - } - tracing::error!(target: "invariant", pid, session_id = sid, - "fresh_agent_lease_expired_kill_unconfirmed: holding closed"); - return self.fail_create_session_reserved(&request_id); - } + match self.claim_launch_lease(sid, &request_id).await { + LaunchLease::Acquired(guard) => lease_guard = Some(guard), + LaunchLease::BoundLive { .. } => { + // Under-lock ADOPT: the winner completed between our pre-check + // and the claim (the V5 TOCTOU window). + return self.adopt_live_create(&request_id, sid, session_type).await; + } + LaunchLease::Held | LaunchLease::ExpiredUnconfirmed => { + return self.fail_create_session_reserved(&request_id); } } } @@ -5561,87 +5499,34 @@ impl FreshClaudeState { } }; let attach_request_id = format!("attach-{}", uuid::Uuid::new_v4()); - let mut lease_guard: Option = None; - for round in 0..2u8 { - match self.leases.claim( - PROVIDER, - &durable, - &attach_request_id, - crate::session_lease::now_epoch_ms(), - ) { - crate::session_lease::FreshSessionClaim::Acquired => { - lease_guard = Some(crate::FreshSessionLeaseGuard::armed( - Arc::clone(&self.leases), - PROVIDER, - &durable, - &attach_request_id, - )); - break; - } - crate::session_lease::FreshSessionClaim::BoundLive { .. } => { - // 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), answer RESERVED -- the client re-drive - // converges on the reopened key. - self.resuming - .lock() - .expect("resuming lock") - .remove(&durable); - if !self + 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 - { - 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::session_lease::FreshSessionClaim::Held { .. } => { - 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; - } - crate::session_lease::FreshSessionClaim::ExpiredNeedsKill { pid, ownership_id } => { - if round == 0 - && crate::session_lease::kill_and_confirm_tree_dead( - pid, - CLAUDE_SIDECAR_OWNERSHIP_ENV, - &ownership_id, - ) - .await - { - self.leases - .force_release_after_confirmed_kill(PROVIDER, &durable); - continue; - } - tracing::error!(target: "invariant", pid, session_id = %durable, - "fresh_agent_lease_expired_kill_unconfirmed: holding closed"); - self.resuming - .lock() - .expect("resuming lock") - .remove(&durable); + .await; + if !rebound { 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; } + 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. @@ -8692,7 +8577,9 @@ mod fork; mod genesis; mod history; mod rollback; -use establish::{BootRetry, DurableClaim, EstablishLane, EstablishPlan, RegisterUnder}; +use establish::{ + BootRetry, DurableClaim, EstablishLane, EstablishPlan, LaunchLease, RegisterUnder, +}; #[cfg(test)] mod teardown_tests; diff --git a/crates/freshell-freshagent/src/claude/establish.rs b/crates/freshell-freshagent/src/claude/establish.rs index e1936a248..86ef0a105 100644 --- a/crates/freshell-freshagent/src/claude/establish.rs +++ b/crates/freshell-freshagent/src/claude/establish.rs @@ -72,6 +72,40 @@ pub(super) struct InheritedRuntime { pub(super) last_synthesized_complete_at: Arc>>, } +/// What a launch's claim of the provider lease on a durable id found (design +/// §4.9 "lease(id)": one live writer per durable transcript). +pub(super) enum LaunchLease { + /// The lease is the launch's: the armed guard releases it if the launch + /// fails, and binds it to the registered session if it succeeds. + Acquired(crate::FreshSessionLeaseGuard), + /// A live session is bound to the id: the launch adopts it or refuses. + BoundLive { live_session_key: String }, + /// Another launch holds the lease. + Held, + /// An expired holder could not be confirmed dead: the id stays closed. + ExpiredUnconfirmed, +} + +/// The binding row of a session a launch minted with no transcript yet (an +/// undo's replacement, a fork's child; design §4.9 "binding write"). +pub(super) struct GenesisBinding<'a> { + /// The minted id. + pub(super) session_id: &'a str, + pub(super) session_type: &'a str, + /// How it came to be, so a launch before its transcript exists boots it + /// again (§4.1). + pub(super) genesis: &'a crate::claude_launch::ClaudeGenesis, + /// The settings it launched with (the plugins too, G15). + pub(super) settings: &'a crate::identity_sink::FreshAgentSettings, + /// The session it replaces (an undo's original): its row retires and links + /// to this one, and its rollback record follows in the same write. + pub(super) supersedes: Option<&'a str>, + /// Whose it is (a fork's forking connection); `None` inherits. + pub(super) provenance: Option, + /// The launch's coordinator claim: the write is fenced with its pair. + pub(super) ticket: &'a Option, +} + /// What a launch that boots a durable session from its recorded state needs /// for its one typed retry (design §4.2, [`retry_refused_boot`]): the session, /// and the genesis its binding row records. @@ -240,6 +274,84 @@ pub(super) struct StartWatch<'a> { } impl FreshClaudeState { + /// 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 + /// kill handle is killed, its whole tree confirmed dead by its ownership + /// tag, and the claim retried once; one that cannot be confirmed dead keeps + /// the id closed. + pub(super) async fn claim_launch_lease(&self, durable: &str, request_id: &str) -> LaunchLease { + for round in 0..2u8 { + match self.leases.claim( + PROVIDER, + durable, + request_id, + crate::session_lease::now_epoch_ms(), + ) { + crate::session_lease::FreshSessionClaim::Acquired => { + return LaunchLease::Acquired(crate::FreshSessionLeaseGuard::armed( + Arc::clone(&self.leases), + PROVIDER, + durable, + request_id, + )); + } + crate::session_lease::FreshSessionClaim::BoundLive { live_session_key } => { + return LaunchLease::BoundLive { live_session_key }; + } + crate::session_lease::FreshSessionClaim::Held { .. } => return LaunchLease::Held, + crate::session_lease::FreshSessionClaim::ExpiredNeedsKill { pid, ownership_id } => { + if round == 0 + && crate::session_lease::kill_and_confirm_tree_dead( + pid, + CLAUDE_SIDECAR_OWNERSHIP_ENV, + &ownership_id, + ) + .await + { + self.leases + .force_release_after_confirmed_kill(PROVIDER, durable); + continue; + } + tracing::error!(target: "invariant", pid, session_id = %durable, + request_id = %request_id, + "fresh_agent_lease_expired_kill_unconfirmed: holding closed"); + return LaunchLease::ExpiredUnconfirmed; + } + } + } + LaunchLease::ExpiredUnconfirmed + } + + /// Write the binding row of a session the launch minted (`binding`), + /// awaited: durable before the launch answers. A lane with no ledger + /// records nothing. + pub(super) async fn record_genesis_binding( + &self, + binding: GenesisBinding<'_>, + ) -> std::io::Result<()> { + let Some(sink) = self.identity_sink() else { + return Ok(()); + }; + let fenced = binding.ticket.as_ref().map(|ticket| ticket.generation()); + sink.record_binding(crate::identity_sink::FreshAgentBindingUpsert { + genesis: crate::identity_sink::GenesisUpdate::Set(binding.genesis.clone()), + provider: PROVIDER.into(), + session_id: binding.session_id.to_string(), + mode: binding.session_type.to_string(), + create_request_id: None, + resolves_pending: None, + supersedes: binding.supersedes.map(str::to_string), + name_transition: None, + provenance: binding.provenance.into(), + observed_epoch: fenced.and(self.ownership.as_ref().map(|r| r.boot_epoch())), + observed_generation: fenced, + authoritative: false, + settings: binding.settings.clone(), + }) + .await + } + /// The transcript store this lane's sidecars write: the roots their CLI /// resolves. That is this process's environment, unless the lane gives its /// sidecars their own (the `test-support` seam a rig uses), whose diff --git a/crates/freshell-freshagent/src/claude/fork.rs b/crates/freshell-freshagent/src/claude/fork.rs index d99808c99..74011376e 100644 --- a/crates/freshell-freshagent/src/claude/fork.rs +++ b/crates/freshell-freshagent/src/claude/fork.rs @@ -20,7 +20,9 @@ use crate::claude_handshake::RESUME_POINT_NOT_FOUND_CODE; use crate::claude_launch::{ClaudeBoot, ClaudeGenesis, ClaudeLaunchSpec}; use crate::claude_snapshot as snap; -use super::establish::{EstablishError, EstablishLane, EstablishPlan, RegisterUnder}; +use super::establish::{ + EstablishError, EstablishLane, EstablishPlan, GenesisBinding, RegisterUnder, +}; use super::history::EffectiveTranscriptError; /// A fork while the parent runs a turn (`BUSY_TURN`). @@ -423,35 +425,26 @@ impl FreshClaudeState { // answer): how it came to be (its genesis, so a restart before its first // send boots the same fork), the parent's settings and the forking // connection's attribution. No `supersedes`: the parent lives on. - if let Some(sink) = self.identity_sink() { - let fenced = own_ticket.as_ref().map(|ticket| ticket.generation()); - if let Err(error) = sink - .record_binding(crate::identity_sink::FreshAgentBindingUpsert { - genesis: crate::identity_sink::GenesisUpdate::Set(genesis.clone()), - provider: PROVIDER.into(), - session_id: child.clone(), - mode: session_type.to_string(), - create_request_id: None, - resolves_pending: None, - supersedes: None, - name_transition: None, - provenance: fork_provenance.clone().into(), - observed_epoch: fenced.and(self.ownership.as_ref().map(|r| r.boot_epoch())), - observed_generation: fenced, - authoritative: false, - settings: launch_settings.clone(), - }) - .await - { - tracing::error!(target: "freshell_freshagent::claude", - event = "freshagent.claude.fork_binding_write_failed", - session_id = %parent.durable, child = %child, error = %error, - "the fork child's binding could not be written; the child is torn down" - ); - self.unwind_fork_child(&child, false).await; - refuse("INTERNAL_ERROR", FORK_LEDGER_WRITE_MESSAGE); - return; - } + if let Err(error) = self + .record_genesis_binding(GenesisBinding { + session_id: &child, + session_type, + genesis: &genesis, + settings: &launch_settings, + supersedes: None, + provenance: fork_provenance.clone(), + ticket: &own_ticket, + }) + .await + { + tracing::error!(target: "freshell_freshagent::claude", + event = "freshagent.claude.fork_binding_write_failed", + session_id = %parent.durable, child = %child, error = %error, + "the fork child's binding could not be written; the child is torn down" + ); + self.unwind_fork_child(&child, false).await; + refuse("INTERNAL_ERROR", FORK_LEDGER_WRITE_MESSAGE); + return; } // Again once the row is written: from here to the answer nothing diff --git a/crates/freshell-freshagent/src/claude/rollback.rs b/crates/freshell-freshagent/src/claude/rollback.rs index 105ef2771..c674ca9d6 100644 --- a/crates/freshell-freshagent/src/claude/rollback.rs +++ b/crates/freshell-freshagent/src/claude/rollback.rs @@ -15,7 +15,7 @@ use crate::claude_snapshot as snap; use crate::rollback_record::*; use super::establish::{ - EstablishError, EstablishLane, EstablishPlan, InheritedRuntime, RegisterUnder, + EstablishError, EstablishLane, EstablishPlan, GenesisBinding, InheritedRuntime, RegisterUnder, }; use super::history::EffectiveTranscriptError; @@ -497,66 +497,21 @@ impl FreshClaudeState { None, ); } - for round in 0..2u8 { - match self.leases.claim( - PROVIDER, - &durable_id, - &rollback_lease_id, - crate::session_lease::now_epoch_ms(), - ) { - crate::session_lease::FreshSessionClaim::Acquired => { - lease_guard = Some(crate::FreshSessionLeaseGuard::armed( - Arc::clone(&self.leases), - PROVIDER, - &durable_id, - &rollback_lease_id, - )); - break; - } - crate::session_lease::FreshSessionClaim::BoundLive { live_session_key } => { - if live_session_key == map_key { - // We ARE the bound live owner of this exact id — proceed - // WITHOUT a lease (the binding already names this map key, - // which the fork keeps). - break; - } - reply_sink(rollback_error_frame( - &op, - "SESSION_RESERVED", - "Another resume for this session is in flight", - )); - return; - } - crate::session_lease::FreshSessionClaim::Held { .. } => { - reply_sink(rollback_error_frame( - &op, - "SESSION_RESERVED", - "Another resume for this session is in flight", - )); - return; - } - crate::session_lease::FreshSessionClaim::ExpiredNeedsKill { pid, ownership_id } => { - if round == 0 - && crate::session_lease::kill_and_confirm_tree_dead( - pid, - CLAUDE_SIDECAR_OWNERSHIP_ENV, - &ownership_id, - ) - .await - { - self.leases - .force_release_after_confirmed_kill(PROVIDER, &durable_id); - continue; - } - tracing::error!(target: "invariant", pid, session_id = %durable_id, - "fresh_agent_lease_expired_kill_unconfirmed: holding closed"); - reply_sink(rollback_error_frame( - &op, - "SESSION_RESERVED", - "Another resume for this session is in flight", - )); - return; - } + match self + .claim_launch_lease(&durable_id, &rollback_lease_id) + .await + { + LaunchLease::Acquired(guard) => lease_guard = Some(guard), + // We ARE the bound live owner of this exact id — proceed WITHOUT a + // lease (the binding already names this map key). + LaunchLease::BoundLive { live_session_key } if live_session_key == map_key => {} + LaunchLease::BoundLive { .. } | LaunchLease::Held | LaunchLease::ExpiredUnconfirmed => { + reply_sink(rollback_error_frame( + &op, + "SESSION_RESERVED", + "Another resume for this session is in flight", + )); + return; } } @@ -1177,43 +1132,34 @@ impl FreshClaudeState { // the original's settings, `supersedes` = the original, which retires // the original's row and links it to the replacement) with the // rollback record re-keyed to the replacement in the same write. - if let Some(sink) = self.identity_sink() { - let fenced = own_ticket.as_ref().map(|ticket| ticket.generation()); - if let Err(error) = sink - .record_binding(crate::identity_sink::FreshAgentBindingUpsert { - genesis: crate::identity_sink::GenesisUpdate::Set(genesis.clone()), - provider: PROVIDER.into(), - session_id: replacement.clone(), - mode: session_type.to_string(), - create_request_id: None, - resolves_pending: None, - supersedes: Some(durable_id.clone()), - name_transition: None, - provenance: None.into(), - observed_epoch: fenced.and(self.ownership.as_ref().map(|r| r.boot_epoch())), - observed_generation: fenced, - authoritative: false, - settings: launch_settings.clone(), - }) - .await - { - tracing::error!(target: "freshell_freshagent::claude", - event = "freshagent.claude.rollback_binding_write_failed", - session_id = %durable_id, replacement = %replacement, error = %error, - "the replacement's binding could not be written; it is torn down and the \ - original stays live" - ); - self.unwind_replacement(&replacement, &durable_id, lease_held) - .await; - self.compensate_rollback_record(&durable_id, existing.clone()) - .await; - reply_sink(rollback_error_frame( - &op, - "INTERNAL_ERROR", - LEDGER_WRITE_REFUSAL_COPY, - )); - return; - } + if let Err(error) = self + .record_genesis_binding(GenesisBinding { + session_id: &replacement, + session_type, + genesis: &genesis, + settings: &launch_settings, + supersedes: Some(&durable_id), + provenance: None, + ticket: &own_ticket, + }) + .await + { + tracing::error!(target: "freshell_freshagent::claude", + event = "freshagent.claude.rollback_binding_write_failed", + session_id = %durable_id, replacement = %replacement, error = %error, + "the replacement's binding could not be written; it is torn down and the \ + original stays live" + ); + self.unwind_replacement(&replacement, &durable_id, lease_held) + .await; + self.compensate_rollback_record(&durable_id, existing.clone()) + .await; + reply_sink(rollback_error_frame( + &op, + "INTERNAL_ERROR", + LEDGER_WRITE_REFUSAL_COPY, + )); + return; } // Ownership moves to the replacement in ONE coordinator step (the From 1eafb7c88cf07e9fe4c3c6a3239988f8068ec7ef Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:57:57 -0700 Subject: [PATCH 22/24] test(freshclaude): e2e lane for the honest undo refusal (F8, decision 2) No browser lane exercised ROLLBACK_REFUSED: the refusal and the pane staying usable afterwards were pinned only by the Rust test an_undo_of_a_turn_claude_folded_a_message_into_is_refused_and_the_session_ stays_live, so a client regression in how the refusal shows (the composer refill, the pane left waiting on the undo) would pass every e2e spec. New lane in fresh-agent-rollback-rust.spec.ts (real sidecar over the CLI simulator): one prompt, then a background agent (`AGENT:async`) whose completion notice the CLI writes into the launching turn (T19); /undo is refused with the server's pinned reason on the notice banner, the simulator booted exactly one guarded fork (never a guard-less retry), the pane keeps its durable id, its conversation and an empty composer, and the next prompt runs in the same conversation. Co-Authored-By: Claude Opus 5.5 --- .../specs/fresh-agent-rollback-rust.spec.ts | 74 ++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/test/e2e-browser/specs/fresh-agent-rollback-rust.spec.ts b/test/e2e-browser/specs/fresh-agent-rollback-rust.spec.ts index d84b820ca..7b6a57847 100644 --- a/test/e2e-browser/specs/fresh-agent-rollback-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-rollback-rust.spec.ts @@ -56,7 +56,7 @@ * ../helpers/ws-capture.js (the shared helper — never re-declared). */ import fs from 'node:fs/promises' -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, readdirSync, readFileSync } from 'node:fs' import crypto from 'node:crypto' import os from 'node:os' import path from 'node:path' @@ -312,6 +312,17 @@ function simForks(simLedger: string): any[] { return simBoots(simLedger).filter((options) => options.forkSession === true) } +/** `.jsonl` under the server home's `.claude/projects` tree, once written. */ +function findClaudeTranscript(homeDir: string, sessionId: string): string | null { + const projects = path.join(homeDir, '.claude', 'projects') + if (!existsSync(projects)) return null + for (const project of readdirSync(projects)) { + const candidate = path.join(projects, project, `${sessionId}.jsonl`) + if (existsSync(candidate)) return candidate + } + return null +} + /** Boot the claude lane: create a freshclaude/kilroy pane on an owned server. */ async function bootClaudeLane( page: Page, @@ -1102,6 +1113,67 @@ test.describe('fresh-agent /undo + /redo conversation rollback (rust, kata 1wxv) } }) + test('claude: /undo of a turn Claude folded a background agent\'s notice into is refused honestly; the pane keeps its conversation and takes the next prompt (decision 2)', async ({ page }) => { + const lane = await bootClaudeLane(page, 'freshclaude') + try { + // A booted pane is `connected` until its first turn settles it `idle`. + await waitForPaneStatus(lane.harness, lane.tabId, ['idle', 'connected']) + await sendComposerText(page, 'prompt one') + const ORIG = await captureDurableId(lane.harness, lane.tabId, CANONICAL_UUID_RE) + await expect + .poll(async () => userRows(await fetchSnapshot(lane.info, 'freshclaude', 'claude', ORIG)), { timeout: 15_000 }) + .toBe(1) + await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + // A background agent: its completion notice is written into the turn + // that launched it (T19), so that turn holds a message the pane never + // showed as its own. + await sendComposerText(page, 'AGENT:async') + await expect + .poll( + () => { + const transcript = findClaudeTranscript(lane.info.homeDir, ORIG) + return transcript + ? readJsonl(transcript).some((line) => line?.origin?.kind === 'task-notification') + : false + }, + { timeout: 30_000 }, + ) + .toBe(true) + await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + const rowsBefore = userRows(await fetchSnapshot(lane.info, 'freshclaude', 'claude', ORIG)) + const bootsBefore = simBoots(lane.simLedger).length + await waitForRollbackCapability(page) + + await typeSlash(page, '/undo') + + // The CLI refused the guarded fork; the server's pinned reason renders + // verbatim on the notice banner, never a guard-less retry. + await expect( + page.getByText("Claude folded another message into that turn, so it can't be undone to that point."), + 'the honest refusal renders on the notice banner', + ).toBeVisible({ timeout: 30_000 }) + const boots = simBoots(lane.simLedger).slice(bootsBefore) + expect(boots, 'one guarded fork, never a guard-less retry').toHaveLength(1) + expect(boots[0].forkSession).toBe(true) + expect(typeof boots[0].resumeDropsTurn).toBe('string') + // Nothing moved: the pane keeps ORIG, its conversation and an empty + // composer (no refill: nothing was undone). + expect((await paneLeaf(lane.harness, lane.tabId))?.content?.sessionRef?.sessionId).toBe(ORIG) + expect(userRows(await fetchSnapshot(lane.info, 'freshclaude', 'claude', ORIG))).toBe(rowsBefore) + await expect(composerLocator(page)).toHaveValue('') + + // The pane stays usable: the next prompt runs in ORIG. + await sendComposerText(page, 'still here?') + await expect + .poll(async () => userRows(await fetchSnapshot(lane.info, 'freshclaude', 'claude', ORIG)), { timeout: 15_000 }) + .toBe(rowsBefore + 1) + expect((await paneLeaf(lane.harness, lane.tabId))?.content?.sessionRef?.sessionId).toBe(ORIG) + } finally { + await lane.server.stop().catch(() => {}) + await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) + } + }) + test('multi-client convergence: sibling raw-WS client + REST see the same post-rollback truth (decision 10)', async ({ page }) => { const lane = await bootOpencodeLane(page) try { From 178401c1de5ce8ea34570ff27e5dc326419ee345 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 05:47:48 -0700 Subject: [PATCH 23/24] test(claude-sidecar): close the exiting sidecar's stdout reader in a relay process The two "exits on its own" stdout-loss cases need the ONE write a sidecar makes after losing its stdout reader to fail with EPIPE. They closed the reader inside the Vitest process, which also spawns children for other test files; each child holds a copy of every descriptor from fork until exec, and for a large Vitest process that window is long. A write in it lands in the copy, no EPIPE follows, and the test sees no stdout.closed record (seen in a 79-file focused run: both cases failed, exit codes right, stdout.closed missing). Reproduced by repeating the boot-refused case while another test file in the same process held 4 GB and spawned /bin/true in a loop: 20 of 20 runs missed the EPIPE with the reader closed in the test process, 0 of 20 with the reader in the relay (twice each). The relay (fixtures/stdout-reader-relay.mjs) runs the sidecar entry as its only child, forwards stdin and stdout lines, inherits stderr, and on SIGUSR2 closes the only reader and answers relay.reader_dropped; it exits the way the sidecar exits. The mid-stream case keeps closing the reader in-process: its sidecar keeps writing, so a later write fails. Co-Authored-By: Claude Opus 5.5 --- .../fixtures/stdout-reader-relay.mjs | 49 +++++++++++++++++++ .../stdout-loss-shutdown.test.ts | 27 ++++++++-- 2 files changed, 71 insertions(+), 5 deletions(-) create mode 100644 test/unit/claude-sidecar/fixtures/stdout-reader-relay.mjs diff --git a/test/unit/claude-sidecar/fixtures/stdout-reader-relay.mjs b/test/unit/claude-sidecar/fixtures/stdout-reader-relay.mjs new file mode 100644 index 000000000..eea36f803 --- /dev/null +++ b/test/unit/claude-sidecar/fixtures/stdout-reader-relay.mjs @@ -0,0 +1,49 @@ +// Runs a sidecar entry (RELAY_ENTRY) as this process's only child and is the +// only process that holds the read end of the sidecar's stdout. Requests on +// this process's stdin go to the sidecar, its stdout lines come back here, +// and its stderr is this process's stderr. A `relay.ready` line says SIGUSR2 +// is handled: it closes that read end and answers with a +// `relay.reader_dropped` line. The relay exits the way the sidecar exits. +// +// A test that closes the reader in the test process itself cannot be sure +// the reader is gone: the test process spawns children for other test files +// too, and every child holds copies of all of its descriptors from fork until +// exec, which takes long for a large test process. A sidecar write in that +// window lands in the copy instead of failing with EPIPE. This relay forks +// once, before the reader exists anywhere else, so once it closes the reader +// nothing else holds one. + +import { spawn } from 'node:child_process' +import { createInterface } from 'node:readline' + +const entry = process.env.RELAY_ENTRY +if (!entry) { + process.stderr.write('RELAY_ENTRY is required\n') + process.exit(64) +} + +const sidecar = spawn(process.execPath, [entry], { stdio: ['pipe', 'pipe', 'inherit'] }) +sidecar.on('error', (error) => { + process.stderr.write(`the relay could not start ${entry}: ${error.message}\n`) + process.exit(70) +}) +// The relay's own end of the request pipe closing (the test killed it) ends +// the sidecar's stdin too, so a sidecar never outlives its relay. +process.stdin.pipe(sidecar.stdin) +sidecar.stdin.on('error', () => { /* the sidecar exited; a late request is moot */ }) + +const lines = createInterface({ input: sidecar.stdout }) +lines.on('line', (line) => process.stdout.write(`${line}\n`)) + +process.on('SIGUSR2', () => { + lines.close() + sidecar.stdout.destroy() + process.stdout.write(`${JSON.stringify({ type: 'relay.reader_dropped' })}\n`) +}) +// Before the handler above, SIGUSR2 would end this process. +process.stdout.write(`${JSON.stringify({ type: 'relay.ready' })}\n`) + +sidecar.on('exit', (code, signal) => { + if (signal) process.kill(process.pid, signal) + else process.exit(code ?? 1) +}) diff --git a/test/unit/claude-sidecar/stdout-loss-shutdown.test.ts b/test/unit/claude-sidecar/stdout-loss-shutdown.test.ts index 1407435d4..e529bc7a2 100644 --- a/test/unit/claude-sidecar/stdout-loss-shutdown.test.ts +++ b/test/unit/claude-sidecar/stdout-loss-shutdown.test.ts @@ -17,7 +17,7 @@ import { createInterface } from 'node:readline' import { fileURLToPath } from 'node:url' import { BOOT_FAILED_EXIT_CODE } from '../../../crates/freshell-claude-sidecar/boot.mjs' import { STREAM_ENDED_EXIT_CODE } from '../../../crates/freshell-claude-sidecar/stream-end.mjs' -import { SIDECAR_INDEX, startRealSidecar } from './harness' +import { SIDECAR_INDEX, startRealSidecar, type RealSidecar } from './harness' const children = new Set() const directories: string[] = [] @@ -87,14 +87,31 @@ function logRecords(stderr: string): Record[] { * The production sidecar behind a slow stderr reader: its exit flush (the * wait of a sidecar exiting on its own) takes half a second, so the stdout * error of a write into a readerless pipe arrives while it is still exiting. + * + * Its stdout reader lives in a relay process (stdout-reader-relay.mjs), not + * in this test process: these cases need the ONE write the sidecar makes + * after losing its reader to fail with EPIPE, and a child this process spawns + * for another test file holds a copy of every descriptor here until it execs, + * long enough for that write to land in the copy instead. */ function startWithSlowStderr() { return startRealSidecar({ - entry: fileURLToPath(new URL('./fixtures/slow-stderr-flush-entry.mjs', import.meta.url)), - env: { SIDECAR_INDEX_PATH: SIDECAR_INDEX, STDERR_FLUSH_DELAY_MS: '500' }, + entry: fileURLToPath(new URL('./fixtures/stdout-reader-relay.mjs', import.meta.url)), + env: { + RELAY_ENTRY: fileURLToPath(new URL('./fixtures/slow-stderr-flush-entry.mjs', import.meta.url)), + SIDECAR_INDEX_PATH: SIDECAR_INDEX, + STDERR_FLUSH_DELAY_MS: '500', + }, }) } +/** Close the only reader of the sidecar's stdout, and wait until it is closed. */ +async function dropStdoutReader(sidecar: RealSidecar) { + await sidecar.waitFor('relay.ready', 'the relay to handle SIGUSR2') + sidecar.child.kill('SIGUSR2') + await sidecar.waitFor('relay.reader_dropped', 'the relay to close its stdout reader') +} + // A sidecar that is already exiting on its own (its SDK stream ended, exit 3; // its boot was refused, exit 2) has closed its query. Losing its stdout reader // then changes nothing: it logs `stdout.closed` and keeps its own exit code, @@ -107,7 +124,7 @@ describe('Claude sidecar losing its stdout reader while it exits on its own', () // The boot catalog is the last line an idle session writes. await sidecar.waitFor((frame) => frame.type === 'sdk.session.changed', 'the boot catalog') - sidecar.child.stdout!.destroy() + await dropStdoutReader(sidecar) // The CLI dies under the idle session: sdk.stream.ended is the first write // into the readerless pipe, made by a sidecar already exiting with code 3. process.kill(cliPid, 'SIGKILL') @@ -124,7 +141,7 @@ describe('Claude sidecar losing its stdout reader while it exits on its own', () it('a boot refused after the reader went away still exits 2', async () => { const sidecar = await startWithSlowStderr() - sidecar.child.stdout!.destroy() + await dropStdoutReader(sidecar) // A create that does not speak protocol 2 is refused at once: create.failed // is the first write into the readerless pipe. sidecar.send({ type: 'create', protocol: 1, boot: { kind: 'fresh' }, requestId: 'create', cwd: sidecar.cwd }) From 0ecfb656f7ed611f207368b3e438474ee2d5af40 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 06:17:20 -0700 Subject: [PATCH 24/24] test(freshell-ws): wait for a runtime's exit through the shared ProcessProbe The fork and rollback integration tests each carried a private copy of `process_gone`, with a weaker rule than the ProcessProbe that #864 added for the freshagent tests: any `Z` state counted as gone (a killed multi-threaded node whose other threads are still exiting read as gone early) and the pid was not pinned to its start time (a reused pid read as still running until the budget ran out). freshell-ws's test harness now includes the same process_probe.rs file, and ClaudeRig gains `runtime_pids` and `runtime_processes`, which opens a probe on the sidecar and simulated CLI while they run (asserting they do, so a wrong pid can no longer make the wait pass vacuously). The rollback test opens A's probes before the undo; the fork test opens the child's while its fork is parked, before the pane closes. Both tests then wait with `RuntimeProcesses::wait_until_exited`. Co-Authored-By: Claude Opus 5.5 --- .../tests/support/process_probe.rs | 5 +- crates/freshell-ws/tests/common/claude_rig.rs | 52 +++++++++++++++++++ crates/freshell-ws/tests/common/mod.rs | 4 ++ .../tests/freshagent_claude_fork.rs | 45 ++++------------ .../tests/freshagent_claude_rollback.rs | 41 ++------------- 5 files changed, 72 insertions(+), 75 deletions(-) diff --git a/crates/freshell-freshagent/tests/support/process_probe.rs b/crates/freshell-freshagent/tests/support/process_probe.rs index 0da793de5..12085c41d 100644 --- a/crates/freshell-freshagent/tests/support/process_probe.rs +++ b/crates/freshell-freshagent/tests/support/process_probe.rs @@ -29,8 +29,9 @@ //! died a moment later. Only a kill nothing confirms (dropping the guard, a //! test reaping its own zombie later) is given time, with `exits_within`. //! -//! Shared by the `claude_sidecar_teardown` integration test and the crate's -//! unit tests (`lib.rs` includes this file under `cfg(all(test, unix))`). +//! Shared by the `claude_sidecar_teardown` integration test, the crate's +//! unit tests (`lib.rs` includes this file under `cfg(all(test, unix))`) and +//! `freshell-ws`'s integration tests (`tests/common/mod.rs` includes it). #![allow(dead_code)] // each including test crate uses a different subset use std::time::{Duration, Instant}; diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index 65b3d0497..e80f51ae7 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -37,6 +37,7 @@ use tokio_tungstenite::tungstenite::Message as WsMessage; use freshell_ws::WsState; +use super::process_probe::ProcessProbe; use super::{handshake_settings_lock, test_settings_value, TestWs, AUTH_TOKEN}; /// The repository root (the rigs read the sidecar, the simulator and the provider @@ -459,6 +460,57 @@ impl ClaudeRig { tokio::time::sleep(Duration::from_millis(50)).await; } } + + /// The pids of the sidecar and the simulated CLI of the runtime that booted + /// `session_id`, from its `boot` row. + pub fn runtime_pids(&self, session_id: &str) -> [i64; 2] { + let boot = self + .ledger_rows("boot") + .into_iter() + .find(|row| row["sessionId"] == session_id) + .unwrap_or_else(|| panic!("a boot row for {session_id}")); + [ + boot["pid"].as_i64().expect("the sidecar's pid"), + boot["cliPid"].as_i64().expect("the CLI's pid"), + ] + } + + /// The processes of the runtime that booted `session_id` (see + /// [`Self::runtime_pids`]), observed from now on. Take them while that + /// runtime runs: a later process that reuses one of the ids is then never + /// mistaken for it. + pub fn runtime_processes(&self, session_id: &str) -> RuntimeProcesses { + let pids = self.runtime_pids(session_id); + let probes = pids.map(|pid| { + let probe = ProcessProbe::open(u32::try_from(pid).expect("a process id")); + assert!( + probe.running(), + "process {pid} of {session_id}'s runtime runs when it is first observed" + ); + probe + }); + RuntimeProcesses { pids, probes } + } +} + +/// A runtime's sidecar and simulated CLI, observed since they ran +/// ([`ClaudeRig::runtime_processes`]). +pub struct RuntimeProcesses { + pids: [i64; 2], + probes: [ProcessProbe; 2], +} + +impl RuntimeProcesses { + /// Returns once both processes have exited (a zombie has: it runs nothing + /// and holds no pipes), or fails naming `what`. + pub async fn wait_until_exited(&self, what: &str) { + for (pid, probe) in self.pids.iter().zip(&self.probes) { + assert!( + probe.exits_within(RIG_FRAME_BUDGET).await, + "{what}: process {pid} is still running" + ); + } + } } /// One client connection to a rig's server. Every frame it reads is kept in diff --git a/crates/freshell-ws/tests/common/mod.rs b/crates/freshell-ws/tests/common/mod.rs index d388e9aee..3bc61b782 100644 --- a/crates/freshell-ws/tests/common/mod.rs +++ b/crates/freshell-ws/tests/common/mod.rs @@ -21,6 +21,10 @@ pub mod claude_rig; pub mod ledger_sink; pub mod log_capture; pub mod opencode_home; +/// Whether a process a test started has exited (a zombie has). The same file +/// `freshell-freshagent`'s tests use, so the rule is defined once. +#[path = "../../../freshell-freshagent/tests/support/process_probe.rs"] +pub mod process_probe; pub const AUTH_TOKEN: &str = "s3cr3t-token-abcdef"; diff --git a/crates/freshell-ws/tests/freshagent_claude_fork.rs b/crates/freshell-ws/tests/freshagent_claude_fork.rs index 2502a75eb..db845b881 100644 --- a/crates/freshell-ws/tests/freshagent_claude_fork.rs +++ b/crates/freshell-ws/tests/freshagent_claude_fork.rs @@ -538,17 +538,6 @@ async fn a_child_whose_sidecar_died_before_its_first_send_resumes_through_the_cr .any(|line| line["message"]["content"][0]["text"] == "second prompt")); } -/// Whether process `pid` is gone (or a zombie, which holds no pipes). -fn process_gone(pid: i64) -> bool { - match std::fs::read_to_string(format!("/proc/{pid}/stat")) { - Err(_) => true, - Ok(stat) => stat - .rsplit_once(')') - .map(|(_, rest)| rest.trim_start().starts_with('Z')) - .unwrap_or(false), - } -} - /// The user closes the pane (the client kills the parent) while the fork's /// child is still booting. Nothing would ever show or close that child, so the /// fork must not hand it out: it answers a refusal instead of `forked`, the @@ -565,6 +554,15 @@ async fn closing_the_pane_while_the_child_boots_leaves_no_child_behind() { let seen = conn.frames.len(); request_fork(&mut conn, &pane, &a, "fork-1", None).await; hold.parked().await; + let boot = queries(&rig) + .into_iter() + .find(|o| o["forkSession"] == true) + .expect("the child booted"); + let child = boot["sessionId"] + .as_str() + .expect("the child's id") + .to_string(); + let child_runtime = rig.runtime_processes(&child); conn.kill(&pane).await; hold.release(); @@ -574,34 +572,11 @@ async fn closing_the_pane_while_the_child_boots_leaves_no_child_behind() { "no child is handed out: {answer}" ); assert_eq!(answer["event"]["code"], "FORK_REFUSED", "{answer}"); - let boot = queries(&rig) - .into_iter() - .find(|o| o["forkSession"] == true) - .expect("the child booted"); - let child = boot["sessionId"] - .as_str() - .expect("the child's id") - .to_string(); assert!( !rig.fresh_claude.has_live_session(&child).await, "the child is torn down" ); - let pids: Vec = rig - .ledger_rows("boot") - .into_iter() - .filter(|row| row["sessionId"] == child.as_str()) - .flat_map(|row| [row["pid"].as_i64(), row["cliPid"].as_i64()]) - .flatten() - .collect(); - assert_eq!(pids.len(), 2, "the child's sidecar and CLI pids"); - let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; - while !pids.iter().all(|pid| process_gone(*pid)) { - assert!( - tokio::time::Instant::now() < deadline, - "the child's processes ({pids:?}) are still running" - ); - tokio::time::sleep(Duration::from_millis(20)).await; - } + child_runtime.wait_until_exited("the child's runtime").await; assert!( ledger(&rig) .load_binding("claude", &child) diff --git a/crates/freshell-ws/tests/freshagent_claude_rollback.rs b/crates/freshell-ws/tests/freshagent_claude_rollback.rs index 1d69ee736..ebfa95503 100644 --- a/crates/freshell-ws/tests/freshagent_claude_rollback.rs +++ b/crates/freshell-ws/tests/freshagent_claude_rollback.rs @@ -254,41 +254,6 @@ fn transcript_of(rig: &ClaudeRig, session_id: &str) -> Option { .find(|path| path.file_name().and_then(|n| n.to_str()) == Some(name.as_str())) } -/// Whether process `pid` is gone (or a zombie, which holds no pipes). -fn process_gone(pid: i64) -> bool { - match std::fs::read_to_string(format!("/proc/{pid}/stat")) { - Err(_) => true, - Ok(stat) => stat - .rsplit_once(')') - .map(|(_, rest)| rest.trim_start().starts_with('Z')) - .unwrap_or(false), - } -} - -/// The sidecar and stand-in CLI processes of the runtime that booted `session_id`. -fn runtime_pids(rig: &ClaudeRig, session_id: &str) -> Vec { - let boot = rig - .ledger_rows("boot") - .into_iter() - .find(|row| row["sessionId"] == session_id) - .unwrap_or_else(|| panic!("a boot row for {session_id}")); - vec![ - boot["pid"].as_i64().expect("the sidecar's pid"), - boot["cliPid"].as_i64().expect("the CLI's pid"), - ] -} - -async fn wait_until_gone(pids: &[i64], what: &str) { - let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; - while !pids.iter().all(|pid| process_gone(*pid)) { - assert!( - tokio::time::Instant::now() < deadline, - "{what} ({pids:?}) is still running" - ); - tokio::time::sleep(Duration::from_millis(20)).await; - } -} - fn assert_never_chimes(conn: &RigConnection) { assert_eq!( conn.events_seen("freshAgent.turn.complete"), @@ -324,7 +289,7 @@ async fn an_undo_answers_in_time_with_a_fork_materialized_under_a_new_id() { let lines = chain_lines(&a_path); let u2 = prompt_uuid(&lines, "second prompt"); let keep = uuid_before(&lines, &u2); - let a_runtime = runtime_pids(&rig, &a); + let a_runtime = rig.runtime_processes(&a); let (ack, c) = rollback_ok(&mut conn, "undo", &pane, &a, "undo-1").await; assert_eq!(ack["removedPromptText"], "second prompt", "{ack}"); @@ -385,7 +350,7 @@ async fn an_undo_answers_in_time_with_a_fork_materialized_under_a_new_id() { "the fork writes nothing yet (T6)" ); assert_eq!(std::fs::read_to_string(&a_path).expect("read A"), a_before); - wait_until_gone(&a_runtime, "A's runtime").await; + a_runtime.wait_until_exited("A's runtime").await; assert!(rig.fresh_claude.has_live_session(&c).await); assert_never_chimes(&conn); } @@ -885,7 +850,7 @@ async fn an_undo_forks_from_the_transcript_it_located_even_if_the_store_stops_li /// SIGKILL the sidecar of the runtime that booted `session_id`, then wait for /// the pane's `SIDECAR_EXITED` (the pane then shows "Resume session"). async fn kill_sidecar_of(rig: &ClaudeRig, conn: &mut RigConnection, session_id: &str) { - let sidecar = runtime_pids(rig, session_id)[0]; + let [sidecar, _cli] = rig.runtime_pids(session_id); // SAFETY: a plain signal to a sidecar process this rig's server spawned. assert_eq!(unsafe { libc::kill(sidecar as i32, libc::SIGKILL) }, 0); conn.wait_for("the sidecar's exit", |v| {