diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 91f7a9782..ccdda204f 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -69,14 +69,14 @@ 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}; -use crate::claude_launch::{create_line, ClaudeBoot}; +use crate::claude_launch::{ClaudeBoot, ClaudeLaunchSpec}; use crate::{FreshAgentCreateDedup, FreshAgentCreateOutcome, SharedPaneIdentitySink}; /// The runtime provider (`AGENT_SESSION_TYPES.claude.provider`). @@ -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,10 +244,13 @@ 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 + /// 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 @@ -559,22 +557,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, @@ -688,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 @@ -713,6 +694,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 @@ -812,6 +799,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(), @@ -866,10 +854,27 @@ 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() + } + + /// 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 { @@ -891,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. @@ -1422,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(), @@ -1587,7 +1557,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 +1575,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 +1635,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 — @@ -2023,7 +1995,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 +2015,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 +2089,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 +2115,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 +2186,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 +2214,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,60 +2237,21 @@ 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 { - 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). - self.adopt_live_create(&request_id, sid, session_type).await; - return; - } - crate::session_lease::FreshSessionClaim::Held { .. } => { - self.fail_create_session_reserved(&request_id); - 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, sid); - continue; - } - tracing::error!(target: "invariant", pid, session_id = sid, - "fresh_agent_lease_expired_kill_unconfirmed: holding closed"); - self.fail_create_session_reserved(&request_id); - return; - } + 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); } } } @@ -2368,9 +2299,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 +2317,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 { @@ -2399,7 +2329,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 @@ -2410,437 +2340,126 @@ 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)); - 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(); + // 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}"), + ); } - self.fail_create(&request_id, "CLAUDE_SIDECAR_START_FAILED", &err); - return; - } + 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, 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, + ), }; - // 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()), + 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 + // 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, + inherit: None, + genesis: boot_genesis, + retry: resume_sid.clone().map(|durable| BootRetry { + durable, + genesis: recorded_genesis, + }), }, - ); - // 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; - self.fail_create( + &mut lease_guard, + &mut own_ticket, + ) + .await + { + Ok(established) => established, + Err(error) => { + return 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 @@ -2872,43 +2491,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 @@ -2920,33 +2544,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, @@ -2980,6 +2599,10 @@ impl FreshClaudeState { name_ref, session_name, })); + ClaudeCreateOutcome::Created { + session_id: durable.to_string(), + session_ref: Some(claude_session_ref(durable)), + } } // ── freshAgent.kill (WS) ───────────────────────────────────────────────── @@ -4489,9 +4112,85 @@ impl FreshClaudeState { // ── freshAgent.send (WS) ───────────────────────────────────────────────────────── - /// 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 + /// 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 + /// 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>, + 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 = op.followed_replacement_event(), + session_id = %wire_id, + replaced = %map_key, + replacement = %key, + through_ledger_chain = terminus.as_deref() == Some(candidate), + "a request 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 /// subtype except a user-interrupted turn's own → `freshAgent.turn.complete`). /// Claude's send returns void, so NO `submittedTurnId` and NO materialization. pub async fn handle_send(&self, msg: FreshAgentSend) { @@ -4514,51 +4213,38 @@ 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) = 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(), - ) - }) - } - 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 + // 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( + TurnLockedRequest::Send, + &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 @@ -4916,7 +4602,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 @@ -4933,24 +4618,37 @@ 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 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())) - } - 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 + // (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. @@ -5173,10 +4871,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 @@ -5198,40 +4896,39 @@ 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) = 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(), - ) - }) - } - 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 // 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 @@ -5313,21 +5010,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; @@ -5335,9 +5028,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!({}); @@ -5407,16 +5099,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)) } @@ -5480,81 +5163,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. @@ -5601,1340 +5209,98 @@ 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; + /// 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); + /// `cli_index` IS the alias table, so every map consumer works under BOTH keys. + async fn resolve_session_key(&self, session_id: &str) -> Option { + if self.sessions.lock().await.contains_key(session_id) { + return Some(session_id.to_string()); } - 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) { + let mapped = self.cli_index.lock().await.get(session_id).cloned()?; + self.sessions + .lock() + .await + .contains_key(&mapped) + .then_some(mapped) + } + + // ── freshAgent.attach (restart parity: resume untracked sessions in place) ────────── + + /// Handle a `freshAgent.attach` for claude/kilroy. Decision table (restart parity): + /// + /// | State | Action | + /// |---|---| + /// | tracked under `msg.session_id` | no-op -- NO frame (wire-shape parity, unchanged). Safe against dead sidecars ONLY because the consumer-exit eviction removes dead entries (ledger A9) | + /// | untracked, no canonical durable id on the message | `lost_session_frame` (`INVALID_SESSION_ID`) -- unchanged fallback (also covers the verified A2 edge: a pane that never learned its UUID pre-kill attaches bare; lost -> client re-create is the designed, non-destructive outcome) | + /// | untracked, durable id already in `cli_index`, aliased session LIVE | REBIND + ACK (Task 10b): flip the live session's envelope stamp to the durable id and answer with the `freshAgent.session.snapshot` stamped with the durable and the session's TRACKED status (the consumer's turn-lifecycle + sdk.status fold, never a hardcoded idle) -- the attaching client must observe success, never silence. The map is never re-keyed (alias, don't move) | + /// | untracked, durable id in `cli_index` but aliased session GONE (stale row, eviction in flight) | fall through to the resume path below (the session is dead; resuming converges the client) | + /// | untracked, transcript EXISTS (in ANY candidate root) | spawn sidecar and resume with the session's ORIGINAL cwd from `transcript_cwd` (ledger A15: the CLI's resume lookup is cwd-slug-scoped); if that cwd no longer exists, resume by the transcript's `.jsonl` PATH (verified cli.js escape hatch that bypasses slug scoping) with the attach cwd. Register under the CLIENT's `msg.session_id`, emit a `freshAgent.session.snapshot` whose `timelineSessionId` is the durable UUID -- NEVER a nanoid (the frozen client persists it unvalidated, ledger A14/N3) -- carrying the session's tracked status ("idle" for a fresh resume, by construction) | + /// | untracked, transcript ABSENT in EVERY candidate root (every root fully readable), binding row records a genesis (core design §4.2/§4.3: a zero-turn session, a pending fork) | boot it from the genesis under the durable id (`Fresh`, or the lazy fork of its parent), register and acknowledge exactly like the resume row above | + /// | untracked, transcript ABSENT in EVERY candidate root (every root fully readable), no genesis | `lost_session_frame` -- positive denial: the store is the authority (honest even under the 30-day GC, ledger A4) | + /// | untracked, store UNREADABLE (no store root, or an IO error other than NotFound/NotADirectory while scanning) | top-level `error` `CLAUDE_ATTACH_RESUME_FAILED` -- cannot check, so NEVER the lost frame (G28) | + /// | untracked, spawn/pipe/created failure | top-level `error` `CLAUDE_ATTACH_RESUME_FAILED` -- NEVER the lost frame | + pub async fn handle_attach(&self, msg: FreshAgentAttach) { + // b8ke delta review F7: a half-sent observed pair (exactly one of + // epoch/generation) is the typed invalid-fence refusal — before any + // state interaction. The attach never proceeds as a silently + // downgraded legacy request. + let attach_fence = + match crate::ownership_lane::wire_fence(msg.observed_epoch, msg.observed_generation) { Ok(fence) => fence, Err(err) => { tracing::warn!(target: "freshell_freshagent::claude", - session_id = %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())); + session_id = %msg.session_id, code = err.code(), + "fresh_agent_attach_refused: the observed fence is half-sent (invalid)"); + self.emit_fresh_agent_error( + &msg.session_id, + session_type_str(msg.session_type), + err.code(), + err.message(), + ); return; } }; - 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 - } + // b8ke delta round-3 F2: BOTH existing-session early exits consult + // the coordinator — a delayed attach during a Handoff (the map + // entry exists until the stop lands) must be BLOCKED or typed, + // never a silent parity no-op or rebind against a runtime the + // handoff owns. + let coordinator_in_flight = |state: &freshell_ownership::OwnershipState| { + matches!( + state, + freshell_ownership::OwnershipState::Handoff { .. } + | freshell_ownership::OwnershipState::Starting { .. } + | freshell_ownership::OwnershipState::Stopping { .. } + | freshell_ownership::OwnershipState::Fenced { .. } + ) }; - // 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, - )); + if self.sessions.lock().await.contains_key(&msg.session_id) { + // The gate observes the session's CANONICAL durable id (the + // registry keys the coordinator record there — the map key is + // the lane's own). + let durable = { + let sessions = self.sessions.lock().await; + sessions + .get(&msg.session_id) + .and_then(|s| s.cli_session_id.clone()) + }; + let observed_id = durable.as_deref().unwrap_or(&msg.session_id); + let snap = self.ownership_snapshot(PROVIDER, observed_id); + if coordinator_in_flight(&snap.state) { + tracing::warn!(target: "freshell_freshagent::claude", + session_id = %msg.session_id, state = ?snap.state, + "fresh_agent_attach_refused: a lifecycle transition owns this \ + session — the tracked-and-alive fast path is blocked (typed)"); + self.emit_fresh_agent_error( + &msg.session_id, + session_type_str(msg.session_type), + "SESSION_RESERVED", + "A lifecycle operation owns this session; retry after it settles", + ); return; } - } - - // 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 snap::locate_transcript_state(&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 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(); - // 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 snap::locate_transcript_state(&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 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); - ( - 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)); - 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 spawn_cwd = original_cwd - .clone() - .or_else(|| op.cwd.clone()) - .or_else(|| recovered.as_ref().and_then(|r| r.cwd.clone())); - // 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 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(), - 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 create_req = create_line( - &format!("rollback-{}", uuid::Uuid::new_v4()), - session_type, - &boot, - Value::Object(create_req), - ); - match self - .rollback_spawn_create(&create_req, &boot, session_type, 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); - /// `cli_index` IS the alias table, so every map consumer works under BOTH keys. - async fn resolve_session_key(&self, session_id: &str) -> Option { - if self.sessions.lock().await.contains_key(session_id) { - return Some(session_id.to_string()); - } - let mapped = self.cli_index.lock().await.get(session_id).cloned()?; - self.sessions - .lock() - .await - .contains_key(&mapped) - .then_some(mapped) - } - - // ── freshAgent.attach (restart parity: resume untracked sessions in place) ────────── - - /// Handle a `freshAgent.attach` for claude/kilroy. Decision table (restart parity): - /// - /// | State | Action | - /// |---|---| - /// | tracked under `msg.session_id` | no-op -- NO frame (wire-shape parity, unchanged). Safe against dead sidecars ONLY because the consumer-exit eviction removes dead entries (ledger A9) | - /// | untracked, no canonical durable id on the message | `lost_session_frame` (`INVALID_SESSION_ID`) -- unchanged fallback (also covers the verified A2 edge: a pane that never learned its UUID pre-kill attaches bare; lost -> client re-create is the designed, non-destructive outcome) | - /// | untracked, durable id already in `cli_index`, aliased session LIVE | REBIND + ACK (Task 10b): flip the live session's envelope stamp to the durable id and answer with the `freshAgent.session.snapshot` stamped with the durable and the session's TRACKED status (the consumer's turn-lifecycle + sdk.status fold, never a hardcoded idle) -- the attaching client must observe success, never silence. The map is never re-keyed (alias, don't move) | - /// | untracked, durable id in `cli_index` but aliased session GONE (stale row, eviction in flight) | fall through to the resume path below (the session is dead; resuming converges the client) | - /// | untracked, transcript EXISTS (in ANY candidate root) | spawn sidecar and resume with the session's ORIGINAL cwd from `transcript_cwd` (ledger A15: the CLI's resume lookup is cwd-slug-scoped); if that cwd no longer exists, resume by the transcript's `.jsonl` PATH (verified cli.js escape hatch that bypasses slug scoping) with the attach cwd. Register under the CLIENT's `msg.session_id`, emit a `freshAgent.session.snapshot` whose `timelineSessionId` is the durable UUID -- NEVER a nanoid (the frozen client persists it unvalidated, ledger A14/N3) -- carrying the session's tracked status ("idle" for a fresh resume, by construction) | - /// | untracked, transcript ABSENT in EVERY candidate root (every root fully readable) | `lost_session_frame` -- positive denial: the store is the authority (honest even under the 30-day GC, ledger A4) | - /// | untracked, store UNREADABLE (no store root, or an IO error other than NotFound/NotADirectory while scanning) | top-level `error` `CLAUDE_ATTACH_RESUME_FAILED` -- cannot check, so NEVER the lost frame (G28) | - /// | untracked, spawn/pipe/created failure | top-level `error` `CLAUDE_ATTACH_RESUME_FAILED` -- NEVER the lost frame | - pub async fn handle_attach(&self, msg: FreshAgentAttach) { - // b8ke delta review F7: a half-sent observed pair (exactly one of - // epoch/generation) is the typed invalid-fence refusal — before any - // state interaction. The attach never proceeds as a silently - // downgraded legacy request. - let attach_fence = - match crate::ownership_lane::wire_fence(msg.observed_epoch, msg.observed_generation) { - Ok(fence) => fence, - Err(err) => { - tracing::warn!(target: "freshell_freshagent::claude", - session_id = %msg.session_id, code = err.code(), - "fresh_agent_attach_refused: the observed fence is half-sent (invalid)"); - self.emit_fresh_agent_error( - &msg.session_id, - session_type_str(msg.session_type), - err.code(), - err.message(), - ); - return; - } - }; - // b8ke delta round-3 F2: BOTH existing-session early exits consult - // the coordinator — a delayed attach during a Handoff (the map - // entry exists until the stop lands) must be BLOCKED or typed, - // never a silent parity no-op or rebind against a runtime the - // handoff owns. - let coordinator_in_flight = |state: &freshell_ownership::OwnershipState| { - matches!( - state, - freshell_ownership::OwnershipState::Handoff { .. } - | freshell_ownership::OwnershipState::Starting { .. } - | freshell_ownership::OwnershipState::Stopping { .. } - | freshell_ownership::OwnershipState::Fenced { .. } - ) - }; - if self.sessions.lock().await.contains_key(&msg.session_id) { - // The gate observes the session's CANONICAL durable id (the - // registry keys the coordinator record there — the map key is - // the lane's own). - let durable = { - let sessions = self.sessions.lock().await; - sessions - .get(&msg.session_id) - .and_then(|s| s.cli_session_id.clone()) - }; - let observed_id = durable.as_deref().unwrap_or(&msg.session_id); - let snap = self.ownership_snapshot(PROVIDER, observed_id); - if coordinator_in_flight(&snap.state) { - tracing::warn!(target: "freshell_freshagent::claude", - session_id = %msg.session_id, state = ?snap.state, - "fresh_agent_attach_refused: a lifecycle transition owns this \ - session — the tracked-and-alive fast path is blocked (typed)"); - self.emit_fresh_agent_error( - &msg.session_id, - session_type_str(msg.session_type), - "SESSION_RESERVED", - "A lifecycle operation owns this session; retry after it settles", - ); - return; - } - return; // tracked-and-alive: no frame (wire-shape parity with codex) + return; // tracked-and-alive: no frame (wire-shape parity with codex) } let Some(durable) = attach_durable_id(&msg) else { // No durable identity to resume from: the pre-parity fallback (PR #529). @@ -7133,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. @@ -7292,8 +5605,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()`. @@ -7340,25 +5654,26 @@ 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())); - } - }; - // 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. + // 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) 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())?; + // 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 + // 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 recovered = sink .as_ref() .and_then(|s| s.load_settings("claude", durable)); @@ -7377,336 +5692,71 @@ 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. - 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, - 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, - }), - ); - 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, - // 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(), - )); - } + // 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 session_type = session_type_str(msg.session_type); + let spec = ClaudeLaunchSpec::new( + format!("attach-resume-{}", uuid::Uuid::new_v4()), + session_type, + boot, + 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 + // 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. + provenance: None, + naming: None, + partial_runtime_key: Some(durable.to_string()), + start_pid_slot: &start_pid_slot, + inherit: None, + genesis: None, + retry: Some(BootRetry { + durable: durable.to_string(), + genesis: recorded_genesis, + }), + }, + 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 @@ -7758,28 +5808,21 @@ 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 - /// 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 { @@ -7788,7 +5831,6 @@ impl FreshClaudeState { session_id, session_type, settings, - supersedes, identity_sink, provenance, false, @@ -7796,12 +5838,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, @@ -7809,15 +5851,13 @@ 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>>, ) -> 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( @@ -7828,7 +5868,6 @@ impl FreshClaudeState { identity_sink, provenance, on_settled, - supersedes, ); return SessionInitAdoptionOutcome::Deferred; } @@ -7838,7 +5877,6 @@ impl FreshClaudeState { session_id, session_type, settings, - supersedes, identity_sink, provenance, false, @@ -7880,7 +5918,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(); @@ -7888,17 +5925,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 { @@ -7909,7 +5935,6 @@ impl FreshClaudeState { &session_id, &session_type, settings.as_ref(), - supersedes.as_deref(), identity_sink.clone(), provenance.as_ref(), true, @@ -8059,13 +6084,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 @@ -8110,28 +6134,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 — @@ -8162,7 +6175,6 @@ impl FreshClaudeState { identity_sink, provenance, None, - supersedes, ); return SessionInitAdoptionOutcome::Deferred; } @@ -8344,29 +6356,28 @@ 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 .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(), 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(), @@ -8493,7 +6504,6 @@ impl FreshClaudeState { cli_id: &str, placeholder: &str, session_type: &str, - supersedes: Option<&str>, ) -> Option { if session_type == "kilroy" { return None; @@ -8541,18 +6551,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, @@ -8657,15 +6662,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<()> { @@ -8674,48 +6673,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; @@ -8810,6 +6767,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.spawn_genesis_clear_after_result(&session_id); } Some("sdk.status") if value.get("status").and_then(Value::as_str) == Some("idle") => @@ -9001,7 +6962,6 @@ impl FreshClaudeState { &session_id, &session_type, current_settings.as_ref(), - None, identity_sink.clone(), provenance.as_ref(), ) @@ -9924,6 +7884,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!({ @@ -9938,199 +7922,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): 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 - /// 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, - lease_guard: Option<&mut crate::FreshSessionLeaseGuard>, - ) -> Result { - let SpawnedSidecar { - mut child, - mut stdin, - stdout, - ownership_id, - log_labels, - tree_guard, - } = self - .spawn_sidecar_process(session_type) - .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()) - }); - } - }; - 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, - 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 @@ -10778,6 +8569,18 @@ fn log_claude_teardown( } } +mod create_outcome; +pub use create_outcome::ClaudeCreateOutcome; + +mod establish; +mod fork; +mod genesis; +mod history; +mod rollback; +use establish::{ + BootRetry, DurableClaim, EstablishLane, EstablishPlan, LaunchLease, RegisterUnder, +}; + #[cfg(test)] mod teardown_tests; @@ -10893,6 +8696,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, }, ); } @@ -12214,6 +10018,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() @@ -12237,10 +10043,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') @@ -12261,6 +10072,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__') { @@ -13853,6 +11669,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 @@ -15459,7 +13361,6 @@ rl.on('line', (line) => { model: Some("gate-model".into()), ..crate::identity_sink::FreshAgentSettings::default() }), - None, Some(fake.clone()), None, ) @@ -15606,7 +13507,6 @@ rl.on('line', (line) => { None, None, None, - None, ) .await; @@ -15725,12 +13625,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; @@ -15762,15 +13663,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, ) @@ -16556,6 +14456,7 @@ rl.on('line', (line) => { &mut no_ticket, new_dur, &placeholder, + &placeholder, Some(replacement_pid), "claude/rollback", ) @@ -16705,7 +14606,6 @@ rl.on('line', (line) => { stale_map_key, "freshclaude", Some(&settings), - None, sink2, None, Some(tx), @@ -16764,101 +14664,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. @@ -16904,7 +14713,6 @@ rl.on('line', (line) => { &placeholder, "freshclaude", None, - Some(FRESH_CREATE_DURABLE_ID), None, None, Some(tx), @@ -16996,16 +14804,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. @@ -17061,16 +14861,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" @@ -17131,7 +14923,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 @@ -17295,7 +15087,6 @@ rl.on('line', (line) => { &placeholder, "freshclaude", None, - None, Some(fake.clone()), None, ) @@ -17413,7 +15204,6 @@ rl.on('line', (line) => { &placeholder, "freshclaude", None, - None, Some(fake.clone()), None, ) @@ -18351,6 +16141,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(), @@ -19931,6 +17722,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, }, ); } @@ -20371,7 +18163,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) ); @@ -20638,6 +18429,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 @@ -20920,6 +18749,7 @@ rl.on('line', (line) => { permission_mode: Some("plan".into()), effort: Some("high".into()), cwd: None, + plugins: None, }, ); state.set_identity_sink(fake); @@ -21739,7 +19569,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( @@ -21763,6 +19592,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 @@ -21814,6 +19644,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 @@ -21885,7 +19716,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( @@ -21909,6 +19739,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 @@ -21918,17 +19749,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; @@ -24984,17 +22821,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/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/claude/establish.rs b/crates/freshell-freshagent/src/claude/establish.rs new file mode 100644 index 000000000..86ef0a105 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/establish.rs @@ -0,0 +1,897 @@ +//! 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. +//! 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`). A fork establishes +//! its child the same way, beside the parent, which it never touches +//! (`claude/fork.rs`, C14). + +use super::*; + +use crate::claude_handshake::{CreateFailure, SidecarCreated}; +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)] +pub(super) enum EstablishLane { + Create, + AttachResume, + /// An undo or redo's replacement runtime. + Rollback, + /// A fork's child runtime. + Fork, +} + +impl EstablishLane { + fn name(self) -> &'static str { + match self { + Self::Create => "create", + Self::AttachResume => "attach-resume", + Self::Rollback => "rollback", + Self::Fork => "fork", + } + } +} + +/// 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), + /// 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>>, +} + +/// 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. +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. +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, + /// 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 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. +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, + )>, + /// 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. +#[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 { + /// 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 + /// `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)) + } + } + } + + /// 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 + /// 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, + mut spec, + register_under, + claim, + rekey_provisional_claim, + recorded_settings, + owning_operation, + owning_generation, + provenance, + naming, + partial_runtime_key, + 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 mut booted = self + .boot_sidecar(&spec, lease_guard.as_mut(), Some(start)) + .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. + 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, 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 + // 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()) + .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 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())); + { + 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), + 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, + 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 + // 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) = claim.as_ref().map(|claim| claim.durable.as_str()) { + 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 | EstablishLane::Rollback | EstablishLane::Fork => { + "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, + sidecar_pid, + }) + } +} diff --git a/crates/freshell-freshagent/src/claude/fork.rs b/crates/freshell-freshagent/src/claude/fork.rs new file mode 100644 index 000000000..74011376e --- /dev/null +++ b/crates/freshell-freshagent/src/claude/fork.rs @@ -0,0 +1,602 @@ +//! 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, GenesisBinding, 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 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."; + +/// 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 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. + 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()), + retry: None, + }, + &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; + } + }; + + 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 + // connection's attribution. No `supersedes`: the parent lives on. + 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 + // 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) = + 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( + TurnLockedRequest::Fork, + map_key, + wire_id, + Some(durable), + turn_lock, + ) + .await?; + 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 { + 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(), + }) + } + + /// 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 + /// 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 new file mode 100644 index 000000000..093469491 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/genesis.rs @@ -0,0 +1,438 @@ +//! 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) 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_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), +} + +/// 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, + /// 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`: +/// 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 + /// (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 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; + 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: + /// 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 + } + + /// 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)); + 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(|| recorded.genesis.clone()); + Ok(Some(GenesisAwareBoot { + boot, + cwd, + session_id, + genesis, + recorded: recorded.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.map(|recorded| &recorded.genesis), + |id| self.locate_transcript(id), + ); + match decision { + BootDecision::Boot { + boot, + cwd, + session_id, + } => { + if let (Some(recorded), Some(sink)) = (recorded, sink) { + if found { + self.clear_found_genesis(sink, durable, recorded); + } else { + tracing::info!( + 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" + ); + } + } + Ok((boot, cwd, session_id)) + } + 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(lane, 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 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 (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, + state.transcript_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/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 new file mode 100644 index 000000000..c674ca9d6 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/rollback.rs @@ -0,0 +1,1315 @@ +//! 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_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::*; + +use super::establish::{ + EstablishError, EstablishLane, EstablishPlan, GenesisBinding, 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 +/// 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."; + +/// 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, +} + +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, + live_settings, + ) = { + 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(), + // 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( + &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, + ); + } + 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; + } + } + + 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).await { + 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).await { + 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 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)); + let spawn_cwd = located + .as_ref() + .and_then(|(_, cwd)| cwd.clone()) + .or_else(|| op.cwd.clone()) + .or_else(|| live_settings.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 = live_settings; + 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), + }), + genesis: Some(genesis.clone()), + retry: None, + }, + &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 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 + // 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 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/teardown_tests.rs b/crates/freshell-freshagent/src/claude/teardown_tests.rs index bde741fc9..d18b10da6 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..b57e85861 100644 --- a/crates/freshell-freshagent/src/claude_handshake.rs +++ b/crates/freshell-freshagent/src/claude_handshake.rs @@ -28,6 +28,23 @@ 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"; + +/// 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_launch.rs b/crates/freshell-freshagent/src/claude_launch.rs index ea2a85e4e..2d59850a2 100644 --- a/crates/freshell-freshagent/src/claude_launch.rs +++ b/crates/freshell-freshagent/src/claude_launch.rs @@ -1,14 +1,28 @@ -//! 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::{Deserialize, Serialize}; 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 +90,317 @@ 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(), +/// 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. +#[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, + /// 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 { + /// 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, + 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()); + self + } + + /// The sidecar `create` line. Every line carries the same keys, whatever + /// the launch: `type`, `protocol`, `requestId`, `sessionType`, `boot`, and + /// `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")); + 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("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)); + 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 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, + /// The store could not be checked. Never lost: the caller answers a + /// retryable failure. + Transient(LocateError), +} + +/// 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::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), + }, + } +} + +/// 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 +/// 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 +435,78 @@ 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_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_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()), + 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_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!({ @@ -149,11 +515,511 @@ mod tests { "requestId": "req-1", "sessionType": "kilroy", "boot": { "kind": "resume", "resume": "P" }, + "sessionId": null, "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), None, no_parent), + BootDecision::Boot { + boot: ClaudeBoot::Resume { resume: "S".into() }, + cwd: Some(cwd), + session_id: None, + } + ); + } + + #[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()), + None, + no_parent + ), + BootDecision::Boot { + boot: ClaudeBoot::Resume { + resume: path.to_string_lossy().to_string() + }, + cwd: None, + session_id: 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), None, no_parent), + BootDecision::Boot { + boot: ClaudeBoot::Resume { resume: "S".into() }, + cwd: None, + session_id: None, + } + ); + } + + #[test] + fn an_absent_transcript_is_lost_and_an_unreadable_store_is_transient() { + assert_eq!( + 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 { + root: "/store".into(), + kind: std::io::ErrorKind::PermissionDenied, + message: "denied".into(), + }; + assert_eq!( + 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), + 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") + ); + } + + // ── §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] diff --git a/crates/freshell-freshagent/src/claude_snapshot.rs b/crates/freshell-freshagent/src/claude_snapshot.rs index 6773a7dd4..5286abc8d 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]"; @@ -1254,9 +1260,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 +1288,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 @@ -1307,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 @@ -1370,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() } @@ -1398,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)] @@ -1435,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); } @@ -1477,23 +1546,68 @@ 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, }) } +/// 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. @@ -1536,11 +1650,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 } } @@ -1562,7 +1676,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 @@ -1694,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 @@ -1742,29 +1858,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 +1917,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 @@ -1840,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); } @@ -2596,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)); } @@ -2692,6 +2927,7 @@ pub(crate) mod tests { "freshclaude", "55555555-5555-4555-8555-555555555555", None, + None, ) .await; assert!( @@ -2717,6 +2953,7 @@ pub(crate) mod tests { "freshclaude", "55555555-5555-4555-8555-555555555555", None, + None, ) .await; assert!( @@ -2943,6 +3180,113 @@ 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" + ); + } + + /// 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( @@ -3111,19 +3455,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 @@ -3170,6 +3501,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/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 + ); +} diff --git a/crates/freshell-freshagent/src/claude_test_holds.rs b/crates/freshell-freshagent/src/claude_test_holds.rs index 17d9fed82..2666e660c 100644 --- a/crates/freshell-freshagent/src/claude_test_holds.rs +++ b/crates/freshell-freshagent/src/claude_test_holds.rs @@ -9,6 +9,13 @@ //! - 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. +//! - 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; @@ -19,31 +26,42 @@ use tokio::sync::Notify; #[derive(Default)] pub(crate) struct ClaudeTestHolds { registration: Mutex>, - exit_report: Mutex>>, + exit_report: Mutex>>, + rollback_boot: Mutex>>, + fork_child: 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 +73,30 @@ 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 + } + + /// 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"); @@ -77,9 +113,31 @@ 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; + } + } + + /// 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-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index e23d3802d..5df35a520 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 @@ -1744,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(), @@ -18242,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(), @@ -18436,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(), @@ -18528,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(), @@ -18551,6 +18556,7 @@ pub(crate) mod tests { permission_mode: None, effort: None, cwd: Some(std::env::temp_dir().to_string_lossy().to_string()), + plugins: None, }, }) .await @@ -18636,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(), @@ -18654,6 +18661,7 @@ pub(crate) mod tests { permission_mode: None, effort: None, cwd: Some("/w".into()), + plugins: None, }, }) .await @@ -22190,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(), @@ -22616,6 +22625,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..e694a7a26 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; @@ -23,6 +24,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 @@ -145,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. @@ -209,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 { @@ -344,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 @@ -429,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 @@ -763,6 +864,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 @@ -957,6 +1065,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(), @@ -1367,8 +1476,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)] @@ -1448,6 +1594,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() @@ -1982,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)] @@ -1996,6 +2183,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(), @@ -2014,6 +2202,7 @@ mod tests { permission_mode: None, effort: Some("low".into()), cwd: Some("/w".into()), + plugins: None, }, }) .await @@ -2039,6 +2228,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(), @@ -2086,6 +2276,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(), @@ -2225,6 +2416,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(), @@ -2260,6 +2452,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(), @@ -2277,6 +2470,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(), @@ -2315,6 +2509,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(), @@ -2340,6 +2535,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(), @@ -2367,6 +2563,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(), @@ -2395,6 +2592,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(), @@ -2419,6 +2617,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(), @@ -2442,6 +2641,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(), @@ -2470,6 +2670,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(), @@ -2583,6 +2784,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(), @@ -2619,6 +2821,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 386ed76b9..6535e0da9 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -76,7 +76,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 @@ -91,9 +91,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::{ @@ -5555,6 +5556,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(), @@ -5579,6 +5581,7 @@ async fn resume_session_ref_tab( permission_mode: None, effort: effort.clone(), cwd: cwd.clone(), + plugins: None, }, }) .await @@ -6612,6 +6615,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(), @@ -6647,6 +6651,7 @@ async fn send_keys( permission_mode: None, effort: pane.effort.clone(), cwd: pane.cwd.clone(), + plugins: None, }, }) .await @@ -7291,17 +7296,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) @@ -7325,17 +7319,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/opencode_ws.rs b/crates/freshell-freshagent/src/opencode_ws.rs index 0c70cf52a..b3c3382be 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, }, ) }; @@ -1912,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(), @@ -1937,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(), @@ -1960,6 +1963,7 @@ impl FreshOpencodeState { permission_mode: None, effort, cwd, + plugins: None, }, }) .await; @@ -2526,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(), @@ -2576,6 +2581,7 @@ impl FreshOpencodeState { permission_mode: None, effort: session.effort.clone(), cwd: session.cwd.clone(), + plugins: None, }, }) .await @@ -2687,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(), @@ -2706,6 +2713,7 @@ impl FreshOpencodeState { permission_mode: None, effort: session.effort.clone(), cwd: session.cwd.clone(), + plugins: None, }, }) .await; @@ -3162,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(), @@ -3180,6 +3189,7 @@ impl FreshOpencodeState { permission_mode: None, effort: effort.clone(), cwd, + plugins: None, }, }) .await; @@ -5998,6 +6008,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). @@ -6022,6 +6033,7 @@ impl FreshOpencodeState { permission_mode: None, effort, cwd: child_cwd.clone(), + plugins: None, }, }) .await @@ -7609,6 +7621,7 @@ impl FreshOpencodeState { permission_mode: None, effort: rec.effort.clone(), cwd, + plugins: None, } } else { crate::identity_sink::FreshAgentSettings::default() @@ -7620,6 +7633,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(), @@ -14074,6 +14088,7 @@ mod tests { permission_mode: None, effort: Some("high".into()), cwd: Some("/real/project".into()), + plugins: None, }, ); state.set_identity_sink(fake); @@ -14116,6 +14131,7 @@ mod tests { permission_mode: None, effort: Some("high".into()), cwd: Some("/real/project".into()), + plugins: None, }, ); state.set_identity_sink(fake.clone()); @@ -14193,6 +14209,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(), @@ -14254,6 +14271,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(), @@ -14578,6 +14596,7 @@ mod tests { permission_mode: None, effort: Some("high".into()), cwd: Some("/real/project".into()), + plugins: None, }, ); state.set_identity_sink(fake); @@ -14652,6 +14671,7 @@ mod tests { permission_mode: None, effort: Some("high".into()), cwd: Some("/real/project".into()), + plugins: None, }, ); state.set_identity_sink(fake.clone()); @@ -15091,6 +15111,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(), @@ -15179,6 +15200,7 @@ mod tests { permission_mode: None, effort: Some("high".into()), cwd: Some("/real/project".into()), + plugins: None, }, ); state.set_identity_sink(fake.clone()); @@ -15361,6 +15383,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(), @@ -15385,6 +15408,7 @@ mod tests { permission_mode: None, effort: Some("high".into()), cwd: Some("/real/project".into()), + plugins: None, }, }) .await @@ -15467,6 +15491,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(), @@ -15486,6 +15511,7 @@ mod tests { permission_mode: None, effort: Some("high".into()), cwd: Some("/real/project".into()), + plugins: None, }, }) .await @@ -15622,6 +15648,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(), @@ -15711,6 +15738,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 0b38b8e74..b019bc949 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 @@ -1301,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(), @@ -1682,6 +1684,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 91d0a3b74..731593dd7 100644 --- a/crates/freshell-freshagent/src/session_handoff/tests.rs +++ b/crates/freshell-freshagent/src/session_handoff/tests.rs @@ -6277,6 +6277,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, }, ); @@ -7000,20 +7001,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 +7019,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 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-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-server/src/identity_sink.rs b/crates/freshell-server/src/identity_sink.rs index c0361602e..759b09b28 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, @@ -340,6 +345,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 +423,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 @@ -430,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` @@ -488,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 @@ -761,6 +799,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(), @@ -779,6 +818,7 @@ mod tests { permission_mode: Some("on-request".into()), effort: None, cwd: Some("/w".into()), + plugins: None, }, }) .await @@ -795,6 +835,193 @@ 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 { + genesis: freshell_freshagent::GenesisUpdate::Keep, + 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) + ); + } + + /// 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") + ); + } + + /// 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(); @@ -861,6 +1088,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(), @@ -908,8 +1136,10 @@ mod tests { permission_mode: None, effort: None, cwd: Some("/w".into()), + plugins: None, }; sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "claude".into(), session_id: "fork-new-uuid".into(), @@ -937,6 +1167,7 @@ mod tests { ); sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "codex".into(), session_id: "new-thread".into(), @@ -982,6 +1213,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(), @@ -1005,6 +1237,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(), @@ -1029,6 +1262,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(), @@ -1085,6 +1319,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(), @@ -1125,6 +1360,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(), @@ -1236,6 +1472,7 @@ mod tests { authoritative: bool, ) -> FreshAgentBindingUpsert { FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, name_transition: None, provider: "claude".into(), session_id: SID.into(), @@ -1982,6 +2219,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(), @@ -2029,6 +2267,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(), @@ -2089,6 +2328,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(), @@ -2122,6 +2362,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(), @@ -2145,6 +2386,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(), @@ -2211,6 +2453,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(), @@ -2270,6 +2513,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(), @@ -2316,6 +2560,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(), @@ -2406,6 +2651,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(), @@ -2532,6 +2778,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(), @@ -2582,6 +2829,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(), @@ -2649,6 +2897,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(), @@ -2747,6 +2996,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(), @@ -2843,6 +3093,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(), @@ -2968,6 +3219,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 803fa3f22..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(), @@ -134,6 +135,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, } } @@ -3112,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", @@ -3133,6 +3136,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"); @@ -3175,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", @@ -3196,6 +3201,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"); @@ -3311,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", @@ -3327,6 +3334,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-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index 53e208cd0..fee32b87f 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, @@ -457,7 +463,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, } @@ -601,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, @@ -638,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, @@ -696,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, }) @@ -1152,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 { @@ -1194,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; } } @@ -1212,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 { @@ -1242,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 { @@ -1317,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 { @@ -1810,6 +1822,177 @@ 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 + /// 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)] @@ -2639,8 +2822,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") ) } } @@ -2767,18 +2949,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", @@ -3222,10 +3393,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(); @@ -3363,17 +3535,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/codex_association.rs b/crates/freshell-ws/src/codex_association.rs index c9b47f0d2..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", @@ -796,6 +797,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..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", @@ -1449,6 +1450,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..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", @@ -1335,6 +1336,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..1f84fe4b7 100644 --- a/crates/freshell-ws/src/pane_ledger.rs +++ b/crates/freshell-ws/src/pane_ledger.rs @@ -868,6 +868,23 @@ 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>, + /// 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 @@ -1277,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. @@ -1291,6 +1336,13 @@ 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]>, + /// 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. @@ -2236,6 +2288,10 @@ impl PaneLedger { sandbox: None, 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, @@ -2758,6 +2814,17 @@ 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), + // 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, @@ -4527,9 +4594,45 @@ 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 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 +4648,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..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, @@ -299,6 +300,7 @@ fn fa_write<'a>(provider: &'a str, session_id: &'a str, now_ms: i64) -> FreshAge authoritative: false, now_ms, + plugins: None, } } @@ -311,6 +313,7 @@ fn fa_write_fenced<'a>( generation: u64, ) -> FreshAgentBindingWrite<'a> { FreshAgentBindingWrite { + genesis: GenesisWrite::Keep, provider, session_id, mode: provider, @@ -327,6 +330,7 @@ fn fa_write_fenced<'a>( authoritative: false, now_ms, + plugins: None, } } @@ -342,6 +346,7 @@ fn fa_write_target<'a>( generation: u64, ) -> FreshAgentBindingWrite<'a> { FreshAgentBindingWrite { + genesis: GenesisWrite::Keep, provider, session_id, mode: provider, @@ -357,6 +362,7 @@ fn fa_write_target<'a>( observed_generation: Some(generation), authoritative: true, now_ms, + plugins: None, } } @@ -2954,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", @@ -2970,6 +2977,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 +4100,267 @@ 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(); +} + +/// 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 @@ -4531,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(), @@ -4556,6 +4826,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 @@ -4659,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", @@ -4675,6 +4947,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"); @@ -4693,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", @@ -4709,6 +4983,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 @@ -4742,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", @@ -4758,6 +5034,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 @@ -4807,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", @@ -4823,6 +5101,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 @@ -4855,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", @@ -4871,6 +5151,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). @@ -4905,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", @@ -4921,6 +5203,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()); @@ -8176,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(), @@ -8201,6 +8485,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/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/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/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index 1102a1a39..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 @@ -57,10 +58,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. @@ -72,6 +98,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, } @@ -83,6 +113,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 +201,25 @@ 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, + server, + } = spawn_rig_server(environment.clone(), &options, pane_ledger.clone()).await; eprintln!( "{}", json!({ @@ -173,15 +230,50 @@ impl ClaudeRig { Self { kind, url, + http_url, + pane_ledger, workspace, claude_config_dir, 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) @@ -206,6 +298,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(); @@ -259,6 +391,126 @@ 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; + } + } + + /// 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 @@ -377,6 +629,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)> { @@ -438,6 +702,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!({ @@ -536,11 +835,21 @@ async fn start_local_provider() -> (tokio::process::Child, String) { (child, url) } +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 -/// 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 +864,29 @@ 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::with_ownership( + Arc::clone(ledger), + Some(Arc::clone(&ownership)), + ))); + } 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 +902,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 +911,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,16 +929,38 @@ 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"); let addr = listener.local_addr().expect("local addr"); - tokio::spawn(async move { + let 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, + server, + } } 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..9c411434f --- /dev/null +++ b/crates/freshell-ws/tests/common/ledger_sink.rs @@ -0,0 +1,446 @@ +//! 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, 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; + +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, + /// 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, + 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) + .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 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(), + 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, + }; + 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, + 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 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) + } + + 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 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()); + 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..3bc61b782 100644 --- a/crates/freshell-ws/tests/common/mod.rs +++ b/crates/freshell-ws/tests/common/mod.rs @@ -18,8 +18,13 @@ 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; +/// 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/cross_kind_liveness.rs b/crates/freshell-ws/tests/cross_kind_liveness.rs index 734cece52..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,436 +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 w = 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(), - 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, - }; - 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, - }; - 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 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 @@ -1151,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()); @@ -3714,12 +3285,10 @@ 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, name_transition: None, provider: "claude".into(), session_id: sid.clone(), @@ -3870,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"); @@ -3903,6 +3469,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(), @@ -3948,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, @@ -3990,6 +3557,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", @@ -4005,6 +3573,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_fork.rs b/crates/freshell-ws/tests/freshagent_claude_fork.rs new file mode 100644 index 000000000..db845b881 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_fork.rs @@ -0,0 +1,632 @@ +//! 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); +} + +/// §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")); +} + +/// 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; + 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(); + + 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}"); + assert!( + !rig.fresh_claude.has_live_session(&child).await, + "the child is torn down" + ); + child_runtime.wait_until_exited("the child's runtime").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" + ); +} 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..2f4c16f0b --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_genesis.rs @@ -0,0 +1,387 @@ +//! 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; +} + +/// §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") + ); +} + +// ── §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"] + ); +} 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..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,288 +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 w = freshell_ws::pane_ledger::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(), - supersedes: upsert.supersedes.as_deref(), - provenance: freshell_ws::pane_ledger::ProvenancePolicy::Inherit, - observed_epoch: None, - observed_generation: None, - authoritative: upsert.authoritative, - now_ms: now, - }; - 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, - }) - } - 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( @@ -416,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) } 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..230b0ab1c --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_launch_parity.rs @@ -0,0 +1,247 @@ +//! 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, 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 + }) + .await; + assert_carries_plugin_and_hosted_mcp(&fork, &plugin, "rollback respawn"); +} diff --git a/crates/freshell-ws/tests/freshagent_claude_real_cli.rs b/crates/freshell-ws/tests/freshagent_claude_real_cli.rs index 044181a55..281784af9 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,315 @@ 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; +} + +/// 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; +} + +/// 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; + } + + #[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 +585,21 @@ 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; + } + + #[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; diff --git a/crates/freshell-ws/tests/freshagent_claude_rollback.rs b/crates/freshell-ws/tests/freshagent_claude_rollback.rs index ee7be32e7..ebfa95503 100644 --- a/crates/freshell-ws/tests/freshagent_claude_rollback.rs +++ b/crates/freshell-ws/tests/freshagent_claude_rollback.rs @@ -1,1195 +1,749 @@ -//! 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') - -let mintCounter = 0 -let cliId = null -let turnCount = 0 - -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 } - }) -} -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 */ } - } - 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 } - } +use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions, RIG_FRAME_BUDGET}; +use freshell_protocol::SessionType; - 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"); - } -} +/// 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); -/// 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) - } +async fn rollback_rig() -> ClaudeRig { + ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await } -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 now = Self::now_ms(); - Box::pin(async move { - tokio::task::spawn_blocking(move || { - let w = 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(), - supersedes: upsert.supersedes.as_deref(), - provenance: freshell_ws::pane_ledger::ProvenancePolicy::Inherit, - observed_epoch: None, - observed_generation: None, - authoritative: upsert.authoritative, - now_ms: now, - }; - 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 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)? - }) - } +fn ledger(rig: &ClaudeRig) -> &freshell_ws::pane_ledger::PaneLedger { + rig.pane_ledger.as_deref().expect("the rig has a ledger") +} - // 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)? - }) +/// 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; + } } +} - // 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) +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; } +} - // 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) +/// `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; +} - // 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(), +/// 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)??; - 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)? - }) - } + }), + ) + .await + .unwrap_or_else(|_| { + panic!( + "rollback {request_id} was not answered within {ROLLBACK_BUDGET:?}; frames: {:?}", + conn.frames + ) + }); + eprintln!( + "{}", + json!({ "severity": "info", "event": "rollback_answered", "request": request_id, + "ms": started.elapsed().as_millis() as u64 }) + ); + answer["event"].clone() } -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 } +/// 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) +} + +/// 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(); - - 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; - } - (ws, addr, ledger, frames, original) +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 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, - }) +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 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() +fn is_uuid(id: &str) -> bool { + uuid::Uuid::parse_str(id).is_ok() } -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" - ); +/// 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) } -/// 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"] +/// 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 = rig.runtime_processes(&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)); - - // 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 materialized = materialized_from(&mut conn, &pane).await; + assert_eq!(materialized["sessionId"], c.as_str()); assert_eq!( - fork_create["boot"]["resumeSessionAt"], - json!("a1"), - "keep prefix through a1" + materialized["sessionRef"], + json!({ "provider": "claude", "sessionId": c }) ); + + 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"]["dropsTurn"], - json!("u2"), - "the guard is the first-to-discard turn's prompt uuid" + row.genesis, + Some(json!({ "kind": "fork", "parent": a, "at": keep, "dropsTurn": u2 })) ); - - // 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!(row.cwd.as_deref(), rig.workspace.to_str()); + let superseded = ledger(&rig) + .load_binding("claude", &a) + .expect("A's binding"); assert_eq!( - record["originalTipUuid"], - json!("a2"), - "undo stamps the original's raw-chain tip" + 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:?}" ); - 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") - .iter() - .flat_map(|e| e["removedTurns"].as_array().expect("removedTurns").iter()) - .filter_map(|t| t["turnId"].as_str()) - .collect(); assert_eq!( - entry_ids, - vec!["u2", "a2"], - "the marker bucket carries the removed slice (r3 union)" + transcript_of(&rig, &c), + None, + "the fork writes nothing yet (T6)" ); - - assert_never_completes(&frames); - (rig, ws, addr, ledger, frames, original, new_id) + assert_eq!(std::fs::read_to_string(&a_path).expect("read A"), a_before); + a_runtime.wait_until_exited("A's runtime").await; + assert!(rig.fresh_claude.has_live_session(&c).await); + assert_never_chimes(&conn); } -#[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); +/// 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() + .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); } -#[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; +/// 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!( + boot["resume"], + a.as_str(), + "redo forks the chain root: {boot}" + ); + 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); +} - send_json( - &mut ws, - &rollback_frame("rb-redo-1", "redo", &new_id, "step"), +/// §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); + assert_eq!( + ledger(&rig) + .load_binding("claude", &c2) + .expect("C2's row") + .genesis, + Some(json!({ "kind": "fork", "parent": a, "at": keep })) + ); + 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); +} - // 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)" +/// 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!( + ledger(&rig) + .load_binding("claude", &c) + .expect("C's row") + .genesis, + Some(json!({ "kind": "fresh" })) ); + assert!(snapshot_turn_ids(&rig, &c).await.is_empty()); - // 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"); + 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(); + + 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!( - turns.len(), - 4, - "the restored conversation is whole: {snapshot}" + 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 ); - // 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] + 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!( + conn.request_errors("send-after"), + Vec::::new(), + "A took the send" ); - 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(); +/// 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!( - creates.len(), - 2, - "the initial create + the rollback's FRESH create: {creates:?}" + conn.frames + .iter() + .filter(|v| v["type"] == "freshAgent.session.materialized") + .count(), + 0 ); - let fresh_create = &creates[1]; + + converse(&rig, &mut conn, &pane, &["third prompt"]).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.request_errors(&format!("send-{pane}-0")), + Vec::::new() ); +} + +/// §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(); - // 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)); + 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!( - rows[0]["originalTipUuid"], - json!("a1"), - "the discarded chain's tip" + prompt["sessionId"], + c.as_str(), + "it ran in the fork: {prompt}" ); - assert_eq!(rows[0]["canRedo"], json!(true)); + 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")); +} - // 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); - - // 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)); +/// §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!( - redo_create["boot"]["resumeSessionAt"], - json!("a1"), - "the r3 boundary rule — resume at a1, NOT u1" + snapshot_turn_ids(&rig, &c).await, + prefix, + "served from the genesis" ); - let snapshot = http_get_json( - &addr, - &format!("/api/fresh-agent/threads/freshclaude/claude/{restored_id}"), - ) - .await; + 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!( - snapshot["turns"].as_array().expect("turns").len(), + boots.len(), 2, - "the whole u1/a1 step is restored" + "the undo's boot and the restart's: {boots:?}" ); - assert_never_completes(&frames); - drop(rig); + 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:?}" + ); +} + +/// 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")); } -/// 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)] +// ── 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(); @@ -1201,7 +755,6 @@ impl UnreadableDir { Some(guard) } } -#[cfg(unix)] impl Drop for UnreadableDir { fn drop(&mut self) { use std::os::unix::fs::PermissionsExt; @@ -1209,208 +762,355 @@ 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") +/// 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; + }; + 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()); } -/// 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 +/// 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; + }; + 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}"); } -/// 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 { +/// 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; }; - - 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; + 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 fork = queries(&rig) + .into_iter() + .nth(boots) + .expect("the fork's boot"); + assert_eq!(fork["resume"], a.as_str(), "{fork}"); assert_eq!( - refusal["event"]["code"], - json!("INTERNAL_ERROR"), - "an unreadable store is a retryable failure, not an empty conversation: {refusal}" - ); - // The create log holds protocol-2 creates, whose boot names the kind: a - // fork is `boot.kind == "fork"` (a v1 `forkSession` key is never written, - // so reading it would pass whatever ran). - let creates = rig.create_lines(); - assert!( - !creates.is_empty() && creates.iter().all(|l| l["boot"]["kind"].is_string()), - "every logged create names its boot kind: {creates:?}" - ); - assert!( - creates.iter().all(|l| l["boot"]["kind"] != json!("fork")), - "no fork may run against an unchecked store: {creates:?}" + fork["cwd"], + rig.workspace.to_str().expect("utf-8"), + "the fork runs in the located transcript's own cwd: {fork}" ); - assert!( - rollback_rows(&rig.ledger_dir()).is_empty(), - "a refused undo writes no rollback record" - ); - assert_never_completes(&frames); + let ack = rollback_answer(&mut conn, "undo-1").await; + assert_eq!(ack["type"], "freshAgent.rolledBack", "{ack}"); } -/// 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 { - return; - }; - - send_json( - &mut ws, - &rollback_frame("rb-redo-unreadable", "redo", &new_id, "step"), - ) +// ── §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, _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| { + v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.error" + && v["event"]["code"] == "SIDECAR_EXITED" + }) .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:?}"); +} + +/// 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!( - rows[0]["canRedo"], - json!(true), - "the refused redo leaves the record redoable for a retry" + created["sessionRef"], + json!({ "provider": "claude", "sessionId": session_id }), + "{created}" ); - assert_never_completes(&frames); + created } -/// 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) - } +/// 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() } -#[cfg(unix)] -impl Drop for EnvVarGuard { - fn drop(&mut self) { - std::env::remove_var(self.0); + +/// §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"); +} + +// ── §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) } -/// 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() { +/// 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, - "timed out waiting for {what}" + "no {what}; frames: {:?}", + &conn.frames[seen..] ); - tokio::time::sleep(Duration::from_millis(10)).await; + conn.read_for(Duration::from_millis(50)).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"), +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; - // 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 { - return; - }; - wait_until(15, "the fork create", || { - rig.create_lines() - .iter() - .any(|l| l["boot"]["kind"] == json!("fork")) - }) - .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")); assert_eq!( - fork_create["cwd"], - json!("/tmp"), - "the fork runs in the located transcript's own cwd: {fork_create}" + 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") ); - 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); +/// 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)); } 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/crates/freshell-ws/tests/pane_reconcile_freshagent.rs b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs index 30c157610..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", @@ -730,11 +739,13 @@ async fn old_thread_claim_after_crash_respawn_answers_the_new_terminus() { authoritative: false, now_ms: now, + plugins: None, }) .unwrap(); server .pane_ledger .record_fresh_agent_binding(&FreshAgentBindingWrite { + genesis: freshell_ws::pane_ledger::GenesisWrite::Keep, provider: "codex", session_id: "new-t", mode: "freshcodex", @@ -751,6 +762,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; @@ -769,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 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-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/fresh-agent-rollback-rust.spec.ts b/test/e2e-browser/specs/fresh-agent-rollback-rust.spec.ts index 88ae27c58..78d172bc7 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 @@ -51,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' @@ -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,46 @@ 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) +} + +/** `.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, @@ -336,8 +334,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 +342,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 +367,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) { @@ -930,7 +926,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()) @@ -949,8 +946,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 ?? '', @@ -963,20 +961,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) @@ -1001,10 +993,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([]) @@ -1017,7 +1015,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 @@ -1040,11 +1039,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') @@ -1058,6 +1057,134 @@ 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('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 { @@ -1173,7 +1300,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 }) @@ -1195,13 +1332,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') @@ -1209,25 +1345,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/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/claude-cli-sim/query.mjs b/test/fixtures/claude-cli-sim/query.mjs index d910bd38b..46230c265 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, @@ -115,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) @@ -411,11 +425,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 }) @@ -535,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) { @@ -577,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' }) @@ -705,6 +721,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' } }) } @@ -714,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/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/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/integration/providers/claude-cli-contract.test.ts b/test/integration/providers/claude-cli-contract.test.ts index 0322520c1..9f04bc764 100644 --- a/test/integration/providers/claude-cli-contract.test.ts +++ b/test/integration/providers/claude-cli-contract.test.ts @@ -267,6 +267,36 @@ 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('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 98e306698..2490c9cac 100644 --- a/test/integration/providers/claude-cli-scenarios.mjs +++ b/test/integration/providers/claude-cli-scenarios.mjs @@ -452,6 +452,73 @@ 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() }) + }, + + /** + * 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') 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(() => { 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 }) 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 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