From fdc6bbce0e5da99cdafb50cabd686f2569544a6f Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:27:39 -0700 Subject: [PATCH 001/100] refactor(freshagent): share plain-text snapshot capture renderer --- crates/freshell-agent-runtime/src/lib.rs | 1 + .../src/snapshot_text.rs | 95 +++++++++++++++++++ .../src/providers/fresh_agent.rs | 85 +---------------- 3 files changed, 97 insertions(+), 84 deletions(-) create mode 100644 crates/freshell-agent-runtime/src/snapshot_text.rs diff --git a/crates/freshell-agent-runtime/src/lib.rs b/crates/freshell-agent-runtime/src/lib.rs index 62f124311..b287f3ec7 100644 --- a/crates/freshell-agent-runtime/src/lib.rs +++ b/crates/freshell-agent-runtime/src/lib.rs @@ -28,6 +28,7 @@ pub use qualification_policy::{ }; pub mod host_actor; pub mod snapshot_projection; +pub mod snapshot_text; #[cfg(test)] #[path = "snapshot_projection_tests.rs"] diff --git a/crates/freshell-agent-runtime/src/snapshot_text.rs b/crates/freshell-agent-runtime/src/snapshot_text.rs new file mode 100644 index 000000000..461d42e16 --- /dev/null +++ b/crates/freshell-agent-runtime/src/snapshot_text.rs @@ -0,0 +1,95 @@ +//! Plain-text capture shared by local and hosted fresh-agent REST lanes. +//! The byte cap always ends at a UTF-8 boundary. + +use serde_json::Value; + +/// Render role-prefixed turn text, commands, output and error-only turns. +pub fn render_snapshot_text(snapshot: &Value, max_bytes: usize) -> (String, bool) { + let mut output = String::new(); + if let Some(turns) = snapshot.get("turns").and_then(Value::as_array) { + for turn in turns { + let role = turn + .get("role") + .and_then(Value::as_str) + .unwrap_or("assistant"); + let mut body = Vec::new(); + if let Some(items) = turn.get("items").and_then(Value::as_array) { + for item in items { + if let Some(text) = item.get("text").and_then(Value::as_str) { + body.push(text); + } else if let Some(command) = item.get("command").and_then(Value::as_str) { + body.push(command); + } else if let Some(result) = item.get("output").and_then(Value::as_str) { + body.push(result); + } + } + } + if body.is_empty() { + // An item-less turn carrying only the durable error module + // (a claude API error or failed local command) reads as its + // error; anything else falls back to the summary. + if let Some(message) = turn.pointer("/error/message").and_then(Value::as_str) { + body.push(message); + } else if let Some(summary) = turn.get("summary").and_then(Value::as_str) { + body.push(summary); + } + } + if !body.is_empty() { + output.push_str(role); + output.push_str(": "); + output.push_str(&body.join("\n")); + output.push('\n'); + } + } + } + if output.len() <= max_bytes { + return (output, false); + } + let mut boundary = max_bytes; + while boundary > 0 && !output.is_char_boundary(boundary) { + boundary -= 1; + } + output.truncate(boundary); + (output, true) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn snapshot_text_renderer_is_plain_and_utf8_bounded() { + let snapshot = json!({"turns":[ + {"role":"user","summary":"ignored","items":[{"kind":"text","text":"héllo"}]}, + {"role":"assistant","summary":"world","items":[]} + ]}); + let (full, truncated) = render_snapshot_text(&snapshot, 100); + assert_eq!(full, "user: héllo\nassistant: world\n"); + assert!(!truncated); + let (short, truncated) = render_snapshot_text(&snapshot, 8); + assert!(short.len() <= 8); + assert!(short.is_char_boundary(short.len())); + assert!(truncated); + } + + #[test] + fn snapshot_text_renders_error_only_turns_by_their_error_message() { + // Real claude snapshots (pinned CLI 2.1.237 goldens): a failed /compact + // and an API error are item-less turns carrying only the error module. + for (golden, expected) in [ + ( + include_str!("../../../test/fixtures/fresh-agent/claude/real-cli-2.1.237/compact-fail/snapshot.golden.json"), + "system: Error during compaction: API Error: 400 compaction refused by fake\n", + ), + ( + include_str!("../../../test/fixtures/fresh-agent/claude/real-cli-2.1.237/api-error/snapshot.golden.json"), + "assistant: API Error: 400 fixture bad request\n", + ), + ] { + let snapshot: Value = serde_json::from_str(golden).unwrap(); + let (text, _) = render_snapshot_text(&snapshot, 1 << 20); + assert!(text.contains(expected), "{text}"); + } + } +} diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index 66bcfb67b..5bb1b07f4 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -10,6 +10,7 @@ use freshell_agent_runtime::host_actor::{ FreshAgentProfile, FreshAgentTransport, OperationAck, OperationFailure, OperationFailureKind, TransportStart, }; +use freshell_agent_runtime::snapshot_text::render_snapshot_text; use freshell_codex::launch_plan::CodexSidecarLaunchContext; use freshell_freshagent::rollback_record::{RollbackDirection, RollbackModeReq, RollbackRequest}; use freshell_freshagent::{FreshAgentState, FreshClaudeState, FreshCodexState, FreshOpencodeState}; @@ -1460,55 +1461,6 @@ fn find_operation_session_id(value: &Value) -> Option { } } -fn render_snapshot_text(snapshot: &Value, max_bytes: usize) -> (String, bool) { - let mut output = String::new(); - if let Some(turns) = snapshot.get("turns").and_then(Value::as_array) { - for turn in turns { - let role = turn - .get("role") - .and_then(Value::as_str) - .unwrap_or("assistant"); - let mut body = Vec::new(); - if let Some(items) = turn.get("items").and_then(Value::as_array) { - for item in items { - if let Some(text) = item.get("text").and_then(Value::as_str) { - body.push(text); - } else if let Some(command) = item.get("command").and_then(Value::as_str) { - body.push(command); - } else if let Some(result) = item.get("output").and_then(Value::as_str) { - body.push(result); - } - } - } - if body.is_empty() { - // An item-less turn carrying only the durable error module - // (a claude API error or failed local command) reads as its - // error; anything else falls back to the summary. - if let Some(message) = turn.pointer("/error/message").and_then(Value::as_str) { - body.push(message); - } else if let Some(summary) = turn.get("summary").and_then(Value::as_str) { - body.push(summary); - } - } - if !body.is_empty() { - output.push_str(role); - output.push_str(": "); - output.push_str(&body.join("\n")); - output.push('\n'); - } - } - } - if output.len() <= max_bytes { - return (output, false); - } - let mut boundary = max_bytes; - while boundary > 0 && !output.is_char_boundary(boundary) { - boundary -= 1; - } - output.truncate(boundary); - (output, true) -} - fn parse_send_outcome(value: &Value) -> Option<(String, bool)> { let event_type = value.get("type")?.as_str()?; let request_id = value.get("requestId").and_then(|value| match value { @@ -3960,39 +3912,4 @@ lines.on('line', line=>{{ Some("new-native".into()) ); } - - #[test] - fn snapshot_text_renderer_is_plain_and_utf8_bounded() { - let snapshot = json!({"turns":[ - {"role":"user","summary":"ignored","items":[{"kind":"text","text":"héllo"}]}, - {"role":"assistant","summary":"world","items":[]} - ]}); - let (full, truncated) = render_snapshot_text(&snapshot, 100); - assert_eq!(full, "user: héllo\nassistant: world\n"); - assert!(!truncated); - let (short, truncated) = render_snapshot_text(&snapshot, 8); - assert!(short.len() <= 8); - assert!(short.is_char_boundary(short.len())); - assert!(truncated); - } - - #[test] - fn snapshot_text_renders_error_only_turns_by_their_error_message() { - // Real claude snapshots (pinned CLI 2.1.237 goldens): a failed /compact - // and an API error are item-less turns carrying only the error module. - for (golden, expected) in [ - ( - include_str!("../../../../test/fixtures/fresh-agent/claude/real-cli-2.1.237/compact-fail/snapshot.golden.json"), - "system: Error during compaction: API Error: 400 compaction refused by fake\n", - ), - ( - include_str!("../../../../test/fixtures/fresh-agent/claude/real-cli-2.1.237/api-error/snapshot.golden.json"), - "assistant: API Error: 400 fixture bad request\n", - ), - ] { - let snapshot: Value = serde_json::from_str(golden).unwrap(); - let (text, _) = render_snapshot_text(&snapshot, 1 << 20); - assert!(text.contains(expected), "{text}"); - } - } } From 28913bca352f81f543404eff1209d1c1531ac18f Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:46:48 -0700 Subject: [PATCH 002/100] feat(freshclaude): resolve REST create defaults with picker parity --- .../src/claude_create_defaults.rs | 223 ++++++++++++++++++ crates/freshell-freshagent/src/lib.rs | 1 + src/components/panes/PaneContainer.tsx | 57 +---- src/lib/fresh-agent-create-content.ts | 71 ++++++ .../fresh-agent/claude-create-defaults.json | 83 +++++++ .../lib/fresh-agent-create-content.test.ts | 32 +++ 6 files changed, 419 insertions(+), 48 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude_create_defaults.rs create mode 100644 src/lib/fresh-agent-create-content.ts create mode 100644 test/fixtures/fresh-agent/claude-create-defaults.json create mode 100644 test/unit/client/lib/fresh-agent-create-content.test.ts diff --git a/crates/freshell-freshagent/src/claude_create_defaults.rs b/crates/freshell-freshagent/src/claude_create_defaults.rs new file mode 100644 index 000000000..8e6028a34 --- /dev/null +++ b/crates/freshell-freshagent/src/claude_create_defaults.rs @@ -0,0 +1,223 @@ +//! Claude REST pane defaults follow the picker, including saved catalog levels. + +use freshell_protocol::{ModelSelection, SettingsFreshAgent}; +use serde::Serialize; +use serde_json::Value; + +#[derive(Clone, Debug, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolvedClaudeCreate { + pub model: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub model_selection: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub model_effort_levels: Option>, + pub permission_mode: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub effort: Option, + pub plugins: Vec, +} + +pub fn resolve_claude_rest_create( + session_type: &str, + body: &Value, + settings: &SettingsFreshAgent, +) -> Result { + if !matches!(session_type, "freshclaude" | "kilroy") { + return Err(format!("unsupported Claude session type: {session_type}")); + } + let inputs = crate::hosted_rest::provider_inputs(body)?; + let saved = &settings.providers[session_type]; + let model_selection = match inputs.model_selection { + Some(selection) => selection, + None => saved + .get("modelSelection") + .and_then(|selection| serde_json::from_value::(selection.clone()).ok()) + .filter(|selection| selection.kind == "exact" && !selection.model_id.trim().is_empty()), + }; + let model = optional_string(body, "model")? + .or_else(|| { + model_selection + .as_ref() + .map(|selection| selection.model_id.trim().to_string()) + }) + .unwrap_or_else(|| "opus[1m]".into()); + let model_effort_levels = saved + .get("modelEffortLevels") + .filter(|stamp| stamp.get("modelId").and_then(Value::as_str) == Some(model.as_str())) + .and_then(|stamp| stamp.get("levels")) + .and_then(|levels| serde_json::from_value::>(levels.clone()).ok()); + let candidate = optional_string(body, "effort")?.or_else(|| { + saved + .get("effort") + .and_then(Value::as_str) + .and_then(non_blank) + }); + let effort = resolve_effort(&model, model_effort_levels.as_deref(), candidate); + Ok(ResolvedClaudeCreate { + model, + model_selection, + model_effort_levels, + permission_mode: inputs + .permission_mode + .or_else(|| { + saved + .get("defaultPermissionMode") + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| "bypassPermissions".into()), + effort, + plugins: inputs + .plugins + .unwrap_or_else(|| settings.default_plugins.clone()), + }) +} + +fn resolve_effort( + model: &str, + levels: Option<&[String]>, + candidate: Option, +) -> Option { + let candidate = candidate.unwrap_or_else(|| "high".into()); + if model != "opus[1m]" { + if let Some(levels) = levels { + return if levels.contains(&candidate) { + Some(candidate) + } else { + levels.first().cloned() + }; + } + } + Some( + if matches!( + candidate.as_str(), + "low" | "medium" | "high" | "xhigh" | "max" + ) { + candidate + } else { + "high".into() + }, + ) +} + +fn non_blank(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_string()) +} + +fn optional_string(body: &Value, key: &str) -> Result, String> { + body.get(key) + .map(|value| { + value + .as_str() + .map(non_blank) + .ok_or_else(|| format!("invalid {key}")) + }) + .transpose() + .map(Option::flatten) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn settings(providers: Value) -> SettingsFreshAgent { + SettingsFreshAgent { + default_plugins: vec!["/default-plugin".into()], + enabled: true, + providers, + initial_setup_done: None, + } + } + + #[test] + fn explicit_request_fields_override_saved_defaults_and_can_clear_selection() { + let settings = settings(json!({"freshclaude": { + "modelSelection": {"kind":"exact", "modelId":"saved-model"}, + "defaultPermissionMode":"plan", "effort":"low" + }})); + let resolved = resolve_claude_rest_create( + "freshclaude", + &json!({ + "model":"opus[1m]", "modelSelection":null, "effort":"max", + "permissionMode":"acceptEdits", "plugins":[] + }), + &settings, + ) + .unwrap(); + assert_eq!( + serde_json::to_value(resolved).unwrap(), + json!({ + "model":"opus[1m]", "effort":"max", "permissionMode":"acceptEdits", "plugins":[] + }) + ); + } + + #[test] + fn explicit_selection_changes_the_model_and_discards_old_catalog_levels() { + let settings = settings(json!({"kilroy": { + "modelSelection": {"kind":"exact", "modelId":"saved-model"}, + "modelEffortLevels":{"modelId":"saved-model", "levels":[]} + }})); + let resolved = resolve_claude_rest_create( + "kilroy", + &json!({ + "modelSelection":{"kind":"exact", "modelId":"new-model"} + }), + &settings, + ) + .unwrap(); + assert_eq!(resolved.model, "new-model"); + assert_eq!(resolved.model_selection.unwrap().model_id, "new-model"); + assert_eq!(resolved.model_effort_levels, None); + assert_eq!(resolved.effort.as_deref(), Some("high")); + } + + #[test] + fn invalid_provider_inputs_are_refused() { + let settings = settings(json!({})); + assert!( + resolve_claude_rest_create("freshclaude", &json!({"plugins":"bad"}), &settings) + .is_err() + ); + assert!(resolve_claude_rest_create("freshcodex", &json!({}), &settings).is_err()); + } + + #[test] + fn rest_and_picker_resolve_the_same_claude_defaults() { + let fixtures: Vec = serde_json::from_str(include_str!( + "../../../test/fixtures/fresh-agent/claude-create-defaults.json" + )) + .unwrap(); + for fixture in fixtures { + let mut settings = SettingsFreshAgent { + default_plugins: vec![], + enabled: true, + providers: json!({}), + initial_setup_done: None, + }; + if let Some(fresh) = fixture["settings"].get("freshAgent") { + if let Some(plugins) = fresh.get("defaultPlugins") { + settings.default_plugins = serde_json::from_value(plugins.clone()).unwrap(); + } + if let Some(providers) = fresh.get("providers") { + settings.providers = providers.clone(); + } + } + let resolved = resolve_claude_rest_create( + fixture["sessionType"].as_str().unwrap(), + &json!({}), + &settings, + ) + .unwrap(); + assert_eq!( + serde_json::to_value(resolved).unwrap(), + fixture["expected"], + "{}", + fixture["name"] + ); + } + } +} diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index ade478f29..f702d6abc 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -37,6 +37,7 @@ //! backstop, by the harness sentinel sweep — no orphans. pub mod claude; +pub mod claude_create_defaults; pub(crate) mod claude_dead_sessions; pub mod claude_freshell_mcp; pub(crate) mod claude_handshake; diff --git a/src/components/panes/PaneContainer.tsx b/src/components/panes/PaneContainer.tsx index 9a15f3c06..9a5683761 100644 --- a/src/components/panes/PaneContainer.tsx +++ b/src/components/panes/PaneContainer.tsx @@ -13,8 +13,8 @@ import HostStatsPane from './HostStatsPane' import PanePicker, { type PanePickerType } from './PanePicker' import DirectoryPicker from './DirectoryPicker' import { getProviderLabel, isCodingCliProviderName } from '@/lib/coding-cli-utils' -import { isFreshAgentProviderName, getFreshAgentProviderConfig } from '@/lib/fresh-agent-provider-utils' -import { normalizeFreshAgentEffort, resolveFreshAgentPaneCreateEffort, resolveFreshAgentPaneModelDefaults, resolveFreshAgentType } from '@/lib/fresh-agent-registry' +import { getFreshAgentProviderConfig } from '@/lib/fresh-agent-provider-utils' +import { normalizeFreshAgentEffort, resolveFreshAgentType } from '@/lib/fresh-agent-registry' import { clearDraft } from '@/lib/draft-store' import { getTerminalActions } from '@/lib/pane-action-registry' import { renamePaneAfterMirrorReady } from '@/lib/pane-rename' @@ -38,14 +38,13 @@ import { ContextIds } from '@/components/context-menu/context-menu-constants' import type { CodingCliProviderName } from '@/lib/coding-cli-types' import type { FreshAgentPendingCreate, FreshAgentSessionState } from '@/store/freshAgentTypes' import type { FreshAgentPaneContent } from '@/store/paneTypes' -import { normalizeFreshAgentEffortOverride, normalizeFreshAgentModelSelection } from '@/store/paneTypes' import { dismissTabGreen } from '@/store/turnCompletionAttention' import { clearPendingCreate as clearFreshAgentPendingCreate, removeSession as removeFreshAgentSession, sessionError as freshAgentSessionError, } from '@/store/freshAgentSlice' -import { DEFAULT_FRESH_AGENT_STYLE } from '@shared/settings' +import { buildFreshAgentPaneContent } from '@/lib/fresh-agent-create-content' import { cancelCreate } from '@/lib/create-cancellation' import { getFreshOpenCodeRouteCwd } from '@/lib/fresh-opencode-route' import type { PaneRuntimeActivityRecord } from '@/store/paneRuntimeActivitySlice' @@ -705,52 +704,14 @@ function PickerWrapper({ const freshAgentType = resolveFreshAgentType(type) if (freshAgentType) { - const providerConfig = freshAgentType.runtimeProvider === 'claude' && isFreshAgentProviderName(type) - ? getFreshAgentProviderConfig(type) - : undefined - const providerSettings = freshAgentSettings?.providers?.[type] - const { model, persistModel: shouldPersistPaneModel, modelEffortLevels } = resolveFreshAgentPaneModelDefaults({ + return buildFreshAgentPaneContent({ entry: freshAgentType, - providerSettings, - runtimeProviderModel: settings?.codingCli?.providers?.[freshAgentType.runtimeProvider]?.model, - }) - const permissionMode = freshAgentType.settingsVisibility.permissionMode === false - ? undefined - : providerSettings?.defaultPermissionMode - ?? (freshAgentType.runtimeProvider === 'codex' - ? settings?.codingCli?.providers?.[freshAgentType.runtimeProvider]?.permissionMode - : undefined) - ?? providerConfig?.defaultPermissionMode - ?? freshAgentType.defaultPermissionMode - const createEffort = resolveFreshAgentPaneCreateEffort({ - sessionType: freshAgentType.sessionType, - provider: freshAgentType.runtimeProvider, - model, - providerEffort: normalizeFreshAgentEffortOverride(providerSettings?.effort), - fallbackEffort: freshAgentType.defaultEffort, - modelEffortLevels, - }) - return { - kind: 'fresh-agent', - sessionType: freshAgentType.sessionType, - provider: freshAgentType.runtimeProvider, + freshAgentSettings, + codingCliSettings: settings?.codingCli, createRequestId: nanoid(), - status: 'creating', - modelSelection: normalizeFreshAgentModelSelection(providerSettings?.modelSelection), - ...(shouldPersistPaneModel ? { model } : {}), - ...(modelEffortLevels ? { modelEffortLevels } : {}), - ...(permissionMode ? { permissionMode } : {}), - sandbox: freshAgentType.runtimeProvider === 'codex' - ? settings?.codingCli?.providers?.[freshAgentType.runtimeProvider]?.sandbox - : undefined, - // Undefined effort is meaningful for opencode live-catalog models: - // the Default row (no variant) persists as the provider default. - ...(createEffort ? { effort: createEffort } : {}), - plugins: freshAgentType.runtimeProvider === 'claude' ? freshAgentSettings?.defaultPlugins : undefined, - style: providerSettings?.style ?? DEFAULT_FRESH_AGENT_STYLE, - ...(cwd ? { initialCwd: cwd } : {}), - ...(checkpointWorkspace ? { checkpointWorkspace } : {}), - } + cwd, + checkpointWorkspace, + }) } if (isCodingCliProviderName(type, extensionEntries)) { diff --git a/src/lib/fresh-agent-create-content.ts b/src/lib/fresh-agent-create-content.ts new file mode 100644 index 000000000..a92b3fd6f --- /dev/null +++ b/src/lib/fresh-agent-create-content.ts @@ -0,0 +1,71 @@ +import type { ServerSettings } from '@shared/settings' +import { DEFAULT_FRESH_AGENT_STYLE } from '@shared/settings' +import type { FreshAgentPaneContent } from '@/store/paneTypes' +import { normalizeFreshAgentEffortOverride, normalizeFreshAgentModelSelection } from '@/store/paneTypes' +import { getFreshAgentProviderConfig, isFreshAgentProviderName } from '@/lib/fresh-agent-provider-utils' +import { resolveFreshAgentPaneCreateEffort, resolveFreshAgentPaneModelDefaults, type FreshAgentRegistryEntry } from '@/lib/fresh-agent-registry' + +/** The new-pane settings shared by the picker and the REST parity fixtures. */ +export function buildFreshAgentPaneContent({ + entry: freshAgentType, + freshAgentSettings, + codingCliSettings, + createRequestId, + cwd, + checkpointWorkspace, +}: { + entry: FreshAgentRegistryEntry + freshAgentSettings?: ServerSettings['freshAgent'] + codingCliSettings?: ServerSettings['codingCli'] + createRequestId: string + cwd?: string + checkpointWorkspace?: FreshAgentPaneContent['checkpointWorkspace'] +}): FreshAgentPaneContent { + const type = freshAgentType.sessionType + const providerConfig = freshAgentType.runtimeProvider === 'claude' && isFreshAgentProviderName(type) + ? getFreshAgentProviderConfig(type) + : undefined + const providerSettings = freshAgentSettings?.providers?.[type] + const { model, persistModel: shouldPersistPaneModel, modelEffortLevels } = resolveFreshAgentPaneModelDefaults({ + entry: freshAgentType, + providerSettings, + runtimeProviderModel: codingCliSettings?.providers?.[freshAgentType.runtimeProvider]?.model, + }) + const permissionMode = freshAgentType.settingsVisibility.permissionMode === false + ? undefined + : providerSettings?.defaultPermissionMode + ?? (freshAgentType.runtimeProvider === 'codex' + ? codingCliSettings?.providers?.[freshAgentType.runtimeProvider]?.permissionMode + : undefined) + ?? providerConfig?.defaultPermissionMode + ?? freshAgentType.defaultPermissionMode + const createEffort = resolveFreshAgentPaneCreateEffort({ + sessionType: freshAgentType.sessionType, + provider: freshAgentType.runtimeProvider, + model, + providerEffort: normalizeFreshAgentEffortOverride(providerSettings?.effort), + fallbackEffort: freshAgentType.defaultEffort, + modelEffortLevels, + }) + return { + kind: 'fresh-agent', + sessionType: freshAgentType.sessionType, + provider: freshAgentType.runtimeProvider, + createRequestId, + status: 'creating', + modelSelection: normalizeFreshAgentModelSelection(providerSettings?.modelSelection), + ...(shouldPersistPaneModel ? { model } : {}), + ...(modelEffortLevels ? { modelEffortLevels } : {}), + ...(permissionMode ? { permissionMode } : {}), + sandbox: freshAgentType.runtimeProvider === 'codex' + ? codingCliSettings?.providers?.[freshAgentType.runtimeProvider]?.sandbox + : undefined, + // Undefined effort is meaningful for opencode live-catalog models: + // the Default row (no variant) persists as the provider default. + ...(createEffort ? { effort: createEffort } : {}), + plugins: freshAgentType.runtimeProvider === 'claude' ? freshAgentSettings?.defaultPlugins : undefined, + style: providerSettings?.style ?? DEFAULT_FRESH_AGENT_STYLE, + ...(cwd ? { initialCwd: cwd } : {}), + ...(checkpointWorkspace ? { checkpointWorkspace } : {}), + } +} diff --git a/test/fixtures/fresh-agent/claude-create-defaults.json b/test/fixtures/fresh-agent/claude-create-defaults.json new file mode 100644 index 000000000..6c6eb2bf7 --- /dev/null +++ b/test/fixtures/fresh-agent/claude-create-defaults.json @@ -0,0 +1,83 @@ +[ + { + "name": "freshclaude registry defaults", + "sessionType": "freshclaude", + "settings": {}, + "expected": { "model": "opus[1m]", "permissionMode": "bypassPermissions", "effort": "high", "plugins": [] } + }, + { + "name": "kilroy has independent saved defaults", + "sessionType": "kilroy", + "settings": { + "freshAgent": { + "defaultPlugins": ["/workspace/plugin"], + "providers": { + "freshclaude": { "effort": "low" }, + "kilroy": { "modelSelection": { "kind": "exact", "modelId": "claude-sonnet-custom" }, "defaultPermissionMode": "plan", "effort": "max" } + } + } + }, + "expected": { "model": "claude-sonnet-custom", "modelSelection": { "kind": "exact", "modelId": "claude-sonnet-custom" }, "permissionMode": "plan", "effort": "max", "plugins": ["/workspace/plugin"] } + }, + { + "name": "terminal model does not set a fresh pane default", + "sessionType": "freshclaude", + "settings": { "codingCli": { "providers": { "claude": { "model": "terminal-model", "permissionMode": "default" } } } }, + "expected": { "model": "opus[1m]", "permissionMode": "bypassPermissions", "effort": "high", "plugins": [] } + }, + { + "name": "saved static effort is trimmed", + "sessionType": "freshclaude", + "settings": { "freshAgent": { "providers": { "freshclaude": { "effort": " xhigh ", "defaultPermissionMode": "default" } } } }, + "expected": { "model": "opus[1m]", "permissionMode": "default", "effort": "xhigh", "plugins": [] } + }, + { + "name": "probed model clamps to its saved levels", + "sessionType": "freshclaude", + "settings": { "freshAgent": { "providers": { "freshclaude": { + "modelSelection": { "kind": "exact", "modelId": "catalog-model" }, + "modelEffortLevels": { "modelId": "catalog-model", "levels": ["low", "max"] }, + "effort": "medium" + } } } }, + "expected": { "model": "catalog-model", "modelSelection": { "kind": "exact", "modelId": "catalog-model" }, "modelEffortLevels": ["low", "max"], "permissionMode": "bypassPermissions", "effort": "low", "plugins": [] } + }, + { + "name": "probed model with no effort levels omits effort", + "sessionType": "freshclaude", + "settings": { "freshAgent": { "providers": { "freshclaude": { + "modelSelection": { "kind": "exact", "modelId": "catalog-model" }, + "modelEffortLevels": { "modelId": "catalog-model", "levels": [] }, + "effort": "high" + } } } }, + "expected": { "model": "catalog-model", "modelSelection": { "kind": "exact", "modelId": "catalog-model" }, "modelEffortLevels": [], "permissionMode": "bypassPermissions", "plugins": [] } + }, + { + "name": "stale levels from a different model are ignored", + "sessionType": "freshclaude", + "settings": { "freshAgent": { "providers": { "freshclaude": { + "modelSelection": { "kind": "exact", "modelId": "catalog-model" }, + "modelEffortLevels": { "modelId": "old-model", "levels": ["low"] }, + "effort": "not-a-level" + } } } }, + "expected": { "model": "catalog-model", "modelSelection": { "kind": "exact", "modelId": "catalog-model" }, "permissionMode": "bypassPermissions", "effort": "high", "plugins": [] } + }, + { + "name": "probed model retains a supported default effort even when it is not first", + "sessionType": "freshclaude", + "settings": { "freshAgent": { "providers": { "freshclaude": { + "modelSelection": { "kind": "exact", "modelId": "catalog-model" }, + "modelEffortLevels": { "modelId": "catalog-model", "levels": ["max", "high"] } + } } } }, + "expected": { "model": "catalog-model", "modelSelection": { "kind": "exact", "modelId": "catalog-model" }, "modelEffortLevels": ["max", "high"], "permissionMode": "bypassPermissions", "effort": "high", "plugins": [] } + }, + { + "name": "static model ignores stamped levels", + "sessionType": "kilroy", + "settings": { "freshAgent": { "providers": { "kilroy": { + "modelSelection": { "kind": "exact", "modelId": "opus[1m]" }, + "modelEffortLevels": { "modelId": "opus[1m]", "levels": [] }, + "effort": "max" + } } } }, + "expected": { "model": "opus[1m]", "modelSelection": { "kind": "exact", "modelId": "opus[1m]" }, "modelEffortLevels": [], "permissionMode": "bypassPermissions", "effort": "max", "plugins": [] } + } +] diff --git a/test/unit/client/lib/fresh-agent-create-content.test.ts b/test/unit/client/lib/fresh-agent-create-content.test.ts new file mode 100644 index 000000000..4f9d3eef3 --- /dev/null +++ b/test/unit/client/lib/fresh-agent-create-content.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest' +import fixtures from '../../../fixtures/fresh-agent/claude-create-defaults.json' +import { createDefaultServerSettings, type ServerSettings } from '@shared/settings' +import { buildFreshAgentPaneContent } from '@/lib/fresh-agent-create-content' +import { resolveFreshAgentType } from '@/lib/fresh-agent-registry' + +describe('fresh-agent create defaults shared with REST', () => { + it.each(fixtures)('$name', ({ sessionType, settings, expected }) => { + const defaultServerSettings = createDefaultServerSettings() + const entry = resolveFreshAgentType(sessionType)! + const input = settings as Partial + const content = buildFreshAgentPaneContent({ + entry, + createRequestId: 'fixture-create', + codingCliSettings: { + ...defaultServerSettings.codingCli, + ...input.codingCli, + }, + freshAgentSettings: { + ...defaultServerSettings.freshAgent, + ...input.freshAgent, + }, + }) + const { model, modelSelection, modelEffortLevels, permissionMode, effort, plugins } = content + expect(JSON.parse(JSON.stringify({ model, modelSelection, modelEffortLevels, permissionMode, effort, plugins }))) + .toEqual(expected) + expect(content).toMatchObject({ + kind: 'fresh-agent', provider: 'claude', sessionType, + createRequestId: 'fixture-create', status: 'creating', + }) + }) +}) From 180fd6e052adcee99ef2c27e7dec8158151074e8 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:57:17 -0700 Subject: [PATCH 003/100] refactor(freshagent): generalize the REST lane interface --- .../src/claude_create_defaults.rs | 2 +- crates/freshell-freshagent/src/lib.rs | 59 +++++++++---------- crates/freshell-freshagent/src/pane_ops.rs | 6 +- .../src/pane_ops_tab_tests.rs | 28 ++++----- .../freshell-freshagent/src/pane_ops_tests.rs | 14 ++--- .../src/{hosted_rest.rs => rest_lane.rs} | 52 ++++++++-------- crates/freshell-freshagent/src/snapshot.rs | 23 ++++---- .../freshell-server/src/fresh_agent_proxy.rs | 9 ++- .../src/fresh_agent_proxy_rest.rs | 33 +++++------ .../src/fresh_agent_proxy_tests.rs | 4 +- .../managed_runtime_opencode_replan_tests.rs | 6 +- 11 files changed, 115 insertions(+), 121 deletions(-) rename crates/freshell-freshagent/src/{hosted_rest.rs => rest_lane.rs} (75%) diff --git a/crates/freshell-freshagent/src/claude_create_defaults.rs b/crates/freshell-freshagent/src/claude_create_defaults.rs index 8e6028a34..a0927aaa7 100644 --- a/crates/freshell-freshagent/src/claude_create_defaults.rs +++ b/crates/freshell-freshagent/src/claude_create_defaults.rs @@ -26,7 +26,7 @@ pub fn resolve_claude_rest_create( if !matches!(session_type, "freshclaude" | "kilroy") { return Err(format!("unsupported Claude session type: {session_type}")); } - let inputs = crate::hosted_rest::provider_inputs(body)?; + let inputs = crate::rest_lane::provider_inputs(body)?; let saved = &settings.providers[session_type]; let model_selection = match inputs.model_selection { Some(selection) => selection, diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index f702d6abc..6ea531241 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -54,7 +54,6 @@ pub(crate) mod claude_test_holds; pub mod codex; pub(crate) mod codex_sidecar_tracking; pub(crate) mod delegation; -pub mod hosted_rest; pub mod identity_sink; pub mod layout_store; pub mod layout_tree; @@ -69,6 +68,7 @@ mod pane_resize; #[cfg(all(test, unix))] #[path = "../tests/support/process_probe.rs"] mod process_probe; +pub mod rest_lane; pub mod rollback_record; pub mod session_handoff; pub mod session_lease; @@ -2051,7 +2051,7 @@ pub struct FreshAgentState { opencode: SharedOpencodeManagerHandle, /// Opt-in durable host gateway. When installed, REST/MCP fresh-agent /// requests never touch this web process's legacy provider manager. - hosted_rest: Arc>, + hosted_rest: Arc>, /// Monotonic `sessions.changed` revision. sessions_revision: Arc, /// Slice 1 (`docs/plans/2026-07-18-agent-api-mcp-parity-spec.md`): the SAME @@ -2515,14 +2515,14 @@ impl FreshAgentState { pub fn set_hosted_rest_gateway( &self, - gateway: hosted_rest::SharedHostedFreshAgentRestGateway, + gateway: rest_lane::SharedFreshAgentRestLane, ) -> Result<(), &'static str> { self.hosted_rest .set(gateway) .map_err(|_| "hosted fresh-agent REST gateway is already installed") } - fn hosted_rest_gateway(&self) -> Option { + fn hosted_rest_gateway(&self) -> Option { self.hosted_rest.get().cloned() } @@ -4828,7 +4828,7 @@ async fn create_tab( let name = body.get("name").and_then(Value::as_str).map(str::to_string); if let Some(gateway) = state.hosted_rest_gateway() { - let provider_inputs = match hosted_rest::provider_inputs(&body) { + let provider_inputs = match rest_lane::provider_inputs(&body) { Ok(inputs) => inputs, Err(error) => return fail_json(StatusCode::BAD_REQUEST, error), }; @@ -4846,10 +4846,10 @@ async fn create_tab( } }, }; - return create_hosted_agent_tab( + return create_lane_agent_tab( &state, gateway, - hosted_rest::HostedRestCreate { + rest_lane::RestLaneCreate { request_id: Uuid::new_v4().simple().to_string(), provider: provider.into(), session_type: session_type.into(), @@ -5017,10 +5017,10 @@ async fn create_tab( ) } -async fn create_hosted_agent_tab( +async fn create_lane_agent_tab( state: &FreshAgentState, - gateway: hosted_rest::SharedHostedFreshAgentRestGateway, - request: hosted_rest::HostedRestCreate, + gateway: rest_lane::SharedFreshAgentRestLane, + request: rest_lane::RestLaneCreate, name: Option, ) -> Response { let created = match gateway.create_agent(request.clone()).await { @@ -5032,7 +5032,7 @@ async fn create_hosted_agent_tab( ) } }; - let hosted_rest::HostedRestCreate { + let rest_lane::RestLaneCreate { request_id, provider, session_type, @@ -6348,7 +6348,7 @@ async fn send_keys( .map(str::to_string) .unwrap_or_else(|| Uuid::new_v4().simple().to_string()); let result = gateway - .send_agent(hosted_rest::HostedRestSend { + .send_agent(rest_lane::RestLaneSend { request_id: request_id.clone(), session_id: pane.placeholder_id.clone(), provider: pane.provider.clone(), @@ -7052,7 +7052,7 @@ async fn capture( .unwrap_or(256 * 1024) .clamp(1, 256 * 1024); return match gateway - .capture(hosted_rest::HostedRestCapture { + .capture(rest_lane::RestLaneCapture { session_id, provider: pane.provider.clone(), session_type: pane.session_type.clone(), @@ -7061,11 +7061,11 @@ async fn capture( .await { Ok(capture) => text_plain(capture.text), - Err(hosted_rest::HostedRestCaptureError::Unsupported) => fail_json( + Err(rest_lane::RestLaneCaptureError::Unsupported) => fail_json( StatusCode::UNPROCESSABLE_ENTITY, "hosted fresh-agent provider does not support transcript capture".to_string(), ), - Err(hosted_rest::HostedRestCaptureError::Unavailable) => fail_json( + Err(rest_lane::RestLaneCaptureError::Unavailable) => fail_json( StatusCode::SERVICE_UNAVAILABLE, "hosted fresh-agent transcript is temporarily unavailable".to_string(), ), @@ -9236,28 +9236,28 @@ mod tests { // -- P1.13 Task 7: REST send-keys materialization writes a binding row -- #[derive(Default)] - struct RecordingHostedRestGateway { - sends: Mutex>, - captures: Mutex>, + struct RecordingRestLaneGateway { + sends: Mutex>, + captures: Mutex>, } #[async_trait::async_trait] - impl hosted_rest::HostedFreshAgentRestGateway for RecordingHostedRestGateway { + impl rest_lane::FreshAgentRestLane for RecordingRestLaneGateway { async fn create_agent( self: Arc, - _request: hosted_rest::HostedRestCreate, - ) -> Result { - Ok(hosted_rest::HostedRestCreated { + _request: rest_lane::RestLaneCreate, + ) -> Result { + Ok(rest_lane::RestLaneCreated { session_id: "managed-kilroy-recovered".into(), }) } async fn send_agent( &self, - request: hosted_rest::HostedRestSend, - ) -> Result { + request: rest_lane::RestLaneSend, + ) -> Result { self.sends.lock().expect("sends mutex").push(request); - Ok(hosted_rest::HostedRestSendResult { + Ok(rest_lane::RestLaneSendResult { session_id: "managed-kilroy-recovered".into(), completed: true, }) @@ -9265,11 +9265,10 @@ mod tests { async fn capture( &self, - request: hosted_rest::HostedRestCapture, - ) -> Result - { + request: rest_lane::RestLaneCapture, + ) -> Result { self.captures.lock().expect("captures mutex").push(request); - Ok(hosted_rest::HostedRestCaptureResult { + Ok(rest_lane::RestLaneCaptureResult { session_id: "managed-kilroy-recovered".into(), native_session_id: "kilroy-native".into(), text: "recovered transcript".into(), @@ -9282,7 +9281,7 @@ mod tests { async fn hosted_rest_send_and_capture_recover_pane_from_persisted_layout_after_web_restart() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("layout.json"); - let gateway = Arc::new(RecordingHostedRestGateway::default()); + let gateway = Arc::new(RecordingRestLaneGateway::default()); let first = state().with_layout(layout_store::LayoutStore::with_persistence(path.clone())); first.set_hosted_rest_gateway(gateway.clone()).unwrap(); let mut headers = HeaderMap::new(); diff --git a/crates/freshell-freshagent/src/pane_ops.rs b/crates/freshell-freshagent/src/pane_ops.rs index b41e8c8f9..a2d891d20 100644 --- a/crates/freshell-freshagent/src/pane_ops.rs +++ b/crates/freshell-freshagent/src/pane_ops.rs @@ -45,7 +45,7 @@ use crate::layout_store::RenameOutcome; use crate::target_resolver::{resolve_target, ResolvedTarget}; use crate::terminal_tabs::{spawn_terminal_pane, TerminalSpawnResult}; use crate::{ - approx_json, authorized, fail_json, hosted_rest, ok_json, parse_required_name, rest_agent_mode, + approx_json, authorized, fail_json, ok_json, parse_required_name, rest_agent_mode, rest_lane, FreshAgentState, PaneEntry, PaneNameResolution, }; @@ -213,7 +213,7 @@ pub(crate) async fn split_pane( }, }; let request_id = uuid::Uuid::new_v4().simple().to_string(); - let provider_inputs = match hosted_rest::provider_inputs(&body) { + let provider_inputs = match rest_lane::provider_inputs(&body) { Ok(inputs) => inputs, Err(error) => { let _ = state.layout.close_pane(&new_pane_id); @@ -230,7 +230,7 @@ pub(crate) async fn split_pane( .and_then(Value::as_str) .map(str::to_string); let created = match gateway - .create_agent(hosted_rest::HostedRestCreate { + .create_agent(rest_lane::RestLaneCreate { request_id: request_id.clone(), provider: provider.into(), session_type: session_type.into(), diff --git a/crates/freshell-freshagent/src/pane_ops_tab_tests.rs b/crates/freshell-freshagent/src/pane_ops_tab_tests.rs index b019bc949..c75583821 100644 --- a/crates/freshell-freshagent/src/pane_ops_tab_tests.rs +++ b/crates/freshell-freshagent/src/pane_ops_tab_tests.rs @@ -5,10 +5,10 @@ use super::tests::{app, create_shell_tab, delete, get, patch, post, state_with_registry}; use super::*; -use crate::hosted_rest; +use crate::rest_lane; use tower::util::ServiceExt; -struct HostedRestFake { +struct RestLaneFake { creates: std::sync::atomic::AtomicUsize, sends: std::sync::atomic::AtomicUsize, captures: std::sync::atomic::AtomicUsize, @@ -16,11 +16,11 @@ struct HostedRestFake { } #[async_trait::async_trait] -impl hosted_rest::HostedFreshAgentRestGateway for HostedRestFake { +impl rest_lane::FreshAgentRestLane for RestLaneFake { async fn create_agent( self: Arc, - request: hosted_rest::HostedRestCreate, - ) -> Result { + request: rest_lane::RestLaneCreate, + ) -> Result { assert_eq!(request.cwd.as_deref(), Some("/workspace")); self.identities .lock() @@ -28,15 +28,15 @@ impl hosted_rest::HostedFreshAgentRestGateway for HostedRestFake { .push((request.provider, request.session_type)); self.creates .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok(hosted_rest::HostedRestCreated { + Ok(rest_lane::RestLaneCreated { session_id: "managed-rest-session".into(), }) } async fn send_agent( &self, - request: hosted_rest::HostedRestSend, - ) -> Result { + request: rest_lane::RestLaneSend, + ) -> Result { assert_eq!(request.session_id, "managed-rest-session"); assert_eq!( (request.provider.as_str(), request.session_type.as_str()), @@ -44,7 +44,7 @@ impl hosted_rest::HostedFreshAgentRestGateway for HostedRestFake { ); assert_eq!(request.text, "continue"); self.sends.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok(hosted_rest::HostedRestSendResult { + Ok(rest_lane::RestLaneSendResult { session_id: request.session_id, completed: true, }) @@ -52,15 +52,15 @@ impl hosted_rest::HostedFreshAgentRestGateway for HostedRestFake { async fn capture( &self, - request: hosted_rest::HostedRestCapture, - ) -> Result { + request: rest_lane::RestLaneCapture, + ) -> Result { assert_eq!( (request.provider.as_str(), request.session_type.as_str()), ("opencode", "freshopencode") ); self.captures .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok(hosted_rest::HostedRestCaptureResult { + Ok(rest_lane::RestLaneCaptureResult { session_id: request.session_id, native_session_id: "native-rest-session".into(), text: "user: continue\nassistant: done\n".into(), @@ -729,7 +729,7 @@ async fn fresh_agent_rest_create_registers_tab_and_pane_in_the_layout_store() { #[tokio::test] async fn hosted_rest_create_and_send_never_start_the_web_owned_opencode_transport() { let state = state_with_registry(); - let gateway = Arc::new(HostedRestFake { + let gateway = Arc::new(RestLaneFake { creates: std::sync::atomic::AtomicUsize::new(0), sends: std::sync::atomic::AtomicUsize::new(0), captures: std::sync::atomic::AtomicUsize::new(0), @@ -790,7 +790,7 @@ async fn hosted_rest_create_and_send_never_start_the_web_owned_opencode_transpor #[tokio::test] async fn hosted_rest_tab_routes_every_fresh_agent_mode_to_the_typed_gateway() { let state = state_with_registry(); - let gateway = Arc::new(HostedRestFake { + let gateway = Arc::new(RestLaneFake { creates: std::sync::atomic::AtomicUsize::new(0), sends: std::sync::atomic::AtomicUsize::new(0), captures: std::sync::atomic::AtomicUsize::new(0), diff --git a/crates/freshell-freshagent/src/pane_ops_tests.rs b/crates/freshell-freshagent/src/pane_ops_tests.rs index d5dc982fd..259df43cf 100644 --- a/crates/freshell-freshagent/src/pane_ops_tests.rs +++ b/crates/freshell-freshagent/src/pane_ops_tests.rs @@ -5,7 +5,7 @@ //! module's `pub(super)` helpers). use super::*; -use crate::hosted_rest; +use crate::rest_lane; use axum::body::Body; use axum::http::Request; use std::sync::Arc; @@ -14,24 +14,24 @@ use tower::util::ServiceExt; struct SplitHostedGateway; #[async_trait::async_trait] -impl hosted_rest::HostedFreshAgentRestGateway for SplitHostedGateway { +impl rest_lane::FreshAgentRestLane for SplitHostedGateway { async fn create_agent( self: Arc, - request: hosted_rest::HostedRestCreate, - ) -> Result { + request: rest_lane::RestLaneCreate, + ) -> Result { assert_eq!( (request.provider.as_str(), request.session_type.as_str()), ("claude", "freshclaude") ); - Ok(hosted_rest::HostedRestCreated { + Ok(rest_lane::RestLaneCreated { session_id: "managed-split-claude".into(), }) } async fn send_agent( &self, - _request: hosted_rest::HostedRestSend, - ) -> Result { + _request: rest_lane::RestLaneSend, + ) -> Result { unreachable!("split creation does not send a prompt") } } diff --git a/crates/freshell-freshagent/src/hosted_rest.rs b/crates/freshell-freshagent/src/rest_lane.rs similarity index 75% rename from crates/freshell-freshagent/src/hosted_rest.rs rename to crates/freshell-freshagent/src/rest_lane.rs index 4e8fd4a9d..f1b669047 100644 --- a/crates/freshell-freshagent/src/hosted_rest.rs +++ b/crates/freshell-freshagent/src/rest_lane.rs @@ -1,19 +1,19 @@ -//! Typed seam for routing the REST/MCP fresh-agent ingress to the durable -//! session-host owner. The implementation lives in `freshell-server`; this -//! crate deliberately knows nothing about supervisor IPC. +//! Typed REST/MCP ingress shared by local and hosted fresh-agent runtimes. +//! Hosted implementations live in `freshell-server`; this crate deliberately +//! knows nothing about supervisor IPC. use async_trait::async_trait; use serde_json::Value; #[derive(Default)] -pub struct HostedRestProviderInputs { +pub struct RestLaneProviderInputs { pub plugins: Option>, pub model_selection: Option>, pub permission_mode: Option, pub sandbox: Option, } -pub fn provider_inputs(body: &Value) -> Result { +pub fn provider_inputs(body: &Value) -> Result { let plugins = body .get("plugins") .map(|value| { @@ -38,7 +38,7 @@ pub fn provider_inputs(body: &Value) -> Result serde_json::from_value(value.clone()).map_err(|_| "invalid sandbox".to_string()) }) .transpose()?; - Ok(HostedRestProviderInputs { + Ok(RestLaneProviderInputs { plugins, model_selection, permission_mode, @@ -47,7 +47,7 @@ pub fn provider_inputs(body: &Value) -> Result } #[derive(Clone, Debug, PartialEq)] -pub struct HostedRestCreate { +pub struct RestLaneCreate { pub request_id: String, pub provider: String, pub session_type: String, @@ -62,14 +62,14 @@ pub struct HostedRestCreate { } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct HostedRestCreated { +pub struct RestLaneCreated { /// Stable presentation identity used by the web layout and gateway alias /// table. It is not substituted for the provider's native identity. pub session_id: String, } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct HostedRestSend { +pub struct RestLaneSend { pub request_id: String, pub session_id: String, pub provider: String, @@ -79,13 +79,13 @@ pub struct HostedRestSend { } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct HostedRestSendResult { +pub struct RestLaneSendResult { pub session_id: String, pub completed: bool, } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct HostedRestCapture { +pub struct RestLaneCapture { pub session_id: String, pub provider: String, pub session_type: String, @@ -93,7 +93,7 @@ pub struct HostedRestCapture { } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct HostedRestCaptureResult { +pub struct RestLaneCaptureResult { pub session_id: String, pub native_session_id: String, pub text: String, @@ -101,50 +101,50 @@ pub struct HostedRestCaptureResult { } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum HostedRestCaptureError { +pub enum RestLaneCaptureError { Unsupported, Unavailable, } #[derive(Clone, Debug, PartialEq, Eq)] -pub struct HostedRestSnapshot { +pub struct RestLaneSnapshot { pub session_id: String, pub provider: String, pub session_type: String, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum HostedRestSnapshotError { +pub enum RestLaneSnapshotError { OwnershipUnavailable, ManagedUnavailable, } #[async_trait] -pub trait HostedFreshAgentRestGateway: Send + Sync { +pub trait FreshAgentRestLane: Send + Sync { /// None leaves genuinely unmanaged threads on their existing read path. /// A hosted read failure must never fall back to saved history as live truth. async fn snapshot( &self, - _request: HostedRestSnapshot, - ) -> Result, HostedRestSnapshotError> { - Err(HostedRestSnapshotError::OwnershipUnavailable) + _request: RestLaneSnapshot, + ) -> Result, RestLaneSnapshotError> { + Err(RestLaneSnapshotError::OwnershipUnavailable) } async fn create_agent( self: std::sync::Arc, - request: HostedRestCreate, - ) -> Result; + request: RestLaneCreate, + ) -> Result; - async fn send_agent(&self, request: HostedRestSend) -> Result; + async fn send_agent(&self, request: RestLaneSend) -> Result; async fn capture( &self, - _request: HostedRestCapture, - ) -> Result { - Err(HostedRestCaptureError::Unsupported) + _request: RestLaneCapture, + ) -> Result { + Err(RestLaneCaptureError::Unsupported) } } -pub type SharedHostedFreshAgentRestGateway = std::sync::Arc; +pub type SharedFreshAgentRestLane = std::sync::Arc; #[cfg(test)] mod tests { diff --git a/crates/freshell-freshagent/src/snapshot.rs b/crates/freshell-freshagent/src/snapshot.rs index e0d049a24..086c1948a 100644 --- a/crates/freshell-freshagent/src/snapshot.rs +++ b/crates/freshell-freshagent/src/snapshot.rs @@ -212,7 +212,7 @@ async fn get_snapshot( { if let Some(gateway) = state.opencode.hosted_rest_gateway() { match gateway - .snapshot(crate::hosted_rest::HostedRestSnapshot { + .snapshot(crate::rest_lane::RestLaneSnapshot { session_id: thread_id.clone(), provider: provider.clone(), session_type: session_type.clone(), @@ -221,13 +221,13 @@ async fn get_snapshot( { Ok(Some(snapshot)) => return Json(snapshot).into_response(), Ok(None) => {} - Err(crate::hosted_rest::HostedRestSnapshotError::ManagedUnavailable) => { + Err(crate::rest_lane::RestLaneSnapshotError::ManagedUnavailable) => { return fail( StatusCode::SERVICE_UNAVAILABLE, "Managed conversation snapshot unavailable".into(), ); } - Err(crate::hosted_rest::HostedRestSnapshotError::OwnershipUnavailable) => { + Err(crate::rest_lane::RestLaneSnapshotError::OwnershipUnavailable) => { return state .saved_history_or_unavailable(&session_type, &provider, &thread_id) .await @@ -504,26 +504,25 @@ mod tests { struct SnapshotGateway; #[async_trait::async_trait] - impl crate::hosted_rest::HostedFreshAgentRestGateway for SnapshotGateway { + impl crate::rest_lane::FreshAgentRestLane for SnapshotGateway { async fn create_agent( self: Arc, - _: crate::hosted_rest::HostedRestCreate, - ) -> Result { + _: crate::rest_lane::RestLaneCreate, + ) -> Result { panic!("GET must not create") } async fn send_agent( &self, - _: crate::hosted_rest::HostedRestSend, - ) -> Result { + _: crate::rest_lane::RestLaneSend, + ) -> Result { panic!("GET must not send") } async fn snapshot( &self, - request: crate::hosted_rest::HostedRestSnapshot, - ) -> Result, crate::hosted_rest::HostedRestSnapshotError> - { + request: crate::rest_lane::RestLaneSnapshot, + ) -> Result, crate::rest_lane::RestLaneSnapshotError> { if request.session_id == "unavailable-host" { - return Err(crate::hosted_rest::HostedRestSnapshotError::ManagedUnavailable); + return Err(crate::rest_lane::RestLaneSnapshotError::ManagedUnavailable); } Ok(Some( json!({"threadId":request.session_id,"status":"running", diff --git a/crates/freshell-server/src/fresh_agent_proxy.rs b/crates/freshell-server/src/fresh_agent_proxy.rs index d5940ee4d..0d871045e 100644 --- a/crates/freshell-server/src/fresh_agent_proxy.rs +++ b/crates/freshell-server/src/fresh_agent_proxy.rs @@ -3,12 +3,11 @@ //! Opt-in external gateway from the retained fresh-agent wire to the //! supervisor/session-host actor. This module owns no provider process. -use freshell_freshagent::hosted_rest::{ - HostedFreshAgentRestGateway, HostedRestCapture, HostedRestCaptureError, - HostedRestCaptureResult, HostedRestCreate, HostedRestCreated, HostedRestSend, - HostedRestSendResult, -}; use freshell_freshagent::naming::{admit_pending_projection, session_projection, SessionNaming}; +use freshell_freshagent::rest_lane::{ + FreshAgentRestLane, RestLaneCapture, RestLaneCaptureError, RestLaneCaptureResult, + RestLaneCreate, RestLaneCreated, RestLaneSend, RestLaneSendResult, +}; use freshell_protocol::session_names::NamedProvider; use freshell_protocol::{ AgentProvider, FreshAgentCreateFailed, FreshAgentCreated, FreshAgentEvent, FreshAgentForked, diff --git a/crates/freshell-server/src/fresh_agent_proxy_rest.rs b/crates/freshell-server/src/fresh_agent_proxy_rest.rs index 01368a2ab..7607887e3 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_rest.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_rest.rs @@ -3,13 +3,13 @@ use super::*; #[async_trait::async_trait] -impl HostedFreshAgentRestGateway for HostedFreshAgentProxy { +impl FreshAgentRestLane for HostedFreshAgentProxy { async fn snapshot( &self, - request: freshell_freshagent::hosted_rest::HostedRestSnapshot, - ) -> Result, freshell_freshagent::hosted_rest::HostedRestSnapshotError> + request: freshell_freshagent::rest_lane::RestLaneSnapshot, + ) -> Result, freshell_freshagent::rest_lane::RestLaneSnapshotError> { - use freshell_freshagent::hosted_rest::HostedRestSnapshotError::{ + use freshell_freshagent::rest_lane::RestLaneSnapshotError::{ ManagedUnavailable, OwnershipUnavailable, }; let (provider, session_type) = @@ -121,10 +121,7 @@ impl HostedFreshAgentRestGateway for HostedFreshAgentProxy { ); Ok(Some(snapshot)) } - async fn create_agent( - self: Arc, - request: HostedRestCreate, - ) -> Result { + async fn create_agent(self: Arc, request: RestLaneCreate) -> Result { let (provider, session_type) = rest_agent_identity(&request.provider, &request.session_type)?; let runtime_provider = fresh_provider(&Some(provider), session_type).ok_or(())?; @@ -160,10 +157,10 @@ impl HostedFreshAgentRestGateway for HostedFreshAgentProxy { .map(|value| value.session_id.as_str()), ); self.create(message).await?; - Ok(HostedRestCreated { session_id }) + Ok(RestLaneCreated { session_id }) } - async fn send_agent(&self, request: HostedRestSend) -> Result { + async fn send_agent(&self, request: RestLaneSend) -> Result { let (provider, session_type) = rest_agent_identity(&request.provider, &request.session_type)?; let soul = self @@ -187,7 +184,7 @@ impl HostedFreshAgentRestGateway for HostedFreshAgentProxy { Duration::from_millis(request.timeout_ms), ) .await; - Ok(HostedRestSendResult { + Ok(RestLaneSendResult { session_id: request.session_id, completed, }) @@ -195,24 +192,24 @@ impl HostedFreshAgentRestGateway for HostedFreshAgentProxy { async fn capture( &self, - request: HostedRestCapture, - ) -> Result { + request: RestLaneCapture, + ) -> Result { let (provider, session_type) = rest_agent_identity(&request.provider, &request.session_type) - .map_err(|_| HostedRestCaptureError::Unavailable)?; + .map_err(|_| RestLaneCaptureError::Unavailable)?; let soul = self .resolve_soul(&provider, session_type, &request.session_id) .await - .ok_or(HostedRestCaptureError::Unavailable)?; + .ok_or(RestLaneCaptureError::Unavailable)?; let capture = self .client .fresh_agent_capture(soul, request.max_bytes.min(256 * 1024) as u32) .await .map_err(|error| match error.runtime_code() { - Some(RuntimeErrorCode::UnsupportedOperation) => HostedRestCaptureError::Unsupported, - _ => HostedRestCaptureError::Unavailable, + Some(RuntimeErrorCode::UnsupportedOperation) => RestLaneCaptureError::Unsupported, + _ => RestLaneCaptureError::Unavailable, })?; - Ok(HostedRestCaptureResult { + Ok(RestLaneCaptureResult { session_id: capture.presentation_session_id, native_session_id: capture.native_session_id, text: capture.text, diff --git a/crates/freshell-server/src/fresh_agent_proxy_tests.rs b/crates/freshell-server/src/fresh_agent_proxy_tests.rs index 6c19b68c6..bc8dd97e9 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_tests.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_tests.rs @@ -974,7 +974,7 @@ async fn snapshot_route_value( #[tokio::test] async fn real_gateway_pre_native_opencode_projects_only_current_empty_owned_registration() { - use freshell_freshagent::hosted_rest::HostedRestSnapshot; + use freshell_freshagent::rest_lane::RestLaneSnapshot; for scenario in [ "empty", "nonempty", @@ -1031,7 +1031,7 @@ async fn real_gateway_pre_native_opencode_projects_only_current_empty_owned_regi }); let proxy = snapshot_outage_proxy(&socket); let result = proxy - .snapshot(HostedRestSnapshot { + .snapshot(RestLaneSnapshot { session_id: "managed-opencode-public".into(), provider: "opencode".into(), session_type: "freshopencode".into(), diff --git a/crates/freshell-server/src/managed_runtime_opencode_replan_tests.rs b/crates/freshell-server/src/managed_runtime_opencode_replan_tests.rs index e168dd342..8dce2e526 100644 --- a/crates/freshell-server/src/managed_runtime_opencode_replan_tests.rs +++ b/crates/freshell-server/src/managed_runtime_opencode_replan_tests.rs @@ -1,6 +1,6 @@ //! Actual initial controller callers with synthetic authenticated control transport. use super::*; -use freshell_freshagent::hosted_rest::{HostedFreshAgentRestGateway, HostedRestCreate}; +use freshell_freshagent::rest_lane::{FreshAgentRestLane, RestLaneCreate}; use freshell_runtime_protocol::*; use std::{collections::BTreeMap, os::unix::fs::PermissionsExt}; use tokio::net::UnixListener; @@ -364,7 +364,7 @@ mod uncertain_launch { .unwrap() .unwrap(); fresh - .create_agent(HostedRestCreate { + .create_agent(RestLaneCreate { request_id: seed.clone(), provider: if provider == "kilroy" { "claude" @@ -714,7 +714,7 @@ fn managed_provider_initial_opencode_callers_replan_changed_selection_with_two_r let prior_stage=control.join("provider-roots").join(&prior_grant); let prior_manifest=std::fs::read(prior_stage.join(".freshell/input-manifest.json")).unwrap(); let result=if fresh_route { - fresh.clone().create_agent(HostedRestCreate {request_id:seed.clone(),provider:"opencode".into(),session_type:"freshopencode".into(),cwd:Some(workspace.to_string_lossy().into_owned()),model:None,effort:None,native_session_id:None,plugins:None,model_selection:None,permission_mode:None,sandbox:None}).await.map(|_|()).map_err(|_|"fresh refusal".to_string()) + fresh.clone().create_agent(RestLaneCreate {request_id:seed.clone(),provider:"opencode".into(),session_type:"freshopencode".into(),cwd:Some(workspace.to_string_lossy().into_owned()),model:None,effort:None,native_session_id:None,plugins:None,model_selection:None,permission_mode:None,sandbox:None}).await.map(|_|()).map_err(|_|"fresh refusal".to_string()) } else { controller.launch(ManagedTerminalLaunch {spec:freshell_platform::SpawnSpec {program:"opencode".into(),args:vec![],env_overrides:BTreeMap::new(),cwd:Some(workspace.to_string_lossy().into_owned()),cols:80,rows:24},env:BTreeMap::new(),terminal_id:seed.clone(),stream_id:seed.clone(),mode:"opencode".into(),resume_session_id:None,provider_model:None,provider_reasoning_effort:None,provider_sandbox:None,provider_permission_mode:None,view_tab_id:None,view_pane_id:None,create_request_id:Some(seed.clone())}).await.map(|_|()).map_err(|error| error.to_string()) }; From eb1ef64abff1a96d8a1f27efc9ba15b46826e67c Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:13:47 -0700 Subject: [PATCH 004/100] feat(freshclaude): allow CLI and MCP create split and resume --- test/unit/cli/action-capabilities.test.ts | 44 +++++++++++++++++++ test/unit/mcp/freshell-tool.test.ts | 35 +++++++++++++-- tools/freshell-cli/index.ts | 17 ++++--- tools/freshell-mcp/freshell-tool.ts | 29 ++++-------- .../action-capabilities.ts | 18 +++++--- 5 files changed, 109 insertions(+), 34 deletions(-) diff --git a/test/unit/cli/action-capabilities.test.ts b/test/unit/cli/action-capabilities.test.ts index fc9b98b32..b5d00757d 100644 --- a/test/unit/cli/action-capabilities.test.ts +++ b/test/unit/cli/action-capabilities.test.ts @@ -72,6 +72,50 @@ describe('standalone CLI capability contract', () => { }) }) + it.each([ + ['new-tab', 'claude'], + ['new-tab', 'kilroy'], + ['split-pane', 'claude'], + ['split-pane', 'kilroy'], + ])('%s sends %s fresh-agent options and canonical Claude resume identity', async (action, agent) => { + const bodies: Array<{ url: string; body: Record }> = [] + const server = createServer(async (request, response) => { + response.setHeader('content-type', 'application/json') + if (request.method === 'POST') { + let text = '' + for await (const chunk of request) text += String(chunk) + bodies.push({ url: request.url ?? '', body: JSON.parse(text) }) + response.end(JSON.stringify({ status: 'ok', data: { tabId: 't1', paneId: 'new-pane' } })) + } else if (request.url === '/api/tabs') { + response.end(JSON.stringify({ tabs: [{ id: 't1', activePaneId: 'p1' }], activeTabId: 't1' })) + } else { + response.end(JSON.stringify({ panes: [{ id: 'p1', index: 0, kind: 'terminal' }] })) + } + }) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (!address || typeof address === 'string') throw new Error('test server did not listen') + try { + const args = [action, '--agent', agent, '--model', 'catalog-model', '--effort', 'max', '--resume', '550e8400-e29b-41d4-a716-446655440000'] + if (action === 'split-pane') args.push('--target', 'p1') + const result = await runCli(args, `http://127.0.0.1:${address.port}`) + expect(result.code, result.stderr).toBe(0) + expect(bodies).toHaveLength(1) + expect(bodies[0]).toMatchObject({ + url: action === 'new-tab' ? '/api/tabs' : '/api/panes/p1/split', + body: { + agent, model: 'catalog-model', effort: 'max', + sessionRef: { provider: 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, + }, + }) + expect(bodies[0].body).not.toHaveProperty('resumeSessionId') + } finally { + server.close() + await once(server, 'close') + } + }) + it.each([ ['run', 'echo', 'blocked'], ['fresh-send', 'blocked'], diff --git a/test/unit/mcp/freshell-tool.test.ts b/test/unit/mcp/freshell-tool.test.ts index 78cd9ec8c..fe0fa1b9a 100644 --- a/test/unit/mcp/freshell-tool.test.ts +++ b/test/unit/mcp/freshell-tool.test.ts @@ -127,6 +127,29 @@ describe('focus neutrality (MCP wrapper)', () => { }) describe('executeAction -- tab actions', () => { + it.each(['claude', 'kilroy'])('new-tab supports agent %s and maps resume sugar to Claude identity', async (agent) => { + mockClient.post.mockResolvedValue({ data: { tabId: 'tab-new', paneId: 'pane-new' } }) + const result = await executeAction('new-tab', { agent, model: 'catalog-model', effort: 'max', resume: '550e8400-e29b-41d4-a716-446655440000' }) + expect(result).not.toHaveProperty('error') + expect(mockClient.post).toHaveBeenCalledWith('/api/tabs', expect.objectContaining({ + agent, model: 'catalog-model', effort: 'max', + sessionRef: { provider: 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, + })) + }) + + it.each(['claude', 'kilroy'])('split-pane supports agent %s and maps resume sugar to Claude identity', async (agent) => { + mockClient.get.mockImplementation((url: string) => Promise.resolve(url === '/api/tabs' + ? { data: { tabs: [{ id: 'tab-a', activePaneId: 'pmain' }], activeTabId: 'tab-a' } } + : { data: { panes: [{ id: 'pmain', tabId: 'tab-a', index: 0 }] } })) + mockClient.post.mockResolvedValue({ data: { paneId: 'pane-new' } }) + const result = await executeAction('split-pane', { target: 'pmain', agent, model: 'catalog-model', effort: 'max', resume: '550e8400-e29b-41d4-a716-446655440000' }) + expect(result).not.toHaveProperty('error') + expect(mockClient.post).toHaveBeenCalledWith('/api/panes/pmain/split', expect.objectContaining({ + agent, model: 'catalog-model', effort: 'max', + sessionRef: { provider: 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, + })) + }) + it('new-tab calls POST /api/tabs with name and mode', async () => { mockClient.post.mockResolvedValue({ id: 't1' }) await executeAction('new-tab', { name: 'Work', mode: 'claude' }) @@ -366,19 +389,23 @@ describe('executeAction -- tab actions', () => { resume: 'thread-pre-durable', }) - expect(result).toEqual(expect.objectContaining({ error: "Only agent 'opencode' is supported with the Rust Freshell server." })) + expect(result).toEqual(expect.objectContaining({ error: 'This fresh-agent provider is unavailable with the Rust Freshell server.' })) expect(mockClient.post).not.toHaveBeenCalled() }) - it.each(['claude', 'kilroy'])('new-tab with agent: %s is locally rejected by the Rust capability contract', async (agent) => { + it.each(['claude', 'kilroy'])('new-tab with agent %s accepts the resumeSessionId alias', async (agent) => { mockClient.post.mockResolvedValue({ id: 't1' }) await executeAction('new-tab', { agent, - resume: '550e8400-e29b-41d4-a716-446655440000', + resumeSessionId: '550e8400-e29b-41d4-a716-446655440000', }) - expect(mockClient.post).not.toHaveBeenCalled() + expect(mockClient.post).toHaveBeenCalledWith('/api/tabs', expect.objectContaining({ + agent, + sessionRef: { provider: 'claude', sessionId: '550e8400-e29b-41d4-a716-446655440000' }, + })) + expect(mockClient.post.mock.calls[0][1]).not.toHaveProperty('resumeSessionId') }) it('list-tabs calls GET /api/tabs', async () => { diff --git a/tools/freshell-cli/index.ts b/tools/freshell-cli/index.ts index a1c399ae1..0b07d94d4 100644 --- a/tools/freshell-cli/index.ts +++ b/tools/freshell-cli/index.ts @@ -8,7 +8,7 @@ import { resolveTarget } from './targets.js' import { runCommand as sendKeysCommand } from './commands/sendKeys.js' import { partitionSendKeysArgs } from './send-keys-args.js' import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../node-client-runtime/codex-restore-contract.js' -import { renderCliHelp, resolveCanonicalAction, unsupportedInvocationResult } from '../node-client-runtime/action-capabilities.js' +import { freshAgentResumeProvider, renderCliHelp, resolveCanonicalAction, unsupportedInvocationResult } from '../node-client-runtime/action-capabilities.js' type Flags = Record @@ -459,7 +459,7 @@ async function main() { const model = getFlag(flags, 'model') as string | undefined const effort = getFlag(flags, 'effort') as string | undefined const resumeSessionId = getFlag(flags, 'resume') as string | undefined - const resumeProvider = mode ?? agent + const resumeProvider = mode ?? freshAgentResumeProvider(agent) const sessionRefResult = resolveSessionRefFlag(resumeProvider, getFlag(flags, 'session-ref')) const prompt = getFlag(flags, 'prompt') as string | undefined if (rejectRawCodexResume(resumeProvider, resumeSessionId)) return @@ -576,11 +576,15 @@ async function main() { const mode = getFlag(flags, 'mode') as string | undefined const shell = getFlag(flags, 'shell') as string | undefined const cwd = getFlag(flags, 'cwd') as string | undefined + const agent = getFlag(flags, 'agent') as string | undefined + const model = getFlag(flags, 'model') as string | undefined + const effort = getFlag(flags, 'effort') as string | undefined const resumeSessionId = getFlag(flags, 'resume') as string | undefined - const sessionRefResult = resolveSessionRefFlag(mode, getFlag(flags, 'session-ref')) - if (rejectRawCodexResume(mode, resumeSessionId)) return + const resumeProvider = mode ?? freshAgentResumeProvider(agent) + const sessionRefResult = resolveSessionRefFlag(resumeProvider, getFlag(flags, 'session-ref')) + if (rejectRawCodexResume(resumeProvider, resumeSessionId)) return if (sessionRefResult.rejected) return - const promoted = promoteResumeFlag(mode, resumeSessionId, sessionRefResult.sessionRef) + const promoted = promoteResumeFlag(resumeProvider, resumeSessionId, sessionRefResult.sessionRef) if (promoted.rejected) return const resolved = await resolvePaneTarget(client, target) @@ -597,6 +601,9 @@ async function main() { mode, shell, cwd, + agent, + model, + effort, ...(isTruthy(getFlag(flags, 'hostStats')) ? { hostStats: true } : {}), ...(promoted.sessionRef ? { sessionRef: promoted.sessionRef } : {}), }) diff --git a/tools/freshell-mcp/freshell-tool.ts b/tools/freshell-mcp/freshell-tool.ts index 203bf01e5..5f2ce2f05 100644 --- a/tools/freshell-mcp/freshell-tool.ts +++ b/tools/freshell-mcp/freshell-tool.ts @@ -11,6 +11,7 @@ import { translateKeys } from '../node-client-runtime/keys.js' import { INVALID_RAW_CODEX_RESUME_MESSAGE } from '../node-client-runtime/codex-restore-contract.js' import { ACTION_ALIASES, + freshAgentResumeProvider, resolveCanonicalAction, supportedActionCapabilities, unsupportedInvocationResult, @@ -57,7 +58,7 @@ FRESHELL_URL and FRESHELL_TOKEN are already set in your environment. ## Fresh agents (in-app) -- Use new-tab with agent="opencode", optional model=, effort=, cwd=. For direct Claude or Codex terminals, use mode="claude" or mode="codex". Then drive the pane with send-keys and read it with capture-pane. Example: +- Use new-tab with agent="claude", "kilroy", or "opencode", optional model=, effort=, cwd=. Use split-pane with agent="claude" or "kilroy" for a fresh pane beside an existing one. For direct Claude or Codex terminals, use mode="claude" or mode="codex". Drive fresh panes with send-keys and read them with capture-pane. Example: new-tab { agent: "opencode", model: "umans-ai-coding-plan/umans-kimi-k2.7", prompt: "Summarize README.md" } ## Choosing the right action @@ -356,19 +357,6 @@ function rejectRawCodexResume( return undefined } -// Resume sugar (`resume`/`resumeSessionId`) on the fresh-agent shorthand path -// (agent param, no mode): only opencode maps to a synthesized sessionRef -- it -// is the only provider the REST resume endpoint honors. codex maps to 'codex' -// so the raw-resume refusal above fires with parity to mode=codex. Every other -// value (claude/kilroy/unknown/non-string) returns undefined: no synthesis, -// resume fields keep their dropped behavior, and explicit sessionRef (already -// forwarded for any provider) remains the documented path. -// Accepts unknown because routeAction args are Record. -function agentResumeProvider(agent: unknown): 'codex' | 'opencode' | undefined { - if (agent === 'opencode' || agent === 'codex') return agent - return undefined -} - // --------------------------------------------------------------------------- // Action router // --------------------------------------------------------------------------- @@ -473,9 +461,9 @@ async function routeAction( // never forwarded over the wire. const legacyResume = typeof resume === 'string' ? resume : resumeSessionId // Provider the resume sugar keys on: an explicit mode wins; otherwise the - // fresh-agent shorthand contributes one only for the REST-honorable set - // (opencode synthesizes, codex rejects raw ids; see agentResumeProvider). - const resumeProvider = mode ?? agentResumeProvider(rest.agent) + // fresh-agent shorthand maps Claude/Kilroy to Claude identity and keeps + // the existing OpenCode promotion and raw-Codex refusal. + const resumeProvider = mode ?? freshAgentResumeProvider(rest.agent) const codexResumeError = rejectRawCodexResume(resumeProvider, legacyResume, explicitSessionRef) if (codexResumeError) return codexResumeError const sessionRef = explicitSessionRef ?? (typeof resumeProvider === 'string' && resumeProvider !== 'codex' && typeof legacyResume === 'string' @@ -546,10 +534,11 @@ async function routeAction( if (!resolved.pane) return { error: resolved.message || 'No pane found', hint: "Run action 'list-panes' to see available panes." } const paneId = resolved.pane.id const { direction, browser, editor, mode, shell, cwd, target: _t, resume, sessionRef, ...rest } = params || {} - const codexResumeError = rejectRawCodexResume(mode, resume, sessionRef) + const resumeProvider = mode ?? freshAgentResumeProvider(rest.agent) + const codexResumeError = rejectRawCodexResume(resumeProvider, resume, sessionRef) if (codexResumeError) return codexResumeError - const effectiveSessionRef = sessionRef ?? (typeof mode === 'string' && mode !== 'codex' && typeof resume === 'string' - ? { provider: mode, sessionId: resume } + const effectiveSessionRef = sessionRef ?? (typeof resumeProvider === 'string' && resumeProvider !== 'codex' && typeof resume === 'string' + ? { provider: resumeProvider, sessionId: resume } : undefined) return c.post(`/api/panes/${encodeURIComponent(paneId)}/split`, { direction, browser, editor, mode, shell, cwd, ...(effectiveSessionRef ? { sessionRef: effectiveSessionRef } : {}), ...rest, diff --git a/tools/node-client-runtime/action-capabilities.ts b/tools/node-client-runtime/action-capabilities.ts index 6064e4cd0..9b74b78fc 100644 --- a/tools/node-client-runtime/action-capabilities.ts +++ b/tools/node-client-runtime/action-capabilities.ts @@ -24,7 +24,7 @@ export const ACTION_CAPABILITIES: readonly ActionCapability[] = [ { action: 'rename-tab', aliases: ['rename-window'], supported: true, params: params(['name'], ['target', 'nameIntent']) }, { action: 'next-tab', aliases: ['next-window'], supported: true, params: noParams }, { action: 'prev-tab', aliases: ['previous-window', 'prev-window'], supported: true, params: noParams }, - { action: 'split-pane', aliases: ['split-window'], supported: true, params: params([], ['target', 'direction', 'mode', 'shell', 'cwd', 'browser', 'editor', 'hostStats', 'resume', 'sessionRef']) }, + { action: 'split-pane', aliases: ['split-window'], supported: true, params: params([], ['target', 'direction', 'mode', 'shell', 'cwd', 'browser', 'editor', 'hostStats', 'resume', 'sessionRef', 'agent', 'model', 'effort']) }, { action: 'display', aliases: ['display-message'], supported: true, params: params([], ['target', 'format']) }, { action: 'screenshot', aliases: ['screenshot-pane', 'screenshot-tab', 'screenshot-view'], supported: true, params: params(['scope'], ['target', 'name']) }, { action: 'has-tab', supported: true, params: params(['target']) }, @@ -132,11 +132,12 @@ export function unsupportedInvocationResult( const unsupported = unsupportedActionResult(action) if (unsupported) return unsupported const canonical = resolveCanonicalAction(action) - if (canonical === 'new-tab' && invocation?.agent !== undefined && invocation.agent !== 'opencode') { - return { error: "Only agent 'opencode' is supported with the Rust Freshell server.", hint: 'Use mode for direct Claude or Codex terminals.' } + if (canonical === 'new-tab' && invocation?.agent !== undefined && !['claude', 'kilroy', 'opencode'].includes(String(invocation.agent))) { + return { error: 'This fresh-agent provider is unavailable with the Rust Freshell server.', hint: 'Use agent claude, kilroy, or opencode; use mode codex for a Codex terminal.' } } - if (canonical === 'split-pane' && ['agent', 'model', 'effort'].some((key) => invocation?.[key] !== undefined)) { - return { error: 'Fresh-agent split parameters are unavailable with the Rust Freshell server.', hint: 'Use a supported mode pane instead.' } + if (canonical === 'split-pane' && ['agent', 'model', 'effort'].some((key) => invocation?.[key] !== undefined) + && !['claude', 'kilroy'].includes(String(invocation?.agent))) { + return { error: 'Fresh-agent splits require agent claude or kilroy.', hint: 'Choose a supported fresh agent or use mode for a terminal pane.' } } const waitPattern = canonical === 'wait-for' ? invocation?.pattern : undefined if (canonical === 'wait-for' && ( @@ -146,3 +147,10 @@ export function unsupportedInvocationResult( } return undefined } + +/** Fresh-agent names can share a runtime; resume identity always names the runtime. */ +export function freshAgentResumeProvider(agent: unknown): 'claude' | 'codex' | 'opencode' | undefined { + if (agent === 'claude' || agent === 'kilroy') return 'claude' + if (agent === 'codex' || agent === 'opencode') return agent + return undefined +} From 717ffb26c850da4cef245531db0e0868580f71fd Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:24:05 -0700 Subject: [PATCH 005/100] feat(freshclaude): prepare REST creates with final context and shared defaults --- crates/freshell-freshagent/src/lib.rs | 131 +++++------ crates/freshell-freshagent/src/pane_ops.rs | 101 +++----- .../src/pane_ops_tab_tests.rs | 5 +- .../freshell-freshagent/src/pane_ops_tests.rs | 5 +- crates/freshell-freshagent/src/rest_lane.rs | 66 +++++- .../src/rest_lane_create.rs | 191 +++++++++++++++ .../src/rest_lane_http_tests.rs | 219 ++++++++++++++++++ crates/freshell-freshagent/src/snapshot.rs | 4 +- .../src/fresh_agent_proxy_rest.rs | 21 +- crates/freshell-server/src/main.rs | 7 + .../managed_runtime_opencode_replan_tests.rs | 3 +- 11 files changed, 591 insertions(+), 162 deletions(-) create mode 100644 crates/freshell-freshagent/src/rest_lane_create.rs create mode 100644 crates/freshell-freshagent/src/rest_lane_http_tests.rs diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index bf8c53391..7949e5dd8 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -77,6 +77,7 @@ mod process_probe; /// 2026-10-22 §3.2, §5.6). pub(crate) mod quiet_deadman; pub mod rest_lane; +pub(crate) mod rest_lane_create; pub mod rollback_record; pub mod session_handoff; pub mod session_lease; @@ -2062,6 +2063,7 @@ pub struct FreshAgentState { /// Opt-in durable host gateway. When installed, REST/MCP fresh-agent /// requests never touch this web process's legacy provider manager. hosted_rest: Arc>, + rest_create_settings: Arc>, /// Monotonic `sessions.changed` revision. sessions_revision: Arc, /// Slice 1 (`docs/plans/2026-07-18-agent-api-mcp-parity-spec.md`): the SAME @@ -2400,6 +2402,7 @@ impl FreshAgentState { panes: Arc::new(Mutex::new(HashMap::new())), opencode: Arc::new(tokio::sync::Mutex::new(None)), hosted_rest: Arc::new(std::sync::OnceLock::new()), + rest_create_settings: Arc::new(std::sync::OnceLock::new()), sessions_revision: Arc::new(AtomicI64::new(0)), terminal_registry: None, session_identity: None, @@ -4708,6 +4711,31 @@ fn fail_json(status: StatusCode, message: String) -> Response { .into_response() } +fn rest_lane_failure(error: rest_lane::RestLaneError) -> Response { + use rest_lane::RestLaneErrorKind; + let status = match error.kind { + RestLaneErrorKind::BadRequest => StatusCode::BAD_REQUEST, + RestLaneErrorKind::Forbidden => StatusCode::FORBIDDEN, + RestLaneErrorKind::NotFound => StatusCode::NOT_FOUND, + RestLaneErrorKind::Conflict => StatusCode::CONFLICT, + RestLaneErrorKind::Unavailable => StatusCode::SERVICE_UNAVAILABLE, + RestLaneErrorKind::Internal => StatusCode::INTERNAL_SERVER_ERROR, + }; + let mut body = json!({ "status": "error", "code": error.code, "message": error.message }); + if let Some(retryable) = error.retryable { + body["retryable"] = json!(retryable); + } + if let Some(retry_after_ms) = error.retry_after_ms { + body["retryAfterMs"] = json!(retry_after_ms); + } + if let Some(owner) = error.owner { + body["ownerKind"] = json!(owner.owner_kind); + body["ownerGeneration"] = json!(owner.owner_generation); + body["ownerEpoch"] = json!(owner.owner_epoch); + } + (status, Json(body)).into_response() +} + /// `fail_json` + a machine-readable code, matching how the WS side keys /// errors (`error["code"] == "RESTORE_UNAVAILABLE"`). Envelope is additive: /// `{status:"error", code, message}`. @@ -4838,43 +4866,14 @@ async fn create_tab( let name = body.get("name").and_then(Value::as_str).map(str::to_string); if let Some(gateway) = state.hosted_rest_gateway() { - let provider_inputs = match rest_lane::provider_inputs(&body) { - Ok(inputs) => inputs, - Err(error) => return fail_json(StatusCode::BAD_REQUEST, error), - }; - let native_session_id = match body.get("sessionRef") { - None => None, - Some(value) => match serde_json::from_value::(value.clone()) { - Ok(locator) if locator.provider == provider && !locator.session_id.is_empty() => { - Some(locator.session_id) - } - _ => { - return fail_json( - StatusCode::BAD_REQUEST, - format!("sessionRef must identify a {provider} session"), - ) - } - }, + let request = match state + .build_rest_lane_create(provider, session_type, &body) + .await + { + Ok(request) => request, + Err(error) => return rest_lane_failure(error), }; - return create_lane_agent_tab( - &state, - gateway, - rest_lane::RestLaneCreate { - request_id: Uuid::new_v4().simple().to_string(), - provider: provider.into(), - session_type: session_type.into(), - cwd, - model, - effort, - native_session_id, - plugins: provider_inputs.plugins, - model_selection: provider_inputs.model_selection, - permission_mode: provider_inputs.permission_mode, - sandbox: provider_inputs.sandbox, - }, - name, - ) - .await; + return create_lane_agent_tab(&state, gateway, request, name).await; } // The retained in-process implementation is OpenCode-only. Every hosted @@ -5030,20 +5029,21 @@ async fn create_tab( async fn create_lane_agent_tab( state: &FreshAgentState, gateway: rest_lane::SharedFreshAgentRestLane, - request: rest_lane::RestLaneCreate, + mut request: rest_lane::RestLaneCreate, name: Option, ) -> Response { + let (tab_id, pane_id) = state.layout.create_tab(name.as_deref()); + request.tab_id = Some(tab_id.clone()); + request.pane_id = Some(pane_id.clone()); let created = match gateway.create_agent(request.clone()).await { Ok(created) => created, - Err(()) => { - return fail_json( - StatusCode::SERVICE_UNAVAILABLE, - "durable fresh-agent host could not be created".to_string(), - ) + Err(error) => { + state.layout.close_tab(&tab_id); + return rest_lane_failure(error); } }; + let pane_content = rest_lane_create::pane_content(&request, &created); let rest_lane::RestLaneCreate { - request_id, provider, session_type, cwd, @@ -5051,24 +5051,6 @@ async fn create_lane_agent_tab( effort, .. } = request; - let (tab_id, pane_id) = state.layout.create_tab(name.as_deref()); - let mut pane_content = json!({ - "kind": "fresh-agent", - "sessionType": session_type, - "provider": provider, - "sessionId": created.session_id, - "createRequestId": request_id, - "status": "connected", - }); - if let Some(value) = &cwd { - pane_content["initialCwd"] = json!(value); - } - if let Some(value) = &model { - pane_content["model"] = json!(value); - } - if let Some(value) = &effort { - pane_content["effort"] = json!(value); - } register_fresh_agent_tab( state, &tab_id, @@ -5082,14 +5064,21 @@ async fn create_lane_agent_tab( cwd, model, effort, - durable_id: Some(created.session_id.clone()), + durable_id: Some( + created + .session_ref + .as_ref() + .map(|reference| reference.session_id.clone()) + .unwrap_or_else(|| created.session_id.clone()), + ), }, ); broadcast_tab_create(state, &tab_id, &pane_id, name.as_deref(), &pane_content); - ok_json( - json!({"tabId":tab_id,"paneId":pane_id,"sessionId":created.session_id}), - "fresh-agent pane created", - ) + let mut data = json!({"tabId":tab_id,"paneId":pane_id,"sessionId":created.session_id}); + if let Some(reference) = created.session_ref { + data["sessionRef"] = json!(reference); + } + ok_json(data, "fresh-agent pane created") } /// The registration every fresh-agent pane-minting path needs: attach the pane @@ -6388,10 +6377,7 @@ async fn send_keys( }), "prompt sent; turn did not complete within deadline", ), - Err(()) => fail_json( - StatusCode::SERVICE_UNAVAILABLE, - "durable fresh-agent input was not accepted".to_string(), - ), + Err(error) => rest_lane_failure(error), }; } @@ -9256,16 +9242,17 @@ mod tests { async fn create_agent( self: Arc, _request: rest_lane::RestLaneCreate, - ) -> Result { + ) -> Result { Ok(rest_lane::RestLaneCreated { session_id: "managed-kilroy-recovered".into(), + ..Default::default() }) } async fn send_agent( &self, request: rest_lane::RestLaneSend, - ) -> Result { + ) -> Result { self.sends.lock().expect("sends mutex").push(request); Ok(rest_lane::RestLaneSendResult { session_id: "managed-kilroy-recovered".into(), diff --git a/crates/freshell-freshagent/src/pane_ops.rs b/crates/freshell-freshagent/src/pane_ops.rs index a2d891d20..cb19c1bb3 100644 --- a/crates/freshell-freshagent/src/pane_ops.rs +++ b/crates/freshell-freshagent/src/pane_ops.rs @@ -39,14 +39,14 @@ use axum::response::Response; use axum::{Json, Router}; use serde_json::{json, Value}; -use freshell_protocol::{ServerMessage, SessionLocator, UiCommand, LEGACY_RESUME_IDENTITY_REFUSAL}; +use freshell_protocol::{ServerMessage, UiCommand, LEGACY_RESUME_IDENTITY_REFUSAL}; use crate::layout_store::RenameOutcome; use crate::target_resolver::{resolve_target, ResolvedTarget}; use crate::terminal_tabs::{spawn_terminal_pane, TerminalSpawnResult}; use crate::{ - approx_json, authorized, fail_json, ok_json, parse_required_name, rest_agent_mode, rest_lane, - FreshAgentState, PaneEntry, PaneNameResolution, + approx_json, authorized, fail_json, ok_json, parse_required_name, rest_agent_mode, + rest_lane_failure, FreshAgentState, PaneEntry, PaneNameResolution, }; /// Mount the pane + tab lifecycle routes onto an existing router. Split out of @@ -197,90 +197,41 @@ pub(crate) async fn split_pane( "durable fresh-agent gateway is not installed".to_string(), ); }; - let native_session_id = match body.get("sessionRef") { - None => None, - Some(value) => match serde_json::from_value::(value.clone()) { - Ok(locator) if locator.provider == provider && !locator.session_id.is_empty() => { - Some(locator.session_id) - } - _ => { - let _ = state.layout.close_pane(&new_pane_id); - return fail_json( - StatusCode::BAD_REQUEST, - format!("sessionRef must identify a {provider} session"), - ); - } - }, - }; - let request_id = uuid::Uuid::new_v4().simple().to_string(); - let provider_inputs = match rest_lane::provider_inputs(&body) { - Ok(inputs) => inputs, + let mut request = match state + .build_rest_lane_create(provider, session_type, &body) + .await + { + Ok(request) => request, Err(error) => { let _ = state.layout.close_pane(&new_pane_id); - return fail_json(StatusCode::BAD_REQUEST, error); + return rest_lane_failure(error); } }; - let cwd = body.get("cwd").and_then(Value::as_str).map(str::to_string); - let model = body - .get("model") - .and_then(Value::as_str) - .map(str::to_string); - let effort = body - .get("effort") - .and_then(Value::as_str) - .map(str::to_string); - let created = match gateway - .create_agent(rest_lane::RestLaneCreate { - request_id: request_id.clone(), - provider: provider.into(), - session_type: session_type.into(), - cwd: cwd.clone(), - model: model.clone(), - effort: effort.clone(), - native_session_id, - plugins: provider_inputs.plugins, - model_selection: provider_inputs.model_selection, - permission_mode: provider_inputs.permission_mode, - sandbox: provider_inputs.sandbox, - }) - .await - { + request.tab_id = Some(tab_id.clone()); + request.pane_id = Some(new_pane_id.clone()); + let created = match gateway.create_agent(request.clone()).await { Ok(created) => created, - Err(()) => { + Err(error) => { let _ = state.layout.close_pane(&new_pane_id); - return fail_json( - StatusCode::SERVICE_UNAVAILABLE, - "durable fresh-agent host could not be created".to_string(), - ); + return rest_lane_failure(error); } }; - let mut content = json!({ - "kind":"fresh-agent", - "sessionType":session_type, - "provider":provider, - "sessionId":created.session_id, - "createRequestId":request_id, - "status":"connected", - }); - if let Some(value) = &cwd { - content["initialCwd"] = json!(value); - } - if let Some(value) = &model { - content["model"] = json!(value); - } - if let Some(value) = &effort { - content["effort"] = json!(value); - } + let content = crate::rest_lane_create::pane_content(&request, &created); state.panes.lock().expect("panes mutex").insert( new_pane_id.clone(), PaneEntry { placeholder_id: created.session_id.clone(), provider: provider.into(), session_type: session_type.into(), - cwd, - model, - effort, - durable_id: Some(created.session_id), + cwd: request.cwd, + model: request.model, + effort: request.effort, + durable_id: Some( + created + .session_ref + .map(|reference| reference.session_id) + .unwrap_or(created.session_id), + ), }, ); content @@ -1315,3 +1266,7 @@ mod tab_tests; #[cfg(test)] #[path = "pane_ops_store_tests.rs"] mod store_tests; + +#[cfg(test)] +#[path = "rest_lane_http_tests.rs"] +mod rest_lane_http_tests; diff --git a/crates/freshell-freshagent/src/pane_ops_tab_tests.rs b/crates/freshell-freshagent/src/pane_ops_tab_tests.rs index c75583821..905c48b17 100644 --- a/crates/freshell-freshagent/src/pane_ops_tab_tests.rs +++ b/crates/freshell-freshagent/src/pane_ops_tab_tests.rs @@ -20,7 +20,7 @@ impl rest_lane::FreshAgentRestLane for RestLaneFake { async fn create_agent( self: Arc, request: rest_lane::RestLaneCreate, - ) -> Result { + ) -> Result { assert_eq!(request.cwd.as_deref(), Some("/workspace")); self.identities .lock() @@ -30,13 +30,14 @@ impl rest_lane::FreshAgentRestLane for RestLaneFake { .fetch_add(1, std::sync::atomic::Ordering::SeqCst); Ok(rest_lane::RestLaneCreated { session_id: "managed-rest-session".into(), + ..Default::default() }) } async fn send_agent( &self, request: rest_lane::RestLaneSend, - ) -> Result { + ) -> Result { assert_eq!(request.session_id, "managed-rest-session"); assert_eq!( (request.provider.as_str(), request.session_type.as_str()), diff --git a/crates/freshell-freshagent/src/pane_ops_tests.rs b/crates/freshell-freshagent/src/pane_ops_tests.rs index 259df43cf..4cf5adeb0 100644 --- a/crates/freshell-freshagent/src/pane_ops_tests.rs +++ b/crates/freshell-freshagent/src/pane_ops_tests.rs @@ -18,20 +18,21 @@ impl rest_lane::FreshAgentRestLane for SplitHostedGateway { async fn create_agent( self: Arc, request: rest_lane::RestLaneCreate, - ) -> Result { + ) -> Result { assert_eq!( (request.provider.as_str(), request.session_type.as_str()), ("claude", "freshclaude") ); Ok(rest_lane::RestLaneCreated { session_id: "managed-split-claude".into(), + ..Default::default() }) } async fn send_agent( &self, _request: rest_lane::RestLaneSend, - ) -> Result { + ) -> Result { unreachable!("split creation does not send a prompt") } } diff --git a/crates/freshell-freshagent/src/rest_lane.rs b/crates/freshell-freshagent/src/rest_lane.rs index f1b669047..aa847be37 100644 --- a/crates/freshell-freshagent/src/rest_lane.rs +++ b/crates/freshell-freshagent/src/rest_lane.rs @@ -5,6 +5,59 @@ use async_trait::async_trait; use serde_json::Value; +pub type RestCreateSettingsReader = std::sync::Arc< + dyn Fn() -> futures_util::future::BoxFuture<'static, freshell_protocol::SettingsFreshAgent> + + Send + + Sync, +>; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RestLaneErrorKind { + BadRequest, + Forbidden, + NotFound, + Conflict, + Unavailable, + Internal, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RestLaneError { + pub kind: RestLaneErrorKind, + pub code: String, + pub message: String, + pub retryable: Option, + pub retry_after_ms: Option, + pub owner: Option, +} + +impl RestLaneError { + pub fn new( + kind: RestLaneErrorKind, + code: impl Into, + message: impl Into, + ) -> Self { + Self { + kind, + code: code.into(), + message: message.into(), + retryable: None, + retry_after_ms: None, + owner: None, + } + } +} + +impl From<()> for RestLaneError { + fn from(_: ()) -> Self { + Self::new( + RestLaneErrorKind::Unavailable, + "FRESH_AGENT_UNAVAILABLE", + "durable fresh-agent operation is temporarily unavailable", + ) + } +} + #[derive(Default)] pub struct RestLaneProviderInputs { pub plugins: Option>, @@ -46,7 +99,7 @@ pub fn provider_inputs(body: &Value) -> Result { }) } -#[derive(Clone, Debug, PartialEq)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct RestLaneCreate { pub request_id: String, pub provider: String, @@ -59,13 +112,18 @@ pub struct RestLaneCreate { pub model_selection: Option>, pub permission_mode: Option, pub sandbox: Option, + pub tab_id: Option, + pub pane_id: Option, + pub naming_handle: Option, + pub model_effort_levels: Option>, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq)] pub struct RestLaneCreated { /// Stable presentation identity used by the web layout and gateway alias /// table. It is not substituted for the provider's native identity. pub session_id: String, + pub session_ref: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -132,9 +190,9 @@ pub trait FreshAgentRestLane: Send + Sync { async fn create_agent( self: std::sync::Arc, request: RestLaneCreate, - ) -> Result; + ) -> Result; - async fn send_agent(&self, request: RestLaneSend) -> Result; + async fn send_agent(&self, request: RestLaneSend) -> Result; async fn capture( &self, diff --git a/crates/freshell-freshagent/src/rest_lane_create.rs b/crates/freshell-freshagent/src/rest_lane_create.rs new file mode 100644 index 000000000..7584ea7f3 --- /dev/null +++ b/crates/freshell-freshagent/src/rest_lane_create.rs @@ -0,0 +1,191 @@ +//! One create recipe for the REST tab and split paths. Defaults are read once +//! and the same resolved values launch the runtime and populate the web pane. + +use crate::rest_lane::{self, RestLaneCreate, RestLaneCreated, RestLaneError, RestLaneErrorKind}; +use crate::{naming, FreshAgentState}; +use freshell_protocol::{SessionLocator, SettingsFreshAgent}; +use serde_json::{json, Value}; + +impl FreshAgentState { + pub fn set_rest_create_settings_reader( + &self, + reader: rest_lane::RestCreateSettingsReader, + ) -> Result<(), &'static str> { + self.rest_create_settings + .set(reader) + .map_err(|_| "REST create settings reader is already installed") + } + + pub(crate) async fn build_rest_lane_create( + &self, + provider: &str, + session_type: &str, + body: &Value, + ) -> Result { + let invalid = + |message| RestLaneError::new(RestLaneErrorKind::BadRequest, "INVALID_REQUEST", message); + let inputs = rest_lane::provider_inputs(body).map_err(invalid)?; + let native_session_id = match body.get("sessionRef") { + None => None, + Some(value) => match serde_json::from_value::(value.clone()) { + Ok(locator) if locator.provider == provider && !locator.session_id.is_empty() => { + Some(locator.session_id) + } + _ => { + return Err(invalid(format!( + "sessionRef must identify a {provider} session" + ))) + } + }, + }; + let mut request = RestLaneCreate { + request_id: uuid::Uuid::new_v4().simple().to_string(), + provider: provider.into(), + session_type: session_type.into(), + cwd: body.get("cwd").and_then(Value::as_str).map(str::to_string), + model: body + .get("model") + .and_then(Value::as_str) + .map(str::to_string), + effort: body + .get("effort") + .and_then(Value::as_str) + .map(str::to_string), + native_session_id, + plugins: inputs.plugins, + model_selection: inputs.model_selection, + permission_mode: inputs.permission_mode, + sandbox: inputs.sandbox, + ..Default::default() + }; + if provider == "claude" { + let settings = if let Some(reader) = self.rest_create_settings.get() { + reader().await + } else { + SettingsFreshAgent { + default_plugins: vec![], + enabled: false, + providers: json!({}), + initial_setup_done: None, + } + }; + let resolved = crate::claude_create_defaults::resolve_claude_rest_create( + session_type, + body, + &settings, + ) + .map_err(invalid)?; + request.model = Some(resolved.model); + request.model_selection = Some(resolved.model_selection); + request.model_effort_levels = resolved.model_effort_levels; + request.effort = resolved.effort; + request.permission_mode = Some(resolved.permission_mode); + request.plugins = Some(resolved.plugins); + } + if naming::is_unified_agent_mode(None, Some(session_type)) { + request.naming_handle = Some( + body.get("namingHandle") + .and_then(Value::as_str) + .filter(|handle| !handle.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| format!("nh-{}", uuid::Uuid::new_v4().simple())), + ); + } + self.admit_rest_create_name(&request, body).await?; + Ok(request) + } + + async fn admit_rest_create_name( + &self, + request: &RestLaneCreate, + body: &Value, + ) -> Result<(), RestLaneError> { + use freshell_protocol::session_names::{NameIntent, NamedProvider, SessionNameRef}; + let intent = body + .get("nameIntent") + .map(|value| serde_json::from_value::(value.clone())) + .transpose() + .map_err(|_| { + RestLaneError::new( + RestLaneErrorKind::BadRequest, + "INVALID_REQUEST", + "nameIntent must be user or automatic", + ) + })? + .unwrap_or(NameIntent::Automatic); + let (Some(handle), Some(sink)) = (request.naming_handle.as_ref(), self.naming()) else { + return Ok(()); + }; + let provider = match request.provider.as_str() { + "claude" => NamedProvider::Claude, + "codex" => NamedProvider::Codex, + "opencode" => NamedProvider::Opencode, + _ => return Ok(()), + }; + let pending = SessionNameRef::Pending { id: handle.clone() }; + match sink + .ensure_pending(naming::PendingNameInput { + handle: handle.clone(), + provider, + cwd: request.cwd.clone(), + }) + .await + { + Ok(_) => { + if let Some(name) = body + .get("name") + .and_then(Value::as_str) + .filter(|name| !name.trim().is_empty()) + { + if let Err(error) = sink + .rename(naming::RenameNameInput { + target: pending.clone(), + name: name.to_string(), + intent, + if_revision: None, + }) + .await + { + naming::log_name_error("rename", &pending, &error); + } + } + } + Err(error) => naming::log_name_error("ensure_pending", &pending, &error), + } + Ok(()) + } +} + +pub(crate) fn pane_content(request: &RestLaneCreate, created: &RestLaneCreated) -> Value { + let mut content = json!({ + "kind": "fresh-agent", "sessionType": request.session_type, "provider": request.provider, + "sessionId": created.session_id, "createRequestId": request.request_id, "status": "connected", + }); + for (key, value) in [ + ("initialCwd", request.cwd.as_ref()), + ("model", request.model.as_ref()), + ("effort", request.effort.as_ref()), + ("permissionMode", request.permission_mode.as_ref()), + ("namingHandle", request.naming_handle.as_ref()), + ] { + if let Some(value) = value { + content[key] = json!(value); + } + } + if let Some(value) = &request.plugins { + content["plugins"] = json!(value); + } + if let Some(value) = &request.model_selection { + content["modelSelection"] = json!(value); + } + if let Some(value) = &request.sandbox { + content["sandbox"] = json!(value); + } + if let Some(value) = &request.model_effort_levels { + content["modelEffortLevels"] = json!({ "modelId": request.model, "levels": value }); + } + if let Some(value) = &created.session_ref { + content["sessionRef"] = json!(value); + } + content +} diff --git a/crates/freshell-freshagent/src/rest_lane_http_tests.rs b/crates/freshell-freshagent/src/rest_lane_http_tests.rs new file mode 100644 index 000000000..97a0177b5 --- /dev/null +++ b/crates/freshell-freshagent/src/rest_lane_http_tests.rs @@ -0,0 +1,219 @@ +use super::tests::{app, get, post, state_with_registry}; +use crate::ownership_lane::TerminalOwnerFields; +use crate::rest_lane::*; +use axum::http::StatusCode; +use serde_json::json; +use std::sync::Arc; + +struct RefusingLane(RestLaneErrorKind); + +#[async_trait::async_trait] +impl FreshAgentRestLane for RefusingLane { + async fn create_agent( + self: Arc, + _: RestLaneCreate, + ) -> Result { + Err(RestLaneError { + kind: self.0, + code: "SESSION_RESERVED".into(), + message: "the conversation already has an owner".into(), + retryable: Some(true), + retry_after_ms: Some(250), + owner: Some(TerminalOwnerFields { + owner_kind: "terminal", + owner_generation: 7, + owner_epoch: 3, + }), + }) + } + + async fn send_agent(&self, _: RestLaneSend) -> Result { + unreachable!("creation is refused") + } +} + +#[tokio::test] +async fn typed_create_failures_preserve_status_and_owner_without_leaving_a_tab() { + for (kind, wanted_status) in [ + (RestLaneErrorKind::BadRequest, StatusCode::BAD_REQUEST), + (RestLaneErrorKind::Forbidden, StatusCode::FORBIDDEN), + (RestLaneErrorKind::Conflict, StatusCode::CONFLICT), + ( + RestLaneErrorKind::Unavailable, + StatusCode::SERVICE_UNAVAILABLE, + ), + ( + RestLaneErrorKind::Internal, + StatusCode::INTERNAL_SERVER_ERROR, + ), + ] { + let state = state_with_registry(); + state + .set_hosted_rest_gateway(Arc::new(RefusingLane(kind))) + .unwrap(); + let router = app(state); + let (status, body) = post( + router.clone(), + "/api/tabs", + json!({ "agent": "claude" }), + true, + ) + .await; + assert_eq!(status, wanted_status, "{body}"); + assert_eq!(body["code"], "SESSION_RESERVED"); + assert_eq!(body["retryable"], true); + assert_eq!(body["retryAfterMs"], 250); + assert_eq!(body["ownerKind"], "terminal"); + assert_eq!(body["ownerGeneration"], 7); + assert_eq!(body["ownerEpoch"], 3); + let (status, tabs) = get(router, "/api/tabs", true).await; + assert_eq!(status, StatusCode::OK); + assert!( + tabs["data"]["tabs"].as_array().unwrap().is_empty(), + "{tabs}" + ); + } +} + +struct ContextLane { + layout: crate::layout_store::LayoutStore, + creates: std::sync::Mutex>, +} + +#[async_trait::async_trait] +impl FreshAgentRestLane for ContextLane { + async fn create_agent( + self: Arc, + request: RestLaneCreate, + ) -> Result { + let pane_id = request + .pane_id + .as_deref() + .expect("create has its final pane context"); + let tab_id = request + .tab_id + .as_deref() + .expect("create has its final tab context"); + let pane = self + .layout + .get_pane_snapshot(pane_id) + .expect("layout exists before runtime create"); + assert_eq!(pane.tab_id, tab_id); + let created = RestLaneCreated { + session_id: format!("session-{}", request.request_id), + ..Default::default() + }; + self.creates.lock().unwrap().push(request); + Ok(created) + } + async fn send_agent(&self, _: RestLaneSend) -> Result { + unreachable!("context test only creates") + } +} + +#[tokio::test] +async fn tab_and_split_create_use_final_layout_context_and_one_create_request_id() { + let state = state_with_registry(); + let (original_tab, original_pane) = state.layout.create_tab(Some("Original")); + state + .layout + .attach_pane_content(&original_tab, &original_pane, json!({ "kind": "terminal" })); + state.layout.select_tab(&original_tab); + let lane = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + state.set_hosted_rest_gateway(lane.clone()).unwrap(); + let router = app(state.clone()); + for (uri, agent, expected_tab) in [ + ("/api/tabs".to_string(), "claude", None), + ( + format!("/api/panes/{original_pane}/split"), + "kilroy", + Some(original_tab.as_str()), + ), + ] { + let (status, body) = post(router.clone(), &uri, json!({ "agent": agent }), true).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let request = lane.creates.lock().unwrap().last().unwrap().clone(); + let pane_id = request.pane_id.as_ref().unwrap(); + let pane = state.layout.get_pane_snapshot(pane_id).unwrap(); + assert_eq!( + pane.pane_content.as_ref().unwrap()["createRequestId"], + request.request_id + ); + assert_eq!( + pane.pane_content.as_ref().unwrap()["sessionId"], + format!("session-{}", request.request_id) + ); + if let Some(tab_id) = expected_tab { + assert_eq!(pane.tab_id, tab_id); + } + let (_, tabs) = get(router.clone(), "/api/tabs", true).await; + assert_eq!( + tabs["data"]["activeTabId"], original_tab, + "automation stays focus neutral" + ); + } + assert_eq!(lane.creates.lock().unwrap().len(), 2); +} + +#[tokio::test] +async fn claude_defaults_are_read_once_and_used_for_the_runtime_and_pane() { + use std::sync::atomic::{AtomicUsize, Ordering}; + let state = state_with_registry(); + let lane = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + state.set_hosted_rest_gateway(lane.clone()).unwrap(); + let reads = Arc::new(AtomicUsize::new(0)); + let reads_for_reader = reads.clone(); + state.set_rest_create_settings_reader(Arc::new(move || { + reads_for_reader.fetch_add(1, Ordering::SeqCst); + Box::pin(async { freshell_protocol::SettingsFreshAgent { + default_plugins: vec!["/saved-plugin".into()], enabled: true, initial_setup_done: None, + providers: json!({ + "freshclaude": { "modelSelection": { "kind": "exact", "modelId": "catalog-a" }, "modelEffortLevels": { "modelId": "catalog-a", "levels": [] }, "effort": "high", "defaultPermissionMode": "plan" }, + "kilroy": { "modelSelection": { "kind": "exact", "modelId": "catalog-b" }, "effort": "low", "defaultPermissionMode": "bypassPermissions" }, + }), + } }) + })).unwrap(); + let router = app(state.clone()); + for (agent, model, mode, effort) in [ + ("claude", "catalog-a", "plan", None), + ("kilroy", "catalog-b", "bypassPermissions", Some("low")), + ] { + let (status, body) = + post(router.clone(), "/api/tabs", json!({ "agent": agent }), true).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let request = lane.creates.lock().unwrap().last().unwrap().clone(); + let pane = state + .layout + .get_pane_snapshot(request.pane_id.as_ref().unwrap()) + .unwrap() + .pane_content + .unwrap(); + assert_eq!(request.model.as_deref(), Some(model)); + assert_eq!(request.permission_mode.as_deref(), Some(mode)); + assert_eq!(request.effort.as_deref(), effort); + assert_eq!(request.plugins, Some(vec!["/saved-plugin".into()])); + assert_eq!(pane["model"], model); + assert_eq!(pane["permissionMode"], mode); + assert_eq!(pane["effort"].as_str(), effort); + assert_eq!(pane["plugins"], json!(["/saved-plugin"])); + assert_eq!(pane["modelSelection"]["modelId"], model); + assert_eq!(request.naming_handle.is_some(), agent == "claude"); + if agent == "claude" { + assert_eq!( + pane["modelEffortLevels"], + json!({ "modelId": model, "levels": [] }) + ); + } + } + assert_eq!( + reads.load(Ordering::SeqCst), + 2, + "one settings read per create" + ); +} diff --git a/crates/freshell-freshagent/src/snapshot.rs b/crates/freshell-freshagent/src/snapshot.rs index 086c1948a..83691e782 100644 --- a/crates/freshell-freshagent/src/snapshot.rs +++ b/crates/freshell-freshagent/src/snapshot.rs @@ -508,13 +508,13 @@ mod tests { async fn create_agent( self: Arc, _: crate::rest_lane::RestLaneCreate, - ) -> Result { + ) -> Result { panic!("GET must not create") } async fn send_agent( &self, _: crate::rest_lane::RestLaneSend, - ) -> Result { + ) -> Result { panic!("GET must not send") } async fn snapshot( diff --git a/crates/freshell-server/src/fresh_agent_proxy_rest.rs b/crates/freshell-server/src/fresh_agent_proxy_rest.rs index 7607887e3..de1a4a16b 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_rest.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_rest.rs @@ -121,12 +121,15 @@ impl FreshAgentRestLane for HostedFreshAgentProxy { ); Ok(Some(snapshot)) } - async fn create_agent(self: Arc, request: RestLaneCreate) -> Result { + async fn create_agent( + self: Arc, + request: RestLaneCreate, + ) -> Result { let (provider, session_type) = rest_agent_identity(&request.provider, &request.session_type)?; let runtime_provider = fresh_provider(&Some(provider), session_type).ok_or(())?; let message = freshell_protocol::FreshAgentCreate { - pane_id: None, + pane_id: request.pane_id, request_id: request.request_id, session_type, cwd: request.cwd, @@ -145,8 +148,8 @@ impl FreshAgentRestLane for HostedFreshAgentProxy { provider: provider_wire(&provider), session_id, }), - tab_id: None, - naming_handle: None, + tab_id: request.tab_id, + naming_handle: request.naming_handle, }; let session_id = presentation_id_for_create( &runtime_provider, @@ -157,10 +160,16 @@ impl FreshAgentRestLane for HostedFreshAgentProxy { .map(|value| value.session_id.as_str()), ); self.create(message).await?; - Ok(RestLaneCreated { session_id }) + Ok(RestLaneCreated { + session_id, + ..Default::default() + }) } - async fn send_agent(&self, request: RestLaneSend) -> Result { + async fn send_agent( + &self, + request: RestLaneSend, + ) -> Result { let (provider, session_type) = rest_agent_identity(&request.provider, &request.session_type)?; let soul = self diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index c084bafbf..59acdcdee 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -1151,6 +1151,13 @@ async fn main() -> ExitCode { fresh_agent_state .set_checkpoint_event_persister(Arc::clone(&checkpoint_event_persister)) .expect("OpenCode checkpoint event persister is installed once"); + let rest_create_settings = settings_store.shared_settings_lock(); + fresh_agent_state + .set_rest_create_settings_reader(Arc::new(move || { + let settings = Arc::clone(&rest_create_settings); + Box::pin(async move { settings.read().await.fresh_agent.clone() }) + })) + .expect("REST create settings reader is installed once"); // The freshopencode WS fresh-agent slice: the post-handshake loop dispatches // `freshAgent.create`/`send`/`kill`/`interrupt` (opencode) here. let mut fresh_opencode_state = diff --git a/crates/freshell-server/src/managed_runtime_opencode_replan_tests.rs b/crates/freshell-server/src/managed_runtime_opencode_replan_tests.rs index 8dce2e526..0a959873b 100644 --- a/crates/freshell-server/src/managed_runtime_opencode_replan_tests.rs +++ b/crates/freshell-server/src/managed_runtime_opencode_replan_tests.rs @@ -385,6 +385,7 @@ mod uncertain_launch { model_selection: None, permission_mode: None, sandbox: None, + ..Default::default() }) .await .map(|_| ()) @@ -714,7 +715,7 @@ fn managed_provider_initial_opencode_callers_replan_changed_selection_with_two_r let prior_stage=control.join("provider-roots").join(&prior_grant); let prior_manifest=std::fs::read(prior_stage.join(".freshell/input-manifest.json")).unwrap(); let result=if fresh_route { - fresh.clone().create_agent(RestLaneCreate {request_id:seed.clone(),provider:"opencode".into(),session_type:"freshopencode".into(),cwd:Some(workspace.to_string_lossy().into_owned()),model:None,effort:None,native_session_id:None,plugins:None,model_selection:None,permission_mode:None,sandbox:None}).await.map(|_|()).map_err(|_|"fresh refusal".to_string()) + fresh.clone().create_agent(RestLaneCreate {request_id:seed.clone(),provider:"opencode".into(),session_type:"freshopencode".into(),cwd:Some(workspace.to_string_lossy().into_owned()),model:None,effort:None,native_session_id:None,plugins:None,model_selection:None,permission_mode:None,sandbox:None,..Default::default()}).await.map(|_|()).map_err(|_|"fresh refusal".to_string()) } else { controller.launch(ManagedTerminalLaunch {spec:freshell_platform::SpawnSpec {program:"opencode".into(),args:vec![],env_overrides:BTreeMap::new(),cwd:Some(workspace.to_string_lossy().into_owned()),cols:80,rows:24},env:BTreeMap::new(),terminal_id:seed.clone(),stream_id:seed.clone(),mode:"opencode".into(),resume_session_id:None,provider_model:None,provider_reasoning_effort:None,provider_sandbox:None,provider_permission_mode:None,view_tab_id:None,view_pane_id:None,create_request_id:Some(seed.clone())}).await.map(|_|()).map_err(|error| error.to_string()) }; From 74150f9229fe4bba9b95f1a1834973bb171d426d Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:35:42 -0700 Subject: [PATCH 006/100] feat(freshclaude): route REST control through the local Claude lane --- .../src/claude_create_defaults.rs | 4 +- crates/freshell-freshagent/src/lib.rs | 86 ++++--- crates/freshell-freshagent/src/pane_ops.rs | 4 +- .../src/pane_ops_tab_tests.rs | 1 + crates/freshell-freshagent/src/rest_lane.rs | 4 +- .../src/rest_lane_http_tests.rs | 222 +++++++++++++++++- crates/freshell-freshagent/src/snapshot.rs | 21 ++ .../src/fresh_agent_proxy_rest.rs | 1 + 8 files changed, 309 insertions(+), 34 deletions(-) diff --git a/crates/freshell-freshagent/src/claude_create_defaults.rs b/crates/freshell-freshagent/src/claude_create_defaults.rs index a0927aaa7..bbf49264a 100644 --- a/crates/freshell-freshagent/src/claude_create_defaults.rs +++ b/crates/freshell-freshagent/src/claude_create_defaults.rs @@ -28,14 +28,16 @@ pub fn resolve_claude_rest_create( } let inputs = crate::rest_lane::provider_inputs(body)?; let saved = &settings.providers[session_type]; + let explicit_model = optional_string(body, "model")?; let model_selection = match inputs.model_selection { Some(selection) => selection, + None if explicit_model.is_some() => None, None => saved .get("modelSelection") .and_then(|selection| serde_json::from_value::(selection.clone()).ok()) .filter(|selection| selection.kind == "exact" && !selection.model_id.trim().is_empty()), }; - let model = optional_string(body, "model")? + let model = explicit_model .or_else(|| { model_selection .as_ref() diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index 7949e5dd8..ad958ea4c 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -2063,6 +2063,7 @@ pub struct FreshAgentState { /// Opt-in durable host gateway. When installed, REST/MCP fresh-agent /// requests never touch this web process's legacy provider manager. hosted_rest: Arc>, + local_claude_rest: Arc>, rest_create_settings: Arc>, /// Monotonic `sessions.changed` revision. sessions_revision: Arc, @@ -2402,6 +2403,7 @@ impl FreshAgentState { panes: Arc::new(Mutex::new(HashMap::new())), opencode: Arc::new(tokio::sync::Mutex::new(None)), hosted_rest: Arc::new(std::sync::OnceLock::new()), + local_claude_rest: Arc::new(std::sync::OnceLock::new()), rest_create_settings: Arc::new(std::sync::OnceLock::new()), sessions_revision: Arc::new(AtomicI64::new(0)), terminal_registry: None, @@ -2539,6 +2541,25 @@ impl FreshAgentState { self.hosted_rest.get().cloned() } + pub fn set_claude_rest_lane( + &self, + lane: rest_lane::SharedFreshAgentRestLane, + ) -> Result<(), &'static str> { + self.local_claude_rest + .set(lane) + .map_err(|_| "local Claude REST lane is already installed") + } + + fn rest_lane_for(&self, provider: &str) -> Option { + self.hosted_rest_gateway().or_else(|| { + if provider == "claude" { + self.local_claude_rest.get().cloned() + } else { + None + } + }) + } + /// Install a scripted model-catalog probe (tests) — the registry's /// `opencodeCatalogProvider` injection seam /// (`model-capability-registry.ts:196-202`). Rebuilds the registry around @@ -4865,7 +4886,7 @@ async fn create_tab( .map(str::to_string); let name = body.get("name").and_then(Value::as_str).map(str::to_string); - if let Some(gateway) = state.hosted_rest_gateway() { + if let Some(gateway) = state.rest_lane_for(provider) { let request = match state .build_rest_lane_create(provider, session_type, &body) .await @@ -4876,9 +4897,8 @@ async fn create_tab( return create_lane_agent_tab(&state, gateway, request, name).await; } - // The retained in-process implementation is OpenCode-only. Every hosted - // mode above uses the common supervisor gateway; never silently fall back - // to a web-owned Claude/Codex provider. + // OpenCode retains its existing implementation. Codex REST creation needs + // the opt-in hosted gateway; the local lane drives Claude modes only. if agent != "opencode" { return fail_json( StatusCode::SERVICE_UNAVAILABLE, @@ -5115,20 +5135,20 @@ fn register_fresh_agent_tab( .insert(pane_id.to_string(), tab_id.to_string()); } -/// A web restart drops the REST pane map while the shared layout and hosted -/// runtime survive. Resolve hosted panes from the persisted layout on a miss; -/// the gateway still checks the session against the supervisor inventory. +/// Browser-created panes and a restored layout need not have a REST pane row. +/// Resolve them when their provider has a lane; that lane owns the live-session +/// checks and transcript lookup. fn pane_entry_for_request(state: &FreshAgentState, pane_id: &str) -> Option { if let Some(pane) = state.panes.lock().expect("panes mutex").get(pane_id) { return Some(pane.clone()); } - state.hosted_rest_gateway()?; let snapshot = state.layout.get_pane_snapshot(pane_id)?; let content = snapshot.pane_content?; if content.get("kind")?.as_str()? != "fresh-agent" { return None; } let provider = content.get("provider")?.as_str()?; + state.rest_lane_for(provider)?; let session_type = content.get("sessionType")?.as_str()?; if !matches!( (provider, session_type), @@ -5143,6 +5163,12 @@ fn pane_entry_for_request(state: &FreshAgentState, pane_id: &str) -> Option(value.clone()).ok()) + .filter(|locator| locator.provider == provider && !locator.session_id.is_empty()) + .map(|locator| locator.session_id) + .unwrap_or_else(|| session_id.clone()); Some(PaneEntry { placeholder_id: session_id.clone(), provider: provider.to_string(), @@ -5159,7 +5185,7 @@ fn pane_entry_for_request(state: &FreshAgentState, pane_id: &str) -> Option ok_json( - json!({ - "paneId":pane_id, - "sessionId":result.session_id, - "submittedTurnId":request_id, - "sessionRef":{"provider":pane.provider,"sessionId":result.session_id}, - "status":"idle", - }), - "prompt sent", - ), - Ok(result) => approx_json( - json!({ + Ok(result) => { + let mut data = json!({ "paneId":pane_id, "sessionId":result.session_id, - "submittedTurnId":request_id, "sessionRef":{"provider":pane.provider,"sessionId":result.session_id}, - "status":"approx", - }), - "prompt sent; turn did not complete within deadline", - ), + "status":if result.noop { "noop" } else if result.completed { "idle" } else { "approx" }, + }); + if !result.noop { + data["submittedTurnId"] = json!(result.submitted_turn_id.unwrap_or(request_id)); + } + if result.noop { + ok_json(data, "no input sent") + } else if result.completed { + ok_json(data, "prompt sent") + } else { + approx_json(data, "prompt sent; turn did not complete within deadline") + } + } Err(error) => rest_lane_failure(error), }; } @@ -7037,7 +7064,7 @@ async fn capture( return fail_json(StatusCode::NOT_FOUND, "pane not found".to_string()); } }; - if let Some(gateway) = state.hosted_rest_gateway() { + if let Some(gateway) = state.rest_lane_for(&pane.provider) { let session_id = pane .durable_id .clone() @@ -9257,6 +9284,7 @@ mod tests { Ok(rest_lane::RestLaneSendResult { session_id: "managed-kilroy-recovered".into(), completed: true, + ..Default::default() }) } diff --git a/crates/freshell-freshagent/src/pane_ops.rs b/crates/freshell-freshagent/src/pane_ops.rs index cb19c1bb3..0b257197c 100644 --- a/crates/freshell-freshagent/src/pane_ops.rs +++ b/crates/freshell-freshagent/src/pane_ops.rs @@ -140,7 +140,7 @@ pub(crate) fn resolve_pane_target(state: &FreshAgentState, raw: &str) -> PaneTar /// via `attachPaneContent` (`router.ts:1374`). A store miss (unknown pane) /// responds Node's `approx('pane split requested; not applied')` /// (`router.ts:1312-1314`). `agent`-based fresh-agent splits route through the -/// same typed hosted gateway used by tab creation, roll the tentative layout +/// same typed runtime lane used by tab creation, roll the tentative layout /// mutation back on launch failure, and attach the returned provider-native /// session to the store-minted pane. Response + broadcast shapes remain /// compatible with Slice 3b-1. @@ -190,7 +190,7 @@ pub(crate) async fn split_pane( format!("unknown agent \"{agent}\""), ); }; - let Some(gateway) = state.hosted_rest_gateway() else { + let Some(gateway) = state.rest_lane_for(provider) else { let _ = state.layout.close_pane(&new_pane_id); return fail_json( StatusCode::SERVICE_UNAVAILABLE, diff --git a/crates/freshell-freshagent/src/pane_ops_tab_tests.rs b/crates/freshell-freshagent/src/pane_ops_tab_tests.rs index 905c48b17..96d930547 100644 --- a/crates/freshell-freshagent/src/pane_ops_tab_tests.rs +++ b/crates/freshell-freshagent/src/pane_ops_tab_tests.rs @@ -48,6 +48,7 @@ impl rest_lane::FreshAgentRestLane for RestLaneFake { Ok(rest_lane::RestLaneSendResult { session_id: request.session_id, completed: true, + ..Default::default() }) } diff --git a/crates/freshell-freshagent/src/rest_lane.rs b/crates/freshell-freshagent/src/rest_lane.rs index aa847be37..5d575db3d 100644 --- a/crates/freshell-freshagent/src/rest_lane.rs +++ b/crates/freshell-freshagent/src/rest_lane.rs @@ -136,10 +136,12 @@ pub struct RestLaneSend { pub timeout_ms: u64, } -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct RestLaneSendResult { pub session_id: String, pub completed: bool, + pub submitted_turn_id: Option, + pub noop: bool, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/crates/freshell-freshagent/src/rest_lane_http_tests.rs b/crates/freshell-freshagent/src/rest_lane_http_tests.rs index 97a0177b5..464d2ab7b 100644 --- a/crates/freshell-freshagent/src/rest_lane_http_tests.rs +++ b/crates/freshell-freshagent/src/rest_lane_http_tests.rs @@ -2,7 +2,7 @@ use super::tests::{app, get, post, state_with_registry}; use crate::ownership_lane::TerminalOwnerFields; use crate::rest_lane::*; use axum::http::StatusCode; -use serde_json::json; +use serde_json::{json, Value}; use std::sync::Arc; struct RefusingLane(RestLaneErrorKind); @@ -217,3 +217,223 @@ async fn claude_defaults_are_read_once_and_used_for_the_runtime_and_pane() { "one settings read per create" ); } + +#[tokio::test] +async fn an_explicit_model_override_clears_a_different_saved_exact_selection() { + let state = state_with_registry(); + let lane = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + state.set_hosted_rest_gateway(lane.clone()).unwrap(); + state + .set_rest_create_settings_reader(Arc::new(|| { + Box::pin(async { + freshell_protocol::SettingsFreshAgent { + default_plugins: vec![], + enabled: true, + initial_setup_done: None, + providers: json!({ "freshclaude": { + "modelSelection": { "kind": "exact", "modelId": "saved-model" }, + "modelEffortLevels": { "modelId": "saved-model", "levels": [] }, + } }), + } + }) + })) + .unwrap(); + let (status, body) = post( + app(state.clone()), + "/api/tabs", + json!({ "agent": "claude", "model": "explicit-model" }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let request = lane.creates.lock().unwrap().last().unwrap().clone(); + assert_eq!(request.model.as_deref(), Some("explicit-model")); + assert_eq!( + request.model_selection, + Some(None), + "provider create must not prefer an unrelated saved selection" + ); + let content = state + .layout + .get_pane_snapshot(request.pane_id.as_ref().unwrap()) + .unwrap() + .pane_content + .unwrap(); + assert_eq!(content["model"], "explicit-model"); + assert_eq!(content["modelSelection"], Value::Null); + assert!(content.get("modelEffortLevels").is_none()); +} + +#[tokio::test] +async fn local_claude_lane_creates_tabs_and_splits_without_enabling_codex() { + let state = state_with_registry(); + let (_, original_pane) = state.layout.create_tab(Some("Original")); + let lane = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state.clone()); + for (uri, agent) in [ + ("/api/tabs".to_string(), "claude"), + (format!("/api/panes/{original_pane}/split"), "kilroy"), + ] { + let (status, body) = post(router.clone(), &uri, json!({"agent":agent}), true).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let request = lane.creates.lock().unwrap().last().unwrap().clone(); + assert_eq!(request.provider, "claude"); + assert_eq!( + request.session_type, + if agent == "claude" { + "freshclaude" + } else { + "kilroy" + } + ); + } + assert_eq!(lane.creates.lock().unwrap().len(), 2); + let (status, _) = post(router, "/api/tabs", json!({"agent":"codex"}), true).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(lane.creates.lock().unwrap().len(), 2); +} + +#[tokio::test] +async fn hosted_gateway_takes_precedence_over_the_local_claude_lane() { + let state = state_with_registry(); + let local = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + let hosted = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + state.set_claude_rest_lane(local.clone()).unwrap(); + state.set_hosted_rest_gateway(hosted.clone()).unwrap(); + let (status, body) = post(app(state), "/api/tabs", json!({"agent":"claude"}), true).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(hosted.creates.lock().unwrap().len(), 1); + assert!(local.creates.lock().unwrap().is_empty()); +} + +#[derive(Default)] +struct ControlLane { + sends: std::sync::Mutex>, + captures: std::sync::Mutex>, +} + +#[async_trait::async_trait] +impl FreshAgentRestLane for ControlLane { + async fn create_agent( + self: Arc, + _: RestLaneCreate, + ) -> Result { + unreachable!("pane was created by the browser") + } + async fn send_agent(&self, request: RestLaneSend) -> Result { + let noop = request.text == "\n"; + let result = RestLaneSendResult { + session_id: request.session_id.clone(), + completed: true, + submitted_turn_id: (!noop).then(|| "actual-submitted-uuid".into()), + noop, + }; + self.sends.lock().unwrap().push(request); + Ok(result) + } + async fn capture( + &self, + request: RestLaneCapture, + ) -> Result { + let result = RestLaneCaptureResult { + session_id: request.session_id.clone(), + native_session_id: request.session_id.clone(), + text: "saved conversation".into(), + truncated: false, + }; + self.captures.lock().unwrap().push(request); + Ok(result) + } +} + +async fn capture_response(router: axum::Router, pane_id: &str) -> (StatusCode, String) { + use tower::util::ServiceExt; + let response = router + .oneshot( + axum::http::Request::builder() + .uri(format!("/api/panes/{pane_id}/capture?maxBytes=30")) + .header("x-auth-token", "tok") + .body(axum::body::Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 4096) + .await + .unwrap(); + (status, String::from_utf8(bytes.to_vec()).unwrap()) +} + +#[tokio::test] +async fn local_lane_controls_browser_created_panes_using_the_provider_matched_durable_ref() { + const DURABLE_ID: &str = "67cebd37-9576-4de0-93bb-8f286f5fc499"; + let state = state_with_registry(); + let (tab, pane_id) = state.layout.create_tab(Some("Browser-created")); + state.layout.attach_pane_content( + &tab, + &pane_id, + json!({ + "kind":"fresh-agent", "provider":"claude", "sessionType":"kilroy", + "sessionId":"legacy-alias", "sessionRef":{"provider":"claude", "sessionId":DURABLE_ID}, + }), + ); + let lane = Arc::new(ControlLane::default()); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state); + let (status, body) = post( + router.clone(), + &format!("/api/panes/{pane_id}/send-keys"), + json!({"data":"continue", "requestId":"caller-request"}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["data"]["submittedTurnId"], "actual-submitted-uuid"); + assert_eq!(body["data"]["sessionRef"]["sessionId"], DURABLE_ID); + let sent = lane.sends.lock().unwrap().last().unwrap().clone(); + assert_eq!(sent.session_id, DURABLE_ID); + assert_eq!(sent.request_id, "caller-request"); + assert_eq!(sent.session_type, "kilroy"); + let (status, text) = capture_response(router, &pane_id).await; + assert_eq!(status, StatusCode::OK, "{text}"); + assert_eq!(text, "saved conversation"); + let captured = lane.captures.lock().unwrap().last().unwrap().clone(); + assert_eq!(captured.session_id, DURABLE_ID); + assert_eq!(captured.max_bytes, 30); +} + +#[tokio::test] +async fn lane_noop_does_not_report_a_submitted_turn() { + let state = state_with_registry(); + let (tab, pane_id) = state.layout.create_tab(None); + state.layout.attach_pane_content(&tab, &pane_id, json!({ + "kind":"fresh-agent", "provider":"claude", "sessionType":"freshclaude", "sessionId":"durable-uuid", + })); + state + .set_hosted_rest_gateway(Arc::new(ControlLane::default())) + .unwrap(); + let (status, body) = post( + app(state), + &format!("/api/panes/{pane_id}/send-keys"), + json!({"data":"\n"}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["data"]["status"], "noop"); + assert!(body["data"].get("submittedTurnId").is_none(), "{body}"); +} diff --git a/crates/freshell-freshagent/src/snapshot.rs b/crates/freshell-freshagent/src/snapshot.rs index 83691e782..0beb6bb44 100644 --- a/crates/freshell-freshagent/src/snapshot.rs +++ b/crates/freshell-freshagent/src/snapshot.rs @@ -562,6 +562,27 @@ mod tests { } } + #[tokio::test] + async fn local_rest_lane_does_not_replace_the_claude_snapshot_reader() { + let state = snapshot_state(); + state + .opencode + .set_claude_rest_lane(Arc::new(SnapshotGateway)) + .unwrap(); + let response = get_snapshot( + State(state), + Path(( + "freshclaude".into(), + "claude".into(), + "absent-local-rest-snapshot".into(), + )), + Query(HashMap::new()), + headers_with_token("tok"), + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + #[tokio::test] async fn missing_auth_header_is_401() { let resp = get_snapshot( diff --git a/crates/freshell-server/src/fresh_agent_proxy_rest.rs b/crates/freshell-server/src/fresh_agent_proxy_rest.rs index de1a4a16b..3f14c67a5 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_rest.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_rest.rs @@ -196,6 +196,7 @@ impl FreshAgentRestLane for HostedFreshAgentProxy { Ok(RestLaneSendResult { session_id: request.session_id, completed, + ..Default::default() }) } From 7fd997ad91546bae2c7b9eb19790009d245d42ae Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:30:48 -0700 Subject: [PATCH 007/100] fix(freshclaude): correlate sends and runtime ends by incarnation --- crates/freshell-freshagent/src/claude.rs | 5 ++ .../src/claude/send_outcome.rs | 3 ++ .../freshell-freshagent/src/claude_edges.rs | 25 +++++++-- .../tests/freshagent_claude_incarnation.rs | 52 +++++++++++++++++++ .../tests/freshagent_claude_turns.rs | 1 + 5 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 crates/freshell-ws/tests/freshagent_claude_incarnation.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 63edb3c15..98c1eefe4 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -4380,6 +4380,7 @@ impl FreshClaudeState { "text": msg.text, }); let writer = session.stdin.clone(); + let ownership_id = session.ownership_id.clone(); drop(guard); if let Some(attempt) = checkpoint_attempt_for_send(&msg) { send_req["checkpointAttempt"] = attempt; @@ -4479,6 +4480,7 @@ impl FreshClaudeState { ClaudeSendOutcome::Accepted { session_key: map_key, submitted_turn_id, + ownership_id, } } @@ -6988,6 +6990,7 @@ impl FreshClaudeState { // bounded confirmation window, confirmed gone before the exit // report offers Resume, and no zombie is left behind. It is not // this consumer's to abort: the consumer is this task. + let ownership_id = evicted.ownership_id.clone(); let processes = SidecarProcesses { child: evicted.child, stdin: evicted.stdin, @@ -7086,6 +7089,7 @@ impl FreshClaudeState { }, rings, cause.reason(), + &ownership_id, ); } }) @@ -17572,6 +17576,7 @@ rl.on('line', (line) => { ClaudeSendOutcome::Accepted { session_key: session_id.clone(), submitted_turn_id: uuid.clone(), + ownership_id: st.sessions.lock().await[&session_id].ownership_id.clone(), } ); } diff --git a/crates/freshell-freshagent/src/claude/send_outcome.rs b/crates/freshell-freshagent/src/claude/send_outcome.rs index fa52333f8..137d14426 100644 --- a/crates/freshell-freshagent/src/claude/send_outcome.rs +++ b/crates/freshell-freshagent/src/claude/send_outcome.rs @@ -24,6 +24,9 @@ pub enum ClaudeSendOutcome { session_key: String, /// The uuid the message runs under: the turn id its user line lands with. submitted_turn_id: String, + /// The exact runtime that accepted this send; an old end after recovery + /// must never settle a send belonging to its replacement. + ownership_id: String, }, /// The message was not sent; the sender got a request error with this code. Refused { code: String, message: String }, diff --git a/crates/freshell-freshagent/src/claude_edges.rs b/crates/freshell-freshagent/src/claude_edges.rs index 2a950c9ab..666416c20 100644 --- a/crates/freshell-freshagent/src/claude_edges.rs +++ b/crates/freshell-freshagent/src/claude_edges.rs @@ -72,7 +72,12 @@ pub enum ClaudeTurnEvent { Waiting { session_key: String, at: i64 }, /// The session's sidecar ended unrequested; `reason` is why /// (`cli_exited`, `stream_error`, `stream_eof` or `sidecar_exited`). - Ended { session_key: String, reason: String }, + Ended { + session_key: String, + reason: String, + /// The runtime that ended, matching the accepted send's ownership id. + ownership_id: String, + }, } /// The two attention edges (AGENTS.md "Agent Status Indicators"). @@ -245,11 +250,18 @@ impl ClaudeEdgeHub { /// The sidecar ended unrequested: ring when a turn was owed, and publish the /// end either way. - pub(crate) fn ended(&self, record: &EdgeRecord<'_>, owed: bool, reason: &str) -> Option { + pub(crate) fn ended( + &self, + record: &EdgeRecord<'_>, + owed: bool, + reason: &str, + ownership_id: &str, + ) -> Option { let at = owed.then(|| self.emit(record, EdgeKind::TurnComplete, EdgeSource::Death)); self.publish(ClaudeTurnEvent::Ended { session_key: record.session_key.to_string(), reason: reason.to_string(), + ownership_id: ownership_id.to_string(), }); at } @@ -364,7 +376,7 @@ mod tests { let complete = hub.settle(&record, settled(true, TurnOutcome::Completed)); let waiting = hub.waiting(&record); - let death = hub.ended(&record, true, "cli_exited"); + let death = hub.ended(&record, true, "cli_exited", "owner-1"); let (complete, death) = (complete.unwrap(), death.unwrap()); assert!( complete < waiting && waiting < death, @@ -441,7 +453,9 @@ mod tests { let result = hub .settle(&record, settled(true, TurnOutcome::Completed)) .unwrap(); - let death = hub.ended(&record, true, "sidecar_exited").unwrap(); + let death = hub + .ended(&record, true, "sidecar_exited", "owner-1") + .unwrap(); assert_eq!(result, ahead + 1); assert_eq!(death, ahead + 2); } @@ -460,7 +474,7 @@ mod tests { assert_eq!(silent, None); let waiting = hub.waiting(&record); assert_eq!( - hub.ended(&record, false, "stream_eof"), + hub.ended(&record, false, "stream_eof", "owner-1"), None, "an idle death is silent" ); @@ -491,6 +505,7 @@ mod tests { ClaudeTurnEvent::Ended { session_key: "key-1".into(), reason: "stream_eof".into(), + ownership_id: "owner-1".into(), }, ] ); diff --git a/crates/freshell-ws/tests/freshagent_claude_incarnation.rs b/crates/freshell-ws/tests/freshagent_claude_incarnation.rs new file mode 100644 index 000000000..7b3f69498 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_incarnation.rs @@ -0,0 +1,52 @@ +//! In-process send observers correlate a sidecar end to its exact runtime. + +#![cfg(unix)] + +mod common; + +use common::claude_rig::{ClaudeRig, RigKind, RigOptions}; +use freshell_freshagent::{claude::ClaudeSendOutcome, ClaudeTurnEvent}; +use freshell_protocol::{AgentProvider, FreshAgentSend, SessionType}; +use std::time::Duration; + +async fn accepted_owner(rig: &ClaudeRig, id: &str, request: &str) -> String { + let outcome = rig.fresh_claude.handle_send(FreshAgentSend { + provider: AgentProvider::Claude, session_id: id.into(), + session_type: SessionType::Freshclaude, text: "HANG".into(), + request_id: Some(request.into()), cwd: None, images: None, settings: None, + observed_epoch: None, observed_generation: None, checkpoint_intent: None, + }).await; + match outcome { + ClaudeSendOutcome::Accepted { ownership_id, .. } => { + assert!(!ownership_id.is_empty()); + ownership_id + } + refused => panic!("send should be accepted: {refused:?}"), + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_ended_event_identifies_the_accepted_runtime_across_a_resume() { + let rig = ClaudeRig::start_with_options(RigKind::Sim, RigOptions { + durable_ledger: true, ..RigOptions::default() + }).await; + let mut conn = rig.connect().await; + let pane = conn.create("create-incarnation").await; + let durable = rig.converse(&mut conn, &pane, &["make the transcript durable"]).await; + let mut events = rig.fresh_claude.subscribe_turn_events(); + let first_owner = accepted_owner(&rig, &durable, "first-incarnation").await; + rig.kill_first_sidecar(&mut conn).await; + conn.attach(&durable).await; + let second_owner = accepted_owner(&rig, &durable, "second-incarnation").await; + assert_ne!(first_owner, second_owner, "a new process has a new ownership id"); + let ended_owner = tokio::time::timeout(Duration::from_secs(15), async { + loop { + if let ClaudeTurnEvent::Ended { ownership_id, .. } = events.recv().await.unwrap() { + break ownership_id; + } + } + }).await.expect("the dead runtime publishes its end"); + assert_eq!(ended_owner, first_owner); + assert_ne!(ended_owner, second_owner, "a delayed old end cannot match a new send"); + rig.fresh_claude.shutdown().await; +} diff --git a/crates/freshell-ws/tests/freshagent_claude_turns.rs b/crates/freshell-ws/tests/freshagent_claude_turns.rs index 02926a43a..1b22df817 100644 --- a/crates/freshell-ws/tests/freshagent_claude_turns.rs +++ b/crates/freshell-ws/tests/freshagent_claude_turns.rs @@ -1317,6 +1317,7 @@ mod sim { let ClaudeSendOutcome::Accepted { session_key, submitted_turn_id, + .. } = outcome else { panic!("the send was accepted: {outcome:?}"); From 0152e248dd1138a3f70841ddcab0365fc1d24a8c Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:40:39 -0700 Subject: [PATCH 008/100] test(freshclaude): reserve durable ownership epochs in the sidecar rig --- crates/freshell-ws/tests/common/claude_rig.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index 9be80ecb7..3cfb1ce84 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -998,7 +998,13 @@ async fn spawn_rig_server( // The ONE ownership coordinator, wired into the WS state and the claude lane // the way main.rs wires it. - let ownership = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); + let epoch = pane_ledger.as_ref().and_then(|ledger| { + ledger.reserve_boot_epoch().expect("reserve the rig server's durable boot epoch") + }); + let ownership = Arc::new(match epoch { + Some(epoch) => freshell_ownership::RuntimeOwnershipRegistry::with_epoch(epoch), + None => freshell_ownership::RuntimeOwnershipRegistry::new(), + }); let mut fresh_claude = freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)) .with_sidecar_environment(environment) .with_hosted_mcp(options.hosted_claude); From 64a5eba9f6fd8153c0fac765a8e518ea874b923a Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:45:31 -0700 Subject: [PATCH 009/100] feat(freshclaude): implement local REST runtime control and capture --- .../src/claude_rest_lane.rs | 369 ++++++++++++++++++ .../src/claude_rest_lane_tests.rs | 195 +++++++++ crates/freshell-freshagent/src/lib.rs | 19 +- crates/freshell-freshagent/src/rest_lane.rs | 1 + .../src/rest_lane_http_tests.rs | 39 +- crates/freshell-server/src/main.rs | 9 + crates/freshell-ws/tests/common/claude_rig.rs | 50 ++- .../tests/freshagent_claude_incarnation.rs | 51 ++- 8 files changed, 713 insertions(+), 20 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude_rest_lane.rs create mode 100644 crates/freshell-freshagent/src/claude_rest_lane_tests.rs diff --git a/crates/freshell-freshagent/src/claude_rest_lane.rs b/crates/freshell-freshagent/src/claude_rest_lane.rs new file mode 100644 index 000000000..649fa80de --- /dev/null +++ b/crates/freshell-freshagent/src/claude_rest_lane.rs @@ -0,0 +1,369 @@ +//! REST control of the same Claude runtime used by WebSocket panes. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use crate::claude_edges::ClaudeTurnEvent; +use crate::claude_snapshot::ClaudeSnapshotError; +use crate::rest_lane::*; +use crate::{ClaudeCreateOutcome, ClaudeSendOutcome, FreshClaudeState}; +use freshell_protocol::{ + AgentProvider, FreshAgentCreate, FreshAgentSend, SessionLocator, SessionType, +}; + +pub struct ClaudeRestLane { + state: FreshClaudeState, + enabled: Arc bool + Send + Sync>, +} + +impl ClaudeRestLane { + pub fn new(state: FreshClaudeState, enabled: Arc bool + Send + Sync>) -> Self { + Self { state, enabled } + } + + async fn create(&self, request: RestLaneCreate) -> Result { + let session_type = claude_session_type(&request.provider, &request.session_type)?; + if !(self.enabled)() { + return Err(RestLaneError::new( + RestLaneErrorKind::Forbidden, + "FRESH_AGENT_DISABLED", + "fresh agents are disabled", + )); + } + let session_ref = request.native_session_id.map(|session_id| SessionLocator { + provider: "claude".into(), + session_id, + }); + match self + .state + .handle_create( + FreshAgentCreate { + request_id: request.request_id, + provider: Some(AgentProvider::Claude), + session_type, + cwd: request.cwd, + model: request.model, + model_selection: request.model_selection, + permission_mode: request.permission_mode, + effort: request.effort, + plugins: request.plugins, + session_ref, + sandbox: request.sandbox, + tab_id: request.tab_id, + pane_id: request.pane_id, + naming_handle: request.naming_handle, + observed_epoch: None, + observed_generation: None, + legacy_restore_context: None, + resume_session_id: None, + }, + None, + ) + .await + { + ClaudeCreateOutcome::Created { + session_id, + session_ref, + } => Ok(RestLaneCreated { + session_id, + session_ref, + }), + ClaudeCreateOutcome::Failed { + code, + message, + retryable, + owner, + } => { + let mut error = RestLaneError::new(create_error_kind(&code), code, message); + error.retryable = retryable; + error.owner = owner; + Err(error) + } + } + } + + async fn send(&self, request: &RestLaneSend) -> Result { + let session_type = claude_session_type(&request.provider, &request.session_type)?; + if request + .text + .bytes() + .all(|byte| matches!(byte, b'\r' | b'\n')) + { + return Ok(RestLaneSendResult { + session_id: request.session_id.clone(), + completed: true, + noop: true, + ..Default::default() + }); + } + let text = request + .text + .strip_suffix(['\r', '\n']) + .unwrap_or(&request.text) + .to_string(); + // A short turn may settle before handle_send returns its acceptance. + let mut events = self.state.subscribe_turn_events(); + let accepted = self + .state + .handle_send(FreshAgentSend { + provider: AgentProvider::Claude, + session_id: request.session_id.clone(), + session_type, + text, + request_id: Some(request.request_id.clone()), + cwd: None, + images: None, + settings: None, + observed_epoch: None, + observed_generation: None, + checkpoint_intent: None, + }) + .await; + let (session_key, submitted_turn_id, ownership_id) = match accepted { + ClaudeSendOutcome::Accepted { + session_key, + submitted_turn_id, + ownership_id, + } => (session_key, submitted_turn_id, ownership_id), + ClaudeSendOutcome::Refused { code, message } => return Err(send_error(code, message)), + }; + let completed = wait_for_turn( + &mut events, + &session_key, + &ownership_id, + &request.request_id, + &submitted_turn_id, + Duration::from_millis(request.timeout_ms), + ) + .await?; + Ok(RestLaneSendResult { + session_id: request.session_id.clone(), + completed, + submitted_turn_id: Some(submitted_turn_id), + noop: false, + }) + } +} + +#[async_trait::async_trait] +impl FreshAgentRestLane for ClaudeRestLane { + async fn create_agent( + self: Arc, + request: RestLaneCreate, + ) -> Result { + let started = Instant::now(); + let request_id = request.request_id.clone(); + let session_type = request.session_type.clone(); + let result = self.create(request).await; + match &result { + Ok(created) => tracing::info!( + event = "freshagent.claude.rest_lane.create", + request_id, + session_type, + session_id = created.session_id, + outcome = "created", + elapsed_ms = started.elapsed().as_millis() as u64 + ), + Err(error) => tracing::warn!( + event = "freshagent.claude.rest_lane.create", + request_id, + session_type, + code = error.code, + outcome = "refused", + elapsed_ms = started.elapsed().as_millis() as u64 + ), + } + result + } + + async fn send_agent(&self, request: RestLaneSend) -> Result { + let started = Instant::now(); + let result = self.send(&request).await; + match &result { + Ok(sent) => tracing::info!( + event = "freshagent.claude.rest_lane.send", + session_id = request.session_id, + request_id = request.request_id, + submitted_turn_id = sent.submitted_turn_id.as_deref(), + completed = sent.completed, + noop = sent.noop, + waited_ms = started.elapsed().as_millis() as u64 + ), + Err(error) => tracing::warn!( + event = "freshagent.claude.rest_lane.send", + session_id = request.session_id, + request_id = request.request_id, + code = error.code, + outcome = "refused", + waited_ms = started.elapsed().as_millis() as u64 + ), + } + result + } + + async fn capture( + &self, + request: RestLaneCapture, + ) -> Result { + claude_session_type(&request.provider, &request.session_type) + .map_err(|_| RestLaneCaptureError::Unsupported)?; + let snapshot = match self + .state + .disk_snapshot(&request.session_type, &request.session_id) + .await + { + Ok(snapshot) => snapshot, + Err(ClaudeSnapshotError::NotFound) + if self.state.has_live_session(&request.session_id).await => + { + serde_json::json!({"turns":[]}) + } + Err(ClaudeSnapshotError::NotFound) => return Err(RestLaneCaptureError::NotFound), + Err(ClaudeSnapshotError::Io(error)) => { + tracing::warn!( + event = "freshagent.claude.rest_lane.capture", + session_id = request.session_id, + outcome = "unreadable", + error + ); + return Err(RestLaneCaptureError::Unavailable); + } + }; + let (text, truncated) = freshell_agent_runtime::snapshot_text::render_snapshot_text( + &snapshot, + request.max_bytes.min(256 * 1024), + ); + tracing::info!( + event = "freshagent.claude.rest_lane.capture", + session_id = request.session_id, + outcome = "captured", + bytes = text.len(), + truncated + ); + Ok(RestLaneCaptureResult { + session_id: request.session_id.clone(), + native_session_id: request.session_id, + text, + truncated, + }) + } +} + +fn claude_session_type(provider: &str, session_type: &str) -> Result { + match (provider, session_type) { + ("claude", "freshclaude") => Ok(SessionType::Freshclaude), + ("claude", "kilroy") => Ok(SessionType::Kilroy), + _ => Err(RestLaneError::new( + RestLaneErrorKind::BadRequest, + "INVALID_REQUEST", + "the local Claude lane supports freshclaude and kilroy sessions", + )), + } +} + +fn create_error_kind(code: &str) -> RestLaneErrorKind { + match code { + "SESSION_RESERVED" | "SESSION_ID_IN_USE" | "STALE_CLAIM" | "SESSION_BUSY" => { + RestLaneErrorKind::Conflict + } + "CONVERSATION_NOT_FOUND" | "RESUME_POINT_NOT_FOUND" => RestLaneErrorKind::NotFound, + "FRESH_AGENT_CREATE_FAILED" | "CLAUDE_CREATE_FAILED" | "SIDECAR_PROTOCOL_MISMATCH" => { + RestLaneErrorKind::Unavailable + } + code if code.starts_with("INVALID_") => RestLaneErrorKind::BadRequest, + code if code.starts_with("CLAUDE_SIDECAR_") => RestLaneErrorKind::Unavailable, + _ => RestLaneErrorKind::Internal, + } +} + +fn retryable_conflict(code: &str, message: &str) -> RestLaneError { + let mut error = RestLaneError::new(RestLaneErrorKind::Conflict, code, message); + error.retryable = Some(true); + error.retry_after_ms = Some(250); + error +} + +fn send_error(code: String, message: String) -> RestLaneError { + match code.as_str() { + "SESSION_NOT_FOUND" | "INVALID_SESSION_ID" => retryable_conflict( + "FRESH_AGENT_SESSION_NOT_LIVE", + "Claude is not running for this session; attach the pane and retry", + ), + "SESSION_BUSY" | "SESSION_RESERVED" | "STALE_CLAIM" => retryable_conflict(&code, &message), + "CLAUDE_SEND_FAILED" | "CLAUDE_SETTINGS_FAILED" => { + RestLaneError::new(RestLaneErrorKind::Unavailable, code, message) + } + code if code.starts_with("INVALID_") => { + RestLaneError::new(RestLaneErrorKind::BadRequest, code, message) + } + _ => RestLaneError::new(RestLaneErrorKind::Internal, code, message), + } +} + +async fn wait_for_turn( + events: &mut tokio::sync::broadcast::Receiver, + session_key: &str, + ownership_id: &str, + request_id: &str, + submitted_turn_id: &str, + budget: Duration, +) -> Result { + let waited = tokio::time::timeout(budget, async { + loop { + match events.recv().await { + Ok(ClaudeTurnEvent::Settled { + session_key: settled_key, + retired_request_ids, + retired_command_ids, + .. + }) if settled_key == session_key + && retired_request_ids + .iter() + .any(|retired| retired == request_id) + && retired_command_ids + .iter() + .any(|retired| retired == submitted_turn_id) => + { + return Ok(true) + } + Ok(ClaudeTurnEvent::Ended { + ownership_id: ended_owner, + reason, + .. + }) if ended_owner == ownership_id => { + tracing::warn!( + event = "freshagent.claude.rest_lane.send_ended", + session_key, + request_id, + submitted_turn_id, + reason + ); + return Err(retryable_conflict( + "FRESH_AGENT_SESSION_ENDED", + "Claude stopped before this turn completed; attach the pane and retry", + )); + } + Ok(_) => {} + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => tracing::warn!( + event = "freshagent.claude.rest_lane.send_lagged", + session_key, + request_id, + skipped + ), + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + return Err(RestLaneError::new( + RestLaneErrorKind::Unavailable, + "FRESH_AGENT_EVENTS_UNAVAILABLE", + "Claude turn events are unavailable", + )) + } + } + } + }) + .await; + waited.unwrap_or(Ok(false)) +} + +#[cfg(test)] +#[path = "claude_rest_lane_tests.rs"] +mod tests; diff --git a/crates/freshell-freshagent/src/claude_rest_lane_tests.rs b/crates/freshell-freshagent/src/claude_rest_lane_tests.rs new file mode 100644 index 000000000..df480b711 --- /dev/null +++ b/crates/freshell-freshagent/src/claude_rest_lane_tests.rs @@ -0,0 +1,195 @@ +use super::*; +use crate::TurnOutcome; +use std::time::Duration; + +fn lane(enabled: bool) -> ClaudeRestLane { + ClaudeRestLane::new( + FreshClaudeState::new(Arc::new(tokio::sync::broadcast::channel(32).0)), + Arc::new(move || enabled), + ) +} + +fn create() -> RestLaneCreate { + RestLaneCreate { + provider: "claude".into(), + session_type: "freshclaude".into(), + request_id: "create-rest".into(), + ..Default::default() + } +} + +fn send(text: &str) -> RestLaneSend { + RestLaneSend { + provider: "claude".into(), + session_type: "freshclaude".into(), + session_id: "67cebd37-9576-4de0-93bb-8f286f5fc499".into(), + request_id: "rest-request".into(), + text: text.into(), + timeout_ms: 100, + } +} + +#[tokio::test] +async fn disabled_create_is_forbidden_before_a_runtime_is_started() { + let error = Arc::new(lane(false)) + .create_agent(create()) + .await + .unwrap_err(); + assert_eq!(error.kind, RestLaneErrorKind::Forbidden); + assert_eq!(error.code, "FRESH_AGENT_DISABLED"); +} + +#[tokio::test] +async fn local_lane_refuses_other_providers_and_session_types() { + let mut request = create(); + request.provider = "codex".into(); + assert_eq!( + Arc::new(lane(true)) + .create_agent(request) + .await + .unwrap_err() + .kind, + RestLaneErrorKind::BadRequest + ); + let mut request = send("hello"); + request.session_type = "freshcodex".into(); + assert_eq!( + lane(true).send_agent(request).await.unwrap_err().kind, + RestLaneErrorKind::BadRequest + ); +} + +#[tokio::test] +async fn line_terminators_only_are_noop_without_a_live_runtime() { + for text in ["\r", "\n", "\r\n", "\n\r\n"] { + let result = lane(true).send_agent(send(text)).await.unwrap(); + assert!(result.noop); + assert!(result.completed); + assert_eq!(result.submitted_turn_id, None); + } +} + +#[tokio::test] +async fn send_without_a_live_runtime_returns_a_retryable_conflict() { + let error = lane(true).send_agent(send("hello\r")).await.unwrap_err(); + assert_eq!(error.kind, RestLaneErrorKind::Conflict); + assert_eq!(error.code, "FRESH_AGENT_SESSION_NOT_LIVE"); + assert_eq!(error.retryable, Some(true)); + assert!(error.retry_after_ms.is_some_and(|delay| delay > 0)); +} + +#[tokio::test] +#[cfg(unix)] +async fn capture_distinguishes_missing_history_from_unreadable_storage() { + let root = tempfile::tempdir().unwrap(); + let state = FreshClaudeState::new(Arc::new(tokio::sync::broadcast::channel(32).0)) + .with_sidecar_environment([( + "CLAUDE_CONFIG_DIR".into(), + root.path().to_str().unwrap().into(), + )]); + let lane = ClaudeRestLane::new(state, Arc::new(|| true)); + let request = RestLaneCapture { + provider: "claude".into(), + session_type: "freshclaude".into(), + session_id: "67cebd37-9576-4de0-93bb-8f286f5fc499".into(), + max_bytes: 1024, + }; + assert_eq!( + lane.capture(request.clone()).await, + Err(RestLaneCaptureError::NotFound) + ); + // A symlink loop gives a real store traversal error even for root, unlike + // mode 000. Capture must not report that unchecked conversation as absent. + std::os::unix::fs::symlink("projects", root.path().join("projects")).unwrap(); + assert_eq!( + lane.capture(request).await, + Err(RestLaneCaptureError::Unavailable) + ); +} + +fn settled(session: &str, request: &str, uuid: &str) -> ClaudeTurnEvent { + ClaudeTurnEvent::Settled { + session_key: session.into(), + retired_request_ids: vec![request.into()], + retired_command_ids: vec![uuid.into()], + outcome: TurnOutcome::Completed, + at: None, + } +} + +#[tokio::test] +async fn completion_requires_the_accepted_session_uuid_and_request_even_when_both_sends_share_a_request_id( +) { + let (tx, mut rx) = tokio::sync::broadcast::channel(16); + let future = wait_for_turn( + &mut rx, + "session", + "runtime-new", + "request", + "uuid", + Duration::from_secs(1), + ); + tokio::pin!(future); + assert!(futures_util::poll!(&mut future).is_pending()); + for unrelated in [ + settled("another-session", "request", "uuid"), + settled("session", "request", "another-uuid"), + settled("session", "another-request", "uuid"), + ClaudeTurnEvent::Waiting { + session_key: "session".into(), + at: 1, + }, + ClaudeTurnEvent::Ended { + session_key: "session".into(), + ownership_id: "runtime-old".into(), + reason: "sidecar_exited".into(), + }, + ] { + tx.send(unrelated).unwrap(); + assert!(futures_util::poll!(&mut future).is_pending()); + } + // A user-silenced result still settles its sender, despite carrying no bell. + tx.send(settled("session", "request", "uuid")).unwrap(); + assert_eq!(future.await, Ok(true)); +} + +#[tokio::test] +async fn only_the_accepted_runtime_ending_refuses_the_waiting_send() { + let (tx, mut rx) = tokio::sync::broadcast::channel(16); + tx.send(ClaudeTurnEvent::Ended { + session_key: "session".into(), + ownership_id: "runtime-new".into(), + reason: "cli_exited".into(), + }) + .unwrap(); + let error = wait_for_turn( + &mut rx, + "session", + "runtime-new", + "request", + "uuid", + Duration::from_secs(1), + ) + .await + .unwrap_err(); + assert_eq!(error.kind, RestLaneErrorKind::Conflict); + assert_eq!(error.code, "FRESH_AGENT_SESSION_ENDED"); + assert_eq!(error.retryable, Some(true)); +} + +#[tokio::test] +async fn a_deadline_reports_an_incomplete_turn() { + let (_tx, mut rx) = tokio::sync::broadcast::channel(16); + assert_eq!( + wait_for_turn( + &mut rx, + "session", + "runtime", + "request", + "uuid", + Duration::ZERO + ) + .await, + Ok(false) + ); +} diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index ad958ea4c..316533df2 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -50,6 +50,7 @@ pub(crate) mod claude_live_events; pub(crate) mod claude_permission_update; pub(crate) mod claude_projection; pub(crate) mod claude_projection_cache; +pub mod claude_rest_lane; pub(crate) mod claude_sidecar_exit; pub(crate) mod claude_sidecar_io; pub(crate) mod claude_snapshot; @@ -5139,9 +5140,17 @@ fn register_fresh_agent_tab( /// Resolve them when their provider has a lane; that lane owns the live-session /// checks and transcript lookup. fn pane_entry_for_request(state: &FreshAgentState, pane_id: &str) -> Option { - if let Some(pane) = state.panes.lock().expect("panes mutex").get(pane_id) { - return Some(pane.clone()); - } + pane_entry_from_layout(state, pane_id).or_else(|| { + state + .panes + .lock() + .expect("panes mutex") + .get(pane_id) + .cloned() + }) +} + +fn pane_entry_from_layout(state: &FreshAgentState, pane_id: &str) -> Option { let snapshot = state.layout.get_pane_snapshot(pane_id)?; let content = snapshot.pane_content?; if content.get("kind")?.as_str()? != "fresh-agent" { @@ -7092,6 +7101,10 @@ async fn capture( StatusCode::SERVICE_UNAVAILABLE, "hosted fresh-agent transcript is temporarily unavailable".to_string(), ), + Err(rest_lane::RestLaneCaptureError::NotFound) => fail_json( + StatusCode::NOT_FOUND, + "fresh-agent transcript was not found".to_string(), + ), }; } let Some(durable_id) = pane.durable_id else { diff --git a/crates/freshell-freshagent/src/rest_lane.rs b/crates/freshell-freshagent/src/rest_lane.rs index 5d575db3d..8e8449afd 100644 --- a/crates/freshell-freshagent/src/rest_lane.rs +++ b/crates/freshell-freshagent/src/rest_lane.rs @@ -164,6 +164,7 @@ pub struct RestLaneCaptureResult { pub enum RestLaneCaptureError { Unsupported, Unavailable, + NotFound, } #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/crates/freshell-freshagent/src/rest_lane_http_tests.rs b/crates/freshell-freshagent/src/rest_lane_http_tests.rs index 464d2ab7b..99021fdf8 100644 --- a/crates/freshell-freshagent/src/rest_lane_http_tests.rs +++ b/crates/freshell-freshagent/src/rest_lane_http_tests.rs @@ -331,7 +331,10 @@ impl FreshAgentRestLane for ControlLane { self: Arc, _: RestLaneCreate, ) -> Result { - unreachable!("pane was created by the browser") + Ok(RestLaneCreated { + session_id: "cached-rest-session".into(), + ..Default::default() + }) } async fn send_agent(&self, request: RestLaneSend) -> Result { let noop = request.text == "\n"; @@ -437,3 +440,37 @@ async fn lane_noop_does_not_report_a_submitted_turn() { assert_eq!(body["data"]["status"], "noop"); assert!(body["data"].get("submittedTurnId").is_none(), "{body}"); } + +#[tokio::test] +async fn lane_controls_use_the_current_layout_identity_after_a_rest_created_pane_is_replaced() { + const CURRENT_ID: &str = "67cebd37-9576-4de0-93bb-8f286f5fc499"; + let state = state_with_registry(); + let lane = Arc::new(ControlLane::default()); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state.clone()); + let (status, created) = + post(router.clone(), "/api/tabs", json!({"agent":"claude"}), true).await; + assert_eq!(status, StatusCode::OK, "{created}"); + let pane_id = created["data"]["paneId"].as_str().unwrap(); + let tab_id = created["data"]["tabId"].as_str().unwrap(); + state.layout.attach_pane_content(tab_id, pane_id, json!({ + "kind":"fresh-agent", "provider":"claude", "sessionType":"kilroy", "sessionId":"new-pane-alias", + "sessionRef":{"provider":"claude", "sessionId":CURRENT_ID}, + })); + let (status, body) = post( + router.clone(), + &format!("/api/panes/{pane_id}/send-keys"), + json!({"data":"continue"}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let send = lane.sends.lock().unwrap().last().unwrap().clone(); + assert_eq!(send.session_id, CURRENT_ID); + assert_eq!(send.session_type, "kilroy"); + let (status, text) = capture_response(router, pane_id).await; + assert_eq!(status, StatusCode::OK, "{text}"); + let capture = lane.captures.lock().unwrap().last().unwrap().clone(); + assert_eq!(capture.session_id, CURRENT_ID); + assert_eq!(capture.session_type, "kilroy"); +} diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index 59acdcdee..97db3e9bb 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -2089,6 +2089,15 @@ async fn main() -> ExitCode { fresh_opencode_state.set_identity_sink(fresh_agent_identity_sink.clone()); // opencode REST surface (Task 7's materialization site; V10 A13-N1) fresh_agent_state.set_identity_sink(fresh_agent_identity_sink.clone()); + let rest_enabled = fresh_codex_state.clone(); + fresh_agent_state + .set_claude_rest_lane(Arc::new( + freshell_freshagent::claude_rest_lane::ClaudeRestLane::new( + fresh_claude_state.clone(), + Arc::new(move || rest_enabled.is_enabled()), + ), + )) + .expect("local Claude REST lane is installed once"); // Lane D1: the crash-event channel for terminal auto-resume. The receiver // is consumed by `auto_resume::spawn_auto_resume_hub`, spawned right // after `ws_state` is assembled (the hub needs the full state). diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index 3cfb1ce84..f6701dfd5 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -63,6 +63,8 @@ pub enum RigKind { /// local claude lane, no handoff route). #[derive(Debug, Clone, Default)] pub struct RigOptions { + /// REST create/split/send/capture over the shared Claude runtime and layout. + pub rest_api: bool, /// 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)>, @@ -106,6 +108,7 @@ pub struct ClaudeRig { /// The rig server's claude lane, for the server-side truths a test checks /// (for example `has_live_session`). pub fresh_claude: freshell_freshagent::FreshClaudeState, + pub fresh_codex: freshell_freshagent::FreshCodexState, /// The local fake Anthropic endpoint ([`RigKind::Cli`] only), killed on drop. _provider: Option, /// What the server was started with, for [`Self::restart`]. @@ -228,6 +231,7 @@ impl ClaudeRig { url, http_url, fresh_claude, + fresh_codex, server, } = spawn_rig_server(environment.clone(), &options, pane_ledger.clone()).await; eprintln!( @@ -246,6 +250,7 @@ impl ClaudeRig { claude_config_dir, ledger, fresh_claude, + fresh_codex, _provider: provider, environment, options, @@ -271,6 +276,7 @@ impl ClaudeRig { url, http_url, fresh_claude, + fresh_codex, server, } = spawn_rig_server(self.environment.clone(), &self.options, pane_ledger.clone()).await; eprintln!( @@ -280,6 +286,7 @@ impl ClaudeRig { self.url = url; self.http_url = http_url; self.fresh_claude = fresh_claude; + self.fresh_codex = fresh_codex; self.pane_ledger = pane_ledger; self.server = server; } @@ -975,6 +982,7 @@ struct RigServer { url: String, http_url: String, fresh_claude: freshell_freshagent::FreshClaudeState, + fresh_codex: freshell_freshagent::FreshCodexState, server: tokio::task::JoinHandle<()>, } @@ -999,12 +1007,27 @@ async fn spawn_rig_server( // The ONE ownership coordinator, wired into the WS state and the claude lane // the way main.rs wires it. let epoch = pane_ledger.as_ref().and_then(|ledger| { - ledger.reserve_boot_epoch().expect("reserve the rig server's durable boot epoch") + ledger + .reserve_boot_epoch() + .expect("reserve the rig server's durable boot epoch") }); let ownership = Arc::new(match epoch { Some(epoch) => freshell_ownership::RuntimeOwnershipRegistry::with_epoch(epoch), None => freshell_ownership::RuntimeOwnershipRegistry::new(), }); + let layout = if options.rest_api { + let home = environment + .iter() + .find(|(key, _)| key == "HOME") + .expect("rig HOME") + .1 + .clone(); + freshell_freshagent::layout_store::LayoutStore::with_persistence( + PathBuf::from(home).join(".freshell/rest-layout.json"), + ) + } else { + Default::default() + }; let mut fresh_claude = freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)) .with_sidecar_environment(environment) .with_hosted_mcp(options.hosted_claude); @@ -1019,12 +1042,13 @@ async fn spawn_rig_server( let fresh_agent_state = freshell_freshagent::FreshAgentState::new( Arc::clone(&auth_token), Arc::clone(&broadcast_tx), - ); + ) + .with_layout(layout.clone()); let registry = freshell_terminal::TerminalRegistry::new(); let cli_commands: Arc> = Arc::new(Vec::new()); let state = WsState { - layout: Default::default(), + layout, terminal_meta: Default::default(), pane_ledger: pane_ledger .clone() @@ -1086,6 +1110,25 @@ async fn spawn_rig_server( ); let mut router = freshell_ws::router(state.clone()).merge(freshell_freshagent::snapshot::router(snapshots)); + if options.rest_api { + let enabled = state.fresh_codex.clone(); + fresh_agent_state + .set_claude_rest_lane(Arc::new( + freshell_freshagent::claude_rest_lane::ClaudeRestLane::new( + lane.clone(), + Arc::new(move || enabled.is_enabled()), + ), + )) + .expect("install the rig's local Claude REST lane"); + let defaults = state.handshake_settings.clone(); + fresh_agent_state + .set_rest_create_settings_reader(Arc::new(move || { + let defaults = defaults.clone(); + Box::pin(async move { defaults.read().await.fresh_agent.clone() }) + })) + .expect("install the rig's REST defaults reader"); + router = router.merge(freshell_freshagent::router(fresh_agent_state.clone())); + } if options.handoff { let runner = Arc::new(freshell_freshagent::SessionHandoffRunner::new( Arc::clone(&auth_token), @@ -1129,6 +1172,7 @@ async fn spawn_rig_server( url: format!("ws://{addr}/ws"), http_url: format!("http://{addr}"), fresh_claude: lane, + fresh_codex: state.fresh_codex.clone(), server, } } diff --git a/crates/freshell-ws/tests/freshagent_claude_incarnation.rs b/crates/freshell-ws/tests/freshagent_claude_incarnation.rs index 7b3f69498..06b4c776b 100644 --- a/crates/freshell-ws/tests/freshagent_claude_incarnation.rs +++ b/crates/freshell-ws/tests/freshagent_claude_incarnation.rs @@ -10,12 +10,22 @@ use freshell_protocol::{AgentProvider, FreshAgentSend, SessionType}; use std::time::Duration; async fn accepted_owner(rig: &ClaudeRig, id: &str, request: &str) -> String { - let outcome = rig.fresh_claude.handle_send(FreshAgentSend { - provider: AgentProvider::Claude, session_id: id.into(), - session_type: SessionType::Freshclaude, text: "HANG".into(), - request_id: Some(request.into()), cwd: None, images: None, settings: None, - observed_epoch: None, observed_generation: None, checkpoint_intent: None, - }).await; + let outcome = rig + .fresh_claude + .handle_send(FreshAgentSend { + provider: AgentProvider::Claude, + session_id: id.into(), + session_type: SessionType::Freshclaude, + text: "HANG".into(), + request_id: Some(request.into()), + cwd: None, + images: None, + settings: None, + observed_epoch: None, + observed_generation: None, + checkpoint_intent: None, + }) + .await; match outcome { ClaudeSendOutcome::Accepted { ownership_id, .. } => { assert!(!ownership_id.is_empty()); @@ -27,26 +37,41 @@ async fn accepted_owner(rig: &ClaudeRig, id: &str, request: &str) -> String { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn an_ended_event_identifies_the_accepted_runtime_across_a_resume() { - let rig = ClaudeRig::start_with_options(RigKind::Sim, RigOptions { - durable_ledger: true, ..RigOptions::default() - }).await; + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; let mut conn = rig.connect().await; let pane = conn.create("create-incarnation").await; - let durable = rig.converse(&mut conn, &pane, &["make the transcript durable"]).await; + let durable = rig + .converse(&mut conn, &pane, &["make the transcript durable"]) + .await; let mut events = rig.fresh_claude.subscribe_turn_events(); let first_owner = accepted_owner(&rig, &durable, "first-incarnation").await; rig.kill_first_sidecar(&mut conn).await; conn.attach(&durable).await; let second_owner = accepted_owner(&rig, &durable, "second-incarnation").await; - assert_ne!(first_owner, second_owner, "a new process has a new ownership id"); + assert_ne!( + first_owner, second_owner, + "a new process has a new ownership id" + ); let ended_owner = tokio::time::timeout(Duration::from_secs(15), async { loop { if let ClaudeTurnEvent::Ended { ownership_id, .. } = events.recv().await.unwrap() { break ownership_id; } } - }).await.expect("the dead runtime publishes its end"); + }) + .await + .expect("the dead runtime publishes its end"); assert_eq!(ended_owner, first_owner); - assert_ne!(ended_owner, second_owner, "a delayed old end cannot match a new send"); + assert_ne!( + ended_owner, second_owner, + "a delayed old end cannot match a new send" + ); rig.fresh_claude.shutdown().await; } From 19440f3a4cf872449487896012af234625329cdc Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:53:17 -0700 Subject: [PATCH 010/100] feat(freshclaude): establish durable UUID identity before create acknowledgement --- crates/freshell-claude-sidecar/index.mjs | 4 +- crates/freshell-freshagent/src/claude.rs | 872 +++++++++--------- .../src/claude/create_binding.rs | 99 ++ .../src/claude/establish.rs | 108 ++- .../src/claude/tests/create_binding_tests.rs | 158 ++++ .../src/providers/fresh_agent.rs | 63 +- .../tests/freshagent_claude_identity.rs | 200 ++++ port/oracle/harness/invariants.ts | 12 +- .../fixtures/fake-kilroy-runtime.mjs | 4 +- .../helpers/harness-06/kilroy-runtime.test.ts | 7 +- test/unit/port/oracle/t2-invariants.test.ts | 16 +- 11 files changed, 1059 insertions(+), 484 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude/create_binding.rs create mode 100644 crates/freshell-freshagent/src/claude/tests/create_binding_tests.rs create mode 100644 crates/freshell-ws/tests/freshagent_claude_identity.rs diff --git a/crates/freshell-claude-sidecar/index.mjs b/crates/freshell-claude-sidecar/index.mjs index 626cc621d..9a43400f9 100644 --- a/crates/freshell-claude-sidecar/index.mjs +++ b/crates/freshell-claude-sidecar/index.mjs @@ -439,7 +439,9 @@ function handleCreate(req) { return } - const sessionId = nanoid() + // Rust assigns the durable identity before create. Retain the bare-id + // fallback for an older host that has not adopted create-time UUIDs yet. + const sessionId = isUuid(req.sessionId) ? req.sessionId : nanoid() const abort = new AbortController() const state = { abort, diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 98c1eefe4..b8764fc0a 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -95,14 +95,6 @@ fn checkpoint_attempt_for_send(msg: &FreshAgentSend) -> Option { })) } -/// b8ke ext r22 F1: the pane-scoped PROVISIONAL identity for a fresh -/// create's pre-spawn claim — the createRequestId the caller already -/// carries, namespaced so it can never collide with a provider-minted -/// session id. The claim under this key owns the whole spawn window -/// (watchdog state + cancellation + typed recovery); at the `created` -/// mint the SAME ticket rekeys to the client-visible session id -/// (rekey_starting) and the RAII drop settles the window record typed. -const PENDING_CLAUDE_CREATE_PREFIX: &str = "pending-create-"; /// The ownership tag env the sidecar + its claude CLI grandchild carry (the codex analog /// is `FRESHELL_CODEX_SIDECAR_ID`); the `/proc` reaper keys on it. const CLAUDE_SIDECAR_OWNERSHIP_ENV: &str = crate::claude_spawn::CLAUDE_SIDECAR_OWNERSHIP_ENV; @@ -1970,10 +1962,10 @@ impl FreshClaudeState { // ── freshAgent.create (WS) ─────────────────────────────────────────────────────── /// Handle a `freshAgent.create` for claude/kilroy: spawn the Node sidecar, drive the - /// SDK `create` to get the BARE nanoid placeholder (the sidecar answers `created` only - /// once the CLI booted the conversation; a refused boot is a typed `create.failed`), + /// SDK `create` under a durable UUID assigned before spawn (the sidecar answers + /// `created` only once the CLI booted; a refused boot is a typed `create.failed`), /// register the session + its stdout consumer, and broadcast `freshAgent.created` - /// (with `sessionRef` for a resume) or `freshAgent.create.failed`. + /// (with the same durable `sessionRef`) or `freshAgent.create.failed`. /// Long-running (cold sidecar spawn), so the WS loop dispatches this as a detached task. /// `provenance` (D8): the WS connection's stamped identity for this create /// (`None` on conn-less lanes); threaded to the `sdk.session.init` binding write. @@ -2042,11 +2034,19 @@ impl FreshClaudeState { .filter(|r| r.provider == PROVIDER) .map(|r| r.session_id.clone()) .filter(|s| !s.is_empty()) + }) + .or_else(|| { + self.identity_sink() + .and_then(|sink| sink.lookup_by_create_request_id(PROVIDER, &request_id)) }); - let mut lease_guard: Option = None; - // kata b8ke Task 3: the coordinator claim ticket (resume lane only — - // a fresh create has no canonical durable id until - // `sdk.session.init`, and the claim happens under the resume id). + // The durable id is known before spawning, including for a zero-turn + // conversation. A re-driven create resolves its persisted lineage. + let launch_id = resume_sid + .clone() + .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()); + let mut lease_guard: Option; + // Every create owns its durable UUID from before spawn. The ticket + // commits only after the runtime and its binding are established. // Hoisted to this scope so the commit at the registration tail can // consume it; every failure return drops it (RAII typed fail). let mut own_ticket: Option = None; @@ -2060,7 +2060,8 @@ impl FreshClaudeState { // end. let start_pid_slot = crate::ownership_lane::sidecar_pid_cancel_slot(); let mut _start_cancellation: Option = None; - if let Some(sid) = resume_sid.as_deref() { + { + let sid = launch_id.as_str(); // Task 13b (cross-kind liveness): a live terminal PTY owning // `(claude, sid)` is the one writer on that JSONL -- refuse the // resume with the retryable loser answer (the terminal may be @@ -2242,77 +2243,6 @@ impl FreshClaudeState { } } - // b8ke ext r22 F1: the FRESH create (no resume id) is - // coordinator-owned from BEFORE spawn too — the pane-scoped - // provisional claim under the createRequestId the caller already - // carries (the same pre-spawn discipline as the resume arm above: - // the cancellation handle arms with the ticket, the settle guard is - // held to this handler's end, the partial runtime registers once - // the child exists). The whole spawn window then has a Starting - // generation, watchdog state, and the typed recovery record; at - // the `created` mint the SAME ticket rekeys to the client-visible - // session id (fencing it through this handler's tail — the - // canonical Live commit stays with the consumer's - // `sdk.session.init` adoption under the CLI session id, the - // existing two-stage identity discipline). - let fresh_claim_key: Option = if resume_sid.is_none() { - let provisional_id = format!("{PENDING_CLAUDE_CREATE_PREFIX}{request_id}"); - let claim_op_id = format!("create-{request_id}"); - let claim_key = self.resolve_ownership_key(&provisional_id); - match self.begin_lane_claim_at( - &claim_key, - &claim_op_id, - None, - &Self::initiator_for(provenance.as_ref(), "freshclaude/create"), - ) { - crate::ownership_lane::LaneClaim::Granted(ticket) => { - own_ticket = Some(ticket); - _start_cancellation = Some( - crate::ownership_lane::register_start_cancellation_for_ticket( - &self.ownership, - PROVIDER, - &claim_key, - &own_ticket, - crate::ownership_lane::pid_slot_cancellation(&start_pid_slot), - ), - ); - Some(claim_key) - } - crate::ownership_lane::LaneClaim::Unwired => None, - crate::ownership_lane::LaneClaim::Adopt => { - // The pane-scoped key resolves to a LIVE same-kind - // owner — an earlier create for this pane completed - // (the key is Aliased{to: the live session} after its - // mint-time rekey); the idempotent re-drive ADOPTS the - // live session, spawning nothing. - return self - .adopt_live_create(&request_id, &claim_key, session_type) - .await; - } - crate::ownership_lane::LaneClaim::Refused(outcome) => { - // A duplicate create for this pane is still in flight - // (the pane-scoped key's Starting record) — the typed - // refusal. - tracing::warn!(target: "freshell_freshagent::claude", - request_id = %request_id, provisional_id = %provisional_id, - outcome = ?outcome, - "fresh_agent_create_refused: the ownership coordinator refused \ - the fresh create's pane-scoped pre-spawn claim (kata b8ke r22 F1)" - ); - let owner_fields = crate::ownership_lane::terminal_owner_fields_from_outcome( - &self.ownership, - &outcome, - ); - return self.fail_create_session_reserved_with_owner( - &request_id, - owner_fields.as_ref(), - ); - } - } - } else { - None - }; - // Retire-on-kill round 2/3 (focused-ep5-r1 Finding 2, -r2 Findings // 4+5): an explicit create-with-resume GENUINELY CLAIMS the durable // identity — but the claim COMMITS only in `establish_runtime`, once the @@ -2328,9 +2258,7 @@ impl FreshClaudeState { // before the spawn — so a kill that lands while the resume awaits // `created` advances the durable tombstone past it and the commit in // `establish_runtime` REFUSES instead of undoing the newer close. - let claim_dead_state = resume_sid - .as_deref() - .and_then(|sid| self.claim_dead_state_snapshot(sid)); + let claim_dead_state = self.claim_dead_state_snapshot(&launch_id); // Which conversation the resume boots (design §4.2, §4.3): a session // that has no transcript yet boots from the genesis its binding row @@ -2373,8 +2301,10 @@ impl FreshClaudeState { None => ( ClaudeBoot::resume_or_fresh(resume_sid.as_deref()), msg.cwd.clone(), - None, - None, + Some(launch_id.clone()), + resume_sid + .is_none() + .then_some(crate::claude_launch::ClaudeGenesis::Fresh), None, ), }; @@ -2399,7 +2329,7 @@ impl FreshClaudeState { &settings, self.hosted_mcp, ) - .with_session_id(boot_session_id) + .with_session_id(boot_session_id.or_else(|| Some(launch_id.clone()))) .with_model_selection(serde_json::to_value(&msg.model_selection).ok()) .with_freshell_context( crate::claude_freshell_mcp::ClaudeFreshellContext::from_ids( @@ -2416,18 +2346,18 @@ impl FreshClaudeState { EstablishPlan { lane: EstablishLane::Create, spec, - register_under: RegisterUnder::Minted, - claim: resume_sid.clone().map(|durable| DurableClaim { - durable, + register_under: RegisterUnder::Adopted(launch_id.clone()), + claim: Some(DurableClaim { + durable: launch_id.clone(), dead_state: claim_dead_state, }), - rekey_provisional_claim: fresh_claim_key.is_some(), + rekey_provisional_claim: false, recorded_settings: Some(settings), owning_operation, owning_generation: None, provenance, naming: Some(&msg), - partial_runtime_key: resume_sid.clone().or_else(|| fresh_claim_key.clone()), + partial_runtime_key: Some(launch_id.clone()), start_pid_slot: &start_pid_slot, inherit: None, genesis: boot_genesis, @@ -2461,7 +2391,7 @@ impl FreshClaudeState { // only after that boot, so the caller learns the native identity here // instead of waiting for a turn's init (design §6, §7.3). A fresh create // has no durable id until its first turn, so it carries none. - let session_ref = resume_sid.as_deref().map(claude_session_ref); + let session_ref = Some(claude_session_ref(&launch_id)); self.create_dedup .record_success( &request_id, @@ -3360,12 +3290,20 @@ impl FreshClaudeState { // b8ke focused round-2 review R2-2: the teardown's typed outcome // decides the stop commit AND the broadcast truth — an unconfirmed // tree is never a silent success. + let closed_runtime = removed.as_ref().map(|session| session.ownership_id.clone()); let teardown = if let Some(session) = removed { self.teardown_removed_session_logged(session).await } else { LoggedTeardown::Confirmed }; + let teardown = self.release_leases_after_teardown( + teardown, + retire_ids.clone(), + map_key.clone(), + closed_runtime, + ); + // Explicit kill evicts this session's requestId dedup cache entries (mirrors // `clearFreshAgentCreateCachesForSession`) -- a later duplicate `create` for the // same requestId must genuinely mint a fresh session, not replay the one just @@ -3854,12 +3792,20 @@ impl FreshClaudeState { let Some(sink) = self.identity_sink() else { return true; }; + // A create registers directly under its durable UUID. That key's + // existing close is compared with the captured claim snapshot below; + // only distinct legacy pane aliases use the unconditional seat fence. + let through_placeholders: Vec = through_placeholders + .iter() + .filter(|id| id.as_str() != durable_id) + .cloned() + .collect(); match sink .commit_claim_aliased( PROVIDER, durable_id, expect_killed_at_ms, - through_placeholders, + &through_placeholders, ) .await { @@ -4041,14 +3987,14 @@ impl FreshClaudeState { true } }); - for durable in &demoted { + for durable in demoted.iter().filter(|durable| durable.as_str() != map_key) { tombstones.record(map_key, durable, now, &row_bound); } demoted }; if !demoted.is_empty() { if let Some(sink) = self.identity_sink() { - for durable in &demoted { + for durable in demoted.iter().filter(|durable| durable.as_str() != map_key) { if let Err(e) = sink .record_alias_tombstone(PROVIDER, map_key, durable, now) .await @@ -6471,7 +6417,7 @@ impl FreshClaudeState { // reaches the post-restart kill naming this pane's bare placeholder. // Best-effort: warn-loud on failure, never a lane blocker (the // in-memory store still answers this process's kills). - if let Some(sink) = &identity_sink { + if let Some(sink) = identity_sink.as_ref().filter(|_| session_id != cli_id) { if let Err(e) = sink .record_alias_tombstone( PROVIDER, @@ -6638,6 +6584,7 @@ impl FreshClaudeState { // (rollback replacement, attach-resume) — the ledger merge keeps prior // stamps. provenance: Option, + create_binding_expected: bool, ) -> tokio::task::JoinHandle<()> { let broadcast_tx = self.broadcast_tx.clone(); let sessions = self.sessions.clone(); @@ -6836,27 +6783,40 @@ impl FreshClaudeState { // entry may not exist yet during create). if value.get("type").and_then(Value::as_str) == Some("sdk.session.init") { if let Some(cli_id) = value.get("cliSessionId").and_then(Value::as_str) { - let current_settings = { + let (current_settings, known_identity) = { let mut guard = sessions.lock().await; guard .get_mut(&session_id) .map(|session| { session.configuration.runtime_cwd = value["cwd"].as_str().map(str::to_string); - session.configuration.settings.clone() + ( + Some(session.configuration.settings.clone()), + session.cli_session_id.as_deref() == Some(cli_id), + ) }) - .or_else(|| settings.clone()) + .unwrap_or_else(|| (settings.clone(), false)) }; - state - .adopt_session_init( - cli_id, - &session_id, - &session_type, - current_settings.as_ref(), - identity_sink.clone(), - provenance.as_ref(), - ) - .await; + if known_identity && create_binding_expected { + // Create/establish already bound this exact durable + // identity. Init verifies it; no second adoption or + // lineage rewrite is needed. A retained naming bind + // may still retry once native persistence appears. + state + .bind_naming_handle_at_init(cli_id, &session_id, &session_type) + .await; + } else { + state + .adopt_session_init( + cli_id, + &session_id, + &session_type, + current_settings.as_ref(), + identity_sink.clone(), + provenance.as_ref(), + ) + .await; + } } } // Task 10b: stamp the envelope from the SHARED handle (not the captured @@ -8164,6 +8124,7 @@ fn log_claude_teardown( } } +mod create_binding; mod create_outcome; pub use create_outcome::ClaudeCreateOutcome; @@ -8224,6 +8185,8 @@ pub(crate) mod tests { use super::*; use crate::naming::SessionNaming as _; + mod create_binding_tests; + fn state() -> FreshClaudeState { let (tx, _rx) = tokio::sync::broadcast::channel::(64); FreshClaudeState::new(Arc::new(tx)) @@ -9545,10 +9508,10 @@ rl.on('line', (line) => { if (spawnLog) { fs.appendFileSync(spawnLog, `${JSON.stringify(msg)}\n`) } - if (failCreate) process.exit(1) const answer = () => { + if (failCreate) process.exit(1) counter += 1 - const sessionId = `fake-claude-session-${process.pid}-${counter}` + const sessionId = msg.sessionId ?? `fake-claude-session-${process.pid}-${counter}` liveSessions.add(sessionId) configuredSettings.set(sessionId, { model: msg.model, effort: msg.effort, permissionMode: msg.permissionMode, cwd: msg.cwd }) process.stdout.write(JSON.stringify({ type: 'created', sessionId, protocol: 2 }) + '\n') @@ -9709,6 +9672,21 @@ rl.on('line', (line) => { &self.spawn_log } + async fn create_request(&self) -> Value { + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + let text = std::fs::read_to_string(self.spawn_log_path()).unwrap_or_default(); + if let Some(line) = text.lines().next() { + return serde_json::from_str(line).expect("logged create JSON"); + } + assert!( + tokio::time::Instant::now() < deadline, + "create never reached sidecar" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + /// Number of times the fake sidecar has been spawned so far (one marker line per /// process start). fn spawn_count(&self) -> usize { @@ -10363,8 +10341,14 @@ rl.on('line', (line) => { .expect("the created session row") .cli_session_id = None; + let legacy_id = "legacy-name-placeholder"; + state + .cli_index + .lock() + .await + .insert(legacy_id.into(), session_id.clone()); state - .handle_send(send_msg(&session_id, "Race the adoption")) + .handle_send(send_msg(legacy_id, "Race the adoption")) .await; let _ = env.respond_log_frames(1).await; @@ -10377,7 +10361,7 @@ rl.on('line', (line) => { assert_eq!( activities[0].target, freshell_protocol::session_names::SessionNameRef::Pending { - id: session_id.clone() + id: legacy_id.into() } ); assert_eq!(activities[0].mode, "freshclaude"); @@ -10681,10 +10665,10 @@ rl.on('line', (line) => { let mut create = dedup_create_msg("local-snapshot-owner"); create.session_type = kind; state.handle_create(create, None).await; - let created = - await_claude_created_and_session_init(&mut rx, "local-snapshot-owner").await; + let created = await_claude_created(&mut rx, "local-snapshot-owner").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let presentation = created["sessionId"].as_str().unwrap(); - let native = FRESH_CREATE_DURABLE_ID; + let native = fresh_create_durable_id; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); while state.local_snapshot_owner(native).await.is_none() { assert!( @@ -10714,6 +10698,43 @@ rl.on('line', (line) => { assert_eq!(env.spawn_count(), 2); } + /// Model a session registered by the older host before it knew its + /// native UUID. Only compatibility tests call this; current creates are + /// already durably owned and never take this path. + async fn prepare_legacy_init(st: &FreshClaudeState, map_key: &str) { + let durable = { + let mut sessions = st.sessions.lock().await; + let session = sessions.get_mut(map_key).expect("legacy fixture runtime"); + session.owning_operation = None; + session.owning_generation = None; + session.cli_session_id.take() + }; + if let Some(durable) = durable { + st.cli_index.lock().await.remove(&durable); + crate::ownership_lane::release_retained_stamp( + &st.ownership, + &st.ownership_stamps, + PROVIDER, + &durable, + "test/legacy-init-fixture", + ); + } + } + + async fn adopt_legacy_fixture(st: &FreshClaudeState, map_key: &str, durable: &str) { + prepare_legacy_init(st, map_key).await; + st.adopt_session_init( + durable, + map_key, + "freshclaude", + None, + st.identity_sink(), + None, + ) + .await; + await_alias(st, durable, map_key).await; + } + /// Drain `rx` until the `freshAgent.created` (or `.create.failed`) frame for /// `request_id` arrives (mirrors codex's `await_created`). async fn await_claude_created( @@ -10743,68 +10764,6 @@ rl.on('line', (line) => { .unwrap_or_else(|_| panic!("freshAgent.created for {request_id} resolves within budget")) } - /// Drain `rx` until BOTH the `freshAgent.created` frame for `request_id` AND that - /// session's `freshAgent.session.init` event have arrived, in EITHER order; returns - /// the created frame. - /// - /// Not [`await_claude_created`] followed by a second drain for the init event: - /// `handle_create` starts the stdout consumer BEFORE it registers the session and - /// broadcasts `freshAgent.created`, and the fake sidecar prints `sdk.session.init` - /// in the same burst as its `created` answer. On a multi-thread runtime another - /// worker can run the consumer while the handler is still between those two points, - /// so the init event can reach the bus FIRST — a created-only drain would discard it - /// and the follow-up drain would wait out its budget for a frame already consumed. - async fn await_claude_created_and_session_init( - rx: &mut tokio::sync::broadcast::Receiver, - request_id: &str, - ) -> Value { - tokio::time::timeout(std::time::Duration::from_secs(15), async { - let mut created: Option = None; - let mut init_session_ids: Vec = Vec::new(); - loop { - let frame: Value = match rx.recv().await { - // Under host load the bounded drain can fall behind the - // 64-frame bus: re-sync and keep waiting (the 15s budget - // stays the dead-man switch); `Closed` surfaces through - // the same deadline as a lost sender. - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, - Err(err) => panic!("broadcast recv failed: {err}"), - Ok(raw) => serde_json::from_str(&raw).unwrap(), - }; - if frame["requestId"] == request_id { - assert_ne!( - frame["type"], "freshAgent.create.failed", - "create for {request_id} failed: {frame}" - ); - if frame["type"] == "freshAgent.created" { - created = Some(frame); - } - } else if frame["type"] == "freshAgent.event" - && frame["event"]["type"] == "freshAgent.session.init" - { - if let Some(sid) = frame["sessionId"].as_str() { - init_session_ids.push(sid.to_string()); - } - } - if let Some(created) = &created { - if init_session_ids - .iter() - .any(|sid| created["sessionId"] == sid.as_str()) - { - return created.clone(); - } - } - } - }) - .await - .unwrap_or_else(|_| { - panic!( - "freshAgent.created and freshAgent.session.init for {request_id} \ - arrive within budget" - ) - }) - } - /// Await the create's `sdk.session.init` binding row in the test's /// FakeIdentitySink — the ROW BARRIER of the combined drain. /// @@ -10825,6 +10784,7 @@ rl.on('line', (line) => { /// budget stays a dead-man switch, never a patience raise. async fn await_claude_session_init_binding_row( fake: &std::sync::Arc, + durable_id: &str, ) -> crate::identity_sink::FreshAgentBindingUpsert { let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { @@ -10833,7 +10793,7 @@ rl.on('line', (line) => { bindings .iter() .rev() - .find(|b| b.provider == "claude" && b.session_id == FRESH_CREATE_DURABLE_ID) + .find(|b| b.provider == "claude" && b.session_id == durable_id) .cloned() }; if let Some(row) = row { @@ -10841,7 +10801,7 @@ rl.on('line', (line) => { } assert!( tokio::time::Instant::now() < deadline, - "the session-init binding row (claude, FRESH_CREATE_DURABLE_ID) \ + "the create binding row (claude, {durable_id}) \ never landed within budget" ); tokio::time::sleep(std::time::Duration::from_millis(10)).await; @@ -11034,11 +10994,10 @@ rl.on('line', (line) => { #[tokio::test(flavor = "multi_thread")] async fn a_fresh_claude_create_window_holds_a_coordinator_starting_record() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let _env = FakeClaudeSidecarEnv::install_with_knobs(Some(2_000), false); + let env = FakeClaudeSidecarEnv::install_with_knobs(Some(2_000), false); let (mut st, mut rx) = state_with_bus(); let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); st.set_ownership(registry.clone()); - let provisional = "pending-create-req-r22-claude-window"; let task_st = st.clone(); let create = tokio::spawn(async move { @@ -11047,19 +11006,22 @@ rl.on('line', (line) => { .await; }); - // THE WINDOW: the provisional record is Starting{FreshAgent} while + let launch = env.create_request().await; + let durable = launch["sessionId"].as_str().unwrap(); + assert!(uuid::Uuid::parse_str(durable).is_ok()); + // THE WINDOW: the durable record is Starting{FreshAgent} while // the create is held open (red pre-r22: no record at all). let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( - registry.observe(PROVIDER, provisional).state, + registry.observe(PROVIDER, durable).state, freshell_ownership::OwnershipState::Starting { .. } ) { break; } assert!( tokio::time::Instant::now() < deadline, - "the fresh claude create's window never showed the provisional \ + "the fresh claude create's window never showed the durable \ Starting record (r22 F1)" ); tokio::time::sleep(std::time::Duration::from_millis(10)).await; @@ -11069,7 +11031,7 @@ rl.on('line', (line) => { assert!(matches!( registry.begin_start( PROVIDER, - provisional, + durable, freshell_ownership::RuntimeOwnerKind::Terminal, "op-r22-claude-compete", None, @@ -11080,19 +11042,22 @@ rl.on('line', (line) => { )); // Let the create finish: the created answer arrives and the SAME - // ticket rekeys to the minted session id (the provisional key + // ticket rekeys to the minted session id (the durable key // becomes the resolution alias). create.await.expect("the create task joins"); let created = await_claude_created(&mut rx, "req-r22-claude-window").await; let session_id = created["sessionId"].as_str().unwrap().to_string(); - assert!( - matches!( - registry.observe(PROVIDER, provisional).state, - freshell_ownership::OwnershipState::Aliased { to, .. } if to == session_id, - ), - "the provisional key is the rekey family's resolution alias — got {:?}", - registry.observe(PROVIDER, provisional).state - ); + assert_eq!(session_id, durable); + assert!(matches!( + registry.observe(PROVIDER, durable).state, + freshell_ownership::OwnershipState::Live { .. } + )); + assert!(matches!( + registry + .observe(PROVIDER, "pending-create-req-r22-claude-window") + .state, + freshell_ownership::OwnershipState::Vacant + )); } /// A fresh create whose pane-scoped claim the coordinator refuses (another @@ -11105,22 +11070,11 @@ rl.on('line', (line) => { let (mut st, mut rx) = state_with_bus(); let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); st.set_ownership(registry.clone()); - assert!(matches!( - registry.begin_start( - PROVIDER, - "pending-create-req-refused-claim", - freshell_ownership::RuntimeOwnerKind::Terminal, - "op-holding-the-pane-key", - None, - "test", - freshell_ownership::now_epoch_ms(), - ), - freshell_ownership::BeginOutcome::Granted { .. } - )); + let mut msg = dedup_create_msg("req-refused-claim"); + msg.observed_epoch = Some(registry.boot_epoch() + 1); + msg.observed_generation = Some(0); - let outcome = st - .handle_create(dedup_create_msg("req-refused-claim"), None) - .await; + let outcome = st.handle_create(msg, None).await; let mut answers = Vec::new(); while let Ok(frame) = rx.try_recv() { @@ -11161,7 +11115,7 @@ rl.on('line', (line) => { fresh, ClaudeCreateOutcome::Created { session_id: frame["sessionId"].as_str().unwrap().to_string(), - session_ref: None, + session_ref: Some(claude_session_ref(frame["sessionId"].as_str().unwrap())), } ); @@ -11186,15 +11140,14 @@ rl.on('line', (line) => { /// ticket — the record is never left wedged in Starting and the create /// answers the typed failure. #[tokio::test(flavor = "multi_thread")] - async fn a_fresh_claude_create_spawn_failure_settles_the_provisional_record() { + async fn a_fresh_claude_create_spawn_failure_settles_the_durable_record() { let _guard = CLAUDE_ENV_LOCK.lock().await; // The FAIL knob: the sidecar dies before `created` (a spawn-time // provider rejection — EOF). - let _env = FakeClaudeSidecarEnv::install_with_knobs(None, true); + let env = FakeClaudeSidecarEnv::install_with_knobs(Some(1500), true); let (mut st, mut rx) = state_with_bus(); let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); st.set_ownership(registry.clone()); - let provisional = "pending-create-req-r22-claude-fail"; let task_st = st.clone(); let create = tokio::spawn(async move { @@ -11203,24 +11156,26 @@ rl.on('line', (line) => { .await; }); + let launch = env.create_request().await; + let durable = launch["sessionId"].as_str().unwrap(); // The window's record exists before the failure lands. let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( - registry.observe(PROVIDER, provisional).state, + registry.observe(PROVIDER, durable).state, freshell_ownership::OwnershipState::Starting { .. } ) { break; } assert!( tokio::time::Instant::now() < deadline, - "the spawn-failure create never showed the provisional record" + "the spawn-failure create never showed the durable record" ); tokio::time::sleep(std::time::Duration::from_millis(10)).await; } // The failure lands: the ticket's RAII typed fail settles the - // provisional record — never a wedged Starting. + // durable record — never a wedged Starting. create.await.expect("the create task joins"); let mut failed_code = None; while let Ok(frame) = rx.try_recv() { @@ -11238,11 +11193,11 @@ rl.on('line', (line) => { ); assert!( matches!( - registry.observe(PROVIDER, provisional).state, + registry.observe(PROVIDER, durable).state, freshell_ownership::OwnershipState::Vacant ), - "the provisional record settles typed (Vacant) — got {:?}", - registry.observe(PROVIDER, provisional).state + "the durable record settles typed (Vacant) — got {:?}", + registry.observe(PROVIDER, durable).state ); } @@ -11251,13 +11206,14 @@ rl.on('line', (line) => { // handoff caller's bounded retry of that transient Blocked outcome. async fn begin_test_terminal_handoff( registry: &freshell_ownership::RuntimeOwnershipRegistry, + durable_id: &str, operation_id: &str, ) -> u64 { let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { match registry.begin_handoff( "claude", - FRESH_CREATE_DURABLE_ID, + durable_id, freshell_ownership::RuntimeOwnerKind::Terminal, operation_id, None, @@ -11332,12 +11288,13 @@ rl.on('line', (line) => { create_f1.model = Some("opus".into()); st.handle_create(create_f1, None).await; let created = await_claude_created(&mut rx, "req-d3-f1").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); // The adoption publishes + commits ownership under the durable id // (non-default settings make the binding RECORDABLE so the // ledger-bound assertions are non-vacuous). let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - while !sink.was_recorded("claude", FRESH_CREATE_DURABLE_ID) { + while !sink.was_recorded("claude", fresh_create_durable_id) { assert!( tokio::time::Instant::now() < deadline, "the create's adoption never recorded the binding" @@ -11346,8 +11303,12 @@ rl.on('line', (line) => { } // THE REFUSAL WINDOW: the handoff owns the key's transition. - let _handoff_generation = - begin_test_terminal_handoff(®istry, "handoff-blocking-d3-f1").await; + let _handoff_generation = begin_test_terminal_handoff( + ®istry, + fresh_create_durable_id, + "handoff-blocking-d3-f1", + ) + .await; st.handle_kill(kill_msg(&placeholder)).await; @@ -11369,7 +11330,7 @@ rl.on('line', (line) => { // row was never closed (pre-d3 the close retired it before the // claim). The load-settings path still resolves the record. assert!( - sink.was_recorded("claude", FRESH_CREATE_DURABLE_ID), + sink.was_recorded("claude", fresh_create_durable_id), "the durable ledger row stays recorded (bound) on refusal" ); // THE deterministic closed-vs-bound probe: the fake's retire log is @@ -11380,14 +11341,14 @@ rl.on('line', (line) => { .lock() .unwrap() .iter() - .all(|(_, id)| id != FRESH_CREATE_DURABLE_ID), + .all(|(_, id)| id != fresh_create_durable_id), "the durable ledger row was NOT closed on refusal — the retire \ log must not name it" ); // And the runtime is still live in the map (has_live_session keys // through cli_index — the DURABLE id is the index's entry). assert!( - st.has_live_session(FRESH_CREATE_DURABLE_ID).await, + st.has_live_session(fresh_create_durable_id).await, "the refused kill left the session live" ); } @@ -11412,14 +11373,20 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-e3r1-f1"), None) .await; let created = await_claude_created(&mut rx, "req-e3r1-f1").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); - await_create_ownership_committed(&st, FRESH_CREATE_DURABLE_ID).await; + await_create_ownership_committed(&st, fresh_create_durable_id).await; // THE COMPLETED HANDOFF (simulated at the coordinator level): the // stamp is consumed and a TERMINAL owner is committed under the // durable id. - crate::ownership_lane::take_retained_stamp(&st.ownership_stamps, FRESH_CREATE_DURABLE_ID); - let generation = begin_test_terminal_handoff(®istry, "handoff-completed-e3r1").await; + crate::ownership_lane::take_retained_stamp(&st.ownership_stamps, fresh_create_durable_id); + let generation = begin_test_terminal_handoff( + ®istry, + fresh_create_durable_id, + "handoff-completed-e3r1", + ) + .await; let terminal_owner = freshell_ownership::OwnerIdentity { kind: freshell_ownership::RuntimeOwnerKind::Terminal, terminal_id: Some("t-replacement".into()), @@ -11430,7 +11397,7 @@ rl.on('line', (line) => { assert!(matches!( registry.commit_live( "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, "handoff-completed-e3r1", generation, terminal_owner.clone(), @@ -11450,7 +11417,7 @@ rl.on('line', (line) => { ); // The replacement TERMINAL owner is untouched — the coordinator // still holds it Live (never fabricated into a Vacant commit). - match registry.observe("claude", FRESH_CREATE_DURABLE_ID).state { + match registry.observe("claude", fresh_create_durable_id).state { freshell_ownership::OwnershipState::Live { owner, .. } => { assert_eq!(owner.kind, freshell_ownership::RuntimeOwnerKind::Terminal); assert_eq!(owner.terminal_id.as_deref(), Some("t-replacement")); @@ -11482,19 +11449,20 @@ rl.on('line', (line) => { create.model = Some("opus".into()); st.handle_create(create, None).await; let created = await_claude_created(&mut rx, "req-e3r1-f2").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); - await_create_adoption_committed(&st, &sink, FRESH_CREATE_DURABLE_ID).await; + await_create_adoption_committed(&st, &sink, fresh_create_durable_id).await; assert!( crate::ownership_lane::peek_retained_stamp( &st.ownership_stamps, - FRESH_CREATE_DURABLE_ID + fresh_create_durable_id ) .is_some(), "the create's commit retained the release stamp" ); // The clean close failure (identity-conditional on the durable id). - sink.fail_retires_for("claude", FRESH_CREATE_DURABLE_ID); + sink.fail_retires_for("claude", fresh_create_durable_id); st.handle_kill(kill_msg(&placeholder)).await; let frame = await_specific_frame(&mut rx, "freshAgent.killed", &placeholder).await; @@ -11505,14 +11473,14 @@ rl.on('line', (line) => { ); // THE ABORT restored the registry to Live{FreshAgent} ... assert!(matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Live { .. } )); // ... AND the stamp is back (pre-e3r1: consumed forever). assert!( crate::ownership_lane::peek_retained_stamp( &st.ownership_stamps, - FRESH_CREATE_DURABLE_ID + fresh_create_durable_id ) .is_some(), "the abort RESTORED the consumed release stamp — the exit watcher \ @@ -11524,12 +11492,12 @@ rl.on('line', (line) => { &Some(Arc::clone(®istry)), &st.ownership_stamps, "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, "claude-exit", ); assert!( matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Vacant ), "the natural exit released the restored stamp — the owner is \ @@ -11559,18 +11527,19 @@ rl.on('line', (line) => { create.model = Some("opus".into()); st.handle_create(create, None).await; let created = await_claude_created(&mut rx, "req-e3r3-f8").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); - await_create_adoption_committed(&st, &sink, FRESH_CREATE_DURABLE_ID).await; + await_create_adoption_committed(&st, &sink, fresh_create_durable_id).await; let stamp = crate::ownership_lane::peek_retained_stamp( &st.ownership_stamps, - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, ) .expect("the create's stamp"); let runtime_pid = stamp.owner.pid.expect("the sidecar pid"); // Arm the close failure + the pre-restore park; the kill parks // AFTER its granted take (the stamp is consumed, the sidecar LIVE). - sink.fail_retires_for("claude", FRESH_CREATE_DURABLE_ID); + sink.fail_retires_for("claude", fresh_create_durable_id); let pause = std::sync::Arc::new(AdoptionTestPause { notify: tokio::sync::Notify::new(), reached: std::sync::atomic::AtomicBool::new(false), @@ -11624,7 +11593,7 @@ rl.on('line', (line) => { ); assert!( matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Vacant ), "the dead sidecar ends VACANT — the registry followed the same \ @@ -11633,7 +11602,7 @@ rl.on('line', (line) => { assert!( crate::ownership_lane::peek_retained_stamp( &st.ownership_stamps, - FRESH_CREATE_DURABLE_ID + fresh_create_durable_id ) .is_none(), "the stamp is NOT restored for the dead sidecar (the atomic \ @@ -11663,16 +11632,17 @@ rl.on('line', (line) => { create.model = Some("opus".into()); st.handle_create(create, None).await; let created = await_claude_created(&mut rx, "req-e3r4-f1").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); - await_create_adoption_committed(&st, &sink, FRESH_CREATE_DURABLE_ID).await; + await_create_adoption_committed(&st, &sink, fresh_create_durable_id).await; // THE NO-STAMP SHAPE: consume the retained stamp (the handoff's // kill_for_handoff consumption) — the kill runs the no-stamp // fallback with a LIVE owner observed. - crate::ownership_lane::take_retained_stamp(&st.ownership_stamps, FRESH_CREATE_DURABLE_ID); + crate::ownership_lane::take_retained_stamp(&st.ownership_stamps, fresh_create_durable_id); // The clean close failure. - sink.fail_retires_for("claude", FRESH_CREATE_DURABLE_ID); + sink.fail_retires_for("claude", fresh_create_durable_id); st.handle_kill(kill_msg(&placeholder)).await; let frame = await_specific_frame(&mut rx, "freshAgent.killed", &placeholder).await; assert_eq!( @@ -11687,7 +11657,7 @@ rl.on('line', (line) => { // key vacated, licensing a second writer). assert!( matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Live { owner, .. } if owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent, ), @@ -11696,7 +11666,7 @@ rl.on('line', (line) => { ); // And the session stays live in the lane. assert!( - st.has_live_session(FRESH_CREATE_DURABLE_ID).await, + st.has_live_session(fresh_create_durable_id).await, "the runtime still runs — nothing was killed" ); @@ -11710,7 +11680,7 @@ rl.on('line', (line) => { { let stamp = crate::ownership_lane::peek_retained_stamp( &st.ownership_stamps, - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, ) .expect("the recovered stamp (the original identity)"); // Kill the sidecar so the exit shape is real. @@ -11735,12 +11705,12 @@ rl.on('line', (line) => { &Some(Arc::clone(®istry)), &st.ownership_stamps, "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, "claude-exit", ); assert!( matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Vacant ), "the natural exit RELEASES the key — the recovered stamp \ @@ -11771,13 +11741,19 @@ rl.on('line', (line) => { create.model = Some("opus".into()); st.handle_create(create, None).await; let created = await_claude_created(&mut rx, "req-e3r2-f1").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); - await_create_adoption_committed(&st, &sink, FRESH_CREATE_DURABLE_ID).await; + await_create_adoption_committed(&st, &sink, fresh_create_durable_id).await; // THE MID-HANDOFF WINDOW: kill_for_handoff consumed the stamp // while the coordinator record stays Handoff. - crate::ownership_lane::take_retained_stamp(&st.ownership_stamps, FRESH_CREATE_DURABLE_ID); - let generation = begin_test_terminal_handoff(®istry, "handoff-in-flight-e3r2").await; + crate::ownership_lane::take_retained_stamp(&st.ownership_stamps, fresh_create_durable_id); + let generation = begin_test_terminal_handoff( + ®istry, + fresh_create_durable_id, + "handoff-in-flight-e3r2", + ) + .await; // THE CONCURRENT KILL: typed refusal, NOTHING durable. st.handle_kill(kill_msg(&placeholder)).await; @@ -11793,11 +11769,11 @@ rl.on('line', (line) => { .lock() .unwrap() .iter() - .all(|(_, id)| id != FRESH_CREATE_DURABLE_ID), + .all(|(_, id)| id != fresh_create_durable_id), "NOTHING durable closed — the ledger row stays bound" ); assert!( - st.has_live_session(FRESH_CREATE_DURABLE_ID).await, + st.has_live_session(fresh_create_durable_id).await, "the session stays live for the handoff" ); @@ -11813,7 +11789,7 @@ rl.on('line', (line) => { assert!(matches!( registry.commit_live( "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, "handoff-in-flight-e3r2", generation, terminal_owner, @@ -11841,11 +11817,12 @@ rl.on('line', (line) => { create.model = Some("opus".into()); st.handle_create(create, None).await; let created = await_claude_created(&mut rx, "req-e3r2-f2").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); while crate::ownership_lane::peek_retained_stamp( &st.ownership_stamps, - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, ) .is_none() { @@ -11881,7 +11858,7 @@ rl.on('line', (line) => { // (its registry release no-ops against our Stopping). let raced_stamp = crate::ownership_lane::take_retained_stamp( &st.ownership_stamps, - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, ); assert!(raced_stamp.is_some(), "the stamp existed at the park"); @@ -11903,7 +11880,7 @@ rl.on('line', (line) => { "the kill completed coherently under the held claim: {frame}" ); assert!(matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Vacant )); } @@ -11927,14 +11904,15 @@ rl.on('line', (line) => { create.model = Some("opus".into()); st.handle_create(create, None).await; let created = await_claude_created(&mut rx, "req-e3r2-f4").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); - await_create_adoption_committed(&st, &sink, FRESH_CREATE_DURABLE_ID).await; + await_create_adoption_committed(&st, &sink, fresh_create_durable_id).await; // THE RUNTIME EXITS (the sidecar's recorded pid is killed outright // — it is dead by the time the kill's unwind verdict runs). let stamp = crate::ownership_lane::peek_retained_stamp( &st.ownership_stamps, - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, ) .expect("the create's stamp"); let runtime_pid = stamp.owner.pid.expect("the sidecar pid"); @@ -11959,7 +11937,7 @@ rl.on('line', (line) => { } // The clean close failure → the unwind's verdict: DEAD → Vacant. - sink.fail_retires_for("claude", FRESH_CREATE_DURABLE_ID); + sink.fail_retires_for("claude", fresh_create_durable_id); st.handle_kill(kill_msg(&placeholder)).await; let frame = await_specific_frame(&mut rx, "freshAgent.killed", &placeholder).await; assert_eq!( @@ -11971,7 +11949,7 @@ rl.on('line', (line) => { // restored a DEAD runtime as Live with no future watcher). assert!( matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Vacant ), "the exited runtime ends VACANT — never resurrected as Live" @@ -11981,7 +11959,7 @@ rl.on('line', (line) => { assert!( crate::ownership_lane::peek_retained_stamp( &st.ownership_stamps, - FRESH_CREATE_DURABLE_ID + fresh_create_durable_id ) .is_none(), "no release stamp is restored for a dead runtime" @@ -12004,20 +11982,21 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-e3r1-f3"), None) .await; let created = await_claude_created(&mut rx, "req-e3r1-f3").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let _placeholder = created["sessionId"].as_str().unwrap().to_string(); - await_create_ownership_committed(&st, FRESH_CREATE_DURABLE_ID).await; + await_create_ownership_committed(&st, fresh_create_durable_id).await; // THE UNOWNED-LIVE shape: the coordinator key goes Vacant (a // confirmed-kill release) while the lane map still holds the live // session. let stamp = crate::ownership_lane::take_retained_stamp( &st.ownership_stamps, - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, ) .expect("the create's stamp"); registry.force_release_for_confirmed_kill( "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, &freshell_ownership::ReleaseClaim { operation_id: stamp.operation_id.clone(), generation: stamp.generation, @@ -12026,7 +12005,7 @@ rl.on('line', (line) => { "test-release", ); assert!(matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Vacant )); @@ -12034,20 +12013,20 @@ rl.on('line', (line) => { // key is Vacant) and the ADOPT must COMMIT the live runtime as the // owner. let mut resume = dedup_create_msg("req-e3r1-f3-resume"); - resume.resume_session_id = Some(FRESH_CREATE_DURABLE_ID.to_string()); + resume.resume_session_id = Some(fresh_create_durable_id.to_string()); st.handle_create(resume, None).await; let adopted = await_specific_frame(&mut rx, "freshAgent.created", "req-e3r1-f3-resume").await; assert_eq!( adopted["sessionId"], - json!(FRESH_CREATE_DURABLE_ID), + json!(fresh_create_durable_id), "the live session was adopted under its durable id (no fresh spawn): {adopted}" ); // THE F3 CONTRACT: the adopted runtime is the AUTHORITATIVE Live // owner — the key is NOT Vacant (pre-e3r1: the ticket's drop // restored Vacant while the runtime kept running). - match registry.observe("claude", FRESH_CREATE_DURABLE_ID).state { + match registry.observe("claude", fresh_create_durable_id).state { freshell_ownership::OwnershipState::Live { owner, .. } => { assert_eq!(owner.kind, freshell_ownership::RuntimeOwnerKind::FreshAgent); } @@ -12059,7 +12038,7 @@ rl.on('line', (line) => { !matches!( registry.begin_start( "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, freshell_ownership::RuntimeOwnerKind::Terminal, "op-second-writer", None, @@ -12086,13 +12065,14 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-d3-f2"), None).await; let created = await_claude_created(&mut rx, "req-d3-f2").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); while st .cli_index .lock() .await - .get(FRESH_CREATE_DURABLE_ID) + .get(fresh_create_durable_id) .is_none() { assert!( @@ -12104,7 +12084,8 @@ rl.on('line', (line) => { // The Handoff window: the map entry still exists (the runtime has // not stopped yet) — the pre-d3 fast paths adopted/no-op'd here. - begin_test_terminal_handoff(®istry, "handoff-blocking-d3-f2").await; + begin_test_terminal_handoff(®istry, fresh_create_durable_id, "handoff-blocking-d3-f2") + .await; // A delayed CREATE (resume through the live session): the claim // sees the Handoff state and answers the typed refusal — never @@ -12112,7 +12093,7 @@ rl.on('line', (line) => { // The delayed create addresses the DURABLE id (the pane's // sessionRef) — the claim resolves and sees the Handoff state. let mut delayed_create = dedup_create_msg("req-d3-f2-delayed"); - delayed_create.resume_session_id = Some(FRESH_CREATE_DURABLE_ID.to_string()); + delayed_create.resume_session_id = Some(fresh_create_durable_id.to_string()); st.handle_create(delayed_create, None).await; let refused = await_specific_frame(&mut rx, "freshAgent.create.failed", "req-d3-f2-delayed").await; @@ -12518,6 +12499,7 @@ rl.on('line', (line) => { let created = await_claude_created(&mut rx, "req-close-before-teardown").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + adopt_legacy_fixture(&st, &placeholder, durable).await; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let present = st @@ -12603,6 +12585,7 @@ rl.on('line', (line) => { let created = await_claude_created(&mut rx, "req-alias-close-fails").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + adopt_legacy_fixture(&st, &placeholder, durable).await; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let present = st @@ -12688,6 +12671,7 @@ rl.on('line', (line) => { let created = await_claude_created(&mut rx, "req-one-envelope").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + adopt_legacy_fixture(&st, &placeholder, durable).await; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let present = st @@ -12839,7 +12823,6 @@ rl.on('line', (line) => { // ── b8ke focused episode-2 round-1: the adoption's atomicity tests ───── /// The fake sidecar's durable id for a create with NO resume boot. - const FRESH_CREATE_DURABLE_ID: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; /// A minimal thread-local tracing capture (the session_handoff /// tracing_capture pattern, trimmed to the events the e2r1 tests need). @@ -12970,6 +12953,7 @@ rl.on('line', (line) => { let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); st.set_ownership(registry.clone()); + let fresh_create_durable_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; // The FOREIGN OWNER: a live terminal under the durable id the // fresh create's sidecar is about to mint. let foreign = freshell_ownership::OwnerIdentity { @@ -12981,7 +12965,7 @@ rl.on('line', (line) => { }; let freshell_ownership::BeginOutcome::Granted { generation } = registry.begin_start( "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, freshell_ownership::RuntimeOwnerKind::Terminal, "op-foreign-owner", None, @@ -12993,7 +12977,7 @@ rl.on('line', (line) => { assert!(matches!( registry.commit_live( "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, "op-foreign-owner", generation, foreign @@ -13007,6 +12991,16 @@ rl.on('line', (line) => { .await; let created = await_claude_created(&mut rx, "req-e2r3-f3").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); + prepare_legacy_init(&st, &placeholder).await; + st.adopt_session_init( + fresh_create_durable_id, + &placeholder, + "freshclaude", + None, + None, + None, + ) + .await; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let removed = !st.sessions.lock().await.contains_key(&placeholder); @@ -13026,13 +13020,13 @@ rl.on('line', (line) => { st.cli_index .lock() .await - .get(FRESH_CREATE_DURABLE_ID) + .get(fresh_create_durable_id) .is_none(), "the refused adoption publishes nothing" ); // The foreign owner is NEVER stolen. assert!(matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Live { owner, .. } if owner.kind == freshell_ownership::RuntimeOwnerKind::Terminal )); @@ -13074,11 +13068,12 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-e2r4-f4"), None) .await; let created = await_claude_created(&mut rx, "req-e2r4-f4").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Live { owner, .. } if owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent ) { @@ -13133,7 +13128,7 @@ rl.on('line', (line) => { assert!(matches!( registry.rekey_live( "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, "new-fork-id-e2r4", &placeholder, freshell_ownership::OwnerIdentity { @@ -13149,7 +13144,7 @@ rl.on('line', (line) => { freshell_ownership::CommitOutcome::Committed )); assert_eq!( - st.resolve_ownership_key(FRESH_CREATE_DURABLE_ID), + st.resolve_ownership_key(fresh_create_durable_id), "new-fork-id-e2r4", "the superseded old id resolves to the client-visible canonical id" ); @@ -13159,7 +13154,7 @@ rl.on('line', (line) => { "the canonical id resolves to itself" ); // The snapshot for the OLD id reports the CANONICAL record. - let snap = st.ownership_snapshot("claude", FRESH_CREATE_DURABLE_ID); + let snap = st.ownership_snapshot("claude", fresh_create_durable_id); assert_eq!( snap.state, registry.observe("claude", "new-fork-id-e2r4").state, @@ -13189,8 +13184,9 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-r13-f3-window"), None) .await; let created = await_claude_created(&mut rx, "req-r13-f3-window").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let map_key = created["sessionId"].as_str().unwrap().to_string(); - let old_dur = FRESH_CREATE_DURABLE_ID; + let old_dur = fresh_create_durable_id; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( @@ -13313,8 +13309,9 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-r38-f1-race"), None) .await; let created = await_claude_created(&mut rx, "req-r38-f1-race").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let map_key = created["sessionId"].as_str().unwrap().to_string(); - let old_dur = FRESH_CREATE_DURABLE_ID; + let old_dur = fresh_create_durable_id; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( @@ -13444,8 +13441,9 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-r13-f3-stale"), None) .await; let created = await_claude_created(&mut rx, "req-r13-f3-stale").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let map_key = created["sessionId"].as_str().unwrap().to_string(); - let old_dur = FRESH_CREATE_DURABLE_ID; + let old_dur = fresh_create_durable_id; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( @@ -13541,8 +13539,9 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-r29-f2-stale-arrival"), None) .await; let created = await_claude_created(&mut rx, "req-r29-f2-stale-arrival").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let map_key = created["sessionId"].as_str().unwrap().to_string(); - let old_dur = FRESH_CREATE_DURABLE_ID; + let old_dur = fresh_create_durable_id; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( @@ -13645,8 +13644,9 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-ep5-r4-f3-cycle"), None) .await; let created = await_claude_created(&mut rx, "req-ep5-r4-f3-cycle").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let map_key = created["sessionId"].as_str().unwrap().to_string(); - let old_dur = FRESH_CREATE_DURABLE_ID; + let old_dur = fresh_create_durable_id; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( @@ -13786,8 +13786,9 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-r29-f2-half-fence"), None) .await; let created = await_claude_created(&mut rx, "req-r29-f2-half-fence").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let map_key = created["sessionId"].as_str().unwrap().to_string(); - let old_dur = FRESH_CREATE_DURABLE_ID; + let old_dur = fresh_create_durable_id; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( @@ -13853,8 +13854,9 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-e2r3-f1"), None) .await; let created = await_claude_created(&mut rx, "req-e2r3-f1").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); - let old_dur = FRESH_CREATE_DURABLE_ID; + let old_dur = fresh_create_durable_id; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( @@ -14000,11 +14002,12 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-postcap-f3"), None) .await; let created = await_claude_created(&mut rx, "req-postcap-f3").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let _placeholder = created["sessionId"].as_str().unwrap().to_string(); // The first create's adoption must COMMIT before the pre-commit // park arms — otherwise it parks there too, and both the `reached` // flag and the stale-start recovery below see the WRONG claim. - await_create_ownership_committed(&st, FRESH_CREATE_DURABLE_ID).await; + await_create_ownership_committed(&st, fresh_create_durable_id).await; // The pre-commit park: a SECOND session (fresh map key — the // ordinary-create shape, no prior durable id) whose adoption @@ -14110,6 +14113,7 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-e2r3-f2"), None) .await; let created = await_claude_created(&mut rx, "req-e2r3-f2").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); // The first adoption publishes. let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); @@ -14117,7 +14121,7 @@ rl.on('line', (line) => { .cli_index .lock() .await - .get(FRESH_CREATE_DURABLE_ID) + .get(fresh_create_durable_id) .is_none() { assert!( @@ -14184,13 +14188,14 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-e2r2-f2a"), None) .await; let created = await_claude_created(&mut rx, "req-e2r2-f2a").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder_a = created["sessionId"].as_str().unwrap().to_string(); // Let the fresh create's OWN adoption commit first (it is the first // claimant — this part is the pre-existing behavior). let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Live { .. } ) { break; @@ -14329,6 +14334,7 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-e2r2-f3"), None) .await; let created = await_claude_created(&mut rx, "req-e2r2-f3").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let placeholder = created["sessionId"].as_str().unwrap().to_string(); // The durable id the sidecar will mint must differ from the FIRST // create's own adoption. The FIRST create's adoption must PROVABLY @@ -14336,7 +14342,7 @@ rl.on('line', (line) => { // pause and the `reached` flag would prove the WRONG task parked // (the e2r3 F10 fix: exactly one parker, unambiguously the one // under test). - let first_dur = FRESH_CREATE_DURABLE_ID; + let first_dur = fresh_create_durable_id; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); while st.cli_index.lock().await.get(first_dur).is_none() { assert!( @@ -14423,7 +14429,7 @@ rl.on('line', (line) => { async fn the_init_adoption_claim_records_the_provenance_initiator() { let _guard = CLAUDE_ENV_LOCK.lock().await; let _env = FakeClaudeSidecarEnv::install(); - let (mut st, _rx) = state_with_bus(); + let (mut st, mut rx) = state_with_bus(); let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); st.set_ownership(registry.clone()); @@ -14437,12 +14443,14 @@ rl.on('line', (line) => { st.handle_create(dedup_create_msg("req-e2r7-f7"), provenance) .await; + let created = await_claude_created(&mut rx, "req-e2r7-f7").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); // The deferred adoption claims under the minted durable id once the // create registers the runtime. let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { if matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Live { owner, .. } if owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent ) { @@ -14458,7 +14466,7 @@ rl.on('line', (line) => { let claim = events .iter() .find(|(event, _initiator, op)| { - event == "ownership.start.begin" && op.starts_with("claude-init-adopt-") + event == "ownership.start.begin" && op == "create-resume-req-e2r7-f7" }) .expect("the adoption's claim event is captured"); assert_eq!( @@ -14552,35 +14560,7 @@ rl.on('line', (line) => { let created = await_claude_created(&mut rx, "req-kill-refused").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); - // Seed the lane's believed runtime identity exactly as a committed - // lane claim does (claim → commit Live{FreshAgent} → retained stamp - // under the placeholder — the kill's stop-claim source). - let mut seed_ticket = match crate::ownership_lane::begin_lane_claim( - &st.ownership, - PROVIDER, - &placeholder, - "test-refused-kill-seed", - None, - "test", - crate::session_lease::now_epoch_ms(), - ) { - crate::ownership_lane::LaneClaim::Granted(ticket) => Some(ticket), - _ => panic!("fixture: the seed claim must be granted on a vacant key"), - }; - assert!( - crate::ownership_lane::commit_lane_claim( - &st.ownership, - &st.ownership_stamps, - Some(&st.broadcast_tx), - PROVIDER, - &placeholder, - &mut seed_ticket, - &placeholder, - None, - ) - .is_ok(), - "fixture: the seed commit lands Live{{FreshAgent}}" - ); + await_create_ownership_committed(&st, &placeholder).await; // The refusal precondition: a handoff owns the placeholder's // transition, so the kill's fenced stop is typed-blocked. let freshell_ownership::BeginOutcome::Granted { .. } = registry.begin_handoff( @@ -14682,35 +14662,7 @@ rl.on('line', (line) => { let created = await_claude_created(&mut rx, "req-kill-unconfirmed").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); - // Seed the lane's believed runtime identity exactly as a committed - // lane claim does (the kill's stop-claim source). - let mut seed_ticket = match crate::ownership_lane::begin_lane_claim( - &st.ownership, - PROVIDER, - &placeholder, - "test-unconfirmed-kill-seed", - None, - "test", - crate::session_lease::now_epoch_ms(), - ) { - crate::ownership_lane::LaneClaim::Granted(ticket) => Some(ticket), - _ => panic!("fixture: the seed claim must be granted on a vacant key"), - }; - assert!( - crate::ownership_lane::commit_lane_claim( - &st.ownership, - &st.ownership_stamps, - Some(&st.broadcast_tx), - PROVIDER, - &placeholder, - &mut seed_ticket, - &placeholder, - None, - ) - .is_ok(), - "fixture: the seed commit lands Live{{FreshAgent}}" - ); - + await_create_ownership_committed(&st, &placeholder).await; // Park a TERM-immune tagged "CLI grandchild" under the sidecar's // ownership tag (the FR3 fixture): it deterministically outlives the // one-round confirmation window. @@ -14849,6 +14801,7 @@ rl.on('line', (line) => { // The fake sidecar's default durable cliSessionId (its script emits it // on sdk.session.init when no resume boot was supplied). let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + adopt_legacy_fixture(&st, &placeholder, durable).await; // The REAL consumer fold adopts the alias (poll, bounded) — the kill // must race NOTHING here: the alias exists before the exit. let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); @@ -14940,6 +14893,7 @@ rl.on('line', (line) => { st.handle_create(msg, None).await; let created = await_claude_created(&mut rx, "req-orphan-kill").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); + adopt_legacy_fixture(&st, &placeholder, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa").await; // The adoption write has been INVOKED (production's spawn-launch // point) — it is in flight, not yet applied. @@ -15010,6 +14964,7 @@ rl.on('line', (line) => { .await; let created = await_claude_created(&mut rx, "req-claim-kill").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); + adopt_legacy_fixture(&st, &placeholder, durable).await; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let present = st @@ -15194,6 +15149,7 @@ rl.on('line', (line) => { .await; let created = await_claude_created(&mut rx, "req-reopen-1").await; let placeholder_old = created["sessionId"].as_str().unwrap().to_string(); + adopt_legacy_fixture(&st, &placeholder_old, durable).await; await_alias(&st, durable, &placeholder_old).await; st.handle_kill(kill_msg(&placeholder_old)).await; assert!( @@ -15281,6 +15237,7 @@ rl.on('line', (line) => { .await; let created = await_claude_created(&mut rx, "req-live-owner").await; let placeholder_live = created["sessionId"].as_str().unwrap().to_string(); + adopt_legacy_fixture(&st, &placeholder_live, durable).await; await_alias(&st, durable, &placeholder_live).await; // Fabricate the surviving stale record: the OLD epoch's placeholder @@ -15330,6 +15287,7 @@ rl.on('line', (line) => { .await; let created = await_claude_created(&mut rx, "req-evict-gap").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); + adopt_legacy_fixture(&st, &placeholder, durable).await; await_alias(&st, durable, &placeholder).await; // THE WINDOW: the eviction's first half (sessions-map removal) has @@ -15391,6 +15349,7 @@ rl.on('line', (line) => { .await; let created = await_claude_created(&mut rx, "req-evict-cs").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); + adopt_legacy_fixture(&st, &placeholder, durable).await; await_alias(&st, durable, &placeholder).await; // The consumer-exit shape per the report: the session entry is GONE @@ -15480,6 +15439,7 @@ rl.on('line', (line) => { st.handle_create(msg, None).await; let created = await_claude_created(&mut rx, "req-fence-orphan").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); + adopt_legacy_fixture(&st, &placeholder, durable).await; gate.entered .recv_timeout(std::time::Duration::from_secs(15)) .expect("the adoption write entered (in flight)"); @@ -16740,7 +16700,7 @@ rl.on('line', (line) => { /// (placeholder → durable), so a restart can never lose it before any /// exit-eviction would demote it. #[tokio::test(flavor = "multi_thread")] - async fn the_create_resume_registration_persists_the_alias_record() { + async fn a_create_resume_uses_its_durable_id_without_writing_a_self_alias() { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); let (st, mut rx) = state_with_bus(); @@ -16757,28 +16717,14 @@ rl.on('line', (line) => { let created = await_claude_created(&mut rx, "req-alias-persist").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); - // The REGISTRATION'S write is observable immediately; the adoption - // corrector echoes the same pair on the init fold. - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - let records = fake.alias_tombstone_records("claude", &placeholder); - if records.iter().any(|(d, _)| d == durable) { - break; - } - assert!( - tokio::time::Instant::now() < deadline, - "the alias mapping never reached the durable store" - ); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - } + assert_eq!(placeholder, durable); + st.handle_send(send_msg(durable, "__emit_assistant__")) + .await; + await_frame_of_inner_type(&mut rx, "freshAgent.assistant").await; + assert!(fake.alias_tombstone_records("claude", durable).is_empty()); assert!( - fake.alias_record_writes - .lock() - .unwrap() - .iter() - .any(|(p, ph, d)| p == "claude" && ph == &placeholder && d == durable), - "the registration persisted the alias: {:?}", - fake.alias_record_writes.lock().unwrap() + fake.alias_record_writes.lock().unwrap().is_empty(), + "a create and its confirming init need no alias when both ids are the UUID" ); drop(env); } @@ -17807,6 +17753,7 @@ rl.on('line', (line) => { crate::claude_turns::SharedLedger::default(), crate::attention::AttentionClock::default(), None, // provenance: test lane is conn-less (D8) + false, ); // The replace-resend is the LAST scripted line: observing its input proves the @@ -17963,13 +17910,15 @@ rl.on('line', (line) => { let created_frame = await_claude_created(&mut rx, "req-cli-idx-1").await; let created = created_frame["sessionId"].as_str().unwrap().to_string(); + st.handle_send(send_msg(&created, "__emit_assistant__")) + .await; // The fake emits sdk.session.init with the durable uuid; poll until the // consumer has recorded it (bounded). let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { { let idx = st.cli_index.lock().await; - if idx.get("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") == Some(&created) { + if idx.get(created.as_str()) == Some(&created) { break; } } @@ -17988,7 +17937,7 @@ rl.on('line', (line) => { .unwrap() .cli_session_id .as_deref(), - Some("aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") + Some(created.as_str()) ); // Kill evicts the index entry. st.handle_kill(FreshAgentKill { @@ -18058,13 +18007,16 @@ rl.on('line', (line) => { // returns (including its `Deferred` arm, claude.rs:8046-8062), so // the frame orders nothing about the row; the row BARRIER below is // what proves the row landed. - await_claude_created_and_session_init(&mut rx, "req-binding-init").await; - let b = await_claude_session_init_binding_row(&fake).await; + let created = await_claude_created(&mut rx, "req-binding-init").await; + let b = + await_claude_session_init_binding_row(&fake, created["sessionId"].as_str().unwrap()) + .await; assert_eq!(b.provider, "claude"); assert_eq!(b.mode, "kilroy", "sessionType flavour preserved in the row"); assert_eq!( - b.session_id, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", - "keyed by cliSessionId" + b.session_id, + created["sessionId"].as_str().unwrap(), + "keyed by the create-time UUID" ); assert_eq!(b.settings.model.as_deref(), Some("opus-x")); assert_eq!(b.settings.permission_mode.as_deref(), Some("plan")); @@ -18087,8 +18039,10 @@ rl.on('line', (line) => { let mut msg = dedup_create_msg("req-binding-plugins"); msg.plugins = Some(vec!["/workspace/.claude/plugins/local".to_string()]); state.handle_create(msg, None).await; - await_claude_created_and_session_init(&mut rx, "req-binding-plugins").await; - let row = await_claude_session_init_binding_row(&fake).await; + let created = await_claude_created(&mut rx, "req-binding-plugins").await; + let row = + await_claude_session_init_binding_row(&fake, created["sessionId"].as_str().unwrap()) + .await; assert_eq!( row.settings.plugins, Some(vec!["/workspace/.claude/plugins/local".to_string()]) @@ -18100,8 +18054,10 @@ rl.on('line', (line) => { let mut msg = dedup_create_msg("req-binding-no-plugins"); msg.plugins = Some(Vec::new()); state.handle_create(msg, None).await; - await_claude_created_and_session_init(&mut rx, "req-binding-no-plugins").await; - let row = await_claude_session_init_binding_row(&fake).await; + let created = await_claude_created(&mut rx, "req-binding-no-plugins").await; + let row = + await_claude_session_init_binding_row(&fake, created["sessionId"].as_str().unwrap()) + .await; assert_eq!( row.settings, crate::identity_sink::FreshAgentSettings::default(), @@ -18138,8 +18094,11 @@ rl.on('line', (line) => { // returns (including its `Deferred` arm, claude.rs:8046-8062), so // the frame orders nothing about the row; the row BARRIER below is // what proves the row landed. - await_claude_created_and_session_init(&mut rx, "req-binding-blank").await; - let b = await_claude_session_init_binding_row(&fake).await; + let created = await_claude_created(&mut rx, "req-binding-blank").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); + let b = + await_claude_session_init_binding_row(&fake, created["sessionId"].as_str().unwrap()) + .await; // THE r27 F4 CONTRACT: the lineage row IS recorded (blank settings // verbatim) — the no-skip truth. @@ -18152,12 +18111,12 @@ rl.on('line', (line) => { // Task 3 keying keeps a later resume from arming a FALSE // SETTINGS_RESET for this legitimately-default session. assert!( - fake.load_settings("claude", FRESH_CREATE_DURABLE_ID) + fake.load_settings("claude", fresh_create_durable_id) .is_none(), "lineage-only row answers no settings snapshot" ); assert!( - !fake.was_recorded("claude", FRESH_CREATE_DURABLE_ID), + !fake.was_recorded("claude", fresh_create_durable_id), "lineage-only row must not count as recorded (false SETTINGS_RESET)" ); drop(env); @@ -18182,15 +18141,26 @@ rl.on('line', (line) => { let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); st.set_identity_sink(fake.clone()); - // Park the adoption's binding write (keyed by the fixed fake cli id). - let stall = fake.arm_binding_stall("claude", FRESH_CREATE_DURABLE_ID); + let mut msg = dedup_create_msg("req-r27-f4-order"); + msg.model = Some("opus-x".to_string()); + st.handle_create(msg, None).await; + let created = await_claude_created(&mut rx, "req-r27-f4-order").await; + let map_key = created["sessionId"].as_str().unwrap().to_string(); + prepare_legacy_init(&st, &map_key).await; + let fresh_create_durable_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; + let stall = fake.arm_binding_stall("claude", fresh_create_durable_id); let st2 = st.clone(); + let identity = st.identity_sink(); let create_task = tokio::spawn(async move { - // Settings-bearing so the write is recordable under BOTH the - // pre- and post-fix contracts (the red must park, not hang). - let mut msg = dedup_create_msg("req-r27-f4-order"); - msg.model = Some("opus-x".to_string()); - st2.handle_create(msg, None).await; + st2.adopt_session_init( + fresh_create_durable_id, + &map_key, + "freshclaude", + None, + identity, + None, + ) + .await; }); stall .entered @@ -18203,18 +18173,18 @@ rl.on('line', (line) => { // could BEGIN while the binding was blocked. assert!( matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Starting { .. } ), "the binding write runs INSIDE the coordinator operation (the \ pre-commit Starting claim) — got {:?}", - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state + registry.observe("claude", fresh_create_durable_id).state ); assert!( matches!( registry.begin_handoff( "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, freshell_ownership::RuntimeOwnerKind::Terminal, "competing-mid-write", None, @@ -18229,7 +18199,7 @@ rl.on('line', (line) => { // Restore the key for the release (the probe handoff never entered). let _ = registry.fail( "claude", - FRESH_CREATE_DURABLE_ID, + fresh_create_durable_id, "competing-mid-write", u64::MAX, false, @@ -18237,11 +18207,9 @@ rl.on('line', (line) => { // Release: the publish + commit complete, the create answers green. let _ = stall.release.send(()); - let created = await_claude_created(&mut rx, "req-r27-f4-order").await; - assert_eq!(created["type"], "freshAgent.created", "{created}"); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); while !matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Live { .. } ) { assert!( @@ -18271,13 +18239,26 @@ rl.on('line', (line) => { st.set_identity_sink(fake.clone()); // The ledger refuses every write (disk-full/permission shape). - fake.set_fail_writes(true); let mut msg = dedup_create_msg("req-r27-f4-fail"); msg.model = Some("opus-x".to_string()); st.handle_create(msg, None).await; let created = await_claude_created(&mut rx, "req-r27-f4-fail").await; + let fresh_create_durable_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; assert_eq!(created["type"], "freshAgent.created", "{created}"); + prepare_legacy_init(&st, &created["sessionId"].as_str().unwrap()).await; + while rx.try_recv().is_ok() {} + fake.set_fail_writes(true); + st.adopt_session_init( + fresh_create_durable_id, + created["sessionId"].as_str().unwrap(), + "freshclaude", + None, + st.identity_sink(), + None, + ) + .await; + // The adoption runs on the sidecar's init event: the binding write // fails → the runtime is torn down and the adoption ABANDONS // BEFORE the publish/commit. Bounded-poll for the settled state. @@ -18288,7 +18269,7 @@ rl.on('line', (line) => { let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let settled = matches!( - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state, + registry.observe("claude", fresh_create_durable_id).state, freshell_ownership::OwnershipState::Vacant ); let session_gone = !st.has_live_session(&created_session_id).await; @@ -18298,7 +18279,7 @@ rl.on('line', (line) => { assert!( tokio::time::Instant::now() < deadline, "the failed binding write never settled (state {:?})", - registry.observe("claude", FRESH_CREATE_DURABLE_ID).state + registry.observe("claude", fresh_create_durable_id).state ); tokio::time::sleep(std::time::Duration::from_millis(10)).await; } @@ -18307,7 +18288,7 @@ rl.on('line', (line) => { while let Ok(raw) = rx.try_recv() { let frame: Value = serde_json::from_str(&raw).unwrap(); if frame["type"] == "session.runtimeOwner" - && frame["sessionId"] == json!(FRESH_CREATE_DURABLE_ID) + && frame["sessionId"] == json!(fresh_create_durable_id) { saw_owner = true; } @@ -18348,8 +18329,10 @@ rl.on('line', (line) => { // returns (including its `Deferred` arm, claude.rs:8046-8062), so // the frame orders nothing about the row; the row BARRIER below is // what proves the row landed. - await_claude_created_and_session_init(&mut rx, "req-binding-prov").await; - let b = await_claude_session_init_binding_row(&fake).await; + let created = await_claude_created(&mut rx, "req-binding-prov").await; + let b = + await_claude_session_init_binding_row(&fake, created["sessionId"].as_str().unwrap()) + .await; assert_eq!( b.asserted_stamps().client_instance_id.as_deref(), Some("client-claude") @@ -18929,7 +18912,7 @@ rl.on('line', (line) => { request_id: &str, ) -> String { st.handle_create(dedup_create_msg(request_id), None).await; - let created = await_claude_created_and_session_init(rx, request_id).await; + let created = await_claude_created(rx, request_id).await; let session_id = created["sessionId"].as_str().unwrap().to_string(); st.sessions .lock() @@ -18963,11 +18946,10 @@ rl.on('line', (line) => { async fn a_resumed_dead_session_no_longer_reads_as_ended() { let _guard = CLAUDE_ENV_LOCK.lock().await; let _env = FakeClaudeSidecarEnv::install(); - // The fake sidecar's session reports this durable id. - const DURABLE: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; let (st, mut rx) = state_with_bus(); let old_pane_id = crash_a_new_session(&st, &mut rx, "req-dead-resume").await; - for id in [old_pane_id.as_str(), DURABLE] { + let durable = old_pane_id.as_str(); + for id in [old_pane_id.as_str(), durable] { assert_eq!( st.live_snapshot_overlay(id).await["status"], "exited", @@ -18976,18 +18958,21 @@ rl.on('line', (line) => { } let home = tempfile::tempdir().unwrap(); - write_fake_transcript(home.path(), DURABLE); + write_fake_transcript(home.path(), durable); std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); - st.handle_attach(attach_msg_with_resume("pane-after-reload", DURABLE)) + st.handle_attach(attach_msg_with_resume("pane-after-reload", durable)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); - assert!(st.has_live_session(DURABLE).await, "the attach resumed it"); + assert!(st.has_live_session(durable).await, "the attach resumed it"); - assert_ne!(st.live_snapshot_overlay(DURABLE).await["status"], "exited"); - assert_eq!( - st.live_snapshot_overlay(&old_pane_id).await, - json!({}), - "the old pane id no longer names an ended session" + assert_ne!(st.live_snapshot_overlay(durable).await["status"], "exited"); + assert_ne!( + st.live_snapshot_overlay(&old_pane_id).await["status"], + "exited" + ); + assert!( + st.dead_sessions.find(&old_pane_id).is_none(), + "resuming forgets the old death under every id" ); } @@ -18998,13 +18983,12 @@ rl.on('line', (line) => { async fn a_resumed_session_keeps_the_attention_clock_of_its_death() { let _guard = CLAUDE_ENV_LOCK.lock().await; let _env = FakeClaudeSidecarEnv::install(); - // The fake sidecar's session reports this durable id. - const DURABLE: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; let (st, mut rx) = state_with_bus(); st.handle_create(dedup_create_msg("req-clock-carry"), None) .await; - let created = await_claude_created_and_session_init(&mut rx, "req-clock-carry").await; + let created = await_claude_created(&mut rx, "req-clock-carry").await; let pane_id = created["sessionId"].as_str().unwrap().to_string(); + let durable = pane_id.as_str(); // A turn in flight (the fake answers no plain send), so the death rings. st.handle_send(send_msg(&pane_id, "a turn the death cuts off")) .await; @@ -19021,14 +19005,14 @@ rl.on('line', (line) => { let death_at = death["event"]["at"].as_i64().expect("a numeric at"); let home = tempfile::tempdir().unwrap(); - write_fake_transcript(home.path(), DURABLE); + write_fake_transcript(home.path(), durable); std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); - st.handle_attach(attach_msg_with_resume("pane-after-reload", DURABLE)) + st.handle_attach(attach_msg_with_resume("pane-after-reload", durable)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); - assert!(st.has_live_session(DURABLE).await, "the attach resumed it"); + assert!(st.has_live_session(durable).await, "the attach resumed it"); - let key = st.resolve_session_key(DURABLE).await.expect("live key"); + let key = st.resolve_session_key(durable).await.expect("live key"); let floor = st .sessions .lock() @@ -19057,7 +19041,7 @@ rl.on('line', (line) => { let (st, mut rx) = state_with_bus(); st.handle_create(dedup_create_msg("req-reap-resume"), None) .await; - let created = await_claude_created_and_session_init(&mut rx, "req-reap-resume").await; + let created = await_claude_created(&mut rx, "req-reap-resume").await; let pane_id = created["sessionId"].as_str().unwrap().to_string(); // The fake never answers a plain send: the turn stays in flight. st.handle_send(send_msg(&pane_id, "a turn the sidecar dies in")) @@ -19308,6 +19292,7 @@ rl.on('line', (line) => { Arc::clone(&ledger), clock.clone(), None, // provenance: test lane is conn-less (D8) + false, ); st.sessions.lock().await.insert( map_key.to_string(), @@ -20020,10 +20005,7 @@ rl.on('line', (line) => { "the send waited, then ran against the POST-rollback session: {send_frame}" ); assert!( - send_frame["sessionId"] - .as_str() - .unwrap_or_default() - .starts_with("fake-claude-session-"), + send_frame["sessionId"] == adopted, "the send addressed the freshly-adopted sidecar: {send_frame}" ); // SESSION_NOT_FOUND is structurally impossible on this lane: the send diff --git a/crates/freshell-freshagent/src/claude/create_binding.rs b/crates/freshell-freshagent/src/claude/create_binding.rs new file mode 100644 index 000000000..3835023e7 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/create_binding.rs @@ -0,0 +1,99 @@ +//! A create establishes recoverable identity before its acknowledgement. The +//! native transcript may not exist until the first prompt; Fresh genesis and +//! the create-request lineage make that zero-turn identity durable meanwhile. + +use super::*; + +impl FreshClaudeState { + pub(super) async fn record_create_binding( + &self, + session_id: &str, + msg: &FreshAgentCreate, + provenance: Option<&crate::BindProvenance>, + ticket: &Option, + ) -> std::io::Result<()> { + let Some(sink) = self.identity_sink() else { + return Ok(()); + }; + let (settings, genesis) = { + let sessions = self.sessions.lock().await; + let session = sessions.get(session_id).ok_or_else(|| { + std::io::Error::other("Claude stopped while recording its create identity") + })?; + ( + session.configuration.settings.clone(), + session.launch_genesis.clone(), + ) + }; + let generation = ticket.as_ref().map(|ticket| ticket.generation()); + sink.record_binding(crate::identity_sink::FreshAgentBindingUpsert { + provider: PROVIDER.into(), + session_id: session_id.into(), + mode: session_type_str(msg.session_type).into(), + create_request_id: Some(msg.request_id.clone()), + resolves_pending: None, + supersedes: None, + name_transition: None, + genesis: genesis.map_or(crate::identity_sink::GenesisUpdate::Keep, |recorded| { + crate::identity_sink::GenesisUpdate::Set(recorded.genesis) + }), + provenance: provenance.cloned().into(), + observed_epoch: generation.and( + self.ownership + .as_ref() + .map(|registry| registry.boot_epoch()), + ), + observed_generation: generation, + authoritative: false, + settings, + }) + .await + } + + /// The persisted binding/genesis verifies durable routing independently of + /// native transcript flush. Naming can bind to this identity at create. + pub(super) async fn bind_create_name( + &self, + session_id: &str, + session_type: &str, + ) -> Option<( + freshell_protocol::session_names::SessionNameRef, + freshell_protocol::session_names::SessionNameRecord, + )> { + if session_type == "kilroy" + || !self.identity_sink().is_some_and(|sink| { + sink.load_genesis(PROVIDER, session_id).is_some() + || sink.was_recorded(PROVIDER, session_id) + }) + { + return None; + } + let handle = self.naming_handles.lock().await.get(session_id).cloned()?; + let target = freshell_protocol::session_names::SessionNameRef::Session { + provider: freshell_protocol::session_names::NamedProvider::Claude, + session_id: session_id.into(), + }; + let transition = crate::naming::NameTransition::binding( + crate::naming::NameTransitionReason::InitialMaterialization, + freshell_protocol::native_location::NativeAcquisition { + location: freshell_protocol::native_location::NativeLocation::Claude { + config_root: self.transcript_roots().first()?.display().to_string(), + transcript_path: None, + project_directory_key: None, + transcript_cwd: None, + effective_project_key_override: None, + }, + evidence: freshell_protocol::native_location::NativeEvidenceKind::PersistedMetadata, + persistence: freshell_protocol::native_location::NativePersistence::Verified, + }, + handle, + ); + let update = + crate::naming::fold_identity_transition(&self.naming(), &transition, target.clone()) + .await?; + if update.record.name_ref == target { + self.naming_handles.lock().await.remove(session_id); + } + Some((update.record.name_ref.clone(), update.record)) + } +} diff --git a/crates/freshell-freshagent/src/claude/establish.rs b/crates/freshell-freshagent/src/claude/establish.rs index e21992a37..5cdb86c8e 100644 --- a/crates/freshell-freshagent/src/claude/establish.rs +++ b/crates/freshell-freshagent/src/claude/establish.rs @@ -51,8 +51,6 @@ impl EstablishLane { /// The map key a new session registers under. pub(super) enum RegisterUnder { - /// The id the sidecar minted (`created.sessionId`): a create. - Minted, /// The attaching pane's id: an attach-resume (the client routes the pane's /// frames by it). Pane(String), @@ -183,6 +181,8 @@ pub(super) enum EstablishError { Spawn(String), /// The `create` line could not be written. Write(String), + /// The create-time identity could not be made durable. + Binding(String), /// The sidecar did not answer `created` (a typed refusal, a protocol /// mismatch, or no answer). Boot(CreateFailure), @@ -200,7 +200,7 @@ impl EstablishError { /// What went wrong, in words. pub(super) fn message(&self, lane: EstablishLane) -> String { match self { - Self::Spawn(detail) | Self::Write(detail) => detail.clone(), + Self::Spawn(detail) | Self::Write(detail) | Self::Binding(detail) => detail.clone(), Self::Boot(failure) => failure.message().to_string(), Self::ClosedInFlight => { "session closed while the resume was in flight; torn down".to_string() @@ -224,6 +224,7 @@ impl EstablishError { match self { Self::Spawn(_) => "CLAUDE_SIDECAR_START_FAILED", Self::Write(_) => "CLAUDE_SIDECAR_WRITE_FAILED", + Self::Binding(_) => "LEDGER_WRITE_FAILED", Self::Boot(failure) => failure.code(), Self::ClosedInFlight | Self::LeaseRevoked @@ -244,6 +245,7 @@ impl EstablishError { match self { Self::Spawn(_) => "spawn", Self::Write(_) => "write", + Self::Binding(_) => "binding", Self::Boot(_) => "boot", Self::ClosedInFlight => "closed_in_flight", Self::LeaseRevoked => "lease_revoked", @@ -273,6 +275,43 @@ pub(super) struct StartWatch<'a> { } impl FreshClaudeState { + /// A completed lease belongs to its exact sidecar incarnation until the + /// process tree is confirmed gone. This continuation runs even on hosted + /// lanes without an ownership coordinator; a replacement's lease is fenced + /// by its map key and ownership tag. + pub(super) fn release_leases_after_teardown( + &self, + teardown: LoggedTeardown, + durables: Vec, + map_key: String, + ownership_id: Option, + ) -> LoggedTeardown { + let leases = self.leases.clone(); + let release = move || { + if let Some(ownership_id) = ownership_id.as_deref() { + for durable in &durables { + leases.clear_binding_if_matches(PROVIDER, durable, &map_key, ownership_id); + } + } + }; + match teardown { + LoggedTeardown::Confirmed => { + release(); + LoggedTeardown::Confirmed + } + LoggedTeardown::NotConfirmed { escalation } => LoggedTeardown::NotConfirmed { + escalation: tokio::spawn(async move { + let confirmed = escalation.await.unwrap_or(false); + if confirmed { + release(); + } + confirmed + }), + }, + other => other, + } + } + /// Claim the provider lease on `durable` for the launch `request_id`, the /// one claim every launch of a durable id makes (create-resume, attach, /// handoff resume, rollback). A holder whose lease expired with a recorded @@ -296,7 +335,14 @@ impl FreshClaudeState { )); } crate::session_lease::FreshSessionClaim::BoundLive { live_session_key } => { - return LaunchLease::BoundLive { live_session_key }; + return if self.sessions.lock().await.contains_key(&live_session_key) { + LaunchLease::BoundLive { live_session_key } + } else { + // Teardown still owns the completed lease. It releases + // only after confirmed death; do not acknowledge its + // absent runtime as an adopted live session. + LaunchLease::Held + }; } crate::session_lease::FreshSessionClaim::Held { .. } => return LaunchLease::Held, crate::session_lease::FreshSessionClaim::ExpiredNeedsKill { pid, ownership_id } => { @@ -606,7 +652,6 @@ impl FreshClaudeState { let sidecar_pid = child.id(); let minted = created.session_id; let (map_key, adopted) = match register_under { - RegisterUnder::Minted => (minted.clone(), None), RegisterUnder::Pane(pane) => (pane, None), RegisterUnder::Adopted(id) => (id.clone(), Some(id)), }; @@ -614,9 +659,9 @@ impl FreshClaudeState { // Unified agent names (Task 2): a create's pending handle is admitted with // the minted id BEFORE any failure arm, so no failure strands a handle the // pane already carries (retries re-ensure the same handle idempotently). - let naming_projection = match naming { + let mut naming_projection = match naming { Some(msg) => { - self.admit_create_handle(msg, &spec.session_type, &minted) + self.admit_create_handle(msg, &spec.session_type, &map_key) .await } None => None, @@ -736,7 +781,8 @@ impl FreshClaudeState { Arc::clone(&pending), Arc::clone(&ledger), clock.clone(), - provenance, + provenance.clone(), + naming.is_some(), ); self.test_holds.registration().await; sessions.insert( @@ -798,7 +844,11 @@ impl FreshClaudeState { } } // Round 6 (Finding 2): the seat→durable mapping is durable at registration. - if let Some(durable) = claim.as_ref().map(|claim| claim.durable.as_str()) { + if let Some(durable) = claim + .as_ref() + .map(|claim| claim.durable.as_str()) + .filter(|durable| *durable != map_key) + { if let Some(sink) = self.identity_sink() { if let Err(e) = sink .record_alias_tombstone( @@ -846,6 +896,46 @@ impl FreshClaudeState { } } + // A created session is already a durable identity, including before + // its first prompt. Write lineage/genesis while the ownership claim + // still fences the registration, before Live or the acknowledgement. + if let Some(create) = naming { + if let Err(error) = self + .record_create_binding(&map_key, create, provenance.as_ref(), own_ticket) + .await + { + if let Some(session) = { self.sessions.lock().await.remove(&map_key) } { + let _teardown: LoggedTeardown = + self.teardown_removed_session_logged(session).await; + } + self.evict_cli_index_aliases(&map_key).await; + if let Some(mut guard) = lease_guard.take() { + guard.fail(); + } + if let Some(claim) = claim.as_ref().filter(|claim| claim.dead_state.is_some()) { + self.rollback_session_claim(&claim.durable).await; + } + return Err(EstablishError::Binding(error.to_string())); + } + if let Some(claim) = claim.as_ref() { + if self.claim_dead_state_advanced(&claim.durable, claim.dead_state) { + if let Some(session) = { self.sessions.lock().await.remove(&map_key) } { + let _teardown: LoggedTeardown = + self.teardown_removed_session_logged(session).await; + } + self.evict_cli_index_aliases(&map_key).await; + if let Some(mut guard) = lease_guard.take() { + guard.fail(); + } + return Err(EstablishError::ClosedInFlight); + } + } + naming_projection = self + .bind_create_name(&map_key, &spec.session_type) + .await + .or(naming_projection); + } + // Task 12/13: bind the durable id to the live session and release the // lease in one lock scope. A revoked lease (an expired handle-less // holder) means the session must not be kept. diff --git a/crates/freshell-freshagent/src/claude/tests/create_binding_tests.rs b/crates/freshell-freshagent/src/claude/tests/create_binding_tests.rs new file mode 100644 index 000000000..3e92d56a7 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/tests/create_binding_tests.rs @@ -0,0 +1,158 @@ +use super::*; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn create_binding_and_name_are_durable_before_live_and_created() { + let _env_lock = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install_with_knobs(Some(1500), false); + let (mut state, mut rx) = state_with_bus(); + let identity = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(identity.clone()); + state.set_ownership(Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new())); + let names = crate::naming::test_support::RecordingSink::new(); + state.set_session_naming(names.clone()); + let mut msg = dedup_create_msg("create-bound-before-live"); + msg.naming_handle = Some("pre-create-name".into()); + let task = { + let state = state.clone(); + tokio::spawn(async move { state.handle_create(msg, None).await }) + }; + let line = env.create_request().await; + let id = line["sessionId"].as_str().unwrap().to_string(); + assert!(uuid::Uuid::parse_str(&id).is_ok()); + let gate = identity.arm_binding_stall(PROVIDER, &id); + gate.entered.recv_timeout(Duration::from_secs(10)).unwrap(); + assert!(matches!( + state.ownership_snapshot(PROVIDER, &id).state, + freshell_ownership::OwnershipState::Starting { .. } + )); + while let Ok(raw) = rx.try_recv() { + assert_ne!( + serde_json::from_str::(&raw).unwrap()["type"], + "freshAgent.created" + ); + } + gate.release.send(()).unwrap(); + let outcome = task.await.unwrap(); + assert!( + matches!(outcome, ClaudeCreateOutcome::Created { ref session_id, .. } if session_id == &id) + ); + let created = await_claude_created(&mut rx, "create-bound-before-live").await; + assert_eq!( + created["nameRef"], + json!({"kind":"session", "provider":"claude", "sessionId":id}) + ); + assert!(matches!( + state.ownership_snapshot(PROVIDER, &id).state, + freshell_ownership::OwnershipState::Live { .. } + )); + let updates = names + .get(vec![ + freshell_protocol::session_names::SessionNameRef::Pending { + id: "pre-create-name".into(), + }, + ]) + .await + .unwrap(); + assert_eq!( + updates[0].record.name_ref, + freshell_protocol::session_names::SessionNameRef::Session { + provider: freshell_protocol::session_names::NamedProvider::Claude, + session_id: id.clone() + } + ); + assert!(state.naming_handles.lock().await.get(&id).is_none()); + assert!(identity + .geneses + .lock() + .unwrap() + .contains_key(&(PROVIDER.into(), id))); + state.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_binding_write_failure_never_publishes_created_or_commits_live() { + let _env_lock = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install_with_knobs(Some(1500), false); + let (mut state, mut rx) = state_with_bus(); + let identity = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(identity.clone()); + state.set_ownership(Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new())); + let task = { + let state = state.clone(); + tokio::spawn(async move { + state + .handle_create(dedup_create_msg("failed-create-binding"), None) + .await + }) + }; + let line = env.create_request().await; + let id = line["sessionId"].as_str().unwrap(); + let gate = identity.arm_post_commit_stall(PROVIDER, id); + gate.applied.recv_timeout(Duration::from_secs(10)).unwrap(); + identity.set_fail_writes(true); + gate.release.send(()).unwrap(); + let outcome = task.await.unwrap(); + assert!( + matches!(outcome, ClaudeCreateOutcome::Failed { ref code, .. } if code == "LEDGER_WRITE_FAILED"), + "{outcome:?}" + ); + assert!(!state.sessions.lock().await.contains_key(id)); + assert!(!matches!( + state.ownership_snapshot(PROVIDER, id).state, + freshell_ownership::OwnershipState::Live { .. } + )); + while let Ok(raw) = rx.try_recv() { + assert_ne!( + serde_json::from_str::(&raw).unwrap()["type"], + "freshAgent.created" + ); + } + assert!(identity.bindings.lock().unwrap().is_empty()); +} + +#[cfg(target_os = "linux")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_confirmed_late_teardown_releases_the_lease_without_an_ownership_registry() { + let _env_lock = CLAUDE_ENV_LOCK.lock().await; + let _env = FakeClaudeSidecarEnv::install(); + let (mut state, mut rx) = state_with_bus(); + state.set_handoff_confirm_rounds_for_test(Some(1)); + state.set_identity_sink(Arc::new(crate::identity_sink::FakeIdentitySink::default())); + state + .handle_create(dedup_create_msg("unwired-lease-create"), None) + .await; + let created = await_claude_created(&mut rx, "unwired-lease-create").await; + let id = created["sessionId"].as_str().unwrap().to_string(); + let ownership_id = state.sessions.lock().await[&id].ownership_id.clone(); + let (mut descendant, _ready) = spawn_term_immune_tagged_child(&ownership_id).await; + state.handle_kill(kill_msg(&id)).await; + assert!(!state.sessions.lock().await.contains_key(&id)); + tokio::time::timeout(Duration::from_secs(15), descendant.wait()) + .await + .expect("the late teardown confirms the descendant died") + .unwrap(); + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + loop { + let mut reopen = dedup_create_msg("unwired-lease-reopen"); + reopen.resume_session_id = Some(id.clone()); + let outcome = state.handle_create(reopen, None).await; + match outcome { + ClaudeCreateOutcome::Created { .. } => { + assert!( + state.sessions.lock().await.contains_key(&id), + "reopen must establish a runtime, never acknowledge a stale lease binding" + ); + break; + } + ClaudeCreateOutcome::Failed { code, .. } => { + assert_eq!(code, "SESSION_RESERVED"); + assert!( + tokio::time::Instant::now() < deadline, + "confirmed cleanup never released its completed lease" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + } + state.shutdown().await; +} diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index 19da294f5..07f9f58ac 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -1620,9 +1620,10 @@ lines.on('line', line=>{ ); } if deferred_identity { - assert!( - started.native_session_id.is_none(), - "unrecorded native identity must remain unknown" + assert_eq!( + started.native_session_id.as_deref(), + observed["sessionId"].as_str(), + "fresh native identity is known before any prompt or init" ); } assert_eq!( @@ -1778,6 +1779,54 @@ lines.on('line', line=>{ Arc::clone(&claude.transport).stop().await.unwrap(); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_fresh_claude_start_resolves_its_uuid_before_any_prompt() { + let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; + let claude = SimulatedClaude::start(&[]).await; + let mut events = claude.transport.take_event_stream().expect("event stream"); + let profile = claude.profile(None); + let started = + tokio::time::timeout(Duration::from_secs(20), claude.transport.start(&profile)) + .await + .expect("fresh start cannot wait for a prompt") + .expect("fresh start succeeds"); + let durable = started + .native_session_id + .expect("identity is known at create"); + assert!(uuid::Uuid::parse_str(&durable).is_ok(), "{durable}"); + assert!(claude.ledger_rows("prompt").is_empty()); + let query = claude.ledger_rows("query"); + assert_eq!(query.len(), 1); + assert_eq!(query[0]["options"]["sessionId"], durable); + claude + .transport + .dispatch( + &RequestId::parse("fresh-first-turn").unwrap(), + "the first prompt", + &profile, + ) + .await + .unwrap(); + let mut announced = Vec::new(); + tokio::time::timeout(Duration::from_secs(20), async { + while let Some(event) = events.recv().await { + match event { + AgentEvent::Started { native_session_id } => announced.push(native_session_id), + AgentEvent::Provider { payload } + if payload["event"]["type"] == "freshAgent.turn.complete" => + { + break + } + _ => {} + } + } + }) + .await + .expect("the turn completes"); + assert_eq!(announced, vec![durable]); + Arc::clone(&claude.transport).stop().await.unwrap(); + } + /// G15 on the hosted lane: every start carries the pane's plugins, and an /// undo starts a replacement CLI. The hosted session host keeps no pane /// ledger, so the replacement must take the live session's settings @@ -2001,7 +2050,7 @@ const lines=createInterface({{input:process.stdin}}); const emit=frame=>process.stdout.write(JSON.stringify(frame)+'\n'); lines.on('line', line=>{{ const req=JSON.parse(line); - if(req.type==='create')emit({{type:'created',requestId:req.requestId,sessionId:'owned-configure',protocol:2}}); + if(req.type==='create')emit({{type:'created',requestId:req.requestId,sessionId:req.sessionId??'owned-configure',protocol:2}}); if(req.type==='configure'){{ appendFileSync(process.env.FRESHELL_TEST_CONFIGURE_RECEIPT,JSON.stringify(req)+'\n'); emit({receipt}); @@ -2119,7 +2168,11 @@ lines.on('line', line=>{{ let metadata = run .settled .expect("the provider's metadata frame reaches the host journal"); - assert_eq!(metadata["sessionId"], "owned-configure"); + assert_eq!(metadata["sessionId"], run.applied[0]["sessionId"]); + assert_eq!( + metadata["sessionId"].as_str(), + run.profile.native_session_id.as_deref() + ); assert_eq!(metadata["model"], "opus"); assert_eq!(metadata["effort"], "high"); } diff --git a/crates/freshell-ws/tests/freshagent_claude_identity.rs b/crates/freshell-ws/tests/freshagent_claude_identity.rs new file mode 100644 index 000000000..5fe76e258 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_identity.rs @@ -0,0 +1,200 @@ +//! The durable identity exists at create, including before the first prompt. + +mod common; + +use common::claude_rig::{ClaudeRig, RigKind, RigOptions}; +use freshell_ws::pane_ledger::RowState; +use serde_json::json; + +async fn create_identity(kind: RigKind) { + for flavor in ["freshclaude", "kilroy"] { + let rig = ClaudeRig::start_with_options( + kind, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let created = conn + .request_create("create-identity", json!({"sessionType":flavor})) + .await; + let id = created["sessionId"].as_str().unwrap(); + assert!( + uuid::Uuid::parse_str(id).is_ok(), + "create must expose its durable UUID: {created}" + ); + assert_eq!( + created["sessionRef"], + json!({"provider":"claude", "sessionId":id}) + ); + let ledger = rig.pane_ledger.as_ref().unwrap(); + let binding = ledger + .lookup_by_session("claude", id) + .expect("bound at create"); + assert_eq!(binding.row.state, RowState::Bound); + assert_eq!(binding.row.mode, flavor); + assert_eq!( + ledger.binding_genesis("claude", id), + Some(json!({"kind":"fresh"})) + ); + assert_eq!( + ledger + .lookup_by_create_request_id("claude", "create-identity") + .unwrap() + .session_id, + id + ); + let (status, snapshot) = rig.snapshot(id).await; + assert_eq!(status, 200, "{snapshot}"); + assert_eq!(snapshot["turns"], json!([])); + conn.send( + json!({"type":"freshAgent.send", "provider":"claude", "sessionType":flavor, + "sessionId":id, "requestId":"first-send", "text":"first prompt"}), + ) + .await; + conn.wait_for("the first turn", |frame| { + frame["event"]["type"] == "freshAgent.result" + }) + .await; + conn.read_for(std::time::Duration::from_millis(200)).await; + assert_eq!(conn.init_session_id(id), id); + assert_eq!( + ledger + .lookup_by_create_request_id("claude", "create-identity") + .unwrap() + .session_id, + id, + "a repeated SDK init preserves create-request lineage" + ); + assert!( + !conn + .frames + .iter() + .any(|frame| frame["type"] == "freshAgent.session.materialized"), + "a create-time UUID never materializes into a second identity" + ); + rig.fresh_claude.shutdown().await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn create_binds_one_durable_uuid_before_any_prompt_for_claude_and_kilroy() { + create_identity(RigKind::Sim).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn real_cli_create_keeps_one_uuid_before_and_after_the_first_prompt() { + create_identity(RigKind::Cli).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn redriving_create_after_restart_recovers_the_same_zero_turn_uuid() { + let mut rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut first = rig.connect().await; + let created = first.request_create("same-create", json!({})).await; + let id = created["sessionId"].as_str().unwrap().to_string(); + rig.restart().await; + let mut second = rig.connect().await; + let replay = second.request_create("same-create", json!({})).await; + assert_eq!(replay["type"], "freshAgent.created", "{replay}"); + assert_eq!( + replay["sessionId"], id, + "restart must not mint another conversation" + ); + assert_eq!(replay["sessionRef"]["sessionId"], id); + let launches = rig.ledger_rows("query"); + assert_eq!(launches.len(), 2); + assert_eq!( + launches[1]["options"]["sessionId"], id, + "zero-turn recovery boots Fresh under its durable id" + ); + assert!(launches[1]["options"]["resume"].is_null()); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn attaching_after_restart_reopens_a_zero_turn_uuid_with_fresh_boot() { + let mut rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut first = rig.connect().await; + let created = first.request_create("zero-turn-attach", json!({})).await; + let id = created["sessionId"].as_str().unwrap().to_string(); + rig.restart().await; + let mut second = rig.connect().await; + second.attach(&id).await; + let (status, snapshot) = rig.snapshot(&id).await; + assert_eq!(status, 200, "{snapshot}"); + assert_eq!(snapshot["turns"], json!([])); + let launches = rig.ledger_rows("query"); + assert_eq!(launches.len(), 2); + assert_eq!(launches[1]["options"]["sessionId"], id); + assert!(launches[1]["options"]["resume"].is_null()); + rig.fresh_claude.shutdown().await; +} + +#[cfg(target_os = "linux")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_explicit_reopen_reclaims_the_same_closed_zero_turn_uuid() { + for flavor in ["freshclaude", "kilroy"] { + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let created = conn + .request_create("zero-turn-close", json!({"sessionType":flavor})) + .await; + let id = created["sessionId"].as_str().unwrap(); + conn.send(json!({"type":"freshAgent.kill", "provider":"claude", + "sessionType":flavor, "sessionId":id})) + .await; + let killed = conn + .wait_for("the zero-turn close", |frame| { + frame["type"] == "freshAgent.killed" + }) + .await; + assert_eq!(killed["success"], true, "{killed}"); + let reopened = conn + .request_create( + "zero-turn-reopen", + json!({ + "sessionType":flavor, "sessionRef":{"provider":"claude", "sessionId":id} + }), + ) + .await; + assert_eq!(reopened["type"], "freshAgent.created", "{reopened}"); + assert_eq!(reopened["sessionId"], id); + let ledger = rig.pane_ledger.as_ref().unwrap(); + assert_eq!( + ledger.lookup_by_session("claude", id).unwrap().row.state, + RowState::Bound + ); + let (status, snapshot) = rig.snapshot(id).await; + assert_eq!(status, 200, "{snapshot}"); + assert_eq!(snapshot["turns"], json!([])); + let launches = rig.ledger_rows("query"); + assert_eq!(launches.len(), 2); + assert_eq!(launches[1]["options"]["sessionId"], id); + assert!(launches[1]["options"]["resume"].is_null()); + rig.fresh_claude.shutdown().await; + } +} diff --git a/port/oracle/harness/invariants.ts b/port/oracle/harness/invariants.ts index 4d9ef63b4..fd343f278 100644 --- a/port/oracle/harness/invariants.ts +++ b/port/oracle/harness/invariants.ts @@ -166,16 +166,8 @@ export const PROVIDER_ID_SHAPES: Record` placeholder, the - // freshclaude create returns the SDK bridge's BARE nanoid - // (server/sdk-bridge.ts createSession → `const sessionId = nanoid()`, surfaced - // verbatim by the claude adapter + runtime-manager). nanoid()'s default is 21 - // url-safe chars ([A-Za-z0-9_-]); the {16,32} bound tolerates length drift - // while still excluding the 36-char durable UUID below (so placeholder≠durable). - // The pre-existing `^freshclaude-` here was aspirational and never validated - // against a live claude run — see notes/t2-claude-haiku.md. - placeholder: /^[A-Za-z0-9_-]{16,32}$/, + // Fresh Claude and Kilroy use one durable UUID from creation onward. + placeholder: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, durable: /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, }, codex: { diff --git a/test/e2e-browser/fixtures/fake-kilroy-runtime.mjs b/test/e2e-browser/fixtures/fake-kilroy-runtime.mjs index 742aa5111..31901f473 100644 --- a/test/e2e-browser/fixtures/fake-kilroy-runtime.mjs +++ b/test/e2e-browser/fixtures/fake-kilroy-runtime.mjs @@ -112,9 +112,9 @@ rl.on('line', (line) => { async function handle(msg) { if (msg.type === 'create') { - const sessionId = nanoid() + const sessionId = msg.sessionId ?? nanoid() const resumeSessionId = msg.boot?.resume - const cliSessionId = resumeSessionId ?? CLI_SESSION_ID + const cliSessionId = resumeSessionId ?? msg.sessionId ?? CLI_SESSION_ID const cwd = msg.cwd ?? process.cwd() sessions.set(sessionId, sessionState(cliSessionId, cwd)) // `created` MUST be the first line written for this request. diff --git a/test/e2e-browser/helpers/harness-06/kilroy-runtime.test.ts b/test/e2e-browser/helpers/harness-06/kilroy-runtime.test.ts index f2936bc35..82ad61a54 100644 --- a/test/e2e-browser/helpers/harness-06/kilroy-runtime.test.ts +++ b/test/e2e-browser/helpers/harness-06/kilroy-runtime.test.ts @@ -59,17 +59,18 @@ async function readEverythingSoFar(rt: KilroyRuntime, sessionId: string): Promis describe('harness-06 fake kilroy runtime', () => { it('answers create with created-first, init, idle — and records the invocation', async () => { const { rt, logPath } = await make() - rt.send({ type: 'create', requestId: 'r-1', cwd: '/tmp/fixture-cwd', model: 'claude-opus-4-6' }) + const sessionId = '11111111-1111-4111-8111-111111111111' + rt.send({ type: 'create', sessionId, requestId: 'r-1', cwd: '/tmp/fixture-cwd', model: 'claude-opus-4-6' }) const created = (await rt.nextEvent('created')) as { requestId: string; sessionId: string } expect(created.requestId).toBe('r-1') - expect(created.sessionId).toMatch(/^[A-Za-z0-9_-]{16,32}$/) // bare nanoid shape + expect(created.sessionId).toBe(sessionId) const init = (await rt.nextEvent('sdk.session.init', undefined, 5000)) as { sessionId: string; cliSessionId: string; model: string; cwd: string } expect(init.sessionId).toBe(created.sessionId) - expect(init.cliSessionId).toMatch(/^[0-9a-f-]{36}$/) // canonical durable UUID + expect(init.cliSessionId).toBe(sessionId) expect(init.cwd).toBe('/tmp/fixture-cwd') const idle = (await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle', 5000)) diff --git a/test/unit/port/oracle/t2-invariants.test.ts b/test/unit/port/oracle/t2-invariants.test.ts index 4b0cd0f6a..590ba9eef 100644 --- a/test/unit/port/oracle/t2-invariants.test.ts +++ b/test/unit/port/oracle/t2-invariants.test.ts @@ -158,7 +158,7 @@ function goodClaudeObservation(overrides: Partial = {}): T2Observ sentinelToken: 'freshell-t2-ok', sessionCreated: true, - initialSessionId: 'V1StGXR8Z5jdHi6B_myT0', // bare nanoid (21 url-safe chars) + initialSessionId: '11111111-1111-4111-8111-111111111111', // durable at create durableSessionId: '11111111-1111-4111-8111-111111111111', // canonical Claude UUID sessionRef: { provider: 'claude', sessionId: '11111111-1111-4111-8111-111111111111' }, @@ -233,10 +233,10 @@ describe('assertT2Invariants — claude/Haiku (SDK completion edge)', () => { expect(report.results.find((r) => r.name === 'provider.emits-completion-signal')!.ok).toBe(false) }) - // The claude placeholder is the SDK bridge's bare nanoid — NOT `freshclaude-...`. + // Claude uses its durable UUID from create. const claudeShapeMutations: Array<{ name: string; invariant: string; patch: Partial }> = [ - { name: 'placeholder too short (not a nanoid)', invariant: 'session.created', patch: { initialSessionId: 'x' } }, - { name: 'placeholder is a UUID (36 chars, must be the durable field)', invariant: 'session.created', patch: { initialSessionId: '11111111-1111-4111-8111-111111111111' } }, + { name: 'create id is not a UUID', invariant: 'session.created', patch: { initialSessionId: 'x' } }, + { name: 'create id is a retired nanoid', invariant: 'session.created', patch: { initialSessionId: 'V1StGXR8Z5jdHi6B_myT0' } }, { name: 'durable id not a UUID', invariant: 'session.durable-id-shape', patch: { durableSessionId: 'not-a-uuid' } }, { name: 'turn not accepted', invariant: 'turn.accepted', patch: { turnAccepted: false } }, { name: 'reply not persisted', invariant: 'turn.completed', patch: { turnCompleted: false } }, @@ -256,11 +256,9 @@ describe('assertT2Invariants — claude/Haiku (SDK completion edge)', () => { }) } - it('accepts the real bare-nanoid placeholder shape and rejects the old aspirational prefix-only id', () => { - // A bare nanoid passes; a value that is ONLY the retired `freshclaude-` prefix - // with an out-of-range body would not — we assert the real shape is honored. - expect(PROVIDER_ID_SHAPES.claude.placeholder.test('V1StGXR8Z5jdHi6B_myT0')).toBe(true) - expect(PROVIDER_ID_SHAPES.claude.placeholder.test('11111111-1111-4111-8111-111111111111')).toBe(false) + it('accepts a durable UUID from create and rejects a retired nanoid', () => { + expect(PROVIDER_ID_SHAPES.claude.placeholder.test('V1StGXR8Z5jdHi6B_myT0')).toBe(false) + expect(PROVIDER_ID_SHAPES.claude.placeholder.test('11111111-1111-4111-8111-111111111111')).toBe(true) expect(PROVIDER_ID_SHAPES.claude.durable.test('11111111-1111-4111-8111-111111111111')).toBe(true) }) }) From b51bc3489a057a77e0ad9b71a4eebcbab1b07f80 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:57:25 -0700 Subject: [PATCH 011/100] feat(freshclaude): announce conversation resets from the actual next init --- crates/freshell-claude-sidecar/index.mjs | 11 ++++ .../claude-sidecar/conversation-reset.test.ts | 56 +++++++++++++++++++ 2 files changed, 67 insertions(+) create mode 100644 test/unit/claude-sidecar/conversation-reset.test.ts diff --git a/crates/freshell-claude-sidecar/index.mjs b/crates/freshell-claude-sidecar/index.mjs index 9a43400f9..a94e47cea 100644 --- a/crates/freshell-claude-sidecar/index.mjs +++ b/crates/freshell-claude-sidecar/index.mjs @@ -286,6 +286,12 @@ function handleSdkMessage(sessionId, msg) { for (const frame of lifecycleFrames(sessionId, msg, st.lifecycle)) emit(frame) switch (msg.type) { + case 'conversation_reset': { + // The reset's new_conversation_id is a nonce, not the next session UUID + // (pinned CLI T16). Its next init identifies the conversation to follow. + st.pendingConversationReset = true + break + } case 'user': { // The SDK replays actual persisted user messages when this query opts in. // Only the unique UUID that this sidecar supplied for a checkpointed send @@ -296,6 +302,10 @@ function handleSdkMessage(sessionId, msg) { } case 'system': { if (msg.subtype === 'init') { + if (st.pendingConversationReset && isUuid(msg.session_id)) { + st.pendingConversationReset = false + emit({ type: 'sdk.session.rekeyed', sessionId, newSessionId: msg.session_id, reason: 'conversation_reset' }) + } st.cliSessionId = msg.session_id st.settings.cwd ??= msg.cwd st.terminalCommandNames = Array.isArray(msg.terminal_slash_commands) ? msg.terminal_slash_commands : [] @@ -447,6 +457,7 @@ function handleCreate(req) { abort, permissionMode: req.permissionMode, settings: { model: req.model, effort: req.effort, permissionMode: req.permissionMode, cwd: req.cwd }, + pendingConversationReset: false, // False until `created` is sent; until then nothing reaches Rust and // bootEvidence collects what a refusal is named from (boot.mjs). booted: false, diff --git a/test/unit/claude-sidecar/conversation-reset.test.ts b/test/unit/claude-sidecar/conversation-reset.test.ts new file mode 100644 index 000000000..42aedb8c0 --- /dev/null +++ b/test/unit/claude-sidecar/conversation-reset.test.ts @@ -0,0 +1,56 @@ +// @vitest-environment node + +import { randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { startRealSidecar, type RealSidecar } from './harness.js' + +async function sendAndSettle(bridge: RealSidecar, sessionId: string, text: string) { + const uuid = randomUUID() + bridge.send({ type: 'send', sessionId, uuid, text }) + await bridge.waitFor( + (frame) => frame.type === 'sdk.command' && frame.uuid === uuid && frame.state === 'completed', + `the completed ${text} command`, + ) + return uuid +} + +describe('conversation reset identity', () => { + it.each([false, true])('announces the actual post-clear session before its init (warm=%s)', async (warm) => { + const bridge = await startRealSidecar() + const durable = randomUUID() + const sessionId = await bridge.create({ sessionId: durable }) + if (warm) await sendAndSettle(bridge, sessionId, 'before clear') + + const beforeClear = bridge.frames.length + const clearUuid = await sendAndSettle(bridge, sessionId, '/clear') + const cleared = bridge.frames.slice(beforeClear) + const init = cleared.find((frame) => frame.type === 'sdk.session.init')! + expect(init.cliSessionId).not.toBe(durable) + const rekeys = cleared.filter((frame) => frame.type === 'sdk.session.rekeyed') + // T16: conversation_reset.new_conversation_id is a reset nonce. The next + // init supplies the actual durable session UUID, which must be followed. + expect(rekeys).toEqual([{ + type: 'sdk.session.rekeyed', sessionId, + newSessionId: init.cliSessionId, reason: 'conversation_reset', + }]) + expect(cleared.indexOf(rekeys[0])).toBeLessThan(cleared.indexOf(init)) + expect(cleared.find((frame) => frame.type === 'sdk.command' && frame.uuid === clearUuid && frame.state === 'completed')).toBeTruthy() + + // The sidecar's control identity survives the durable rekey. Its next + // prompt runs in the new conversation and creates no second rekey. + await sendAndSettle(bridge, sessionId, 'after clear') + const prompt = await bridge.waitForPrompt(warm ? 3 : 2) + expect(prompt.durable).toBe(init.cliSessionId) + expect(bridge.frames.filter((frame) => frame.type === 'sdk.session.rekeyed')).toHaveLength(1) + }) + + it('does not treat ordinary per-turn init frames as conversation resets', async () => { + const bridge = await startRealSidecar() + const durable = randomUUID() + const sessionId = await bridge.create({ sessionId: durable }) + await sendAndSettle(bridge, sessionId, 'first') + await sendAndSettle(bridge, sessionId, 'second') + expect(bridge.frames.filter((frame) => frame.type === 'sdk.session.init').map((frame) => frame.cliSessionId)).toEqual([durable, durable]) + expect(bridge.frames.filter((frame) => frame.type === 'sdk.session.rekeyed')).toEqual([]) + }) +}) From 48bf918de0afdf790d4b03f79b00e0f065fdad19 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:08:38 -0700 Subject: [PATCH 012/100] fix(freshclaude): preserve REST pane identity and failed split layout --- .../src/claude_rest_lane.rs | 30 +- .../src/claude_rest_lane_tests.rs | 27 +- .../freshell-freshagent/src/layout_store.rs | 94 ++++++ .../src/layout_store_tests.rs | 33 ++ crates/freshell-freshagent/src/lib.rs | 26 +- crates/freshell-freshagent/src/pane_ops.rs | 8 +- crates/freshell-freshagent/src/rest_lane.rs | 23 ++ .../src/rest_lane_create.rs | 3 + .../src/rest_lane_http_tests.rs | 255 +++++++++++++- .../freshell-freshagent/src/rest_lane_sync.rs | 44 +++ .../src/fresh_agent_proxy_rest.rs | 4 +- .../tests/rest_freshclaude_lane.rs | 313 ++++++++++++++++++ 12 files changed, 831 insertions(+), 29 deletions(-) create mode 100644 crates/freshell-freshagent/src/rest_lane_sync.rs create mode 100644 crates/freshell-ws/tests/rest_freshclaude_lane.rs diff --git a/crates/freshell-freshagent/src/claude_rest_lane.rs b/crates/freshell-freshagent/src/claude_rest_lane.rs index 649fa80de..f9e48765a 100644 --- a/crates/freshell-freshagent/src/claude_rest_lane.rs +++ b/crates/freshell-freshagent/src/claude_rest_lane.rs @@ -52,8 +52,8 @@ impl ClaudeRestLane { tab_id: request.tab_id, pane_id: request.pane_id, naming_handle: request.naming_handle, - observed_epoch: None, - observed_generation: None, + observed_epoch: request.observed_epoch, + observed_generation: request.observed_generation, legacy_restore_context: None, resume_session_id: None, }, @@ -76,6 +76,7 @@ impl ClaudeRestLane { } => { let mut error = RestLaneError::new(create_error_kind(&code), code, message); error.retryable = retryable; + error.retry_after_ms = retryable.filter(|retryable| *retryable).map(|_| 250); error.owner = owner; Err(error) } @@ -114,8 +115,8 @@ impl ClaudeRestLane { cwd: None, images: None, settings: None, - observed_epoch: None, - observed_generation: None, + observed_epoch: request.observed_epoch, + observed_generation: request.observed_generation, checkpoint_intent: None, }) .await; @@ -127,7 +128,7 @@ impl ClaudeRestLane { } => (session_key, submitted_turn_id, ownership_id), ClaudeSendOutcome::Refused { code, message } => return Err(send_error(code, message)), }; - let completed = wait_for_turn( + let settled_key = wait_for_turn( &mut events, &session_key, &ownership_id, @@ -136,8 +137,9 @@ impl ClaudeRestLane { Duration::from_millis(request.timeout_ms), ) .await?; + let completed = settled_key.is_some(); Ok(RestLaneSendResult { - session_id: request.session_id.clone(), + session_id: settled_key.unwrap_or_else(|| request.session_id.clone()), completed, submitted_turn_id: Some(submitted_turn_id), noop: false, @@ -307,7 +309,7 @@ async fn wait_for_turn( request_id: &str, submitted_turn_id: &str, budget: Duration, -) -> Result { +) -> Result, RestLaneError> { let waited = tokio::time::timeout(budget, async { loop { match events.recv().await { @@ -316,15 +318,17 @@ async fn wait_for_turn( retired_request_ids, retired_command_ids, .. - }) if settled_key == session_key - && retired_request_ids - .iter() - .any(|retired| retired == request_id) + }) if retired_request_ids + .iter() + .any(|retired| retired == request_id) && retired_command_ids .iter() .any(|retired| retired == submitted_turn_id) => { - return Ok(true) + // /clear moves this runtime to a new conversation key. + // Its minted command UUID and request still identify the + // exact accepted send, including across that key change. + return Ok(Some(settled_key)); } Ok(ClaudeTurnEvent::Ended { ownership_id: ended_owner, @@ -361,7 +365,7 @@ async fn wait_for_turn( } }) .await; - waited.unwrap_or(Ok(false)) + waited.unwrap_or(Ok(None)) } #[cfg(test)] diff --git a/crates/freshell-freshagent/src/claude_rest_lane_tests.rs b/crates/freshell-freshagent/src/claude_rest_lane_tests.rs index df480b711..ea29c2ae2 100644 --- a/crates/freshell-freshagent/src/claude_rest_lane_tests.rs +++ b/crates/freshell-freshagent/src/claude_rest_lane_tests.rs @@ -26,6 +26,8 @@ fn send(text: &str) -> RestLaneSend { request_id: "rest-request".into(), text: text.into(), timeout_ms: 100, + observed_epoch: None, + observed_generation: None, } } @@ -132,7 +134,7 @@ async fn completion_requires_the_accepted_session_uuid_and_request_even_when_bot tokio::pin!(future); assert!(futures_util::poll!(&mut future).is_pending()); for unrelated in [ - settled("another-session", "request", "uuid"), + settled("another-session", "request", "another-uuid"), settled("session", "request", "another-uuid"), settled("session", "another-request", "uuid"), ClaudeTurnEvent::Waiting { @@ -150,7 +152,26 @@ async fn completion_requires_the_accepted_session_uuid_and_request_even_when_bot } // A user-silenced result still settles its sender, despite carrying no bell. tx.send(settled("session", "request", "uuid")).unwrap(); - assert_eq!(future.await, Ok(true)); + assert_eq!(future.await, Ok(Some("session".into()))); +} + +#[tokio::test] +async fn the_accepted_uuid_settles_after_the_runtime_moves_to_a_new_session_key() { + let (tx, mut rx) = tokio::sync::broadcast::channel(16); + tx.send(settled("new-session-key", "request", "uuid")) + .unwrap(); + assert_eq!( + wait_for_turn( + &mut rx, + "accepted-session-key", + "runtime", + "request", + "uuid", + Duration::ZERO + ) + .await, + Ok(Some("new-session-key".into())) + ); } #[tokio::test] @@ -190,6 +211,6 @@ async fn a_deadline_reports_an_incomplete_turn() { Duration::ZERO ) .await, - Ok(false) + Ok(None) ); } diff --git a/crates/freshell-freshagent/src/layout_store.rs b/crates/freshell-freshagent/src/layout_store.rs index 8446498e2..8f55bd932 100644 --- a/crates/freshell-freshagent/src/layout_store.rs +++ b/crates/freshell-freshagent/src/layout_store.rs @@ -1105,6 +1105,87 @@ impl LayoutStore { } } + /// Remove an unannounced split leaf after its runtime failed to launch. + /// Preserve the surrounding split IDs, direction, sizes and any content + /// updates that arrived while creation awaited the provider. + pub(crate) fn rollback_split(&self, pane_id: &str) -> bool { + let mut inner = self.lock(); + let mut removed = false; + for snapshot in inner.snapshots_mut() { + let tab_id = snapshot.layouts.iter_mut().find_map(|(tab_id, root)| { + collapse_split_leaf(root, pane_id).then(|| tab_id.clone()) + }); + if let Some(tab_id) = tab_id { + remove_pane_metadata(snapshot, &tab_id, pane_id); + removed = true; + } + } + if removed { + inner.recovery_stamps.remove(pane_id); + persist_locked(&inner); + } + removed + } + + /// A settled send can change its live conversation (/clear). Update only + /// the pane that still names the accepted conversation; a concurrent + /// replacement owns its newer content. The same lock guards that check + /// and the write, and the recovery stamp protects an old client mirror. + pub(crate) fn rekey_agent_pane_if_current( + &self, + pane_id: &str, + provider: &str, + session_type: &str, + before: &str, + after: &str, + ) -> bool { + let matches = |content: &Value| { + let identity = content + .get("sessionRef") + .filter(|reference| reference["provider"].as_str() == Some(provider)) + .and_then(|reference| reference["sessionId"].as_str()) + .filter(|id| !id.is_empty()) + .or_else(|| content["sessionId"].as_str()); + content["kind"] == "fresh-agent" + && content["provider"].as_str() == Some(provider) + && content["sessionType"].as_str() == Some(session_type) + && identity == Some(before) + }; + let mut inner = self.lock(); + let Some(pre_recovery) = inner + .snapshots() + .find_map(|snapshot| pane_content_in_snapshot(snapshot, pane_id)) + else { + return false; + }; + if !matches(&pre_recovery) { + return false; + } + for snapshot in inner.snapshots_mut() { + for root in snapshot.layouts.values_mut() { + let Some(PaneNode::Leaf { content, .. }) = root.find_leaf(pane_id) else { + continue; + }; + if !matches(content) { + continue; + } + let mut current = content.clone(); + current["sessionId"] = json!(after); + current["sessionRef"] = json!({ "provider":provider, "sessionId":after }); + root.replace_leaf_content(pane_id, current); + break; + } + } + inner.recovery_stamps.insert( + pane_id.to_string(), + RecoveryStamp { + pre_recovery_content: pre_recovery, + }, + ); + persist_locked(&inner); + true + } + /// Re-seeds the derived title (non-sticky). Runs the legacy content /// migration first (`attachPaneContent`, `layout-store.ts:680-694`). pub fn attach_pane_content( @@ -1687,6 +1768,19 @@ fn close_pane_in(snapshot: &mut UiSnapshot, pane_id: &str) -> Option bool { + let PaneNode::Split { children, .. } = root else { + return false; + }; + for removed in 0..2 { + if matches!(&children[removed], PaneNode::Leaf { id, .. } if id == pane_id) { + *root = children[1 - removed].clone(); + return true; + } + } + collapse_split_leaf(&mut children[0], pane_id) || collapse_split_leaf(&mut children[1], pane_id) +} + fn find_pane_tab(snapshot: &UiSnapshot, pane_id: &str) -> Option { snapshot .tabs diff --git a/crates/freshell-freshagent/src/layout_store_tests.rs b/crates/freshell-freshagent/src/layout_store_tests.rs index d2ee437a3..32eb780b5 100644 --- a/crates/freshell-freshagent/src/layout_store_tests.rs +++ b/crates/freshell-freshagent/src/layout_store_tests.rs @@ -23,6 +23,39 @@ fn split(id: &str, direction: &str, sizes: [i64; 2], a: Value, b: Value) -> Valu }) } +#[test] +fn failed_split_rollback_preserves_the_surrounding_tree_and_later_content_updates() { + let store = LayoutStore::default(); + let original = split( + "existing-split", + "vertical", + [30, 70], + leaf("p1", json!({"kind":"terminal", "terminalId":"old"})), + leaf("p2", json!({"kind":"browser", "url":"https://example.com"})), + ); + store.update_from_ui( + &sync_from(json!({ + "tabs":[{"id":"tab"}], "activeTabId":"tab", "layouts":{"tab":original}, + "activePane":{"tab":"p1"}, "timestamp":1, + })), + "client", + ); + let (_, temporary) = store.split_pane("p1", "horizontal").unwrap(); + store.attach_pane_content( + "tab", + "p1", + json!({"kind":"terminal", "terminalId":"replacement"}), + ); + assert!(store.rollback_split(&temporary)); + let mut expected = original; + expected["children"][0]["content"]["terminalId"] = json!("replacement"); + let snapshot = store.get_normalized_snapshot(None); + assert_eq!(snapshot["layouts"]["tab"], expected); + assert_eq!(snapshot["activePane"]["tab"], "p1"); + assert!(store.get_pane_snapshot(&temporary).is_none()); + assert!(!store.rollback_split(&temporary)); +} + /// One tab `t1`, single claude-terminal leaf `p1`. fn single_pane_sync( pane_titles: Option, diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index 316533df2..72ef90ff6 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -79,6 +79,7 @@ mod process_probe; pub(crate) mod quiet_deadman; pub mod rest_lane; pub(crate) mod rest_lane_create; +mod rest_lane_sync; pub mod rollback_record; pub mod session_handoff; pub mod session_lease; @@ -6375,27 +6376,40 @@ async fn send_keys( .unwrap_or(DEFAULT_TURN_TIMEOUT); if let Some(gateway) = state.rest_lane_for(&pane.provider) { + let (observed_epoch, observed_generation) = match rest_lane::observed_fence(&body) { + Ok(fence) => fence, + Err(error) => return rest_lane_failure(error), + }; let request_id = body .get("requestId") .and_then(Value::as_str) .filter(|value| !value.is_empty()) .map(str::to_string) .unwrap_or_else(|| Uuid::new_v4().simple().to_string()); + let addressed_session_id = pane + .durable_id + .clone() + .unwrap_or_else(|| pane.placeholder_id.clone()); let result = gateway .send_agent(rest_lane::RestLaneSend { request_id: request_id.clone(), - session_id: pane - .durable_id - .clone() - .unwrap_or_else(|| pane.placeholder_id.clone()), + session_id: addressed_session_id.clone(), provider: pane.provider.clone(), session_type: pane.session_type.clone(), text, timeout_ms: u64::try_from(turn_timeout.as_millis()).unwrap_or(u64::MAX), + observed_epoch, + observed_generation, }) .await; return match result { Ok(result) => { + state.reconcile_rest_send_session( + &pane_id, + &pane, + &addressed_session_id, + &result.session_id, + ); let mut data = json!({ "paneId":pane_id, "sessionId":result.session_id, @@ -7095,11 +7109,11 @@ async fn capture( Ok(capture) => text_plain(capture.text), Err(rest_lane::RestLaneCaptureError::Unsupported) => fail_json( StatusCode::UNPROCESSABLE_ENTITY, - "hosted fresh-agent provider does not support transcript capture".to_string(), + "fresh-agent provider does not support transcript capture".to_string(), ), Err(rest_lane::RestLaneCaptureError::Unavailable) => fail_json( StatusCode::SERVICE_UNAVAILABLE, - "hosted fresh-agent transcript is temporarily unavailable".to_string(), + "fresh-agent transcript is temporarily unavailable".to_string(), ), Err(rest_lane::RestLaneCaptureError::NotFound) => fail_json( StatusCode::NOT_FOUND, diff --git a/crates/freshell-freshagent/src/pane_ops.rs b/crates/freshell-freshagent/src/pane_ops.rs index 0b257197c..625c9f6bb 100644 --- a/crates/freshell-freshagent/src/pane_ops.rs +++ b/crates/freshell-freshagent/src/pane_ops.rs @@ -184,14 +184,14 @@ pub(crate) async fn split_pane( let new_content = if let Some(agent) = body.get("agent").and_then(Value::as_str) { let Some((provider, session_type)) = rest_agent_mode(agent) else { - let _ = state.layout.close_pane(&new_pane_id); + state.layout.rollback_split(&new_pane_id); return fail_json( StatusCode::BAD_REQUEST, format!("unknown agent \"{agent}\""), ); }; let Some(gateway) = state.rest_lane_for(provider) else { - let _ = state.layout.close_pane(&new_pane_id); + state.layout.rollback_split(&new_pane_id); return fail_json( StatusCode::SERVICE_UNAVAILABLE, "durable fresh-agent gateway is not installed".to_string(), @@ -203,7 +203,7 @@ pub(crate) async fn split_pane( { Ok(request) => request, Err(error) => { - let _ = state.layout.close_pane(&new_pane_id); + state.layout.rollback_split(&new_pane_id); return rest_lane_failure(error); } }; @@ -212,7 +212,7 @@ pub(crate) async fn split_pane( let created = match gateway.create_agent(request.clone()).await { Ok(created) => created, Err(error) => { - let _ = state.layout.close_pane(&new_pane_id); + state.layout.rollback_split(&new_pane_id); return rest_lane_failure(error); } }; diff --git a/crates/freshell-freshagent/src/rest_lane.rs b/crates/freshell-freshagent/src/rest_lane.rs index 8e8449afd..c28ed4e81 100644 --- a/crates/freshell-freshagent/src/rest_lane.rs +++ b/crates/freshell-freshagent/src/rest_lane.rs @@ -99,6 +99,25 @@ pub fn provider_inputs(body: &Value) -> Result { }) } +pub(crate) fn observed_fence(body: &Value) -> Result<(Option, Option), RestLaneError> { + let read = |field: &str| match body.get(field) { + None | Some(Value::Null) => Ok(None), + Some(value) => value.as_u64().map(Some).ok_or_else(|| { + RestLaneError::new( + RestLaneErrorKind::BadRequest, + "INVALID_FENCE", + format!("{field} must be an unsigned integer"), + ) + }), + }; + let epoch = read("observedEpoch")?; + let generation = read("observedGeneration")?; + crate::ownership_lane::wire_fence(epoch, generation).map_err(|error| { + RestLaneError::new(RestLaneErrorKind::BadRequest, error.code(), error.message()) + })?; + Ok((epoch, generation)) +} + #[derive(Clone, Debug, Default, PartialEq)] pub struct RestLaneCreate { pub request_id: String, @@ -116,6 +135,8 @@ pub struct RestLaneCreate { pub pane_id: Option, pub naming_handle: Option, pub model_effort_levels: Option>, + pub observed_epoch: Option, + pub observed_generation: Option, } #[derive(Clone, Debug, Default, PartialEq)] @@ -134,6 +155,8 @@ pub struct RestLaneSend { pub session_type: String, pub text: String, pub timeout_ms: u64, + pub observed_epoch: Option, + pub observed_generation: Option, } #[derive(Clone, Debug, Default, PartialEq, Eq)] diff --git a/crates/freshell-freshagent/src/rest_lane_create.rs b/crates/freshell-freshagent/src/rest_lane_create.rs index 7584ea7f3..8c9db9000 100644 --- a/crates/freshell-freshagent/src/rest_lane_create.rs +++ b/crates/freshell-freshagent/src/rest_lane_create.rs @@ -25,6 +25,7 @@ impl FreshAgentState { let invalid = |message| RestLaneError::new(RestLaneErrorKind::BadRequest, "INVALID_REQUEST", message); let inputs = rest_lane::provider_inputs(body).map_err(invalid)?; + let (observed_epoch, observed_generation) = rest_lane::observed_fence(body)?; let native_session_id = match body.get("sessionRef") { None => None, Some(value) => match serde_json::from_value::(value.clone()) { @@ -56,6 +57,8 @@ impl FreshAgentState { model_selection: inputs.model_selection, permission_mode: inputs.permission_mode, sandbox: inputs.sandbox, + observed_epoch, + observed_generation, ..Default::default() }; if provider == "claude" { diff --git a/crates/freshell-freshagent/src/rest_lane_http_tests.rs b/crates/freshell-freshagent/src/rest_lane_http_tests.rs index 99021fdf8..eadcc6740 100644 --- a/crates/freshell-freshagent/src/rest_lane_http_tests.rs +++ b/crates/freshell-freshagent/src/rest_lane_http_tests.rs @@ -323,6 +323,8 @@ async fn hosted_gateway_takes_precedence_over_the_local_claude_lane() { struct ControlLane { sends: std::sync::Mutex>, captures: std::sync::Mutex>, + materialized_session_id: Option, + concurrent_replacement: Option<(crate::layout_store::LayoutStore, String, String, Value)>, } #[async_trait::async_trait] @@ -338,8 +340,17 @@ impl FreshAgentRestLane for ControlLane { } async fn send_agent(&self, request: RestLaneSend) -> Result { let noop = request.text == "\n"; + let first = self.sends.lock().unwrap().is_empty(); + if first { + if let Some((layout, tab_id, pane_id, content)) = &self.concurrent_replacement { + layout.attach_pane_content(tab_id, pane_id, content.clone()); + } + } let result = RestLaneSendResult { - session_id: request.session_id.clone(), + session_id: first + .then(|| self.materialized_session_id.clone()) + .flatten() + .unwrap_or_else(|| request.session_id.clone()), completed: true, submitted_turn_id: (!noop).then(|| "actual-submitted-uuid".into()), noop, @@ -362,6 +373,82 @@ impl FreshAgentRestLane for ControlLane { } } +#[tokio::test] +async fn settled_session_identity_updates_headless_layout_for_later_send_and_capture() { + const NEXT_ID: &str = "67cebd37-9576-4de0-93bb-8f286f5fc499"; + let state = state_with_registry(); + let lane = Arc::new(ControlLane { + materialized_session_id: Some(NEXT_ID.into()), + ..Default::default() + }); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state); + let (_, created) = post(router.clone(), "/api/tabs", json!({"agent":"claude"}), true).await; + let pane = created["data"]["paneId"].as_str().unwrap(); + for text in ["/clear", "next turn"] { + let (status, body) = post( + router.clone(), + &format!("/api/panes/{pane}/send-keys"), + json!({"data":text}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!(body["data"]["sessionId"], NEXT_ID); + } + assert_eq!(lane.sends.lock().unwrap()[1].session_id, NEXT_ID); + assert_eq!(capture_response(router, pane).await.0, StatusCode::OK); + assert_eq!( + lane.captures.lock().unwrap().last().unwrap().session_id, + NEXT_ID + ); +} + +#[tokio::test] +async fn an_old_send_settling_does_not_overwrite_a_concurrent_pane_replacement() { + const SETTLED_ID: &str = "67cebd37-9576-4de0-93bb-8f286f5fc499"; + const REPLACED_ID: &str = "39a6e5a0-3601-4c71-a878-d8da4db265c0"; + let state = state_with_registry(); + let (tab, pane) = state.layout.create_tab(None); + state.layout.attach_pane_content(&tab, &pane, json!({"kind":"fresh-agent", "provider":"claude", "sessionType":"freshclaude", "sessionId":"prior-session"})); + let lane = Arc::new(ControlLane { + materialized_session_id: Some(SETTLED_ID.into()), + concurrent_replacement: Some(( + state.layout.clone(), + tab, + pane.clone(), + json!({ + "kind":"fresh-agent", "provider":"claude", "sessionType":"freshclaude", "sessionId":REPLACED_ID, + "sessionRef":{"provider":"claude", "sessionId":REPLACED_ID}, "model":"replacement-model", + }), + )), + ..Default::default() + }); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state.clone()); + let (status, body) = post( + router.clone(), + &format!("/api/panes/{pane}/send-keys"), + json!({"data":"/clear"}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let content = state + .layout + .get_pane_snapshot(&pane) + .unwrap() + .pane_content + .unwrap(); + assert_eq!(content["sessionId"], REPLACED_ID); + assert_eq!(content["model"], "replacement-model"); + assert_eq!(capture_response(router, &pane).await.0, StatusCode::OK); + assert_eq!( + lane.captures.lock().unwrap().last().unwrap().session_id, + REPLACED_ID + ); +} + async fn capture_response(router: axum::Router, pane_id: &str) -> (StatusCode, String) { use tower::util::ServiceExt; let response = router @@ -474,3 +561,169 @@ async fn lane_controls_use_the_current_layout_identity_after_a_rest_created_pane assert_eq!(capture.session_id, CURRENT_ID); assert_eq!(capture.session_type, "kilroy"); } + +#[tokio::test] +async fn actual_claude_create_conflict_preserves_the_refusing_owner_and_retry_hint() { + use freshell_ownership::{ + BeginOutcome, CommitOutcome, OwnerIdentity, RuntimeOwnerKind, RuntimeOwnershipRegistry, + }; + const ID: &str = "67cebd37-9576-4de0-93bb-8f286f5fc499"; + let owner = Arc::new(RuntimeOwnershipRegistry::with_epoch(41)); + let BeginOutcome::Granted { generation } = owner.begin_start( + "claude", + ID, + RuntimeOwnerKind::Terminal, + "terminal-create", + None, + "test", + 1, + ) else { + panic!("terminal claim granted") + }; + assert_eq!( + owner.commit_live( + "claude", + ID, + "terminal-create", + generation, + OwnerIdentity { + kind: RuntimeOwnerKind::Terminal, + terminal_id: Some("existing-terminal".into()), + live_session_key: None, + pid: None, + ownership_id: None, + } + ), + CommitOutcome::Committed + ); + let state = state_with_registry(); + let mut claude = crate::FreshClaudeState::new(Arc::new(tokio::sync::broadcast::channel(16).0)); + claude.set_ownership(owner); + state + .set_claude_rest_lane(Arc::new(crate::claude_rest_lane::ClaudeRestLane::new( + claude, + Arc::new(|| true), + ))) + .unwrap(); + let router = app(state); + let (status, body) = post( + router.clone(), + "/api/tabs", + json!({"agent":"claude", "sessionRef":{"provider":"claude", "sessionId":ID}}), + true, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT, "{body}"); + assert_eq!(body["code"], "SESSION_RESERVED"); + assert_eq!(body["ownerKind"], "terminal"); + assert_eq!(body["ownerEpoch"], 41); + assert_eq!(body["ownerGeneration"], generation); + assert_eq!(body["retryable"], true); + assert!( + body["retryAfterMs"].as_u64().is_some_and(|delay| delay > 0), + "{body}" + ); + let (_, tabs) = get(router, "/api/tabs", true).await; + assert!(tabs["data"]["tabs"].as_array().unwrap().is_empty()); +} + +#[tokio::test] +async fn observed_ownership_pair_reaches_the_create_and_send_lanes_unchanged() { + let state = state_with_registry(); + let creates = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + state.set_claude_rest_lane(creates.clone()).unwrap(); + let (status, body) = post( + app(state), + "/api/tabs", + json!({"agent":"claude", "observedEpoch":41, "observedGeneration":7}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let created = creates.creates.lock().unwrap().last().unwrap().clone(); + assert_eq!( + (created.observed_epoch, created.observed_generation), + (Some(41), Some(7)) + ); + let state = state_with_registry(); + let (tab, pane) = state.layout.create_tab(None); + state.layout.attach_pane_content(&tab, &pane, json!({"kind":"fresh-agent", "provider":"claude", "sessionType":"freshclaude", "sessionId":"session"})); + let sends = Arc::new(ControlLane::default()); + state.set_claude_rest_lane(sends.clone()).unwrap(); + let (status, body) = post( + app(state), + &format!("/api/panes/{pane}/send-keys"), + json!({"data":"continue", "observedEpoch":41, "observedGeneration":7}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let sent = sends.sends.lock().unwrap().last().unwrap().clone(); + assert_eq!( + (sent.observed_epoch, sent.observed_generation), + (Some(41), Some(7)) + ); +} + +#[tokio::test] +async fn actual_claude_spawn_failure_is_unavailable_and_leaves_no_tab() { + let _guard = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; + struct NodeOverride(Option); + impl Drop for NodeOverride { + fn drop(&mut self) { + match self.0.take() { + Some(value) => std::env::set_var("FRESHELL_CLAUDE_NODE", value), + None => std::env::remove_var("FRESHELL_CLAUDE_NODE"), + } + } + } + let root = tempfile::tempdir().unwrap(); + let _restore = NodeOverride(std::env::var_os("FRESHELL_CLAUDE_NODE")); + std::env::set_var("FRESHELL_CLAUDE_NODE", root.path().join("missing-node")); + let state = state_with_registry(); + let claude = crate::FreshClaudeState::new(Arc::new(tokio::sync::broadcast::channel(16).0)); + state + .set_claude_rest_lane(Arc::new(crate::claude_rest_lane::ClaudeRestLane::new( + claude, + Arc::new(|| true), + ))) + .unwrap(); + let router = app(state); + let (status, body) = post( + router.clone(), + "/api/tabs", + json!({"agent":"claude", "cwd":root.path().to_str().unwrap()}), + true, + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{body}"); + assert_eq!(body["code"], "CLAUDE_SIDECAR_START_FAILED"); + let (_, tabs) = get(router, "/api/tabs", true).await; + assert!(tabs["data"]["tabs"].as_array().unwrap().is_empty()); +} + +#[tokio::test] +async fn malformed_or_incomplete_rest_fences_refuse_before_provider_creation() { + let state = state_with_registry(); + let lane = Arc::new(ContextLane { + layout: state.layout.clone(), + creates: Default::default(), + }); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state); + for body in [ + json!({"agent":"claude", "observedEpoch":41}), + json!({"agent":"claude", "observedEpoch":"41", "observedGeneration":7}), + json!({"agent":"claude", "observedEpoch":41, "observedGeneration":-1}), + ] { + let (status, body) = post(router.clone(), "/api/tabs", body, true).await; + assert_eq!(status, StatusCode::BAD_REQUEST, "{body}"); + assert_eq!(body["code"], "INVALID_FENCE"); + } + assert!(lane.creates.lock().unwrap().is_empty()); + let (_, tabs) = get(router, "/api/tabs", true).await; + assert!(tabs["data"]["tabs"].as_array().unwrap().is_empty()); +} diff --git a/crates/freshell-freshagent/src/rest_lane_sync.rs b/crates/freshell-freshagent/src/rest_lane_sync.rs new file mode 100644 index 000000000..953cb0011 --- /dev/null +++ b/crates/freshell-freshagent/src/rest_lane_sync.rs @@ -0,0 +1,44 @@ +//! Carry a REST-driven conversation rekey into its headless pane identity. + +use crate::{FreshAgentState, PaneEntry}; + +impl FreshAgentState { + pub(crate) fn reconcile_rest_send_session( + &self, + pane_id: &str, + accepted: &PaneEntry, + before: &str, + after: &str, + ) { + if before == after || after.is_empty() { + return; + } + let updated = self.layout.rekey_agent_pane_if_current( + pane_id, + &accepted.provider, + &accepted.session_type, + before, + after, + ); + tracing::info!( + event = "freshagent.rest_lane.send_identity", + pane_id, + provider = accepted.provider, + before, + after, + layout_updated = updated + ); + if !updated && self.layout.get_pane_snapshot(pane_id).is_some() { + return; + } + let mut panes = self.panes.lock().expect("panes mutex"); + if let Some(pane) = panes.get_mut(pane_id).filter(|pane| { + pane.provider == accepted.provider + && pane.session_type == accepted.session_type + && pane.durable_id.as_deref().unwrap_or(&pane.placeholder_id) == before + }) { + pane.placeholder_id = after.into(); + pane.durable_id = Some(after.into()); + } + } +} diff --git a/crates/freshell-server/src/fresh_agent_proxy_rest.rs b/crates/freshell-server/src/fresh_agent_proxy_rest.rs index 3f14c67a5..b111953bb 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_rest.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_rest.rs @@ -137,8 +137,8 @@ impl FreshAgentRestLane for HostedFreshAgentProxy { legacy_restore_context: None, model: request.model, model_selection: request.model_selection, - observed_epoch: None, - observed_generation: None, + observed_epoch: request.observed_epoch, + observed_generation: request.observed_generation, permission_mode: request.permission_mode, plugins: request.plugins, provider: Some(provider), diff --git a/crates/freshell-ws/tests/rest_freshclaude_lane.rs b/crates/freshell-ws/tests/rest_freshclaude_lane.rs new file mode 100644 index 000000000..e1c2ce9bc --- /dev/null +++ b/crates/freshell-ws/tests/rest_freshclaude_lane.rs @@ -0,0 +1,313 @@ +//! The default REST/MCP door controls the real WebSocket Claude runtime. +#![cfg(unix)] + +mod common; + +use common::claude_rig::{ClaudeRig, RigKind, RigOptions, RIG_FRAME_BUDGET}; +use serde_json::{json, Value}; +use std::time::Duration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +fn options() -> RigOptions { + RigOptions { + rest_api: true, + durable_ledger: true, + ..Default::default() + } +} + +async fn get(rig: &ClaudeRig, path: &str) -> (u16, String) { + let host = rig.http_url.strip_prefix("http://").unwrap(); + let mut stream = tokio::net::TcpStream::connect(host).await.unwrap(); + stream.write_all(format!("GET {path} HTTP/1.1\r\nHost: {host}\r\nx-auth-token: {}\r\nConnection: close\r\n\r\n", common::AUTH_TOKEN).as_bytes()).await.unwrap(); + let mut raw = Vec::new(); + tokio::time::timeout(RIG_FRAME_BUDGET, stream.read_to_end(&mut raw)) + .await + .unwrap() + .unwrap(); + let response = String::from_utf8(raw).unwrap(); + let (head, body) = response.split_once("\r\n\r\n").unwrap(); + ( + head.split_whitespace().nth(1).unwrap().parse().unwrap(), + body.to_string(), + ) +} + +async fn create(rig: &ClaudeRig, agent: &str) -> Value { + let (status, body) = rig + .post_json( + "/api/tabs", + &json!({"agent":agent, "cwd":rig.workspace.to_str().unwrap()}), + ) + .await; + assert_eq!(status, 200, "{body}"); + body["data"].clone() +} + +async fn send(rig: &ClaudeRig, pane: &str, text: &str) -> Value { + let (status, body) = rig + .post_json( + &format!("/api/panes/{pane}/send-keys"), + &json!({"data":text, "requestId":"rest-request", "timeout":5}), + ) + .await; + assert_eq!(status, 200, "{body}"); + body["data"].clone() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rest_create_send_capture_and_ws_create_replay_share_one_writer() { + let rig = ClaudeRig::start_with_options(RigKind::Sim, options()).await; + let mut observer = rig.connect().await; + let created = create(&rig, "claude").await; + let id = created["sessionId"].as_str().unwrap(); + let pane = created["paneId"].as_str().unwrap(); + assert!( + uuid::Uuid::parse_str(id).is_ok(), + "REST exposes the durable UUID immediately: {created}" + ); + assert_eq!( + created["sessionRef"], + json!({"provider":"claude", "sessionId":id}) + ); + let ws_created = observer + .wait_for("REST-created session", |frame| { + frame["type"] == "freshAgent.created" && frame["sessionId"] == id + }) + .await; + let command = observer + .wait_for("REST tab command", |frame| { + frame["type"] == "ui.command" && frame["command"] == "tab.create" + }) + .await; + let content = &command["payload"]["paneContent"]; + assert_eq!(content["createRequestId"], ws_created["requestId"]); + assert_eq!(content["sessionId"], id); + assert_eq!(content["model"], "opus[1m]"); + assert_eq!(content["permissionMode"], "bypassPermissions"); + assert_eq!(content["effort"], "high"); + assert_eq!(content["status"], "connected"); + let mut second_browser = rig.connect().await; + let replay = second_browser + .request_create( + content["createRequestId"].as_str().unwrap(), + json!({"paneId":pane, "tabId":created["tabId"]}), + ) + .await; + assert_eq!(replay["sessionId"], id); + assert_eq!( + rig.query_options().len(), + 1, + "REST and browser deduplicate at the same runtime create ingress" + ); + let (status, empty) = get(&rig, &format!("/api/panes/{pane}/capture")).await; + assert_eq!(status, 200, "{empty}"); + assert!(empty.is_empty()); + let sent = send(&rig, pane, "héllo\r").await; + assert_eq!(sent["status"], "idle"); + let prompts = rig.ledger_rows("prompt"); + assert_eq!(prompts.len(), 1); + assert_eq!(prompts[0]["text"], "héllo"); + assert_eq!(prompts[0]["model"], "opus[1m]"); + assert_eq!(prompts[0]["effort"], "high"); + assert_eq!(prompts[0]["permissionMode"], "bypassPermissions"); + assert_eq!(sent["submittedTurnId"], prompts[0]["uuid"]); + assert_ne!(sent["submittedTurnId"], "rest-request"); + observer + .wait_for("REST turn completion", |frame| { + frame["event"]["type"] == "freshAgent.turn.complete" && frame["sessionId"] == id + }) + .await; + let (status, text) = get(&rig, &format!("/api/panes/{pane}/capture")).await; + assert_eq!(status, 200, "{text}"); + assert!(text.contains("user: héllo"), "{text}"); + assert!(text.contains("assistant: ok"), "{text}"); + let (status, bounded) = get(&rig, &format!("/api/panes/{pane}/capture?maxBytes=8")).await; + assert_eq!(status, 200); + assert!(bounded.len() <= 8); + assert!(!bounded.is_empty()); + let noop = send(&rig, pane, "\r\n").await; + assert_eq!(noop["status"], "noop"); + assert!(noop.get("submittedTurnId").is_none()); + assert_eq!(rig.ledger_rows("prompt").len(), 1); + send(&rig, pane, "second\r\n").await; + assert_eq!( + rig.ledger_rows("prompt")[1]["text"], + "second\r", + "strip exactly one final line terminator" + ); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn local_split_uses_kilroy_and_rolls_back_a_disabled_launch() { + let rig = ClaudeRig::start_with_options(RigKind::Sim, options()).await; + let mut observer = rig.connect().await; + let original = create(&rig, "claude").await; + let source = original["paneId"].as_str().unwrap(); + let (status, split) = rig.post_json(&format!("/api/panes/{source}/split"), &json!({"agent":"kilroy", "cwd":rig.workspace.to_str().unwrap(), "direction":"vertical"})).await; + assert_eq!(status, 200, "{split}"); + let pane = split["data"]["paneId"].as_str().unwrap(); + let command = observer + .wait_for("Kilroy split command", |frame| { + frame["type"] == "ui.command" && frame["command"] == "pane.split" + }) + .await; + assert_eq!(command["payload"]["newContent"]["sessionType"], "kilroy"); + assert_eq!(command["payload"]["newContent"]["provider"], "claude"); + assert!(command["payload"]["newContent"] + .get("namingHandle") + .is_none()); + assert_eq!(send(&rig, pane, "kilroy hello").await["status"], "idle"); + observer.read_for(Duration::from_millis(50)).await; + let announcements = observer + .frames + .iter() + .filter(|frame| frame["type"] == "ui.command") + .count(); + let (_, before) = get(&rig, "/api/layout/snapshot").await; + rig.fresh_codex.set_enabled(false); + let (status, refused) = rig + .post_json( + &format!("/api/panes/{source}/split"), + &json!({"agent":"claude"}), + ) + .await; + assert_eq!(status, 403, "{refused}"); + assert_eq!(refused["code"], "FRESH_AGENT_DISABLED"); + observer.read_for(Duration::from_millis(50)).await; + assert_eq!( + observer + .frames + .iter() + .filter(|frame| frame["type"] == "ui.command") + .count(), + announcements, + "a failed launch announces no pane create or split" + ); + let (_, after) = get(&rig, "/api/layout/snapshot").await; + let before: Value = serde_json::from_str(&before).unwrap(); + let after: Value = serde_json::from_str(&after).unwrap(); + assert_eq!( + after["data"]["layouts"], before["data"]["layouts"], + "failed split leaves the previous layout intact" + ); + let (status, refused) = rig.post_json("/api/tabs", &json!({"agent":"claude"})).await; + assert_eq!(status, 403, "{refused}"); + assert_eq!(rig.query_options().len(), 2); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rest_controls_a_browser_created_pane_and_capture_survives_server_restart() { + let mut rig = ClaudeRig::start_with_options(RigKind::Sim, options()).await; + let mut browser = rig.connect().await; + let answer = browser + .request_create( + "browser-create", + json!({"tabId":"browser-tab", "paneId":"browser-pane"}), + ) + .await; + assert_eq!(answer["type"], "freshAgent.created", "{answer}"); + let id = answer["sessionId"].as_str().unwrap(); + browser.send(json!({ + "type":"ui.layout.sync", "tabs":[{"id":"browser-tab"}], "activeTabId":"browser-tab", + "layouts":{"browser-tab":{"type":"leaf", "id":"browser-pane", "content":{ + "kind":"fresh-agent", "provider":"claude", "sessionType":"freshclaude", "sessionId":id, + "sessionRef":{"provider":"claude", "sessionId":id}, "createRequestId":"browser-create", + }}}, "activePane":{"browser-tab":"browser-pane"}, "timestamp":1, + })).await; + browser.send(json!({"type":"ping"})).await; + browser + .wait_for("layout sync ordering barrier", |frame| { + frame["type"] == "pong" + }) + .await; + assert_eq!( + send(&rig, "browser-pane", "browser pane via REST").await["status"], + "idle" + ); + let (status, before) = get(&rig, "/api/panes/browser-pane/capture").await; + assert_eq!(status, 200, "{before}"); + assert!(before.contains("browser pane via REST")); + rig.restart().await; + let (status, after) = get(&rig, "/api/panes/browser-pane/capture").await; + assert_eq!(status, 200, "{after}"); + assert_eq!(after, before, "the restored layout resolves durable transcript capture without a REST pane row or live runtime"); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rest_deadline_is_approx_and_a_runtime_exit_is_a_retryable_conflict() { + let mut config = options(); + config.env.push(( + "FRESHELL_CLAUDE_SIM_PROGRAM".into(), + json!({"rules":[{ + "on":"msg:prompt", "match":{"text":"crash"}, "emit":[{"kind":"crash"}], + }]}) + .to_string(), + )); + let rig = ClaudeRig::start_with_options(RigKind::Sim, config).await; + let created = create(&rig, "claude").await; + let pane = created["paneId"].as_str().unwrap(); + let (status, body) = rig + .post_json( + &format!("/api/panes/{pane}/send-keys"), + &json!({"data":"HANG", "timeout":1}), + ) + .await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["status"], "approx"); + assert_eq!(body["data"]["status"], "approx"); + assert!(body["data"]["submittedTurnId"].as_str().is_some()); + let crashing = create(&rig, "claude").await; + let pane = crashing["paneId"].as_str().unwrap(); + let (status, ended) = rig + .post_json( + &format!("/api/panes/{pane}/send-keys"), + &json!({"data":"crash", "timeout":10}), + ) + .await; + assert_eq!(status, 409, "{ended}"); + assert_eq!(ended["code"], "FRESH_AGENT_SESSION_ENDED"); + assert_eq!(ended["retryable"], true); + assert!(ended["retryAfterMs"] + .as_u64() + .is_some_and(|delay| delay > 0)); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn actual_fenced_send_refusal_reports_its_owner_and_keeps_the_prompt_unsent() { + let rig = ClaudeRig::start_with_options(RigKind::Sim, options()).await; + let created = create(&rig, "claude").await; + let id = created["sessionId"].as_str().unwrap(); + let pane = created["paneId"].as_str().unwrap(); + let owner = rig.fresh_claude.ownership_snapshot("claude", id); + assert!( + owner.generation > 0, + "the runtime owns a committed generation" + ); + let route = format!("/api/panes/{pane}/send-keys"); + let (status, refused) = rig.post_json(&route, &json!({ + "data":"stale prompt", "observedEpoch":owner.epoch, "observedGeneration":owner.generation - 1, + })).await; + assert_eq!(status, 409, "{refused}"); + assert_eq!(refused["code"], "SESSION_RESERVED"); + assert_eq!(refused["retryable"], true); + assert_eq!(refused["ownerKind"], "fresh-agent"); + assert_eq!(refused["ownerEpoch"], owner.epoch); + assert_eq!(refused["ownerGeneration"], owner.generation); + assert!(refused["retryAfterMs"] + .as_u64() + .is_some_and(|delay| delay > 0)); + assert!(rig.ledger_rows("prompt").is_empty()); + let (status, sent) = rig.post_json(&route, &json!({ + "data":"current prompt", "observedEpoch":owner.epoch, "observedGeneration":owner.generation, "timeout":5, + })).await; + assert_eq!(status, 200, "{sent}"); + assert_eq!(sent["data"]["status"], "idle"); + assert_eq!(rig.ledger_rows("prompt").len(), 1); + assert_eq!(rig.ledger_rows("prompt")[0]["text"], "current prompt"); + rig.fresh_claude.shutdown().await; +} From dcfcf38475993f4cc81ecbadf7517ae49c385652 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:06:48 -0700 Subject: [PATCH 013/100] fix(freshclaude): retain ownership and retry details from refusals --- crates/freshell-freshagent/src/claude.rs | 9 +- .../src/claude/send_outcome.rs | 24 ++- .../src/claude/tests/refusal_tests.rs | 198 ++++++++++++++++++ crates/freshell-freshagent/src/codex.rs | 2 +- crates/freshell-freshagent/src/lib.rs | 33 ++- crates/freshell-freshagent/src/opencode_ws.rs | 10 +- crates/freshell-ownership/src/lib.rs | 10 + crates/freshell-ws/src/terminal.rs | 2 + 8 files changed, 273 insertions(+), 15 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude/tests/refusal_tests.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index b8764fc0a..8056182a4 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -2220,7 +2220,7 @@ impl FreshClaudeState { // The additive owner fields ride the refusal when the // coordinator can name the owner (Task 6: the post-handoff // refusal says the terminal owner). - let owner_fields = crate::ownership_lane::terminal_owner_fields_from_outcome( + let owner_fields = crate::ownership_lane::fresh_agent_owner_fields_from_outcome( &self.ownership, &outcome, ); @@ -4198,12 +4198,12 @@ impl FreshClaudeState { ) { crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), crate::ownership_lane::LaneOpGuard::Unwired => None, - crate::ownership_lane::LaneOpGuard::Refused { message } => { + crate::ownership_lane::LaneOpGuard::Refused { message, owner } => { tracing::warn!(target: "freshell_freshagent::claude", session_id = %session_id, owner_key = %owner_key, request_id = ?request_id, "fresh_agent_send_refused: ownership guard refused"); - return self.refuse_send(&request_id, "SESSION_RESERVED", &message); + return self.refuse_reserved_send(&request_id, &message, owner); } } } else { @@ -5068,6 +5068,8 @@ impl FreshClaudeState { ClaudeSendOutcome::Refused { code: "INVALID_SESSION_ID".to_string(), message: crate::claude_dead_sessions::SEND_TO_DEAD_SESSION_MESSAGE.to_string(), + retryable: None, + owner: None, } } @@ -8186,6 +8188,7 @@ pub(crate) mod tests { use crate::naming::SessionNaming as _; mod create_binding_tests; + mod refusal_tests; fn state() -> FreshClaudeState { let (tx, _rx) = tokio::sync::broadcast::channel::(64); diff --git a/crates/freshell-freshagent/src/claude/send_outcome.rs b/crates/freshell-freshagent/src/claude/send_outcome.rs index 137d14426..de413b67b 100644 --- a/crates/freshell-freshagent/src/claude/send_outcome.rs +++ b/crates/freshell-freshagent/src/claude/send_outcome.rs @@ -29,7 +29,12 @@ pub enum ClaudeSendOutcome { ownership_id: String, }, /// The message was not sent; the sender got a request error with this code. - Refused { code: String, message: String }, + Refused { + code: String, + message: String, + retryable: Option, + owner: Option, + }, } impl FreshClaudeState { @@ -45,6 +50,23 @@ impl FreshClaudeState { ClaudeSendOutcome::Refused { code: code.to_string(), message: message.to_string(), + retryable: None, + owner: None, + } + } + + pub(super) fn refuse_reserved_send( + &self, + request_id: &Option, + message: &str, + owner: Option, + ) -> ClaudeSendOutcome { + self.send_error(request_id, "SESSION_RESERVED", message); + ClaudeSendOutcome::Refused { + code: "SESSION_RESERVED".into(), + message: message.into(), + retryable: Some(true), + owner, } } diff --git a/crates/freshell-freshagent/src/claude/tests/refusal_tests.rs b/crates/freshell-freshagent/src/claude/tests/refusal_tests.rs new file mode 100644 index 000000000..cddbb9d65 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/tests/refusal_tests.rs @@ -0,0 +1,198 @@ +use super::*; + +fn terminal_owner(registry: &freshell_ownership::RuntimeOwnershipRegistry, id: &str) -> u64 { + let freshell_ownership::BeginOutcome::Granted { generation } = registry.begin_start( + PROVIDER, + id, + freshell_ownership::RuntimeOwnerKind::Terminal, + "terminal-owner", + None, + "test", + 0, + ) else { + panic!("terminal start must be granted") + }; + assert!(matches!( + registry.commit_live( + PROVIDER, + id, + "terminal-owner", + generation, + freshell_ownership::OwnerIdentity { + kind: freshell_ownership::RuntimeOwnerKind::Terminal, + terminal_id: Some("terminal-original".into()), + live_session_key: None, + pid: None, + ownership_id: None, + } + ), + freshell_ownership::CommitOutcome::Committed + )); + generation +} + +#[tokio::test] +async fn a_claude_create_refusal_names_the_terminal_that_refused_it() { + let (mut state, _rx) = state_with_bus(); + let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::with_epoch(41)); + let id = uuid::Uuid::new_v4().to_string(); + let generation = terminal_owner(®istry, &id); + state.set_ownership(registry); + let mut msg = dedup_create_msg("refused-by-terminal"); + msg.resume_session_id = Some(id); + let outcome = state.handle_create(msg, None).await; + match outcome { + ClaudeCreateOutcome::Failed { + code, + retryable, + owner, + .. + } => { + assert_eq!(code, "SESSION_RESERVED"); + assert_eq!(retryable, Some(true)); + assert_eq!( + owner, + Some(crate::ownership_lane::TerminalOwnerFields { + owner_kind: "terminal", + owner_epoch: 41, + owner_generation: generation, + }) + ); + } + created => panic!("foreign ownership must refuse before any spawn: {created:?}"), + } +} + +#[test] +fn a_stale_guard_refusal_keeps_the_owner_captured_by_the_atomic_decision() { + let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::with_epoch(41)); + let id = uuid::Uuid::new_v4().to_string(); + let generation = terminal_owner(®istry, &id); + let refusal = crate::ownership_lane::arm_reclaimless_op_guard( + &Some(registry.clone()), + PROVIDER, + &id, + "stale-operation", + Some(freshell_ownership::ObservedFence { + epoch: 41, + generation: generation - 1, + }), + "test", + ); + let crate::ownership_lane::LaneOpGuard::Refused { owner, .. } = refusal else { + panic!("stale guard must refuse") + }; + let freshell_ownership::BeginOutcome::Granted { generation: newer } = registry.begin_handoff( + PROVIDER, + &id, + freshell_ownership::RuntimeOwnerKind::FreshAgent, + "move-after-refusal", + None, + "test", + 0, + ) else { + panic!("later ownership transition starts") + }; + assert!(matches!( + registry.commit_live( + PROVIDER, + &id, + "move-after-refusal", + newer, + freshell_ownership::OwnerIdentity { + kind: freshell_ownership::RuntimeOwnerKind::FreshAgent, + terminal_id: None, + live_session_key: Some(id.clone()), + pid: None, + ownership_id: None, + } + ), + freshell_ownership::CommitOutcome::Committed + )); + assert_eq!( + owner, + Some(crate::ownership_lane::TerminalOwnerFields { + owner_kind: "terminal", + owner_epoch: 41, + owner_generation: generation, + }), + "a later ownership lookup must not replace the refusal's captured owner" + ); +} + +#[tokio::test] +async fn a_fenced_claude_send_returns_retry_and_owner_details_from_its_refusal() { + let (mut state, mut rx) = state_with_bus(); + let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::with_epoch(41)); + let id = uuid::Uuid::new_v4().to_string(); + let generation = terminal_owner(®istry, &id); + state.set_ownership(registry); + insert_fake_claude_session(&state, &id).await; + let mut msg = send_msg(&id, "must not reach another owner's process"); + msg.request_id = Some("refused-send".into()); + msg.observed_epoch = Some(41); + msg.observed_generation = Some(generation - 1); + let outcome = state.handle_send(msg).await; + assert!( + matches!(outcome, ClaudeSendOutcome::Refused { ref code, retryable: Some(true), + owner: Some(crate::ownership_lane::TerminalOwnerFields { + owner_kind: "terminal", owner_epoch: 41, owner_generation, + }), .. } if code == "SESSION_RESERVED" && owner_generation == generation), + "{outcome:?}" + ); + let wire: Value = serde_json::from_str(&rx.try_recv().unwrap()).unwrap(); + assert_eq!(wire["type"], "error"); + assert_eq!(wire["requestId"], "refused-send"); + assert!( + wire.get("ownerKind").is_none(), + "the existing wire response stays unchanged" + ); + state.shutdown().await; +} + +#[test] +fn a_fresh_lane_blocked_outcome_preserves_its_captured_fresh_agent_kind() { + let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::with_epoch(41)); + let id = uuid::Uuid::new_v4().to_string(); + let freshell_ownership::BeginOutcome::Granted { generation } = registry.begin_start( + PROVIDER, + &id, + freshell_ownership::RuntimeOwnerKind::FreshAgent, + "fresh-owner", + None, + "test", + 0, + ) else { + panic!("fresh start") + }; + assert!(matches!( + registry.commit_live( + PROVIDER, + &id, + "fresh-owner", + generation, + freshell_ownership::OwnerIdentity { + kind: freshell_ownership::RuntimeOwnerKind::FreshAgent, + terminal_id: None, + live_session_key: Some(id.clone()), + pid: None, + ownership_id: None, + } + ), + freshell_ownership::CommitOutcome::Committed + )); + let outcome = freshell_ownership::BeginOutcome::Blocked { + state: registry.observe(PROVIDER, &id).state, + retry_after_ms: 25, + }; + let owner = + crate::ownership_lane::fresh_agent_owner_fields_from_outcome(&Some(registry), &outcome); + assert_eq!( + owner, + Some(crate::ownership_lane::TerminalOwnerFields { + owner_kind: "fresh-agent", + owner_epoch: 41, + owner_generation: generation, + }) + ); +} diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index ed3631379..fd0fcd9fd 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -3449,7 +3449,7 @@ impl FreshCodexState { ) { crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), crate::ownership_lane::LaneOpGuard::Unwired => None, - crate::ownership_lane::LaneOpGuard::Refused { message } => { + crate::ownership_lane::LaneOpGuard::Refused { message, .. } => { tracing::warn!(target: "freshell_freshagent::codex", session_id = %session_id, request_id = ?request_id, "fresh_agent_send_refused: ownership guard refused"); diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index 72ef90ff6..fc5a0db2a 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -471,7 +471,10 @@ pub mod ownership_lane { /// `Live{FreshAgent}` (a terminal owner or a vacated/crashed /// shape), or the supplied observed pair is stale. Every arm /// answers the lanes' typed `SESSION_RESERVED` surface. - Refused { message: String }, + Refused { + message: String, + owner: Option, + }, } /// Arm the reclaim-less op guard (see [`LaneOpGuard`]). The @@ -512,6 +515,7 @@ pub mod ownership_lane { pre-restart epoch) — the operation aborts typed"); return LaneOpGuard::Refused { message: STALE_OP_GUARD_MESSAGE.to_string(), + owner: terminal_owner_fields_from_snapshot(&snap), }; } } @@ -579,9 +583,11 @@ pub mod ownership_lane { the operation aborts typed; the client re-observes and \ retries with the pair" ); + let owner = terminal_owner_fields_from_snapshot(&under); drop(guard); return LaneOpGuard::Refused { message: FENCE_REQUIRED_OP_GUARD_MESSAGE.to_string(), + owner, }; } // Verify `Live{FreshAgent}` UNDER the guard — stable now @@ -590,7 +596,7 @@ pub mod ownership_lane { // completed handoff) refuses typed here, never mutating // another kind's session. let under = registry.observe(provider, session_id); - match under.state { + match &under.state { freshell_ownership::OwnershipState::Live { owner, .. } if owner.kind == RuntimeOwnerKind::FreshAgent => { @@ -605,9 +611,11 @@ pub mod ownership_lane { outcome = "refused", failure_reason = "NOT_FRESH_AGENT_LIVE", "the op guard armed over a non-fresh-agent owner — the \ operation aborts typed and the guard releases"); + let owner = terminal_owner_fields_from_snapshot(&under); drop(guard); LaneOpGuard::Refused { message: KIND_OP_GUARD_MESSAGE.to_string(), + owner, } } } @@ -624,11 +632,13 @@ pub mod ownership_lane { nothing is mutated"); LaneOpGuard::Refused { message: LIFECYCLE_OP_GUARD_MESSAGE.to_string(), + owner: owner_fields_from_state(&state, registry.boot_epoch()), } } freshell_ownership::AttachGuardOutcome::StaleGeneration { - current_epoch: _, + current_epoch, current_generation, + current_state, } => { tracing::warn!(target: "freshell_ownership", operation_id = %operation_id, provider = %provider, @@ -640,6 +650,7 @@ pub mod ownership_lane { stale) — the operation aborts typed, nothing is mutated"); LaneOpGuard::Refused { message: STALE_OP_GUARD_MESSAGE.to_string(), + owner: owner_fields_from_state(¤t_state, current_epoch), } } } @@ -1731,6 +1742,7 @@ pub mod ownership_lane { freshell_ownership::AttachGuardOutcome::StaleGeneration { current_epoch, current_generation, + .. } => { tracing::warn!(target: "freshell_ownership", operation_id = %operation_id, provider = %provider, @@ -1792,6 +1804,7 @@ pub mod ownership_lane { freshell_ownership::AttachGuardOutcome::StaleGeneration { current_epoch, current_generation, + .. } => { tracing::warn!(target: "freshell_ownership", operation_id = %operation_id, provider = %provider, @@ -1918,6 +1931,9 @@ pub mod ownership_lane { owner_epoch: registry.boot_epoch(), }) } + BeginOutcome::Blocked { state, .. } => { + owner_fields_from_state(state, registry.as_ref()?.boot_epoch()) + } _ => terminal_owner_fields_from_outcome(registry, outcome), } } @@ -1927,7 +1943,14 @@ pub mod ownership_lane { pub fn terminal_owner_fields_from_snapshot( snapshot: &freshell_ownership::OwnershipSnapshot, ) -> Option { - match &snapshot.state { + owner_fields_from_state(&snapshot.state, snapshot.epoch) + } + + fn owner_fields_from_state( + state: &freshell_ownership::OwnershipState, + epoch: u64, + ) -> Option { + match state { freshell_ownership::OwnershipState::Live { owner, generation, .. } => Some(TerminalOwnerFields { @@ -1936,7 +1959,7 @@ pub mod ownership_lane { RuntimeOwnerKind::FreshAgent => "fresh-agent", }, owner_generation: *generation, - owner_epoch: snapshot.epoch, + owner_epoch: epoch, }), _ => None, } diff --git a/crates/freshell-freshagent/src/opencode_ws.rs b/crates/freshell-freshagent/src/opencode_ws.rs index b75687db6..699ce85f6 100644 --- a/crates/freshell-freshagent/src/opencode_ws.rs +++ b/crates/freshell-freshagent/src/opencode_ws.rs @@ -2163,7 +2163,7 @@ impl FreshOpencodeState { ) { crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), crate::ownership_lane::LaneOpGuard::Unwired => None, - crate::ownership_lane::LaneOpGuard::Refused { message } => { + crate::ownership_lane::LaneOpGuard::Refused { message, .. } => { drop(session); tracing::warn!(target: "freshell_freshagent::opencode", session_id = %session_id, request_id = ?request_id, @@ -3042,7 +3042,7 @@ impl FreshOpencodeState { ) { crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), crate::ownership_lane::LaneOpGuard::Unwired => None, - crate::ownership_lane::LaneOpGuard::Refused { message } => { + crate::ownership_lane::LaneOpGuard::Refused { message, .. } => { session.pending_sends.pop_front(); tracing::warn!(target: "freshell_freshagent::opencode", session_id = %lookup_id, request_id = ?msg.request_id, @@ -5258,7 +5258,7 @@ impl FreshOpencodeState { ) { crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), crate::ownership_lane::LaneOpGuard::Unwired => None, - crate::ownership_lane::LaneOpGuard::Refused { message } => { + crate::ownership_lane::LaneOpGuard::Refused { message, .. } => { tracing::warn!(target: "freshell_freshagent::opencode", session_id = %session_id, real_id = %real_id, "fresh_agent_compact_refused: the op guard refused to arm"); @@ -5739,7 +5739,7 @@ impl FreshOpencodeState { ) { crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), crate::ownership_lane::LaneOpGuard::Unwired => None, - crate::ownership_lane::LaneOpGuard::Refused { message } => { + crate::ownership_lane::LaneOpGuard::Refused { message, .. } => { tracing::warn!(target: "freshell_freshagent::opencode", session_id = %msg.session_id, real_id = %real_id, "fresh_agent_fork_refused: the op guard refused to arm"); @@ -6330,7 +6330,7 @@ impl FreshOpencodeState { ) { crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), crate::ownership_lane::LaneOpGuard::Unwired => None, - crate::ownership_lane::LaneOpGuard::Refused { message } => { + crate::ownership_lane::LaneOpGuard::Refused { message, .. } => { tracing::warn!(target: "freshell_freshagent::opencode", session_id = %op.session_id, real_id = %real_id, request_id = %op.request_id, diff --git a/crates/freshell-ownership/src/lib.rs b/crates/freshell-ownership/src/lib.rs index 3e6860c9e..10e48b689 100644 --- a/crates/freshell-ownership/src/lib.rs +++ b/crates/freshell-ownership/src/lib.rs @@ -857,6 +857,8 @@ pub enum AttachGuardOutcome { StaleGeneration { current_epoch: u64, current_generation: u64, + /// The state at this atomic decision, for callers reporting its owner. + current_state: OwnershipState, }, } @@ -874,10 +876,12 @@ impl std::fmt::Debug for AttachGuardOutcome { Self::StaleGeneration { current_epoch, current_generation, + current_state, } => f .debug_struct("StaleGeneration") .field("current_epoch", current_epoch) .field("current_generation", current_generation) + .field("current_state", current_state) .finish(), } } @@ -1543,6 +1547,7 @@ impl RuntimeOwnershipRegistry { return AttachGuardOutcome::StaleGeneration { current_epoch: self.epoch, current_generation, + current_state: record.state.clone(), }; } } @@ -1636,6 +1641,10 @@ impl RuntimeOwnershipRegistry { return AttachGuardOutcome::StaleGeneration { current_epoch: self.epoch, current_generation: 0, + current_state: inner + .get(&key) + .map(|record| record.state.clone()) + .unwrap_or(OwnershipState::Vacant), }; } let Some(record) = inner.get_mut(&key) else { @@ -1701,6 +1710,7 @@ impl RuntimeOwnershipRegistry { return AttachGuardOutcome::StaleGeneration { current_epoch: self.epoch, current_generation, + current_state: record.state.clone(), }; } // (4) THE EXPECTED OWNER — the kind always; every identity field diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index 804836cae..f81a612bc 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -1582,6 +1582,7 @@ async fn handle_client_text( freshell_ownership::AttachGuardOutcome::StaleGeneration { current_epoch, current_generation, + .. } => { tracing::warn!(target: "freshell_ws::terminal", terminal_id = %attach.terminal_id, @@ -5284,6 +5285,7 @@ pub(crate) async fn handle_create( freshell_ownership::AttachGuardOutcome::StaleGeneration { current_epoch, current_generation, + .. } => { tracing::warn!( target: "freshell_ws::terminal", From 85a449ece129c02735db65a701eee624a7fe97f6 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:15:00 -0700 Subject: [PATCH 014/100] fix(freshclaude): preserve REST refusal metadata and await saved test turns --- .../src/claude_rest_lane.rs | 13 +++++- .../src/claude_rest_lane_tests.rs | 2 +- .../tests/rest_freshclaude_lane.rs | 45 ++++++++++++++++--- 3 files changed, 53 insertions(+), 7 deletions(-) diff --git a/crates/freshell-freshagent/src/claude_rest_lane.rs b/crates/freshell-freshagent/src/claude_rest_lane.rs index f9e48765a..de182c9be 100644 --- a/crates/freshell-freshagent/src/claude_rest_lane.rs +++ b/crates/freshell-freshagent/src/claude_rest_lane.rs @@ -126,7 +126,18 @@ impl ClaudeRestLane { submitted_turn_id, ownership_id, } => (session_key, submitted_turn_id, ownership_id), - ClaudeSendOutcome::Refused { code, message } => return Err(send_error(code, message)), + ClaudeSendOutcome::Refused { + code, + message, + retryable, + owner, + } => { + let mut error = send_error(code, message); + error.retryable = retryable.or(error.retryable); + error.retry_after_ms = error.retryable.filter(|retryable| *retryable).map(|_| 250); + error.owner = owner; + return Err(error); + } }; let settled_key = wait_for_turn( &mut events, diff --git a/crates/freshell-freshagent/src/claude_rest_lane_tests.rs b/crates/freshell-freshagent/src/claude_rest_lane_tests.rs index ea29c2ae2..23d623db0 100644 --- a/crates/freshell-freshagent/src/claude_rest_lane_tests.rs +++ b/crates/freshell-freshagent/src/claude_rest_lane_tests.rs @@ -120,7 +120,7 @@ fn settled(session: &str, request: &str, uuid: &str) -> ClaudeTurnEvent { } #[tokio::test] -async fn completion_requires_the_accepted_session_uuid_and_request_even_when_both_sends_share_a_request_id( +async fn completion_requires_the_accepted_command_uuid_and_request_even_when_sends_share_a_request_id( ) { let (tx, mut rx) = tokio::sync::broadcast::channel(16); let future = wait_for_turn( diff --git a/crates/freshell-ws/tests/rest_freshclaude_lane.rs b/crates/freshell-ws/tests/rest_freshclaude_lane.rs index e1c2ce9bc..232e709db 100644 --- a/crates/freshell-ws/tests/rest_freshclaude_lane.rs +++ b/crates/freshell-ws/tests/rest_freshclaude_lane.rs @@ -55,6 +55,37 @@ async fn send(rig: &ClaudeRig, pane: &str, text: &str) -> Value { body["data"].clone() } +async fn wait_for_saved_turn(rig: &ClaudeRig, id: &str, submitted: &str) { + // The pinned CLI flushes transcript lines after their SDK frames (T3). + // A completed REST send promises turn settlement; capture reads the saved + // history, so observe this exact turn on disk before asserting its text. + let path = rig.wait_for_transcript(id).await; + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + 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 user = lines + .iter() + .rposition(|line| line["type"] == "user" && line["uuid"] == submitted); + if user.is_some_and(|at| { + lines[at + 1..] + .iter() + .any(|line| line["type"] == "assistant") + }) { + return; + } + assert!( + tokio::time::Instant::now() < deadline, + "submitted turn {submitted} never reached {}", + path.display() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn rest_create_send_capture_and_ws_create_replay_share_one_writer() { let rig = ClaudeRig::start_with_options(RigKind::Sim, options()).await; @@ -113,6 +144,7 @@ async fn rest_create_send_capture_and_ws_create_replay_share_one_writer() { assert_eq!(prompts[0]["permissionMode"], "bypassPermissions"); assert_eq!(sent["submittedTurnId"], prompts[0]["uuid"]); assert_ne!(sent["submittedTurnId"], "rest-request"); + wait_for_saved_turn(&rig, id, sent["submittedTurnId"].as_str().unwrap()).await; observer .wait_for("REST turn completion", |frame| { frame["event"]["type"] == "freshAgent.turn.complete" && frame["sessionId"] == id @@ -223,13 +255,16 @@ async fn rest_controls_a_browser_created_pane_and_capture_survives_server_restar frame["type"] == "pong" }) .await; - assert_eq!( - send(&rig, "browser-pane", "browser pane via REST").await["status"], - "idle" - ); + let sent = send(&rig, "browser-pane", "browser pane via REST").await; + assert_eq!(sent["status"], "idle"); + wait_for_saved_turn(&rig, id, sent["submittedTurnId"].as_str().unwrap()).await; let (status, before) = get(&rig, "/api/panes/browser-pane/capture").await; assert_eq!(status, 200, "{before}"); - assert!(before.contains("browser pane via REST")); + assert!( + before.contains("browser pane via REST"), + "capture after settlement: {before:?}; prompts: {:?}", + rig.ledger_rows("prompt") + ); rig.restart().await; let (status, after) = get(&rig, "/api/panes/browser-pane/capture").await; assert_eq!(status, 200, "{after}"); From 3287ba027e58c0e60af4c5ddea53a6fa22c04211 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:15:01 -0700 Subject: [PATCH 015/100] test(freshclaude): cover external MCP control in the browser --- AGENTS.md | 2 +- README.md | 7 ++ .../fresh-agent-rest-claude-rust.spec.ts | 118 ++++++++++++++++++ 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 test/e2e-browser/specs/fresh-agent-rest-claude-rust.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 963efb852..c8047c424 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -284,7 +284,7 @@ stderr note). Provisioning, rotation, and revocation live in **Agent Status Indicators:** Blue/busy status is derived from provider activity slices through `resolvePaneActivity`; the unified needs-attention signal (green highlight + one bell) flows through `recordTurnComplete`/`recordTerminalIdle` and `useTurnCompletionNotifications`. The signal is server-authoritative everywhere and outcome-uniform: any turn end the user did not witness — finished, errored, max-turns, crashed (sidecar death with a turn in flight), mid-turn stream exception, wedged/stuck (codex deadman), or waiting on an approval/question — produces the identical attention treatment (tab highlight, sidebar row highlight, pane-header mark, and a single bell), with no distinction by outcome; only a USER-initiated interrupt is silent. Terminal CLI panes keep today's behavior unchanged (a per-event source discriminator partitions the hook — `recordTerminalIdle` stamps `source:'terminal'`, `recordTurnComplete` stamps `source:'freshAgent'`): terminals mark tab+pane attention on the truly-idle `terminal.idle` edge, suppress only the sound when watched, and coalesce a batch into one chime, while fresh-agent panes (freshclaude/kilroy/freshcodex/freshopencode) ring a discrete `freshAgent.turn.complete` edge emitted for every turn end except a user-initiated interrupt — freshclaude/kilroy for ANY SDK `result` subtype except the stopped turn's own result: the sidecar's interrupt gate silences a result only when it holds a pending accepted user-interrupt mark AND the result is aborted (`terminal_reason` aborted_streaming/aborted_tools), so a Stop that missed its turn (sent while the CLI was still starting) never silences it (the mark is armed only while a result is pending, set before the interrupt RPC, disarmed on RPC failure, and consumed by the next result either way — ordering-scoped, deliberately no reset-at-send; a stopped turn shows no error banner), plus the consumeStream-finally mint when a pending turn ends with no result frame (stream exception or natural EOF; a pending mark never silences it) and a Rust-synthesized edge on an unrequested sidecar death mid-turn (requested teardown and idle deaths stay silent); freshcodex for `completed`/`failed`/absent statuses and non-user `interrupted` (the interrupt lane arms a per-session marker before the RPC and disarms it on RPC failure; the marker is consumed by the matching `interrupted` completion and cleared at the next `turn/started` — the opencode discipline, where `turn_aborted` clears at every turn dispatch — so only user-caused interrupts are silent); freshopencode for every settle except `turn_aborted`. Every fresh-agent edge carries a finite numeric strictly-monotonic per-session `at` (the client silently drops a malformed `at`) and folds in via `applyFreshAgentCompletion` under the `at`-monotonic dedupe regime (wall-clock `at`, no per-session counter, so a resumed durable session can't swallow completions across a server restart — a real restart also clears the per-terminal baselines). Attention flags are no longer persisted across reloads: a reload witnesses nothing (idle/busy state rehydrates; no highlight or bell ever replays a pre-load turn end). A watched fresh-agent ending (window focused + tab active) sets a tab-strip-only watched mark (`watchedCompletionByTab` — no sidebar row, no pane-header mark, no sound) that clears when the user navigates away and back. The fresh-agent bell rings audibly once per event through a serialized unbounded ring queue (a fresh chime per ring; the next ring starts when the previous chime ends or is paused, or the built-in fallback tone completes; an unplayable or aborted chime file substitutes a quiet built-in tone; a 5 s per-ring watchdog (`RING_WATCHDOG_MS` in `src/hooks/useNotificationSound.ts`, logged as the `notification_ring_watchdog_fired` warn event) releases a ring whose completion signal never arrives, so one dead ring cannot silence later bells; with sound disabled the highlights still appear). The waiting-for-approval edge is ALSO server-authoritative: freshclaude/kilroy emit a discrete `freshAgent.turn.waiting` edge on the 0→≥1 pending permission/question transition, and codex's approval/question controls emit the same edge on their own 0→≥1 pending transition (Claude/kilroy and codex both raise approvals/questions); the client folds it in via `applyFreshAgentWaiting` under a distinct `${provider}:${sessionId}#waiting` dedupe namespace so it can never poison (or be poisoned by) the turn-complete bucket. The fragile client-side busy→idle derivation AND the client-side waiting-edge hook (`useAgentSessionTurnCompletion`) were both removed — all attention edges are server-emitted. freshcodex additionally self-heals a crashed/disconnected codex sidecar by consuming the runtime `onExit` hook in `subscribe()`, broadcasting the terminal `exited` status to clear BLUE while the exit watcher's crash arm rings the same unified edge when a turn was in flight (an idle crash stays silent). freshcodex also runs a wedged-sidecar deadman: after a bounded quiet window (default 10 min, env `FRESHELL_FRESHCODEX_QUIET_WINDOW_MS`) with a turn in flight and no sidecar events, the server stops asserting busy, marks the pane `stuck`, and fires the same unified edge; the client shows an amber "Agent appears stuck" card (`role="alert"`) with "Restart sidecar" (kill + resume re-mint) and "Start new conversation" actions as pane-level inline content. `freshopencode` still runs on a shared long-lived `opencode serve` sidecar and uses server-pushed `session.idle`/`session.status` events to drive busy. A freshAgent.send arriving while a native /compact drive is in flight — or while older queued sends are still pending — is queued server-side (FIFO, immediate send.accepted) and auto-driven when the compact settles; kill drops the queue, and the drain re-validates a captured observed fence through the lane op guard (stale pairs refuse typed, request-correlated), releasing the guard at the prompt's dispatch boundary. The runtime self-heals a shared-daemon death (the 2026-09-20 incident class): daemon loss fans a typed `freshAgent.error{code:"OPENCODE_DAEMON_LOST"}` edge out to exactly one frame per materialized session (the client's generic `sessionError` banner + busy-clear; the typed edge itself carries no chime — a daemon death mid-turn is a crashed turn end and rings the unified edge through the turn's own non-clean settle), the manager respawns the daemon on a backoff ladder (fresh-incident reset, crash-loop escalation), every successful cold start drives a level-triggered revival pass that restarts dead session bridges and pushes `freshAgent.session.snapshot{status:"idle"}` (the client's transcript-refetch trigger) while respecting the ownership coordinator (retired, transitioned, or terminal-owned sessions are never revived), and a generation-fenced `freshAgent.attach` is itself a recovery verb that respawns the daemon before re-bridging. Client-side, a snapshot GET that still answers the typed 409 `RESTORE_UNAVAILABLE` for the pane's own stale fresh-agent claim (e.g. the pane loaded while the server was restarting) does not dead-end on the dismiss-only banner: the pane drives the documented recovery ONCE per pane identity — it refreshes the observed owner fence from the refusal's own `ownerGeneration` (preserving the record's epoch), sends one generation-fenced `freshAgent.attach`, and refetches through the reveal path when reveal-dirty (so the "Refreshing conversation" overlay can clear) or via `manual` otherwise; a suppressed attach restores the once-guard, and repeated 409s fall through to the honest error banner — terminal-owned refusals stay out of this path (their recovery door is the session-directory handoff). Gemini and Kimi terminal modes are status-in... [truncated] Separately, the sidebar shows cross-device remote status rings around a session row's icon: a green ring means the session is open on another device, a blue ring means it is busy on another device (blue wins over green), and rings are suppressed entirely when the session is open on this device (derived from `tabs.sync` registry snapshots — producing clients stamp pane payloads with `sessionKeys`/`busySessionKeys`, consumers re-query remote snapshots on a 30s interval, and the server partitions same-device records into `sameDeviceOpen`, which never produces rings). -**Fresh-Agent Orchestration:** The Rust REST agent API (`/api/tabs`, `/api/panes/:id/split`, `/api/panes/:id/send-keys`, `/api/panes/:id/capture`, `/api/panes/:id/wait-for`) and the standalone Node MCP client accept `agent`/`model`/`effort` parameters where the Rust contract supports them. The Rust orchestration layer dispatches to the registered fresh-agent runtimes. On MCP `new-tab`, resume sugar (`resume`/`resumeSessionId`) is honored for `agent: "opencode"`; terminal-mode resume uses an explicit provider-matched `sessionRef` (raw Codex resume IDs are rejected because they are not sufficient restore identity). Unsupported legacy actions return a deterministic unavailable result instead of contacting a removed backend route. +**Fresh-Agent Orchestration:** The Rust REST agent API (`/api/tabs`, `/api/panes/:id/split`, `/api/panes/:id/send-keys`, `/api/panes/:id/capture`, `/api/panes/:id/wait-for`) and standalone CLI/MCP client accept `agent`/`model`/`effort` where supported. In the default server, `new-tab` supports `agent: "claude" | "kilroy" | "opencode"`; fresh-agent `split-pane` supports Claude and Kilroy. Claude operations use the SAME `FreshClaudeState` and `createRequestId` deduplication as browser panes, including headless creation. Create defaults are resolved once from live settings and copied into the provider request and pane content. On MCP `new-tab`, `resume`/`resumeSessionId` map Claude and Kilroy to a Claude `sessionRef`, and OpenCode to an OpenCode ref; terminal-mode resume uses a provider-matched `sessionRef` (raw Codex IDs remain insufficient restore identity). REST Claude send strips exactly one trailing CR or LF; line-terminator-only input returns `noop`. A completed send returns its actual `submittedTurnId`; a deadline returns `approx`, and an unavailable or ended runtime returns a retryable 409. Capture reads durable history, including after restart. The opt-in hosted gateway takes precedence over local lanes; installing a local REST lane does not change the snapshot HTTP authority. Unsupported legacy actions return a deterministic unavailable result. Agent-driven tab/pane creation is focus-neutral by contract: server-broadcast ui.command create/split folds carry `activate:false` into `addTab`/`splitPane` and never change the user's active tab/pane — focus moves only via the explicit select-tab/select-pane verbs. The Rust layout store (`crates/freshell-freshagent/src/layout_store.rs`) mirrors the same rule: agent creates/splits never move the server cursor either, so REST/MCP cursor-relative operations (next/prev tab, omitted targets) keep addressing the user's selection through the mirror window. Client-side this contract has three layers: (1) pane components gate mount-time autofocus on `focusEligible` (tab active + pane active + visible), and non-eligible browser iframes render `inert`; (2) eligible mounts are additionally ownership-gated (`usePaneFocusAdoption` + `src/lib/pane-focus-ownership.ts`): components record at teardown whether their pane held document focus, so an agent-driven leaf→split remount refocuses only if the pane owned focus before — flips (explicit select) always bypass, and same-target re-selects (no eligibility transition) are signaled via a per-pane focus epoch (`panes.focusEpochByPaneId`, nudged only by the select folds, never by pointer mousedowns, and forwarded by PaneContainer as the `focusEpoch` prop); (3) the app-wide focus-steal guard (`src/lib/focus-steal-guard.ts` via `useFocusStealGuard` in App) rebuffs nested-document focus hoists from `data-focus-locked` non-eligible iframes, which `inert` alone cannot stop (Chromium-verified). A store-level selection serial (`paneSelectionMiddleware`) bumps on every `setActivePane`/`nudgePaneFocus`/`setActiveTab` dispatch, and — by before/after state diff — on any `addTab`/`switchToNextTab`/`switchToPrevTab`/`splitPane`/`addPane`/`removeTab`/`closePane` that actually moved the selection (agent folds pass `activate:false` and stay serial-invisible; background closes move nothing): ownership records captured before any later selection are voided (newer selection wins). Screenshot captures (`captureUiScreenshot`, driven by the `screenshot.capture` ui.command behind `POST /api/screenshots` and the MCP capture verbs) render through html2canvas's off-DOM clone and never mutate the live page: background tabs stay mounted and fully laid out under `.tab-hidden` (CSS `visibility`, not `display` — kept measurable for xterm), html2canvas skips visibility-hidden subtrees when painting, so the clone's target chain (target up to body) gets an inline `visibility: visible` reveal via `onclone` — armed live-side only when the target has a hidden ancestor. Because nothing dispatches and nothing moves, there is no queue, no deadline/ttl, no `screenshot.cancel`, no restore, and no engagement detection; iframes are pre-rendered separately (html2canvas cannot paint them) and swapped into the clone by index+src-fingerprint correlation (a tree that changed between preparation and clone gets no replacements rather than a wrong one), and xterm's WebGL renderers are suspended around the render (refcounted `suspendTerminalRenderersForScreenshot`) so their canvases read back complete. Records for panes re-appearing in layouts (e.g. reopened tabs) are forgotten, and an eligible mount with focus stranded on `document.body` always uses unknown-pane focus semantics (stranding typing is worse than a rare honest refocus — round-14/16 trade). Locked iframes wake on pointerdown anywhere in the pane shell and on Enter/Space targeted at the shell itself (`useIframeFocusLock`) — keyboard-parity click-to-wake. diff --git a/README.md b/README.md index 82430b108..ba58f782e 100644 --- a/README.md +++ b/README.md @@ -269,6 +269,13 @@ turning it off removes the server from Freshclaude and Kilroy agents started or resumed after the change, with no restart. Terminal panes do not follow the switch yet. +With fresh agents enabled, the CLI and MCP client can create Freshclaude or +Kilroy tabs, split them into panes, send prompts, and capture their conversation +as plain text. These operations also work without an open browser. Use +`agent: "claude"` or `agent: "kilroy"` with MCP `new-tab` and `split-pane`, or +`--agent claude` / `--agent kilroy` with the CLI. Both support resuming a saved +Claude conversation through `resume`. + ### Rust server scope The Rust server supports the browser UI, terminal and session workflows, the diff --git a/test/e2e-browser/specs/fresh-agent-rest-claude-rust.spec.ts b/test/e2e-browser/specs/fresh-agent-rest-claude-rust.spec.ts new file mode 100644 index 000000000..1bd42d799 --- /dev/null +++ b/test/e2e-browser/specs/fresh-agent-rest-claude-rust.spec.ts @@ -0,0 +1,118 @@ +import type { Browser } from '@playwright/test' +import { test, expect } from '../helpers/fixtures.js' +import { ClaudeSimLane, onPrompt } from '../helpers/claude-sim-lane.js' +import { ensureMcpServerBuilt, McpStdioClient, REPO_ROOT } from '../helpers/mcp-stdio-client.js' + +const PROMPT = 'hello from the external MCP client' +const REPLY = 'REST-created Freshclaude reply' +const UI_PROMPT = 'hello to the browser-created pane' +const UI_REPLY = 'Browser-created pane received the MCP prompt' + +async function withLane(browser: Browser, body: (lane: ClaudeSimLane, mcp: McpStdioClient) => Promise) { + const mcpPath = ensureMcpServerBuilt(REPO_ROOT).path + const lane = await ClaudeSimLane.start(browser, { + flavour: 'freshclaude', env: { KILROY_ENABLED: '1' }, + program: { rules: [ + onPrompt(PROMPT, [{ kind: 'text', delayMs: 300, data: { text: REPLY } }]), + onPrompt(UI_PROMPT, [{ kind: 'text', data: { text: UI_REPLY } }]), + onPrompt('kilroy hello', [{ kind: 'text', data: { text: 'Kilroy REST reply' } }]), + ] }, + }) + const mcp = new McpStdioClient({ + command: process.execPath, + args: [mcpPath], + env: { ...process.env, FRESHELL_URL: lane.info.baseUrl, FRESHELL_TOKEN: lane.info.token }, + requestTimeoutMs: 60_000, + }) + try { + await mcp.initialize() + await body(lane, mcp) + } finally { + await lane.attachLogsOnFailure(test.info()) + await mcp.close() + await lane.stop() + } +} + +function leaf(node: any, id: string): any { + if (node?.type === 'leaf') return node.id === id ? node : undefined + return (node?.children ?? []).map((child: any) => leaf(child, id)).find(Boolean) +} + +test.describe('external REST/MCP controls the shared Claude runtime', () => { + test.setTimeout(180_000) + + test('MCP creates and prompts a background Freshclaude tab once, captures it, splits Kilroy, and captures after restart', async ({ browser }) => { + await withLane(browser, async (lane, mcp) => { + const { page, harness } = lane + const originalTab = await harness.getActiveTabId() + // Observe the production notification's audio boundary. End the test + // device's sound immediately so another ring cannot hide in its queue. + await page.evaluate(() => { + const browserWindow = window as typeof window & { __restNotificationPlays?: number } + browserWindow.__restNotificationPlays = 0 + const play = HTMLMediaElement.prototype.play + HTMLMediaElement.prototype.play = function () { + if (this.src.endsWith('/your-code-is-ready.mp3')) { + browserWindow.__restNotificationPlays! += 1 + queueMicrotask(() => this.dispatchEvent(new Event('ended'))) + return Promise.resolve() + } + return play.call(this) + } + }) + const created = await mcp.callFreshellAction('new-tab', { agent: 'claude', cwd: lane.workspace, prompt: PROMPT }) + expect(created.status).toBe('ok') + const { tabId, paneId, sessionId } = created.data + await expect.poll(() => harness.getPaneLayout(tabId), { timeout: 20_000 }).not.toBeNull() + expect(await harness.getActiveTabId()).toBe(originalTab) + await expect.poll(async () => (await harness.getState()).turnCompletion?.attentionByTab?.[tabId] ?? false, { timeout: 20_000 }).toBe(true) + await expect.poll(() => page.evaluate(() => (window as typeof window & { __restNotificationPlays?: number }).__restNotificationPlays ?? 0), { timeout: 10_000 }).toBe(1) + expect(lane.ledger().filter((row) => row.kind === 'boot' && row.sessionId === sessionId)).toHaveLength(1) + await expect.poll(() => mcp.callFreshellAction('capture-pane', { target: paneId }), { + timeout: 20_000, message: 'the CLI flushed the settled turn to saved history', + }).toContain(REPLY) + const captured = await mcp.callFreshellAction('capture-pane', { target: paneId }) + expect(captured).toContain(`user: ${PROMPT}`) + expect(captured).toContain(`assistant: ${REPLY}`) + await mcp.callFreshellAction('select-tab', { target: tabId }) + await expect(page.locator(`[data-pane-id="${paneId}"]`)).toBeVisible() + await expect(page.locator(`[data-pane-id="${paneId}"]`)).toContainText(REPLY) + expect(await page.evaluate(() => (window as typeof window & { __restNotificationPlays?: number }).__restNotificationPlays)).toBe(1) + + const split = await mcp.callFreshellAction('split-pane', { target: paneId, agent: 'kilroy', cwd: lane.workspace, direction: 'vertical' }) + expect(split.status).toBe('ok') + const splitId = split.data.paneId + await expect.poll(async () => leaf(await harness.getPaneLayout(tabId), splitId)?.content?.sessionType, { timeout: 20_000 }).toBe('kilroy') + await mcp.callFreshellAction('send-keys', { target: splitId, keys: 'kilroy hello\r', literal: true }) + await expect(page.locator(`[data-pane-id="${splitId}"]`)).toBeVisible() + await expect(page.locator(`[data-pane-id="${splitId}"]`)).toContainText('Kilroy REST reply') + + // With no browser to attach or repair a REST pane row, the persisted + // layout and durable transcript must still serve the external client. + await lane.context.close() + await lane.server.restart() + const restored = await mcp.callFreshellAction('capture-pane', { target: paneId }) + expect(restored).toBe(captured) + }) + }) + + test('MCP send and capture work on a pane created through the picker', async ({ browser }) => { + await withLane(browser, async (lane, mcp) => { + const pane = await lane.openPane() + await expect.poll(async () => { + const response = await fetch(`${lane.info.baseUrl}/api/panes`, { headers: { 'x-auth-token': lane.info.token } }) + const body = await response.json() + return JSON.stringify(body).includes(pane.paneId) + }, { timeout: 15_000, message: 'the browser layout reached the shared server layout' }).toBe(true) + const result = await mcp.callFreshellAction('send-keys', { target: pane.paneId, keys: `${UI_PROMPT}\r`, literal: true }) + expect(result.status).toBe('ok') + expect(result.data.status).toBe('idle') + await expect(pane.paneRoot).toContainText(UI_REPLY) + await expect.poll(() => mcp.callFreshellAction('capture-pane', { target: pane.paneId }), { + timeout: 20_000, message: 'capture reads the browser-created pane’s saved reply', + }).toContain(UI_REPLY) + expect(lane.ledger().filter((row) => row.kind === 'boot')).toHaveLength(1) + }) + }) +}) From 0cd45cd562d30525a6482ccb16e048de08955455 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:33:07 -0700 Subject: [PATCH 016/100] test(freshclaude): require correlated interrupt failure acknowledgements --- .../claude_sidecar_interrupt_dispatch.rs | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs b/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs index 042de35db..c24aad32f 100644 --- a/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs +++ b/crates/freshell-freshagent/tests/claude_sidecar_interrupt_dispatch.rs @@ -17,10 +17,9 @@ //! `query()`; the stub throws if it is called). Only module RESOLUTION is redirected //! — every dispatched line of JS is the real source, read at test time. //! -//! Observable contract (fire-and-forget interrupt, per the comment above -//! `handleInterrupt`): an interrupt for an unknown session emits an -//! `sdk.error "session not found"` frame on stdout. Pre-fix behavior was a -//! stderr `unknown request type: interrupt` log and NO stdout frame. +//! Protocol v2 answers an interrupt for an unknown session with a failed +//! `sdk.interrupt_settled` carrying the submitted Stop id. A missing dispatch +//! produces no stdout response; a generic error cannot settle the Stop latch. //! //! Requires `node` on PATH — the same requirement the crate's existing //! fake-sidecar tests already impose. @@ -168,22 +167,21 @@ fn real_sidecar_dispatches_interrupt_frames_to_handle_interrupt() { let mut stdin = sidecar.child.stdin.take().expect("piped stdin"); stdin - .write_all(b"{\"type\":\"interrupt\",\"sessionId\":\"nope\"}\n") + .write_all(b"{\"type\":\"interrupt\",\"sessionId\":\"nope\",\"stopId\":\"missing-session-stop\"}\n") .expect("write interrupt frame"); stdin.flush().expect("flush interrupt frame"); - // Post-fix observable: handleInterrupt emits the session-not-found sdk.error - // frame on stdout. Pre-fix: the frame is dropped by the `default:` arm and - // stderr logs `unknown request type: interrupt` instead. - let mut error_frame: Option = None; + // The real dispatch must settle this exact Stop, even when its session is + // missing. The old default arm dropped it without answering. + let mut settled_frame: Option = None; let deadline = std::time::Instant::now() + Duration::from_secs(30); - while error_frame.is_none() { + while settled_frame.is_none() { let Some(remaining) = deadline.checked_duration_since(std::time::Instant::now()) else { break; }; match rx.recv_timeout(remaining) { Ok(("stdout", line)) => { - error_frame = + settled_frame = Some(serde_json::from_str(&line).expect("sidecar stdout is newline-JSON")); } Ok((_, line)) => stderr_lines.push(line), @@ -200,10 +198,17 @@ fn real_sidecar_dispatches_interrupt_frames_to_handle_interrupt() { "pre-fix behavior: the interrupt frame fell into the dispatch default arm and was \ dropped; stderr: {stderr_lines:?}" ); - let frame = error_frame.expect( + let frame = settled_frame.expect( "no stdout frame arrived: the interrupt request was not dispatched to handleInterrupt", ); - assert_eq!(frame["type"], "sdk.error", "frame: {frame}"); + assert_eq!(frame["type"], "sdk.interrupt_settled", "frame: {frame}"); assert_eq!(frame["sessionId"], "nope", "frame: {frame}"); - assert_eq!(frame["message"], "session not found", "frame: {frame}"); + assert_eq!(frame["stopId"], "missing-session-stop", "frame: {frame}"); + assert_eq!(frame["ok"], false, "frame: {frame}"); + assert!( + frame["message"] + .as_str() + .is_some_and(|message| !message.is_empty()), + "a refused Stop includes its reason: {frame}" + ); } From 664ddd2a4fdd22ca1d779ca7d528eefa07f87b95 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:30:01 -0700 Subject: [PATCH 017/100] fix(freshclaude): hydrate legacy panes with their durable identity --- src/store/persistedState.ts | 5 ++++ .../store/persisted-state.fresh-agent.test.ts | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/src/store/persistedState.ts b/src/store/persistedState.ts index 473356cc6..720912c19 100644 --- a/src/store/persistedState.ts +++ b/src/store/persistedState.ts @@ -431,6 +431,11 @@ function normalizeFreshAgentContent(content: Record): Record): Pick { beforeEach(seedWindow) + it.each(['freshclaude', 'kilroy'])('restores %s placeholder panes under their durable identity', (sessionType) => { + const sessionId = '00000000-0000-4000-8000-000000000779' + const parsed = parsePersistedLayoutRaw(layoutRaw({ + 'tab-1': leaf('legacy', { + kind: 'fresh-agent', sessionType, provider: 'claude', + sessionId: 'old-placeholder', sessionRef: { provider: 'claude', sessionId }, + serverInstanceId: 'old-server', initialCwd: '/repo', + }), + })) + expect(collectLeafContents(parsed!.panes.layouts['tab-1'])[0]).toMatchObject({ + sessionId, sessionRef: { provider: 'claude', sessionId }, + createRequestId: 'req-legacy', initialCwd: '/repo', + }) + }) + + it('leaves a legacy zero-turn placeholder without a durable reference available for lost-session recovery', () => { + const parsed = parsePersistedLayoutRaw(layoutRaw({ + 'tab-1': leaf('legacy', { + kind: 'fresh-agent', sessionType: 'freshclaude', provider: 'claude', + sessionId: 'old-zero-turn-placeholder', serverInstanceId: 'old-server', + }), + })) + const content = collectLeafContents(parsed!.panes.layouts['tab-1'])[0] + expect(content.sessionId).toBe('old-zero-turn-placeholder') + expect(content.sessionRef).toBeUndefined() + }) + it('migrates persisted agent-chat panes to fresh-agent panes in the combined layout key shape', () => { const parsed = parsePersistedLayoutRaw(layoutRaw({ 'tab-1': { From e2ae852302db75c0f11772de232028628da3d9f5 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:31:04 -0700 Subject: [PATCH 018/100] feat(freshclaude): follow conversation resets with one live runtime --- crates/freshell-freshagent/src/claude.rs | 46 +++++ .../freshell-freshagent/src/claude/rekey.rs | 176 ++++++++++++++++++ .../freshell-freshagent/src/session_lease.rs | 79 ++++++++ crates/freshell-ws/src/pane_ledger.rs | 27 ++- .../tests/freshagent_claude_reset.rs | 161 ++++++++++++++++ test/fixtures/claude-cli-sim/query.mjs | 3 + 6 files changed, 482 insertions(+), 10 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude/rekey.rs create mode 100644 crates/freshell-ws/tests/freshagent_claude_reset.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 8056182a4..e78b8902f 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -6593,6 +6593,7 @@ impl FreshClaudeState { let identity_sink = self.identity_sink(); let state = self.clone(); tokio::spawn(async move { + let mut session_id = session_id; // 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; @@ -6608,6 +6609,27 @@ impl FreshClaudeState { stream_ended = Some(ended); continue; } + if value["type"] == "sdk.session.rekeyed" { + if let Some(new) = value["newSessionId"].as_str() { + match state + .rekey_conversation( + &session_id, + new, + &session_type, + &pending, + "conversation_reset", + ) + .await + { + Ok(new) => session_id = new, + Err(cause) => tracing::error!(target: "freshell_freshagent::claude", + event = "freshagent.claude.rekey_refused", provider = PROVIDER, + session_id = %session_id, requested_session_id = new, cause, + "the conversation identity could not follow its reset"), + } + } + continue; + } // C17: fold the line into the session's turn ledger (design // §5.2) before anything else reads it: a gate, an attach // acknowledgement or the status published below sees it. @@ -6807,6 +6829,29 @@ impl FreshClaudeState { state .bind_naming_handle_at_init(cli_id, &session_id, &session_type) .await; + } else if create_binding_expected { + tracing::error!(target: "freshell_freshagent::claude", + event = "freshagent.claude.unannounced_rekey", provider = PROVIDER, + session_id = %session_id, requested_session_id = cli_id, + "the CLI changed conversation without a reset announcement"); + match state + .rekey_conversation( + &session_id, + cli_id, + &session_type, + &pending, + "unannounced", + ) + .await + { + Ok(new) => session_id = new, + Err(cause) => { + tracing::error!(target: "freshell_freshagent::claude", + event = "freshagent.claude.rekey_refused", provider = PROVIDER, + session_id = %session_id, requested_session_id = cli_id, cause, + "the unannounced conversation identity could not be followed") + } + } } else { state .adopt_session_init( @@ -8138,6 +8183,7 @@ mod fork; mod genesis; mod history; mod pending_runtime; +mod rekey; use pending_runtime::{is_pending_frame, PendingResponse}; mod rollback; mod sidecar_writer; diff --git a/crates/freshell-freshagent/src/claude/rekey.rs b/crates/freshell-freshagent/src/claude/rekey.rs new file mode 100644 index 000000000..e4d24fa4a --- /dev/null +++ b/crates/freshell-freshagent/src/claude/rekey.rs @@ -0,0 +1,176 @@ +//! A CLI conversation reset moves one live runtime; it never starts another. + +use super::*; + +impl FreshClaudeState { + pub(super) async fn rekey_conversation( + &self, + old: &str, + new: &str, + session_type: &str, + expected_pending: &Arc>, + reason: &str, + ) -> Result { + if uuid::Uuid::parse_str(new).is_err() { + return Err("Claude reported an invalid conversation id".into()); + } + if old == new { + return Ok(old.into()); + } + let turn_lock = { + let sessions = self.sessions.lock().await; + let session = sessions + .get(old) + .filter(|session| Arc::ptr_eq(&session.pending, expected_pending)) + .ok_or_else(|| "the conversation runtime was replaced".to_string())?; + Arc::clone(&session.turn_lock) + }; + let _turn = turn_lock.lock().await; + let guard_at = self.ownership.as_ref().map(|registry| { + let observed = registry.observe(PROVIDER, old); + freshell_ownership::ObservedFence { + epoch: observed.epoch, + generation: observed.generation, + } + }); + let old_guard = match crate::ownership_lane::arm_reclaimless_op_guard( + &self.ownership, + PROVIDER, + old, + &format!("claude-reset-{}", uuid::Uuid::new_v4()), + guard_at, + "claude/conversation-reset", + ) { + crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), + crate::ownership_lane::LaneOpGuard::Unwired => None, + crate::ownership_lane::LaneOpGuard::Refused { message, .. } => return Err(message), + }; + let now = crate::session_lease::now_epoch_ms() as i64; + let settings = { + // The same map-lock order as kill/adoption. Ownership, routing, + // and the envelope move together before metadata IO starts. + let mut index = self.cli_index.lock().await; + let mut sessions = self.sessions.lock().await; + let session = sessions + .get(old) + .filter(|session| Arc::ptr_eq(&session.pending, expected_pending)) + .ok_or_else(|| "the conversation runtime was replaced".to_string())?; + if sessions.contains_key(new) { + return Err("the new conversation already has a live runtime".into()); + } + if self + .close_pending + .lock() + .expect("close-pending lock") + .get(old) + .copied() + .unwrap_or(0) + > 0 + { + return Err("the conversation is closing".into()); + } + self.commit_lane_claim_rekey_at( + old, + &mut None, + new, + old, + new, + session.child.id(), + "claude/conversation-reset", + ) + .map_err(|outcome| format!("conversation ownership could not move: {outcome:?}"))?; + let mut session = sessions.remove(old).expect("the validated runtime exists"); + if !self + .leases + .rekey_binding(PROVIDER, old, new, old, new, &session.ownership_id) + { + tracing::error!(target: "freshell_freshagent::claude", + event = "freshagent.claude.rekey_lease_conflict", provider = PROVIDER, + previous_session_id = old, session_id = new, + "the runtime moved but its lease binding could not follow"); + } + session.cli_session_id = Some(new.into()); + *session.broadcast_id.lock().expect("broadcast id lock") = new.into(); + session.launch_genesis = Some(crate::identity_sink::RecordedGenesis { + genesis: crate::identity_sink::ClaudeGenesis::Fresh, + updated_at_ms: now, + }); + let settings = session.configuration.settings.clone(); + sessions.insert(new.into(), session); + for target in index.values_mut() { + if target == old { + *target = new.into(); + } + } + index.insert(old.into(), new.into()); + index.insert(new.into(), new.into()); + self.ownership_stamps + .lock() + .expect("ownership stamps lock") + .remove(old); + settings + }; + // rekey_live moves the coordinator to a new generation. Protect the + // new key during persistence; a concurrent transition may already own + // it, in which case its authoritative metadata wins. + drop(old_guard); + let new_at = self.ownership.as_ref().map(|registry| { + let observed = registry.observe(PROVIDER, new); + freshell_ownership::ObservedFence { + epoch: observed.epoch, + generation: observed.generation, + } + }); + let new_guard = crate::ownership_lane::arm_reclaimless_op_guard( + &self.ownership, + PROVIDER, + new, + &format!("claude-reset-binding-{}", uuid::Uuid::new_v4()), + new_at, + "claude/conversation-reset-binding", + ); + if !matches!( + new_guard, + crate::ownership_lane::LaneOpGuard::Refused { .. } + ) { + if let Some(sink) = self.identity_sink() { + if let Err(error) = sink + .record_binding(crate::identity_sink::FreshAgentBindingUpsert { + provider: PROVIDER.into(), + session_id: new.into(), + mode: session_type.into(), + create_request_id: None, + resolves_pending: None, + supersedes: Some(old.into()), + name_transition: None, + provenance: crate::identity_sink::ProvenanceUpdate::Inherit, + observed_epoch: new_at.map(|at| at.epoch), + observed_generation: new_at.map(|at| at.generation), + authoritative: false, + settings, + genesis: crate::identity_sink::GenesisUpdate::Set( + crate::identity_sink::ClaudeGenesis::Fresh, + ), + }) + .await + { + // The CLI already changed its file. Keep following the + // healthy runtime and report failed metadata persistence. + tracing::error!(target: "freshell_freshagent::claude", + event = "freshagent.claude.rekey_binding_failed", provider = PROVIDER, + previous_session_id = old, session_id = new, cause = %error, + "the conversation reset but its recovery settings could not be saved"); + } + } + } + self.create_dedup + .clear_for_session(|record| record.session_id == old) + .await; + self.broadcast_materialized(old, new, session_type).await; + tracing::info!(target: "freshell_freshagent::claude", + event = "freshagent.claude.session_rekeyed", provider = PROVIDER, + previous_session_id = old, session_id = new, session_type, reason, + "the live conversation follows its new durable id"); + Ok(new.into()) + } +} diff --git a/crates/freshell-freshagent/src/session_lease.rs b/crates/freshell-freshagent/src/session_lease.rs index fbe8a4c5e..d0286fcfc 100644 --- a/crates/freshell-freshagent/src/session_lease.rs +++ b/crates/freshell-freshagent/src/session_lease.rs @@ -1022,6 +1022,36 @@ impl FreshAgentSessionLeases { inner.bindings.remove(&lease_key(provider, session_id)); } + /// Move a completed binding with the same live process after a native + /// conversation reset. An unrelated or in-flight target is untouched. + pub(crate) fn rekey_binding( + &self, + provider: &str, + old: &str, + new: &str, + old_live_key: &str, + new_live_key: &str, + ownership_id: &str, + ) -> bool { + let mut inner = self.inner.lock().expect("fresh-agent lease lock poisoned"); + let old_key = lease_key(provider, old); + let new_key = lease_key(provider, new); + let Some(binding) = inner.bindings.get(&old_key) else { + return true; + }; + if binding.live_session_key != old_live_key + || binding.ownership_id.as_deref() != Some(ownership_id) + || inner.bindings.contains_key(&new_key) + || inner.leases.contains_key(&new_key) + { + return false; + } + let mut binding = inner.bindings.remove(&old_key).expect("validated binding"); + binding.live_session_key = new_live_key.into(); + inner.bindings.insert(new_key, binding); + true + } + /// Clear a completed binding only when it still names the watched /// runtime. A missing or different ownership tag is a deliberate no-op: /// the caller is stale or cannot prove it owns the binding. @@ -1201,6 +1231,55 @@ mod tests { ); } + #[test] + fn conversation_reset_moves_only_the_matching_runtime_binding() { + let leases = FreshAgentSessionLeases::new(); + leases.claim("claude", "old", "create", 0); + assert!(leases.complete_with_identity("claude", "old", "create", "old", Some("owner"))); + assert!(!leases.rekey_binding("claude", "old", "new", "old", "new", "stale")); + assert_eq!( + leases.claim("claude", "old", "observe", 1), + FreshSessionClaim::BoundLive { + live_session_key: "old".into() + } + ); + assert!(leases.rekey_binding("claude", "old", "new", "old", "new", "owner")); + assert_eq!( + leases.claim("claude", "new", "observe", 2), + FreshSessionClaim::BoundLive { + live_session_key: "new".into() + } + ); + assert_eq!( + leases.claim("claude", "old", "reuse", 2), + FreshSessionClaim::Acquired + ); + assert!(leases.clear_binding_if_matches("claude", "new", "new", "owner")); + assert_eq!( + leases.claim("claude", "new", "resume", 3), + FreshSessionClaim::Acquired + ); + } + + #[test] + fn conversation_reset_does_not_overwrite_another_launch() { + let leases = FreshAgentSessionLeases::new(); + leases.claim("claude", "old", "create", 0); + assert!(leases.complete_with_identity("claude", "old", "create", "old", Some("owner"))); + leases.claim("claude", "new", "other", 0); + assert!(!leases.rekey_binding("claude", "old", "new", "old", "new", "owner")); + assert_eq!( + leases.claim("claude", "old", "observe", 1), + FreshSessionClaim::BoundLive { + live_session_key: "old".into() + } + ); + assert!(matches!( + leases.claim("claude", "new", "observe", 1), + FreshSessionClaim::Held { .. } + )); + } + #[test] fn clear_binding_reopens_after_the_bound_session_exits() { let leases = FreshAgentSessionLeases::new(); diff --git a/crates/freshell-ws/src/pane_ledger.rs b/crates/freshell-ws/src/pane_ledger.rs index 1f84fe4b7..4a8f66f34 100644 --- a/crates/freshell-ws/src/pane_ledger.rs +++ b/crates/freshell-ws/src/pane_ledger.rs @@ -2679,12 +2679,28 @@ impl PaneLedger { } } let created_at = existing.map(|r| r.created_at).unwrap_or(w.now_ms); + // A superseding conversation keeps its pane's create lineage, so a + // replay after reset or rollback still finds the current session. + let superseded_parent = w + .supersedes + .filter(|old| *old != w.session_id) + .and_then(|old| { + index + .bindings + .get(&(w.provider.to_string(), old.to_string())) + .cloned() + }); // Advisory field: keep the existing row's value when the new write // has none (latest-observed semantics, D4). let create_request_id = w .create_request_id .map(str::to_string) - .or_else(|| existing.and_then(|r| r.create_request_id.clone())); + .or_else(|| existing.and_then(|r| r.create_request_id.clone())) + .or_else(|| { + superseded_parent + .as_ref() + .and_then(|r| r.create_request_id.clone()) + }); // Lineage (delta-r7-round-3, F1): the fresh-agent row's origin // mirrors its pane key — the same computed value — so the coverage // join against either key is identical to the pre-F1 crid arm on @@ -2738,15 +2754,6 @@ impl PaneLedger { // documented in the terminal body (focused-ep4-r5 Finding 2a); no // sequence counter is built for it. The retire/link below is // unchanged by the policy; this block is stamps+time only. - let superseded_parent = w - .supersedes - .filter(|old| *old != w.session_id) - .and_then(|old| { - index - .bindings - .get(&(w.provider.to_string(), old.to_string())) - .cloned() - }); let inherit = existing.or(superseded_parent.as_ref()); let prior_attribution = inherit.and_then(|r| r.last_attributed_at); let apply = match w.provenance { diff --git a/crates/freshell-ws/tests/freshagent_claude_reset.rs b/crates/freshell-ws/tests/freshagent_claude_reset.rs new file mode 100644 index 000000000..904332d6e --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_reset.rs @@ -0,0 +1,161 @@ +//! A conversation reset changes the durable identity of one live runtime. + +mod common; + +use common::claude_rig::{ClaudeRig, RigKind, RigOptions, RIG_FRAME_BUDGET}; +use serde_json::json; +use std::time::Duration; + +fn dialogue_rows(text: &str) -> Vec { + text.lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .filter(|row: &serde_json::Value| row["type"] == "user" || row["type"] == "assistant") + .collect() +} + +async fn conversation_reset(kind: RigKind, drop_announcement: bool) { + let rig = ClaudeRig::start_with_options( + kind, + RigOptions { + durable_ledger: true, + env: if drop_announcement { + vec![( + "FRESHELL_CLAUDE_SIM_DROP_CONVERSATION_RESET".into(), + "1".into(), + )] + } else { + vec![] + }, + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let old = conn.create("create-reset").await; + rig.converse(&mut conn, &old, &["before clear"]).await; + let original_path = rig.wait_for_transcript(&old).await; + let original_text = std::fs::read_to_string(&original_path).unwrap(); + let before = conn.frames.len(); + + conn.send_text(&old, "clear-command", "/clear").await; + let init = conn + .wait_for("the post-clear init", |frame| { + frame["event"]["type"] == "freshAgent.session.init" + && frame["event"]["cliSessionId"] != old + }) + .await; + let new = init["event"]["cliSessionId"].as_str().unwrap().to_string(); + assert!(uuid::Uuid::parse_str(&new).is_ok() && new != old); + let materialized = conn.frames[before..] + .iter() + .find(|frame| frame["type"] == "freshAgent.session.materialized") + .expect("the pane must follow the reset before receiving the new init"); + assert_eq!(materialized["previousSessionId"], old); + assert_eq!(materialized["sessionId"], new); + assert_eq!( + materialized["sessionRef"], + json!({"provider":"claude", "sessionId":new}) + ); + assert_eq!( + init["sessionId"], new, + "the init is stamped with the new id" + ); + + conn.wait_for("the cleared turn's result", |frame| { + frame["sessionId"] == new && frame["event"]["type"] == "freshAgent.result" + }) + .await; + conn.wait_for("the clear command retired under the new id", |frame| { + frame["sessionId"] == new + && frame["event"]["type"] == "freshAgent.status" + && frame["event"]["status"] == "idle" + }) + .await; + assert_eq!(rig.fresh_claude.session_busy(&new).await, Some(false)); + let ledger = rig.pane_ledger.as_ref().unwrap(); + assert_eq!( + ledger + .load_binding("claude", &old) + .unwrap() + .superseded_by + .unwrap() + .session_id, + new, + ); + assert!(ledger.load_binding("claude", &new).is_some()); + assert_eq!( + ledger + .lookup_by_create_request_id("claude", "create-reset") + .unwrap() + .session_id, + new, + "replaying the create must follow the same conversation reset", + ); + let (status, snapshot) = rig.snapshot(&new).await; + assert_eq!(status, 200, "{snapshot}"); + assert!(!snapshot.to_string().contains("before clear")); + + let results_before = conn.events_seen("freshAgent.result"); + let after_reset = conn.frames.len(); + conn.send_text(&new, "after-reset", "after clear").await; + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + let frames = &conn.frames[after_reset..]; + let completed = frames.iter().any(|frame| { + frame["sessionId"] == new && frame["event"]["type"] == "freshAgent.turn.complete" + }); + let idle = frames.iter().any(|frame| { + frame["sessionId"] == new + && frame["event"]["type"] == "freshAgent.status" + && frame["event"]["status"] == "idle" + }); + if completed && idle { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "the new conversation never finished its next turn" + ); + conn.read_for(Duration::from_millis(50)).await; + } + assert_eq!(conn.events_seen("freshAgent.result"), results_before + 1); + assert_eq!(rig.fresh_claude.session_busy(&new).await, Some(false)); + // The CLI appends queue bookkeeping to the old file during /clear. Its + // conversation messages remain intact; the next turn belongs only to B. + assert_eq!( + dialogue_rows(&std::fs::read_to_string(original_path).unwrap()), + dialogue_rows(&original_text), + ); + assert_eq!( + conn.frames[before..] + .iter() + .filter(|frame| frame["type"] == "freshAgent.session.materialized") + .count(), + 1, + "the per-turn init after reset does not rekey again", + ); + // A request issued before /clear still targets this runtime. Teardown + // must release the moved lease, so B can subsequently be resumed. + conn.kill(&old).await; + assert!(!rig.fresh_claude.has_live_session(&new).await); + let mut resumed = rig.connect().await; + resumed.attach(&new).await; + rig.converse(&mut resumed, &new, &["after reset and resume"]) + .await; + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn clear_moves_the_live_record_binding_and_turn_to_the_new_uuid() { + conversation_reset(RigKind::Sim, false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn pinned_cli_clear_then_send_keeps_the_new_conversation_working() { + conversation_reset(RigKind::Cli, false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_unannounced_init_identity_change_keeps_following_the_healthy_runtime() { + conversation_reset(RigKind::Sim, true).await; +} diff --git a/test/fixtures/claude-cli-sim/query.mjs b/test/fixtures/claude-cli-sim/query.mjs index 2eca574a9..2a59ee5db 100644 --- a/test/fixtures/claude-cli-sim/query.mjs +++ b/test/fixtures/claude-cli-sim/query.mjs @@ -359,6 +359,9 @@ class SimulatedCli { emit(message) { if (this.dead || this.closing) return + // Fault injection for consumers that must follow an init identity even + // when the reset announcement is missing. The default trace is intact. + if (message.type === 'conversation_reset' && this.env.FRESHELL_CLAUDE_SIM_DROP_CONVERSATION_RESET === '1') return this.channel.push({ ...message, uuid: message.uuid ?? randomUUID(), session_id: message.session_id ?? this.sessionId }) } From dd7ab0777016c6f07d8c127c9518c7b99724812d Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:32:00 -0700 Subject: [PATCH 019/100] fix(freshclaude): keep REST control on durable and accepted identities --- .../src/claude_rest_lane.rs | 2 +- crates/freshell-freshagent/src/lib.rs | 14 +- .../src/rest_lane_http_tests.rs | 32 +++++ .../tests/rest_freshclaude_lane.rs | 122 ++++++++++++++++++ .../fresh-agent-rest-claude-rust.spec.ts | 8 +- 5 files changed, 167 insertions(+), 11 deletions(-) diff --git a/crates/freshell-freshagent/src/claude_rest_lane.rs b/crates/freshell-freshagent/src/claude_rest_lane.rs index de182c9be..7122f0c99 100644 --- a/crates/freshell-freshagent/src/claude_rest_lane.rs +++ b/crates/freshell-freshagent/src/claude_rest_lane.rs @@ -150,7 +150,7 @@ impl ClaudeRestLane { .await?; let completed = settled_key.is_some(); Ok(RestLaneSendResult { - session_id: settled_key.unwrap_or_else(|| request.session_id.clone()), + session_id: settled_key.unwrap_or(session_key), completed, submitted_turn_id: Some(submitted_turn_id), noop: false, diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index fc5a0db2a..cc1528bb5 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -5192,16 +5192,18 @@ fn pane_entry_from_layout(state: &FreshAgentState, pane_id: &str) -> Option(value.clone()).ok()) .filter(|locator| locator.provider == provider && !locator.session_id.is_empty()) - .map(|locator| locator.session_id) - .unwrap_or_else(|| session_id.clone()); + .map(|locator| locator.session_id); + let session_id = content + .get("sessionId") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .map(str::to_string) + .or_else(|| durable_id.clone())?; + let durable_id = durable_id.unwrap_or_else(|| session_id.clone()); Some(PaneEntry { placeholder_id: session_id.clone(), provider: provider.to_string(), diff --git a/crates/freshell-freshagent/src/rest_lane_http_tests.rs b/crates/freshell-freshagent/src/rest_lane_http_tests.rs index eadcc6740..8f2625000 100644 --- a/crates/freshell-freshagent/src/rest_lane_http_tests.rs +++ b/crates/freshell-freshagent/src/rest_lane_http_tests.rs @@ -506,6 +506,38 @@ async fn local_lane_controls_browser_created_panes_using_the_provider_matched_du assert_eq!(captured.max_bytes, 30); } +#[tokio::test] +async fn a_restored_pane_with_only_its_durable_ref_supports_send_and_capture() { + const ID: &str = "67cebd37-9576-4de0-93bb-8f286f5fc499"; + let state = state_with_registry(); + let (tab, pane) = state.layout.create_tab(None); + state.layout.attach_pane_content( + &tab, + &pane, + json!({ + "kind":"fresh-agent", "provider":"claude", "sessionType":"freshclaude", + "sessionRef":{"provider":"claude", "sessionId":ID}, + }), + ); + let lane = Arc::new(ControlLane::default()); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state); + let (status, sent) = post( + router.clone(), + &format!("/api/panes/{pane}/send-keys"), + json!({"data":"restored prompt"}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{sent}"); + assert_eq!(sent["data"]["sessionId"], ID); + assert_eq!(lane.sends.lock().unwrap().last().unwrap().session_id, ID); + let (status, text) = capture_response(router, &pane).await; + assert_eq!(status, StatusCode::OK, "{text}"); + assert_eq!(text, "saved conversation"); + assert_eq!(lane.captures.lock().unwrap().last().unwrap().session_id, ID); +} + #[tokio::test] async fn lane_noop_does_not_report_a_submitted_turn() { let state = state_with_registry(); diff --git a/crates/freshell-ws/tests/rest_freshclaude_lane.rs b/crates/freshell-ws/tests/rest_freshclaude_lane.rs index 232e709db..fb8a4a98f 100644 --- a/crates/freshell-ws/tests/rest_freshclaude_lane.rs +++ b/crates/freshell-ws/tests/rest_freshclaude_lane.rs @@ -346,3 +346,125 @@ async fn actual_fenced_send_refusal_reports_its_owner_and_keeps_the_prompt_unsen assert_eq!(rig.ledger_rows("prompt")[0]["text"], "current prompt"); rig.fresh_claude.shutdown().await; } + +async fn clear_rekeys_the_headless_rest_pane(kind: RigKind) { + let rig = ClaudeRig::start_with_options(kind, options()).await; + let created = create(&rig, "claude").await; + let old_id = created["sessionId"].as_str().unwrap(); + let pane = created["paneId"].as_str().unwrap(); + let route = format!("/api/panes/{pane}/send-keys"); + let first = send(&rig, pane, "before clear conversation").await; + wait_for_saved_turn(&rig, old_id, first["submittedTurnId"].as_str().unwrap()).await; + let (_, old_capture) = get(&rig, &format!("/api/panes/{pane}/capture")).await; + assert!( + old_capture.contains("before clear conversation"), + "{old_capture}" + ); + let (status, cleared) = rig + .post_json( + &route, + &json!({"data":"/clear", "requestId":"rest-request", "timeout":15}), + ) + .await; + assert_eq!(status, 200, "{cleared}"); + assert_eq!(cleared["data"]["status"], "idle", "{cleared}"); + let new_id = cleared["data"]["sessionId"].as_str().unwrap(); + assert_ne!(new_id, old_id, "clear chooses a new conversation"); + assert!(uuid::Uuid::parse_str(new_id).is_ok()); + assert_eq!( + cleared["data"]["sessionRef"], + json!({"provider":"claude", "sessionId":new_id}) + ); + let (status, fresh_capture) = get(&rig, &format!("/api/panes/{pane}/capture")).await; + assert_eq!(status, 200, "{fresh_capture}"); + assert!( + fresh_capture.is_empty(), + "the same headless pane now captures the new empty conversation: {fresh_capture}" + ); + let next = send(&rig, pane, "after clear conversation").await; + assert_eq!(next["status"], "idle"); + assert_eq!(next["sessionId"], new_id); + wait_for_saved_turn(&rig, new_id, next["submittedTurnId"].as_str().unwrap()).await; + let (status, next_capture) = get(&rig, &format!("/api/panes/{pane}/capture")).await; + assert_eq!(status, 200, "{next_capture}"); + assert!( + next_capture.contains("after clear conversation"), + "{next_capture}" + ); + assert!( + !next_capture.contains("before clear conversation"), + "{next_capture}" + ); + let old_path = rig.wait_for_transcript(old_id).await; + let old_lines: Vec = std::fs::read_to_string(old_path) + .unwrap() + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect(); + assert!(old_lines.iter().any(|line| line["type"] == "user")); + assert!( + old_lines.iter().any(|line| line["type"] == "assistant"), + "clear preserves the previous conversation" + ); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn clear_sim_rekeys_the_headless_rest_pane_before_capture_and_next_send() { + clear_rekeys_the_headless_rest_pane(RigKind::Sim).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn clear_pinned_cli_rekeys_the_headless_rest_pane_before_capture_and_next_send() { + clear_rekeys_the_headless_rest_pane(RigKind::Cli).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_timed_out_send_reports_the_accepted_replacement_and_updates_headless_capture() { + let rig = ClaudeRig::start_with_options(RigKind::Sim, options()).await; + let created = create(&rig, "claude").await; + let original = created["sessionId"].as_str().unwrap(); + let pane = created["paneId"].as_str().unwrap(); + for text in ["kept timeout conversation", "removed timeout conversation"] { + let sent = send(&rig, pane, text).await; + wait_for_saved_turn(&rig, original, sent["submittedTurnId"].as_str().unwrap()).await; + } + // A protocol client performs the undo but never mirrors a browser layout. + // The REST pane still addresses A when the runtime supersedes it with B. + let mut control = rig.connect().await; + let owner = rig.fresh_claude.ownership_snapshot("claude", original); + control.send(json!({ + "type":"freshAgent.undo", "provider":"claude", "sessionType":"freshclaude", "sessionId":original, + "requestId":"undo-before-timeout", "mode":"step", "observedEpoch":owner.epoch, "observedGeneration":owner.generation, + })).await; + let ack = control + .wait_for("undo replacement", |frame| { + frame["event"]["requestId"] == "undo-before-timeout" + && matches!( + frame["event"]["type"].as_str(), + Some("freshAgent.rolledBack" | "freshAgent.error") + ) + }) + .await; + assert_eq!(ack["event"]["type"], "freshAgent.rolledBack", "{ack}"); + let replacement = ack["event"]["newSessionId"].as_str().unwrap(); + assert_ne!(replacement, original); + let (status, body) = rig + .post_json( + &format!("/api/panes/{pane}/send-keys"), + &json!({"data":"HANG", "timeout":1}), + ) + .await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["data"]["status"], "approx"); + assert_eq!(body["data"]["sessionId"], replacement); + assert_eq!(body["data"]["sessionRef"]["sessionId"], replacement); + let (status, captured) = get(&rig, &format!("/api/panes/{pane}/capture")).await; + assert_eq!(status, 200, "{captured}"); + assert!(captured.contains("kept timeout conversation"), "{captured}"); + assert!( + !captured.contains("removed timeout conversation"), + "{captured}" + ); + rig.fresh_claude.shutdown().await; +} diff --git a/test/e2e-browser/specs/fresh-agent-rest-claude-rust.spec.ts b/test/e2e-browser/specs/fresh-agent-rest-claude-rust.spec.ts index 1bd42d799..dfae1bdb9 100644 --- a/test/e2e-browser/specs/fresh-agent-rest-claude-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-rest-claude-rust.spec.ts @@ -76,8 +76,8 @@ test.describe('external REST/MCP controls the shared Claude runtime', () => { expect(captured).toContain(`user: ${PROMPT}`) expect(captured).toContain(`assistant: ${REPLY}`) await mcp.callFreshellAction('select-tab', { target: tabId }) - await expect(page.locator(`[data-pane-id="${paneId}"]`)).toBeVisible() - await expect(page.locator(`[data-pane-id="${paneId}"]`)).toContainText(REPLY) + await expect(page.locator(`[data-context="fresh-agent"][data-pane-id="${paneId}"]`)).toBeVisible() + await expect(page.locator(`[data-context="fresh-agent"][data-pane-id="${paneId}"]`)).toContainText(REPLY) expect(await page.evaluate(() => (window as typeof window & { __restNotificationPlays?: number }).__restNotificationPlays)).toBe(1) const split = await mcp.callFreshellAction('split-pane', { target: paneId, agent: 'kilroy', cwd: lane.workspace, direction: 'vertical' }) @@ -85,8 +85,8 @@ test.describe('external REST/MCP controls the shared Claude runtime', () => { const splitId = split.data.paneId await expect.poll(async () => leaf(await harness.getPaneLayout(tabId), splitId)?.content?.sessionType, { timeout: 20_000 }).toBe('kilroy') await mcp.callFreshellAction('send-keys', { target: splitId, keys: 'kilroy hello\r', literal: true }) - await expect(page.locator(`[data-pane-id="${splitId}"]`)).toBeVisible() - await expect(page.locator(`[data-pane-id="${splitId}"]`)).toContainText('Kilroy REST reply') + await expect(page.locator(`[data-context="fresh-agent"][data-pane-id="${splitId}"]`)).toBeVisible() + await expect(page.locator(`[data-context="fresh-agent"][data-pane-id="${splitId}"]`)).toContainText('Kilroy REST reply') // With no browser to attach or repair a REST pane row, the persisted // layout and durable transcript must still serve the external client. From d96dc63c3cf5bb39929403569ee5df33166f7443 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:44:54 -0700 Subject: [PATCH 020/100] fix(freshclaude): honor replaced pane content in REST controls --- crates/freshell-freshagent/src/lib.rs | 41 +++++++--- .../src/rest_lane_http_tests.rs | 74 +++++++++++++++++++ 2 files changed, 103 insertions(+), 12 deletions(-) diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index cc1528bb5..8147a2993 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -5164,24 +5164,41 @@ fn register_fresh_agent_tab( /// Resolve them when their provider has a lane; that lane owns the live-session /// checks and transcript lookup. fn pane_entry_for_request(state: &FreshAgentState, pane_id: &str) -> Option { - pane_entry_from_layout(state, pane_id).or_else(|| { - state - .panes - .lock() - .expect("panes mutex") - .get(pane_id) - .cloned() - }) + let cached = state + .panes + .lock() + .expect("panes mutex") + .get(pane_id) + .cloned(); + let Some(snapshot) = state.layout.get_pane_snapshot(pane_id) else { + return cached; + }; + // An existing snapshot is authoritative even when its current content + // cannot be controlled by this route. A retired cache row must not make + // a browser, shell or unsupported provider address the previous session. + let current = pane_entry_from_content(snapshot.pane_content.as_ref()?)?; + if state.rest_lane_for(¤t.provider).is_some() { + return Some(current); + } + // Local OpenCode predates the generic lane and keeps its materialized + // identity in the cache while the layout can still show its placeholder. + // Retain that path only for the same, still-visible OpenCode identity. + let cached = cached?; + (current.provider == PROVIDER + && current.session_type == SESSION_TYPE + && cached.provider == PROVIDER + && cached.session_type == SESSION_TYPE + && current.placeholder_id == cached.placeholder_id + && (current.durable_id.as_ref() == Some(¤t.placeholder_id) + || current.durable_id == cached.durable_id)) + .then_some(cached) } -fn pane_entry_from_layout(state: &FreshAgentState, pane_id: &str) -> Option { - let snapshot = state.layout.get_pane_snapshot(pane_id)?; - let content = snapshot.pane_content?; +fn pane_entry_from_content(content: &Value) -> Option { if content.get("kind")?.as_str()? != "fresh-agent" { return None; } let provider = content.get("provider")?.as_str()?; - state.rest_lane_for(provider)?; let session_type = content.get("sessionType")?.as_str()?; if !matches!( (provider, session_type), diff --git a/crates/freshell-freshagent/src/rest_lane_http_tests.rs b/crates/freshell-freshagent/src/rest_lane_http_tests.rs index 8f2625000..97e06236a 100644 --- a/crates/freshell-freshagent/src/rest_lane_http_tests.rs +++ b/crates/freshell-freshagent/src/rest_lane_http_tests.rs @@ -538,6 +538,80 @@ async fn a_restored_pane_with_only_its_durable_ref_supports_send_and_capture() { assert_eq!(lane.captures.lock().unwrap().last().unwrap().session_id, ID); } +#[tokio::test] +async fn replacing_a_cached_claude_pane_with_other_content_never_controls_the_old_session() { + for (content, capture_status) in [ + ( + json!({"kind":"terminal", "mode":"shell"}), + StatusCode::NOT_FOUND, + ), + ( + json!({"kind":"browser", "url":"https://example.com"}), + StatusCode::UNPROCESSABLE_ENTITY, + ), + ( + json!({"kind":"editor", "filePath":"/tmp/example.txt"}), + StatusCode::UNPROCESSABLE_ENTITY, + ), + ] { + let state = state_with_registry(); + let lane = Arc::new(ControlLane::default()); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state.clone()); + let (_, created) = post(router.clone(), "/api/tabs", json!({"agent":"claude"}), true).await; + let tab = created["data"]["tabId"].as_str().unwrap(); + let pane = created["data"]["paneId"].as_str().unwrap(); + state.layout.attach_pane_content(tab, pane, content); + let (status, body) = post( + router.clone(), + &format!("/api/panes/{pane}/send-keys"), + json!({"data":"do not send to the retired pane"}), + true, + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND, "{body}"); + let (status, text) = capture_response(router, pane).await; + assert_eq!(status, capture_status, "{text}"); + assert!(lane.sends.lock().unwrap().is_empty()); + assert!(lane.captures.lock().unwrap().is_empty()); + } +} + +#[tokio::test] +async fn the_existing_local_opencode_pane_keeps_its_cached_materialization() { + let state = state_with_registry(); + let router = app(state.clone()); + let (status, created) = post( + router.clone(), + "/api/tabs", + json!({"agent":"opencode"}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{created}"); + let tab = created["data"]["tabId"].as_str().unwrap(); + let pane = created["data"]["paneId"].as_str().unwrap(); + state + .panes + .lock() + .unwrap() + .get_mut(pane) + .unwrap() + .durable_id = Some("ses_materialized".into()); + // The local OpenCode path stores materialization separately from the + // placeholder still shown by the layout. No generic REST lane is installed. + let (status, text) = capture_response(router.clone(), pane).await; + assert_eq!(status, StatusCode::OK, "{text}"); + assert!(text.is_empty()); + state.layout.attach_pane_content( + tab, + pane, + json!({"kind":"browser", "url":"https://example.com"}), + ); + let (status, text) = capture_response(router, pane).await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY, "{text}"); +} + #[tokio::test] async fn lane_noop_does_not_report_a_submitted_turn() { let state = state_with_registry(); From cf758ab6372243754ff5dc0c6affa70ca1564ad6 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:45:07 -0700 Subject: [PATCH 021/100] fix(freshclaude): re-resolve kill target after rekey --- crates/freshell-freshagent/src/claude.rs | 105 ++++++++++++++++++++++- 1 file changed, 104 insertions(+), 1 deletion(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index e78b8902f..f3f8fb32b 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -205,6 +205,11 @@ pub struct FreshClaudeState { /// the released take must be Option-tolerant, never a panic). #[cfg(test)] kill_stop_take_pause: Option>, + /// C24 regression seam: park after the kill's first alias resolution, + /// before the close gate's index+sessions critical section. A concurrent + /// conversation rekey can then prove the gate recomputes the live map key. + #[cfg(test)] + kill_after_resolve_pause: Option>, /// b8ke e3r3 F8 test seam: park in the KILL path's clean-close /// FAILURE arm, after the granted take and immediately BEFORE the /// atomic verdict+restore — the deterministic exit-after-verdict @@ -783,6 +788,8 @@ impl FreshClaudeState { #[cfg(test)] kill_stop_take_pause: None, #[cfg(test)] + kill_after_resolve_pause: None, + #[cfg(test)] kill_close_failure_pause: None, } } @@ -943,6 +950,15 @@ impl FreshClaudeState { self.kill_stop_take_pause = pause; } + /// C24 regression seam: park after the kill's initial session-key lookup. + #[cfg(test)] + pub fn set_kill_after_resolve_test_pause( + &mut self, + pause: Option>, + ) { + self.kill_after_resolve_pause = pause; + } + /// b8ke focused episode-2 post-cap F3 test seam installer (the /// pre-commit park). #[cfg(test)] @@ -2613,10 +2629,17 @@ impl FreshClaudeState { // Task 10b: durable ids resolve through `cli_index` (alias, don't move) -- // a kill addressed by the durable id must tear down the live aliased session. // Unresolvable ids keep today's idempotent success path. - let map_key = self + let mut map_key = self .resolve_session_key(&session_id) .await .unwrap_or_else(|| session_id.clone()); + #[cfg(test)] + if let Some(pause) = &self.kill_after_resolve_pause { + pause + .reached + .store(true, std::sync::atomic::Ordering::SeqCst); + let _ = pause.notify.notified().await; + } // Focused-episode-6 round 4 (Finding F5) — discovery completes // BEFORE the one envelope and BEFORE teardown. ONE critical section // (cli_index → sessions → alias_tombstones → close_pending — the @@ -2633,6 +2656,22 @@ impl FreshClaudeState { let index = self.cli_index.lock().await; let sessions = self.sessions.lock().await; let mut tombstones = self.alias_tombstones.lock().expect("alias tombstones lock"); + // `resolve_session_key` ran before this critical section. A + // conversation reset can move the same runtime in between and + // leave the addressed id as an alias to its new map key. Refresh + // the key against these exact index+map guards so the close gate, + // retire set, and eventual teardown all name the same runtime. + let current_map_key = if sessions.contains_key(&map_key) { + Some(map_key.clone()) + } else { + index + .get(&session_id) + .filter(|mapped| sessions.contains_key(*mapped)) + .cloned() + }; + if let Some(current_map_key) = current_map_key { + map_key = current_map_key; + } if sessions.contains_key(&map_key) { let mut gates = self.close_pending.lock().expect("close-pending lock"); *gates.entry(map_key.clone()).or_insert(0) += 1; @@ -12239,6 +12278,70 @@ rl.on('line', (line) => { assert_eq!(frame["sessionId"], "unknown-session"); } + /// A kill can resolve the old key just before `/clear` rekeys the same + /// live runtime. Its close gate must re-resolve the wire id while holding + /// the alias index and session map together, including in hosted states + /// without an ownership coordinator. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn kill_re_resolves_a_conversation_rekey_before_arming_its_close_gate() { + let (mut st, mut rx) = state_with_bus(); + let identity_sink = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + st.set_identity_sink(identity_sink.clone()); + let old = uuid::Uuid::new_v4().to_string(); + let new = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&st, &old).await; + { + let mut sessions = st.sessions.lock().await; + sessions.get_mut(&old).unwrap().cli_session_id = Some(old.clone()); + } + st.cli_index.lock().await.insert(old.clone(), old.clone()); + + let pause = std::sync::Arc::new(AdoptionTestPause { + notify: tokio::sync::Notify::new(), + reached: std::sync::atomic::AtomicBool::new(false), + }); + st.set_kill_after_resolve_test_pause(Some(std::sync::Arc::clone(&pause))); + let kill_state = st.clone(); + let kill_id = old.clone(); + let kill = tokio::spawn(async move { + kill_state.handle_kill(kill_msg(&kill_id)).await; + }); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); + while !pause.reached.load(std::sync::atomic::Ordering::SeqCst) { + assert!( + tokio::time::Instant::now() < deadline, + "kill did not reach the post-resolution pause" + ); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + + let pending = Arc::clone(&st.sessions.lock().await[&old].pending); + assert_eq!( + st.rekey_conversation(&old, &new, "freshclaude", &pending, "test") + .await + .expect("the live conversation rekeys"), + new, + ); + pause.notify.notify_one(); + tokio::time::timeout(std::time::Duration::from_secs(15), kill) + .await + .expect("the kill settles") + .expect("the kill task completes"); + + assert!( + !st.sessions.lock().await.contains_key(&new), + "kill(old) must remove the runtime after it was rekeyed to new" + ); + let killed = await_specific_frame(&mut rx, "freshAgent.killed", &old).await; + assert_eq!(killed["success"], true, "the kill result is {killed}"); + let retired = identity_sink.retires.lock().unwrap().clone(); + assert!( + retired.contains(&("claude".to_string(), old.clone())) + && retired.contains(&("claude".to_string(), new.clone())), + "kill(old) must close both the addressed alias and current identity: {retired:?}" + ); + } + /// Retire-on-kill (delta-review round 5, restore-open-sessions-only): an /// explicit kill is an intentional session END — the pane-ledger row retires /// `Closed` through the identity sink, so the recovery inventory (Bound-only From 7a6c5271f34351e9534e5ca77ff9f9f3a1fcad55 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:45:14 -0700 Subject: [PATCH 022/100] test(claude): match clear transcript storage to pinned CLI --- test/fixtures/claude-cli-sim/local-commands.mjs | 4 +++- test/integration/providers/claude-cli-contract.test.ts | 4 +++- test/integration/providers/claude-cli-scenarios.mjs | 8 +++++++- 3 files changed, 13 insertions(+), 3 deletions(-) diff --git a/test/fixtures/claude-cli-sim/local-commands.mjs b/test/fixtures/claude-cli-sim/local-commands.mjs index 6a1dbb16c..d5f66cb9f 100644 --- a/test/fixtures/claude-cli-sim/local-commands.mjs +++ b/test/fixtures/claude-cli-sim/local-commands.mjs @@ -71,7 +71,9 @@ export async function runLocalCommand(cli, turn, { name, args }) { await cli.sleep(T.localCommand) cli.emit(cli.systemInit()) replay(commandText, cmd.uuid) - cli.transcript.append({ type: 'user', uuid: cmd.uuid, message: { role: 'user', content: commandText } }) + // T16: /clear creates B immediately with a mode record. Its replayed + // command is SDK output only and is not saved dialogue in B. + cli.transcript.appendVerbatim({ type: 'mode', mode: 'normal', sessionId: cli.sessionId }) cli.transcript.flush() cli.modelTurnsSinceCompact = 0 return localResult('') diff --git a/test/integration/providers/claude-cli-contract.test.ts b/test/integration/providers/claude-cli-contract.test.ts index 95fb1cfad..1e1749513 100644 --- a/test/integration/providers/claude-cli-contract.test.ts +++ b/test/integration/providers/claude-cli-contract.test.ts @@ -388,7 +388,7 @@ describe(`pinned Claude CLI ${PINNED_CLI_VERSION} contract (real CLI, loopback p expect(messagesOf(r, 'S').find((m) => m.subtype === 'compact_boundary')?.compact_metadata?.trigger).toBe('manual') }, SCENARIO_TIMEOUT) - it('T16: /clear sends conversation_reset stamped with the old id, then the new session id arrives in the next init and the result; its transcript exists at once and resumes', async () => { + it('T16: /clear sends conversation_reset stamped with the old id, then the new session id arrives in the next init and the result; its transcript exists at once without saved dialogue and resumes', async () => { const r = await real('clear') const events = eventsOf(r, 'S') const reset = messagesOf(r, 'S').find((m) => m.type === 'conversation_reset') @@ -401,6 +401,8 @@ describe(`pinned Claude CLI ${PINNED_CLI_VERSION} contract (real CLI, loopback p expect(results(r, 'S').map((m) => m.session_id)).toEqual(['A', 'B', 'B']) expect(lifecycleStates(messagesOf(r, 'S'), 'u2')).toEqual(['queued', 'started', 'completed']) expect(r.observations.afterClear.files.map((f: string) => path.basename(f)).sort()).toEqual(['A.jsonl', 'B.jsonl']) + expect(r.observations.afterClear.newSessionLineTypes).toEqual(['mode']) + expect(r.observations.afterClear.newSessionMessages).toEqual([]) expect(eventsOf(r, 'resumeB').some((e) => e.k === 'init-resolved')).toBe(true) }, SCENARIO_TIMEOUT) diff --git a/test/integration/providers/claude-cli-scenarios.mjs b/test/integration/providers/claude-cli-scenarios.mjs index 12d1c8297..8addda20a 100644 --- a/test/integration/providers/claude-cli-scenarios.mjs +++ b/test/integration/providers/claude-cli-scenarios.mjs @@ -670,7 +670,13 @@ const SCENARIOS = { const inits = s.messages((m) => m.type === 'system' && m.subtype === 'init') const B = ctx.need(inits.at(-1)?.session_id, 'post-reset session id') ctx.label(B, 'B') - ctx.observe('afterClear', { files: await ctx.files() }) + const cleared = ctx.need(await ctx.fixture.readTranscript(B), 'post-reset transcript exists') + ctx.observe('afterClear', { + files: await ctx.files(), + newSessionLineTypes: cleared.map((row) => row.type), + newSessionMessages: cleared.filter((row) => row.type === 'user' || row.type === 'assistant') + .map((row) => ({ type: row.type, isMeta: row.isMeta === true, content: row.message?.content })), + }) s.push('after clear', 'u3') await s.waitForResults(3) await sleep(500) From b8f019f3aa8d3380581f27cf8b98f2738ff8216a Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:46:19 -0700 Subject: [PATCH 023/100] docs(freshclaude): show fork controls and describe durable reset frames --- crates/freshell-claude-sidecar/index.mjs | 16 ++++++++++------ docs/index.html | 4 ++++ 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/freshell-claude-sidecar/index.mjs b/crates/freshell-claude-sidecar/index.mjs index a94e47cea..36b96e6f9 100644 --- a/crates/freshell-claude-sidecar/index.mjs +++ b/crates/freshell-claude-sidecar/index.mjs @@ -36,8 +36,10 @@ // sidecar → Rust (stdout, one JSON per line): // { type:'created', requestId, sessionId, protocol:2, cliPid } // — sent only once the CLI booted (initializationResult() resolved, -// T2); sessionId is the SDK bridge's BARE nanoid placeholder and -// cliPid the CLI's pid. The command catalog follows it. +// T2); sessionId is the bridge's control id (the requested UUID +// when provided), and cliPid the CLI's pid. The command catalog +// follows it. Control messages keep using this bridge id after +// a conversation reset; Rust follows the new durable id below. // { type:'create.failed', requestId, code, message } // — the CLI refused the boot (boot.mjs names the codes) or the // request is not protocol v2. Nothing from the refused boot is @@ -46,6 +48,9 @@ // { type:'sdk.configured', sessionId, requestId, ok, settings?, message? } // { type:'sdk.session.init', sessionId, cliSessionId, model, cwd, tools, capabilities? } // durable Claude UUID; // capabilities: the CLI's (msg_lifecycle_v1 says it reports every sent command's lifecycle) +// { type:'sdk.session.rekeyed', sessionId, newSessionId, reason:'conversation_reset' } +// — before the post-reset init, names its actual durable UUID. +// conversation_reset.new_conversation_id is only a reset nonce. // { type:'sdk.assistant', sessionId, content, model, uuid, messageId, parentToolUseId } // — one per completed content block (blocks of one API message share // messageId); parentToolUseId is set inside a subagent, else null @@ -109,10 +114,9 @@ // ({ severity, component, event, ...ids }, log.mjs). Rust re-emits each // through `tracing` at its severity (claude_sidecar_io.rs). // -// The Rust side normalizes `sdk.* -> freshAgent.*` (a port of -// server/fresh-agent/sdk-events.ts) and wraps each in a `freshAgent.event` -// envelope — so this sidecar is the faithful analog of server/sdk-bridge.ts's -// SdkBridge (it emits the SAME `sdk.*` shapes SdkBridge broadcasts). +// Rust maps content facts into `freshAgent.event` envelopes. Its per-session +// turn ledger owns busy status, single-command Stop targets, and attention +// edges; process EOF owns death and recovery. This bridge forwards CLI facts. // // This sidecar makes no turn or attention decision (design §3.1, C18): it // forwards facts, and the server owns busy, the user's Stop, and every diff --git a/docs/index.html b/docs/index.html index 58123c64b..4cc28a20f 100644 --- a/docs/index.html +++ b/docs/index.html @@ -477,6 +477,9 @@ .fresh-minimap-tick { position: absolute; left: 0; right: 0; height: 3px; border-radius: 2px; background: rgba(127, 127, 127, 0.45); } .fresh-turn { border-left: 2px solid hsl(var(--border)); padding-left: 10px; margin-bottom: 14px; } .fresh-turn.user { border-left-color: hsl(var(--foreground)); } +.fresh-turn-actions { display: flex; justify-content: flex-end; margin-top: 8px; } +.fresh-turn-action { display: inline-flex; align-items: center; gap: 5px; padding: 4px 7px; border: 1px solid hsl(var(--border)); border-radius: 5px; background: transparent; color: hsl(var(--muted-foreground)); font: inherit; font-size: 11px; } +.fresh-turn-action:disabled { opacity: .5; } .fresh-role { font-size: 11px; text-transform: uppercase; color: hsl(var(--muted-foreground)); margin-bottom: 6px; } .fresh-copy { font-size: 16px; line-height: 1.25; color: hsl(var(--foreground)); } .fresh-activity { font: 11px 'JetBrains Mono', monospace; color: hsl(var(--muted-foreground)); margin-bottom: 6px; } @@ -1026,6 +1029,7 @@

Task Board

Readsrc/store/store.ts
✓Editsrc/store/store.ts
+
Renamed the slice and its two imports. Running the store tests next, so nothing still points at the old name
From 9b5912e21d048153d370eede825614b1a477ae13 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:53:59 -0700 Subject: [PATCH 024/100] fix(opencode): retain REST capture after materialized layout sync --- crates/freshell-freshagent/src/lib.rs | 8 ++++---- crates/freshell-freshagent/src/rest_lane_http_tests.rs | 6 ++++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index 8147a2993..66cf582cc 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -5188,10 +5188,10 @@ fn pane_entry_for_request(state: &FreshAgentState, pane_id: &str) -> Option Option { diff --git a/crates/freshell-freshagent/src/rest_lane_http_tests.rs b/crates/freshell-freshagent/src/rest_lane_http_tests.rs index 97e06236a..63095c71d 100644 --- a/crates/freshell-freshagent/src/rest_lane_http_tests.rs +++ b/crates/freshell-freshagent/src/rest_lane_http_tests.rs @@ -603,6 +603,12 @@ async fn the_existing_local_opencode_pane_keeps_its_cached_materialization() { let (status, text) = capture_response(router.clone(), pane).await; assert_eq!(status, StatusCode::OK, "{text}"); assert!(text.is_empty()); + state.layout.attach_pane_content(tab, pane, json!({ + "kind":"fresh-agent", "provider":"opencode", "sessionType":"freshopencode", + "sessionId":"ses_materialized", "sessionRef":{"provider":"opencode", "sessionId":"ses_materialized"}, + })); + let (status, text) = capture_response(router.clone(), pane).await; + assert_eq!(status, StatusCode::OK, "{text}"); state.layout.attach_pane_content( tab, pane, From a69cde0b406e2cf3c752f2036e5f5afbc621ad5f Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:55:52 -0700 Subject: [PATCH 025/100] fix(freshclaude): preserve REST recovery refusal semantics --- AGENTS.md | 2 +- .../src/claude_rest_lane.rs | 16 ++- .../src/claude_rest_lane_tests.rs | 19 ++- .../tests/rest_freshclaude_lane.rs | 116 ++++++++++++++++++ 4 files changed, 141 insertions(+), 12 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c8047c424..3f2a87bd7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -284,7 +284,7 @@ stderr note). Provisioning, rotation, and revocation live in **Agent Status Indicators:** Blue/busy status is derived from provider activity slices through `resolvePaneActivity`; the unified needs-attention signal (green highlight + one bell) flows through `recordTurnComplete`/`recordTerminalIdle` and `useTurnCompletionNotifications`. The signal is server-authoritative everywhere and outcome-uniform: any turn end the user did not witness — finished, errored, max-turns, crashed (sidecar death with a turn in flight), mid-turn stream exception, wedged/stuck (codex deadman), or waiting on an approval/question — produces the identical attention treatment (tab highlight, sidebar row highlight, pane-header mark, and a single bell), with no distinction by outcome; only a USER-initiated interrupt is silent. Terminal CLI panes keep today's behavior unchanged (a per-event source discriminator partitions the hook — `recordTerminalIdle` stamps `source:'terminal'`, `recordTurnComplete` stamps `source:'freshAgent'`): terminals mark tab+pane attention on the truly-idle `terminal.idle` edge, suppress only the sound when watched, and coalesce a batch into one chime, while fresh-agent panes (freshclaude/kilroy/freshcodex/freshopencode) ring a discrete `freshAgent.turn.complete` edge emitted for every turn end except a user-initiated interrupt — freshclaude/kilroy for ANY SDK `result` subtype except the stopped turn's own result: the sidecar's interrupt gate silences a result only when it holds a pending accepted user-interrupt mark AND the result is aborted (`terminal_reason` aborted_streaming/aborted_tools), so a Stop that missed its turn (sent while the CLI was still starting) never silences it (the mark is armed only while a result is pending, set before the interrupt RPC, disarmed on RPC failure, and consumed by the next result either way — ordering-scoped, deliberately no reset-at-send; a stopped turn shows no error banner), plus the consumeStream-finally mint when a pending turn ends with no result frame (stream exception or natural EOF; a pending mark never silences it) and a Rust-synthesized edge on an unrequested sidecar death mid-turn (requested teardown and idle deaths stay silent); freshcodex for `completed`/`failed`/absent statuses and non-user `interrupted` (the interrupt lane arms a per-session marker before the RPC and disarms it on RPC failure; the marker is consumed by the matching `interrupted` completion and cleared at the next `turn/started` — the opencode discipline, where `turn_aborted` clears at every turn dispatch — so only user-caused interrupts are silent); freshopencode for every settle except `turn_aborted`. Every fresh-agent edge carries a finite numeric strictly-monotonic per-session `at` (the client silently drops a malformed `at`) and folds in via `applyFreshAgentCompletion` under the `at`-monotonic dedupe regime (wall-clock `at`, no per-session counter, so a resumed durable session can't swallow completions across a server restart — a real restart also clears the per-terminal baselines). Attention flags are no longer persisted across reloads: a reload witnesses nothing (idle/busy state rehydrates; no highlight or bell ever replays a pre-load turn end). A watched fresh-agent ending (window focused + tab active) sets a tab-strip-only watched mark (`watchedCompletionByTab` — no sidebar row, no pane-header mark, no sound) that clears when the user navigates away and back. The fresh-agent bell rings audibly once per event through a serialized unbounded ring queue (a fresh chime per ring; the next ring starts when the previous chime ends or is paused, or the built-in fallback tone completes; an unplayable or aborted chime file substitutes a quiet built-in tone; a 5 s per-ring watchdog (`RING_WATCHDOG_MS` in `src/hooks/useNotificationSound.ts`, logged as the `notification_ring_watchdog_fired` warn event) releases a ring whose completion signal never arrives, so one dead ring cannot silence later bells; with sound disabled the highlights still appear). The waiting-for-approval edge is ALSO server-authoritative: freshclaude/kilroy emit a discrete `freshAgent.turn.waiting` edge on the 0→≥1 pending permission/question transition, and codex's approval/question controls emit the same edge on their own 0→≥1 pending transition (Claude/kilroy and codex both raise approvals/questions); the client folds it in via `applyFreshAgentWaiting` under a distinct `${provider}:${sessionId}#waiting` dedupe namespace so it can never poison (or be poisoned by) the turn-complete bucket. The fragile client-side busy→idle derivation AND the client-side waiting-edge hook (`useAgentSessionTurnCompletion`) were both removed — all attention edges are server-emitted. freshcodex additionally self-heals a crashed/disconnected codex sidecar by consuming the runtime `onExit` hook in `subscribe()`, broadcasting the terminal `exited` status to clear BLUE while the exit watcher's crash arm rings the same unified edge when a turn was in flight (an idle crash stays silent). freshcodex also runs a wedged-sidecar deadman: after a bounded quiet window (default 10 min, env `FRESHELL_FRESHCODEX_QUIET_WINDOW_MS`) with a turn in flight and no sidecar events, the server stops asserting busy, marks the pane `stuck`, and fires the same unified edge; the client shows an amber "Agent appears stuck" card (`role="alert"`) with "Restart sidecar" (kill + resume re-mint) and "Start new conversation" actions as pane-level inline content. `freshopencode` still runs on a shared long-lived `opencode serve` sidecar and uses server-pushed `session.idle`/`session.status` events to drive busy. A freshAgent.send arriving while a native /compact drive is in flight — or while older queued sends are still pending — is queued server-side (FIFO, immediate send.accepted) and auto-driven when the compact settles; kill drops the queue, and the drain re-validates a captured observed fence through the lane op guard (stale pairs refuse typed, request-correlated), releasing the guard at the prompt's dispatch boundary. The runtime self-heals a shared-daemon death (the 2026-09-20 incident class): daemon loss fans a typed `freshAgent.error{code:"OPENCODE_DAEMON_LOST"}` edge out to exactly one frame per materialized session (the client's generic `sessionError` banner + busy-clear; the typed edge itself carries no chime — a daemon death mid-turn is a crashed turn end and rings the unified edge through the turn's own non-clean settle), the manager respawns the daemon on a backoff ladder (fresh-incident reset, crash-loop escalation), every successful cold start drives a level-triggered revival pass that restarts dead session bridges and pushes `freshAgent.session.snapshot{status:"idle"}` (the client's transcript-refetch trigger) while respecting the ownership coordinator (retired, transitioned, or terminal-owned sessions are never revived), and a generation-fenced `freshAgent.attach` is itself a recovery verb that respawns the daemon before re-bridging. Client-side, a snapshot GET that still answers the typed 409 `RESTORE_UNAVAILABLE` for the pane's own stale fresh-agent claim (e.g. the pane loaded while the server was restarting) does not dead-end on the dismiss-only banner: the pane drives the documented recovery ONCE per pane identity — it refreshes the observed owner fence from the refusal's own `ownerGeneration` (preserving the record's epoch), sends one generation-fenced `freshAgent.attach`, and refetches through the reveal path when reveal-dirty (so the "Refreshing conversation" overlay can clear) or via `manual` otherwise; a suppressed attach restores the once-guard, and repeated 409s fall through to the honest error banner — terminal-owned refusals stay out of this path (their recovery door is the session-directory handoff). Gemini and Kimi terminal modes are status-in... [truncated] Separately, the sidebar shows cross-device remote status rings around a session row's icon: a green ring means the session is open on another device, a blue ring means it is busy on another device (blue wins over green), and rings are suppressed entirely when the session is open on this device (derived from `tabs.sync` registry snapshots — producing clients stamp pane payloads with `sessionKeys`/`busySessionKeys`, consumers re-query remote snapshots on a 30s interval, and the server partitions same-device records into `sameDeviceOpen`, which never produces rings). -**Fresh-Agent Orchestration:** The Rust REST agent API (`/api/tabs`, `/api/panes/:id/split`, `/api/panes/:id/send-keys`, `/api/panes/:id/capture`, `/api/panes/:id/wait-for`) and standalone CLI/MCP client accept `agent`/`model`/`effort` where supported. In the default server, `new-tab` supports `agent: "claude" | "kilroy" | "opencode"`; fresh-agent `split-pane` supports Claude and Kilroy. Claude operations use the SAME `FreshClaudeState` and `createRequestId` deduplication as browser panes, including headless creation. Create defaults are resolved once from live settings and copied into the provider request and pane content. On MCP `new-tab`, `resume`/`resumeSessionId` map Claude and Kilroy to a Claude `sessionRef`, and OpenCode to an OpenCode ref; terminal-mode resume uses a provider-matched `sessionRef` (raw Codex IDs remain insufficient restore identity). REST Claude send strips exactly one trailing CR or LF; line-terminator-only input returns `noop`. A completed send returns its actual `submittedTurnId`; a deadline returns `approx`, and an unavailable or ended runtime returns a retryable 409. Capture reads durable history, including after restart. The opt-in hosted gateway takes precedence over local lanes; installing a local REST lane does not change the snapshot HTTP authority. Unsupported legacy actions return a deterministic unavailable result. +**Fresh-Agent Orchestration:** The Rust REST agent API (`/api/tabs`, `/api/panes/:id/split`, `/api/panes/:id/send-keys`, `/api/panes/:id/capture`, `/api/panes/:id/wait-for`) and standalone CLI/MCP client accept `agent`/`model`/`effort` where supported. In the default server, `new-tab` supports `agent: "claude" | "kilroy" | "opencode"`; fresh-agent `split-pane` supports Claude and Kilroy. Claude operations use the SAME `FreshClaudeState` and `createRequestId` deduplication as browser panes, including headless creation. Create defaults are resolved once from live settings and copied into the provider request and pane content. On MCP `new-tab`, `resume`/`resumeSessionId` map Claude and Kilroy to a Claude `sessionRef`, and OpenCode to an OpenCode ref; terminal-mode resume uses a provider-matched `sessionRef` (raw Codex IDs remain insufficient restore identity). REST Claude send strips exactly one trailing CR or LF; line-terminator-only input returns `noop`. A completed send returns its actual `submittedTurnId`; a deadline returns `approx`. Sends recover stopped Claude conversations automatically. An ended runtime returns a retryable 409; an explicitly closed session returns a non-retryable 409, unreadable storage returns a retryable 503, and confirmed missing history returns a non-retryable 404. Capture reads durable history, including after restart. The opt-in hosted gateway takes precedence over local lanes; installing a local REST lane does not change the snapshot HTTP authority. Unsupported legacy actions return a deterministic unavailable result. Agent-driven tab/pane creation is focus-neutral by contract: server-broadcast ui.command create/split folds carry `activate:false` into `addTab`/`splitPane` and never change the user's active tab/pane — focus moves only via the explicit select-tab/select-pane verbs. The Rust layout store (`crates/freshell-freshagent/src/layout_store.rs`) mirrors the same rule: agent creates/splits never move the server cursor either, so REST/MCP cursor-relative operations (next/prev tab, omitted targets) keep addressing the user's selection through the mirror window. Client-side this contract has three layers: (1) pane components gate mount-time autofocus on `focusEligible` (tab active + pane active + visible), and non-eligible browser iframes render `inert`; (2) eligible mounts are additionally ownership-gated (`usePaneFocusAdoption` + `src/lib/pane-focus-ownership.ts`): components record at teardown whether their pane held document focus, so an agent-driven leaf→split remount refocuses only if the pane owned focus before — flips (explicit select) always bypass, and same-target re-selects (no eligibility transition) are signaled via a per-pane focus epoch (`panes.focusEpochByPaneId`, nudged only by the select folds, never by pointer mousedowns, and forwarded by PaneContainer as the `focusEpoch` prop); (3) the app-wide focus-steal guard (`src/lib/focus-steal-guard.ts` via `useFocusStealGuard` in App) rebuffs nested-document focus hoists from `data-focus-locked` non-eligible iframes, which `inert` alone cannot stop (Chromium-verified). A store-level selection serial (`paneSelectionMiddleware`) bumps on every `setActivePane`/`nudgePaneFocus`/`setActiveTab` dispatch, and — by before/after state diff — on any `addTab`/`switchToNextTab`/`switchToPrevTab`/`splitPane`/`addPane`/`removeTab`/`closePane` that actually moved the selection (agent folds pass `activate:false` and stay serial-invisible; background closes move nothing): ownership records captured before any later selection are voided (newer selection wins). Screenshot captures (`captureUiScreenshot`, driven by the `screenshot.capture` ui.command behind `POST /api/screenshots` and the MCP capture verbs) render through html2canvas's off-DOM clone and never mutate the live page: background tabs stay mounted and fully laid out under `.tab-hidden` (CSS `visibility`, not `display` — kept measurable for xterm), html2canvas skips visibility-hidden subtrees when painting, so the clone's target chain (target up to body) gets an inline `visibility: visible` reveal via `onclone` — armed live-side only when the target has a hidden ancestor. Because nothing dispatches and nothing moves, there is no queue, no deadline/ttl, no `screenshot.cancel`, no restore, and no engagement detection; iframes are pre-rendered separately (html2canvas cannot paint them) and swapped into the clone by index+src-fingerprint correlation (a tree that changed between preparation and clone gets no replacements rather than a wrong one), and xterm's WebGL renderers are suspended around the render (refcounted `suspendTerminalRenderersForScreenshot`) so their canvases read back complete. Records for panes re-appearing in layouts (e.g. reopened tabs) are forgotten, and an eligible mount with focus stranded on `document.body` always uses unknown-pane focus semantics (stranding typing is worse than a rare honest refocus — round-14/16 trade). Locked iframes wake on pointerdown anywhere in the pane shell and on Enter/Space targeted at the shell itself (`useIframeFocusLock`) — keyboard-parity click-to-wake. diff --git a/crates/freshell-freshagent/src/claude_rest_lane.rs b/crates/freshell-freshagent/src/claude_rest_lane.rs index 7122f0c99..166c8905f 100644 --- a/crates/freshell-freshagent/src/claude_rest_lane.rs +++ b/crates/freshell-freshagent/src/claude_rest_lane.rs @@ -298,12 +298,18 @@ fn retryable_conflict(code: &str, message: &str) -> RestLaneError { fn send_error(code: String, message: String) -> RestLaneError { match code.as_str() { - "SESSION_NOT_FOUND" | "INVALID_SESSION_ID" => retryable_conflict( - "FRESH_AGENT_SESSION_NOT_LIVE", - "Claude is not running for this session; attach the pane and retry", - ), + "SESSION_NOT_FOUND" | "INVALID_SESSION_ID" | "SESSION_CLOSED" => { + let kind = if code == "SESSION_CLOSED" { + RestLaneErrorKind::Conflict + } else { + RestLaneErrorKind::NotFound + }; + let mut error = RestLaneError::new(kind, code, message); + error.retryable = Some(false); + error + } "SESSION_BUSY" | "SESSION_RESERVED" | "STALE_CLAIM" => retryable_conflict(&code, &message), - "CLAUDE_SEND_FAILED" | "CLAUDE_SETTINGS_FAILED" => { + "CLAUDE_SEND_FAILED" | "CLAUDE_SETTINGS_FAILED" | "CLAUDE_ATTACH_RESUME_FAILED" => { RestLaneError::new(RestLaneErrorKind::Unavailable, code, message) } code if code.starts_with("INVALID_") => { diff --git a/crates/freshell-freshagent/src/claude_rest_lane_tests.rs b/crates/freshell-freshagent/src/claude_rest_lane_tests.rs index 23d623db0..d2b76e039 100644 --- a/crates/freshell-freshagent/src/claude_rest_lane_tests.rs +++ b/crates/freshell-freshagent/src/claude_rest_lane_tests.rs @@ -72,12 +72,19 @@ async fn line_terminators_only_are_noop_without_a_live_runtime() { } #[tokio::test] -async fn send_without_a_live_runtime_returns_a_retryable_conflict() { - let error = lane(true).send_agent(send("hello\r")).await.unwrap_err(); - assert_eq!(error.kind, RestLaneErrorKind::Conflict); - assert_eq!(error.code, "FRESH_AGENT_SESSION_NOT_LIVE"); - assert_eq!(error.retryable, Some(true)); - assert!(error.retry_after_ms.is_some_and(|delay| delay > 0)); +async fn send_for_a_missing_conversation_returns_a_permanent_not_found() { + let root = tempfile::tempdir().unwrap(); + let state = FreshClaudeState::new(Arc::new(tokio::sync::broadcast::channel(32).0)) + .with_sidecar_environment([( + "CLAUDE_CONFIG_DIR".into(), + root.path().to_str().unwrap().into(), + )]); + let lane = ClaudeRestLane::new(state, Arc::new(|| true)); + let error = lane.send_agent(send("hello\r")).await.unwrap_err(); + assert_eq!(error.kind, RestLaneErrorKind::NotFound); + assert_eq!(error.code, "INVALID_SESSION_ID"); + assert_eq!(error.retryable, Some(false)); + assert_eq!(error.retry_after_ms, None); } #[tokio::test] diff --git a/crates/freshell-ws/tests/rest_freshclaude_lane.rs b/crates/freshell-ws/tests/rest_freshclaude_lane.rs index fb8a4a98f..7f5919b7f 100644 --- a/crates/freshell-ws/tests/rest_freshclaude_lane.rs +++ b/crates/freshell-ws/tests/rest_freshclaude_lane.rs @@ -312,6 +312,122 @@ async fn rest_deadline_is_approx_and_a_runtime_exit_is_a_retryable_conflict() { rig.fresh_claude.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rest_recovery_does_not_reopen_an_explicitly_closed_session() { + let rig = ClaudeRig::start_with_options(RigKind::Sim, options()).await; + let mut browser = rig.connect().await; + let created = create(&rig, "claude").await; + let id = created["sessionId"].as_str().unwrap(); + let pane = created["paneId"].as_str().unwrap(); + browser.kill(id).await; + let (status, refused) = rig + .post_json( + &format!("/api/panes/{pane}/send-keys"), + &json!({"data":"a late prompt must stay unsent"}), + ) + .await; + assert_eq!(status, 409, "{refused}"); + assert_eq!(refused["code"], "SESSION_CLOSED"); + assert_eq!(refused["retryable"], false); + assert!(refused.get("retryAfterMs").is_none(), "{refused}"); + assert_eq!(rig.query_options().len(), 1); + assert!(rig.ledger_rows("prompt").is_empty()); + assert!(!rig.fresh_claude.has_live_session(id).await); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rest_recovery_reports_unreadable_storage_as_retryable_and_can_retry_after_repair() { + let rig = ClaudeRig::start_with_options(RigKind::Sim, options()).await; + let mut browser = rig.connect().await; + let created = create(&rig, "claude").await; + let id = created["sessionId"].as_str().unwrap(); + let pane = created["paneId"].as_str().unwrap(); + let first = send(&rig, pane, "the existing saved conversation").await; + wait_for_saved_turn(&rig, id, first["submittedTurnId"].as_str().unwrap()).await; + rig.wait_for_genesis_cleared(id).await; + rig.kill_first_sidecar(&mut browser).await; + let saved = rig.claude_config_dir.with_extension("saved"); + std::fs::rename(&rig.claude_config_dir, &saved).unwrap(); + std::fs::create_dir_all(&rig.claude_config_dir).unwrap(); + // An actual traversal failure remains an error even when tests run as root. + std::os::unix::fs::symlink("projects", rig.claude_config_dir.join("projects")).unwrap(); + let (status, refused) = rig + .post_json( + &format!("/api/panes/{pane}/send-keys"), + &json!({"data":"continue after storage is repaired"}), + ) + .await; + assert_eq!(status, 503, "{refused}"); + assert_eq!(refused["code"], "CLAUDE_ATTACH_RESUME_FAILED"); + assert_eq!(refused["retryable"], true); + assert!(refused["retryAfterMs"] + .as_u64() + .is_some_and(|delay| delay > 0)); + assert_eq!( + rig.query_options().len(), + 1, + "unchecked history never starts a replacement" + ); + assert_eq!( + rig.ledger_rows("prompt").len(), + 1, + "the refused prompt was not written" + ); + std::fs::rename( + &rig.claude_config_dir, + rig.claude_config_dir.with_extension("unreadable"), + ) + .unwrap(); + std::fs::rename(saved, &rig.claude_config_dir).unwrap(); + let retried = send(&rig, pane, "continue after storage is repaired").await; + assert_eq!(retried["sessionId"], id); + wait_for_saved_turn(&rig, id, retried["submittedTurnId"].as_str().unwrap()).await; + let (status, capture) = get(&rig, &format!("/api/panes/{pane}/capture")).await; + assert_eq!(status, 200, "{capture}"); + assert!( + capture.contains("the existing saved conversation"), + "{capture}" + ); + assert!( + capture.contains("continue after storage is repaired"), + "{capture}" + ); + assert_eq!(rig.query_options().len(), 2); + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rest_recovery_reports_a_positively_missing_conversation_as_permanent_not_found() { + let rig = ClaudeRig::start_with_options(RigKind::Sim, options()).await; + let mut browser = rig.connect().await; + let created = create(&rig, "claude").await; + let id = created["sessionId"].as_str().unwrap(); + let pane = created["paneId"].as_str().unwrap(); + let first = send(&rig, pane, "the conversation that later goes missing").await; + wait_for_saved_turn(&rig, id, first["submittedTurnId"].as_str().unwrap()).await; + rig.wait_for_genesis_cleared(id).await; + rig.kill_first_sidecar(&mut browser).await; + std::fs::rename( + rig.claude_config_dir.join("projects"), + rig.claude_config_dir.with_extension("projects-saved"), + ) + .unwrap(); + let (status, refused) = rig + .post_json( + &format!("/api/panes/{pane}/send-keys"), + &json!({"data":"never replace missing saved history"}), + ) + .await; + assert_eq!(status, 404, "{refused}"); + assert_eq!(refused["code"], "INVALID_SESSION_ID"); + assert_eq!(refused["retryable"], false); + assert!(refused.get("retryAfterMs").is_none(), "{refused}"); + assert_eq!(rig.query_options().len(), 1); + assert_eq!(rig.ledger_rows("prompt").len(), 1); + rig.fresh_claude.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn actual_fenced_send_refusal_reports_its_owner_and_keeps_the_prompt_unsent() { let rig = ClaudeRig::start_with_options(RigKind::Sim, options()).await; From 9bef732241cbeca9422c18da3c71d5de17c6e705 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:00:59 -0700 Subject: [PATCH 026/100] test(freshclaude): verify external Kilroy submission and saved history --- crates/freshell-ws/tests/rest_freshclaude_lane.rs | 12 +++++++++++- .../specs/fresh-agent-rest-claude-rust.spec.ts | 7 ++++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/freshell-ws/tests/rest_freshclaude_lane.rs b/crates/freshell-ws/tests/rest_freshclaude_lane.rs index 7f5919b7f..6f62f9afe 100644 --- a/crates/freshell-ws/tests/rest_freshclaude_lane.rs +++ b/crates/freshell-ws/tests/rest_freshclaude_lane.rs @@ -190,7 +190,17 @@ async fn local_split_uses_kilroy_and_rolls_back_a_disabled_launch() { assert!(command["payload"]["newContent"] .get("namingHandle") .is_none()); - assert_eq!(send(&rig, pane, "kilroy hello").await["status"], "idle"); + let sent = send(&rig, pane, "kilroy hello").await; + assert_eq!(sent["status"], "idle"); + wait_for_saved_turn( + &rig, + sent["sessionId"].as_str().unwrap(), + sent["submittedTurnId"].as_str().unwrap(), + ) + .await; + let (status, capture) = get(&rig, &format!("/api/panes/{pane}/capture")).await; + assert_eq!(status, 200, "{capture}"); + assert!(capture.contains("kilroy hello"), "{capture}"); observer.read_for(Duration::from_millis(50)).await; let announcements = observer .frames diff --git a/test/e2e-browser/specs/fresh-agent-rest-claude-rust.spec.ts b/test/e2e-browser/specs/fresh-agent-rest-claude-rust.spec.ts index dfae1bdb9..6ce6320f7 100644 --- a/test/e2e-browser/specs/fresh-agent-rest-claude-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-rest-claude-rust.spec.ts @@ -84,7 +84,12 @@ test.describe('external REST/MCP controls the shared Claude runtime', () => { expect(split.status).toBe('ok') const splitId = split.data.paneId await expect.poll(async () => leaf(await harness.getPaneLayout(tabId), splitId)?.content?.sessionType, { timeout: 20_000 }).toBe('kilroy') - await mcp.callFreshellAction('send-keys', { target: splitId, keys: 'kilroy hello\r', literal: true }) + const kilroySend = await mcp.callFreshellAction('send-keys', { target: splitId, keys: 'kilroy hello\r', literal: true }) + expect(kilroySend.status, JSON.stringify(kilroySend)).toBe('ok') + expect(kilroySend.data.status, JSON.stringify(kilroySend)).toBe('idle') + await expect.poll(() => mcp.callFreshellAction('capture-pane', { target: splitId }), { + timeout: 20_000, message: 'the externally prompted Kilroy turn reached saved history', + }).toContain('Kilroy REST reply') await expect(page.locator(`[data-context="fresh-agent"][data-pane-id="${splitId}"]`)).toBeVisible() await expect(page.locator(`[data-context="fresh-agent"][data-pane-id="${splitId}"]`)).toContainText('Kilroy REST reply') From aa447fde3dc32d6b7026dc3a8aff6e79296ba9e4 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:19:33 -0700 Subject: [PATCH 027/100] fix(freshclaude): let native rekeys advance past pending controls --- crates/freshell-freshagent/src/claude.rs | 32 +++++++++++++++++++ .../src/claude/end_of_output_tests.rs | 4 +-- .../src/claude/live_frames_tests.rs | 2 +- .../freshell-freshagent/src/claude/rekey.rs | 13 +++----- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 306808693..54adf1202 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -12287,6 +12287,38 @@ rl.on('line', (line) => { assert_eq!(frame["sessionId"], "unknown-session"); } + /// A configure handler can hold the turn lock while awaiting a receipt + /// from this same consumer. Native rekey must let that consumer advance. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_conversation_rekey_does_not_wait_on_a_handler_awaiting_its_receipt() { + let (st, _rx) = state_with_bus(); + let old = uuid::Uuid::new_v4().to_string(); + let new = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&st, &old).await; + let (turn, pending) = { + let sessions = st.sessions.lock().await; + let session = &sessions[&old]; + (session.turn_lock.clone(), session.pending.clone()) + }; + let held = turn.lock().await; + let moved = tokio::time::timeout( + Duration::from_millis(500), + st.rekey_conversation(&old, &new, "freshclaude", &pending, "test-configure"), + ) + .await; + assert!( + moved.is_ok(), + "the consumer cannot wait on a handler that needs its next receipt" + ); + assert_eq!(moved.unwrap().unwrap(), new); + assert!(Arc::ptr_eq( + &st.sessions.lock().await[&new].turn_lock, + &turn + )); + drop(held); + st.shutdown().await; + } + /// A kill can resolve the old key just before `/clear` rekeys the same /// live runtime. Its close gate must re-resolve the wire id while holding /// the alias index and session map together, including in hosted states diff --git a/crates/freshell-freshagent/src/claude/end_of_output_tests.rs b/crates/freshell-freshagent/src/claude/end_of_output_tests.rs index c9ad2244a..bfe9da175 100644 --- a/crates/freshell-freshagent/src/claude/end_of_output_tests.rs +++ b/crates/freshell-freshagent/src/claude/end_of_output_tests.rs @@ -50,7 +50,7 @@ require('node:readline').createInterface({ input: process.stdin }).on('line', (l if (msg.type === 'create') { const sessionId = 'ending-sidecar-session' out({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) - out({ type: 'sdk.session.init', sessionId, cliSessionId: DURABLE, model: 'fake-model', cwd: '/tmp', tools: [] }) + out({ type: 'sdk.session.init', sessionId, cliSessionId: msg.sessionId || DURABLE, model: 'fake-model', cwd: '/tmp', tools: [] }) out({ type: 'sdk.status', sessionId, status: 'idle' }) } else if (msg.type === 'send') { if (MODE === 'stream-ends') { @@ -610,7 +610,7 @@ async fn a_requested_stop_kills_the_same_tree_and_reports_nothing() { assert_eq!(session.count(is_exit_report), 0, "{:?}", session.frames); assert_eq!(session.count(is_turn_complete), 0, "{:?}", session.frames); assert!(session.st.dead_sessions.find(&session.pane).is_none()); - assert!(session.st.dead_sessions.find(DURABLE).is_none()); + assert!(session.st.dead_sessions.find(&session.pane).is_none()); assert!(records(&events, "freshagent.claude.sidecar_exit").is_empty()); let teardowns = capture::teardown_events(&events); assert_eq!(teardowns.len(), 1, "{teardowns:?}"); diff --git a/crates/freshell-freshagent/src/claude/live_frames_tests.rs b/crates/freshell-freshagent/src/claude/live_frames_tests.rs index c2ff7d261..7452a2e5e 100644 --- a/crates/freshell-freshagent/src/claude/live_frames_tests.rs +++ b/crates/freshell-freshagent/src/claude/live_frames_tests.rs @@ -25,7 +25,7 @@ require('node:readline').createInterface({ input: process.stdin }).on('line', (l if (msg.type === 'create') { const sessionId = 'live-frames-session' out({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) - out({ type: 'sdk.session.init', sessionId, cliSessionId: DURABLE, model: 'fake-model', cwd: '/tmp', tools: [] }) + out({ type: 'sdk.session.init', sessionId, cliSessionId: msg.sessionId || DURABLE, model: 'fake-model', cwd: '/tmp', tools: [] }) } else if (msg.type === 'send') { for (const frame of JSON.parse(msg.text)) out({ ...frame, sessionId: msg.sessionId }) } else if (msg.type === 'configure') { diff --git a/crates/freshell-freshagent/src/claude/rekey.rs b/crates/freshell-freshagent/src/claude/rekey.rs index e4d24fa4a..6e9dce715 100644 --- a/crates/freshell-freshagent/src/claude/rekey.rs +++ b/crates/freshell-freshagent/src/claude/rekey.rs @@ -17,15 +17,10 @@ impl FreshClaudeState { if old == new { return Ok(old.into()); } - let turn_lock = { - let sessions = self.sessions.lock().await; - let session = sessions - .get(old) - .filter(|session| Arc::ptr_eq(&session.pending, expected_pending)) - .ok_or_else(|| "the conversation runtime was replaced".to_string())?; - Arc::clone(&session.turn_lock) - }; - let _turn = turn_lock.lock().await; + // This is a fact from the consumer. A handler can hold turn_lock + // while awaiting this consumer's next receipt, so taking that lock + // here would stop both tasks. The incarnation check and map/owner + // guards below protect the move while its turn state stays intact. let guard_at = self.ownership.as_ref().map(|registry| { let observed = registry.observe(PROVIDER, old); freshell_ownership::ObservedFence { From f2ad58952352b2a54f51b39dfc186ac3386e88f3 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:19:34 -0700 Subject: [PATCH 028/100] feat(freshclaude): acknowledge canonical attaches to the requesting page --- crates/freshell-freshagent/src/claude.rs | 461 ++++-------------- .../freshell-freshagent/src/claude/attach.rs | 210 ++++++++ .../src/claude/recovery.rs | 9 +- crates/freshell-ws/src/terminal.rs | 3 +- .../tests/freshagent_claude_attach.rs | 12 +- .../freshagent_claude_kill_after_restart.rs | 461 +++--------------- .../tests/freshagent_claude_mcp_launch.rs | 1 + .../tests/freshagent_claude_observers.rs | 108 ++++ 8 files changed, 494 insertions(+), 771 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude/attach.rs create mode 100644 crates/freshell-ws/tests/freshagent_claude_observers.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 54adf1202..5e5d49ee6 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -5254,7 +5254,12 @@ impl FreshClaudeState { /// Unified agent names: the frame carries the durable record's current /// projection — the pending→durable transfer commits in /// [`Self::adopt_session_init`] BEFORE this publishes the identity. - async fn broadcast_materialized(&self, old_id: &str, new_id: &str, session_type: &str) { + async fn materialized_message( + &self, + old_id: &str, + new_id: &str, + session_type: &str, + ) -> ServerMessage { let projection = if session_type == "kilroy" { None } else { @@ -5292,6 +5297,13 @@ impl FreshClaudeState { ); } } + message + } + + async fn broadcast_materialized(&self, old_id: &str, new_id: &str, session_type: &str) { + let message = self + .materialized_message(old_id, new_id, session_type) + .await; self.broadcast(&message); } @@ -5311,263 +5323,6 @@ impl FreshClaudeState { .then_some(mapped) } - // ── freshAgent.attach (restart parity: resume untracked sessions in place) ────────── - - /// Handle a `freshAgent.attach` for claude/kilroy. Decision table (restart parity): - /// - /// | State | Action | - /// |---|---| - /// | tracked under `msg.session_id` | no-op -- NO frame (wire-shape parity, unchanged). Safe against dead sidecars ONLY because the consumer-exit eviction removes dead entries (ledger A9) | - /// | untracked, no canonical durable id on the message | `lost_session_frame` (`INVALID_SESSION_ID`) -- unchanged fallback (also covers the verified A2 edge: a pane that never learned its UUID pre-kill attaches bare; lost -> client re-create is the designed, non-destructive outcome) | - /// | untracked, durable id already in `cli_index`, aliased session LIVE | REBIND + ACK (Task 10b): flip the live session's envelope stamp to the durable id and answer with the `freshAgent.session.snapshot` stamped with the durable and the session's TRACKED status (the consumer's turn-lifecycle + sdk.status fold, never a hardcoded idle) -- the attaching client must observe success, never silence. The map is never re-keyed (alias, don't move) | - /// | untracked, durable id in `cli_index` but aliased session GONE (stale row, eviction in flight) | fall through to the resume path below (the session is dead; resuming converges the client) | - /// | untracked, transcript EXISTS (in ANY candidate root) | spawn sidecar and resume with the session's ORIGINAL cwd from `transcript_cwd` (ledger A15: the CLI's resume lookup is cwd-slug-scoped); if that cwd no longer exists, resume by the transcript's `.jsonl` PATH (verified cli.js escape hatch that bypasses slug scoping) with the attach cwd. Register under the CLIENT's `msg.session_id`, emit a `freshAgent.session.snapshot` whose `timelineSessionId` is the durable UUID -- NEVER a nanoid (the frozen client persists it unvalidated, ledger A14/N3) -- carrying the session's tracked status ("idle" for a fresh resume, by construction) | - /// | untracked, transcript ABSENT in EVERY candidate root (every root fully readable), binding row records a genesis (core design §4.2/§4.3: a zero-turn session, a pending fork) | boot it from the genesis under the durable id (`Fresh`, or the lazy fork of its parent), register and acknowledge exactly like the resume row above | - /// | untracked, transcript ABSENT in EVERY candidate root (every root fully readable), no genesis | `lost_session_frame` -- positive denial: the store is the authority (honest even under the 30-day GC, ledger A4) | - /// | untracked, store UNREADABLE (no store root, or an IO error other than NotFound/NotADirectory while scanning) | top-level `error` `CLAUDE_ATTACH_RESUME_FAILED` -- cannot check, so NEVER the lost frame (G28) | - /// | untracked, spawn/pipe/created failure | top-level `error` `CLAUDE_ATTACH_RESUME_FAILED` -- NEVER the lost frame | - pub async fn handle_attach(&self, msg: FreshAgentAttach) { - // b8ke delta review F7: a half-sent observed pair (exactly one of - // epoch/generation) is the typed invalid-fence refusal — before any - // state interaction. The attach never proceeds as a silently - // downgraded legacy request. - let attach_fence = - match crate::ownership_lane::wire_fence(msg.observed_epoch, msg.observed_generation) { - Ok(fence) => fence, - Err(err) => { - tracing::warn!(target: "freshell_freshagent::claude", - session_id = %msg.session_id, code = err.code(), - "fresh_agent_attach_refused: the observed fence is half-sent (invalid)"); - self.emit_fresh_agent_error( - &msg.session_id, - session_type_str(msg.session_type), - err.code(), - err.message(), - ); - return; - } - }; - // b8ke delta round-3 F2: BOTH existing-session early exits consult - // the coordinator — a delayed attach during a Handoff (the map - // entry exists until the stop lands) must be BLOCKED or typed, - // never a silent parity no-op or rebind against a runtime the - // handoff owns. - let coordinator_in_flight = |state: &freshell_ownership::OwnershipState| { - matches!( - state, - freshell_ownership::OwnershipState::Handoff { .. } - | freshell_ownership::OwnershipState::Starting { .. } - | freshell_ownership::OwnershipState::Stopping { .. } - | freshell_ownership::OwnershipState::Fenced { .. } - ) - }; - if self.sessions.lock().await.contains_key(&msg.session_id) { - // The gate observes the session's CANONICAL durable id (the - // registry keys the coordinator record there — the map key is - // the lane's own). - let durable = { - let sessions = self.sessions.lock().await; - sessions - .get(&msg.session_id) - .and_then(|s| s.cli_session_id.clone()) - }; - let observed_id = durable.as_deref().unwrap_or(&msg.session_id); - let snap = self.ownership_snapshot(PROVIDER, observed_id); - if coordinator_in_flight(&snap.state) { - tracing::warn!(target: "freshell_freshagent::claude", - session_id = %msg.session_id, state = ?snap.state, - "fresh_agent_attach_refused: a lifecycle transition owns this \ - session — the tracked-and-alive fast path is blocked (typed)"); - self.emit_fresh_agent_error( - &msg.session_id, - session_type_str(msg.session_type), - "SESSION_RESERVED", - "A lifecycle operation owns this session; retry after it settles", - ); - return; - } - return; // tracked-and-alive: no frame (wire-shape parity with codex) - } - let Some(durable) = attach_durable_id(&msg) else { - // No durable identity to resume from: the pre-parity fallback (PR #529). - self.broadcast(&lost_session_frame(&msg.session_id, msg.session_type)); - return; - }; - // A lifecycle transition blocks either a live rebind or a cold - // resume. The coordinator is the authority for both paths. - let rebind_key = self.resolve_ownership_key(&durable); - let rebind_snap = self.ownership_snapshot(PROVIDER, &rebind_key); - if coordinator_in_flight(&rebind_snap.state) { - tracing::warn!(target: "freshell_freshagent::claude", - session_id = %durable, state = ?rebind_snap.state, - "fresh_agent_attach_refused: a lifecycle transition owns this \ - session — the rebind fast path is blocked (typed)"); - self.emit_fresh_agent_error( - &msg.session_id, - session_type_str(msg.session_type), - "SESSION_RESERVED", - "A lifecycle operation owns this session; retry after it settles", - ); - return; - } - // A fresh server has no live alias or coordinator record to guard. - // Only the observer rebind needs an attach guard; a cold resume claims - // the vacant key through begin_lane_claim_at below. Check both maps: - // a stale cli_index row whose session was evicted is cold too. - let mapped = { self.cli_index.lock().await.get(&durable).cloned() }; - let live_rebind_candidate = match mapped { - Some(map_key) => self.sessions.lock().await.contains_key(&map_key), - None => false, - }; - if live_rebind_candidate { - // b8ke ext r12 F2: the rebind's REAL claim — the guard arms under - // the coordinator lock and is held ACROSS the rebind, so the - // coordinator covers the attach through completion: a handoff or - // stop beginning inside the window answers the typed Blocked - // outcome and can never commit around the rebind (pre-r12 the - // point-in-time snapshot closed no window). - // b8ke ext r38 F1: `try_rebind_to_live` is an OBSERVER bind (the - // broadcast alias flip + the status ack; it never spawns or - // restarts — the fall-through RESUME claims independently through - // `begin_lane_claim_at` with the attach's observed fence, the - // Task-3 round-2 discipline). So the rebind ADOPTS SPECIFICALLY - // when the request carries a pair (a superseded-session bind — - // ownership advanced or a foreign owner — refuses typed, the - // laundering class closed), and keeps the pre-r38 owner-agnostic - // window when the pair is ABSENT (the observer boundary: a pane - // bind misdirected by a mid-window handoff self-heals through - // the dead-session/reconcile flow; no writer hazard). - let expected_fresh_owner = freshell_ownership::OwnerIdentity { - kind: freshell_ownership::RuntimeOwnerKind::FreshAgent, - terminal_id: None, - live_session_key: None, - pid: None, - ownership_id: None, - }; - let rebind_guard = match attach_fence { - Some(adopt_fence) => { - match crate::ownership_lane::arm_adopt_guard( - &self.ownership, - PROVIDER, - &rebind_key, - &format!("attach-rebind-{}", uuid::Uuid::new_v4()), - &expected_fresh_owner, - adopt_fence, - "freshclaude/attach-rebind", - ) { - crate::ownership_lane::LaneAttachGuard::Armed(guard) => Some(guard), - crate::ownership_lane::LaneAttachGuard::Unwired => None, - crate::ownership_lane::LaneAttachGuard::Refused => { - self.emit_fresh_agent_error( - &msg.session_id, - session_type_str(msg.session_type), - "SESSION_RESERVED", - "A lifecycle operation owns this session; retry after it settles", - ); - return; - } - } - } - None => match crate::ownership_lane::arm_attach_guard( - &self.ownership, - PROVIDER, - &rebind_key, - &format!("attach-rebind-{}", uuid::Uuid::new_v4()), - None, - "freshclaude/attach-rebind", - ) { - crate::ownership_lane::LaneAttachGuard::Armed(guard) => Some(guard), - crate::ownership_lane::LaneAttachGuard::Unwired => None, - crate::ownership_lane::LaneAttachGuard::Refused => { - self.emit_fresh_agent_error( - &msg.session_id, - session_type_str(msg.session_type), - "SESSION_RESERVED", - "A lifecycle operation owns this session; retry after it settles", - ); - return; - } - }, - }; - let rebound = self - .try_rebind_to_live(&durable, session_type_str(msg.session_type)) - .await; - drop(rebind_guard); - if rebound { - // Task 10b: a live alias answers with a rebind ACK. If its - // session dies inside the guarded window, the failed rebind - // falls through to the cold claim below. - return; - } - } - let Some(_flight) = self.try_resume_flight(&durable) else { - return; - }; - match self - .resume_untracked(&msg, &durable, attach_fence, true) - .await - { - Ok(()) => {} - Err(recovery::RuntimeResumeError::Lost | recovery::RuntimeResumeError::Closed) => { - self.broadcast(&lost_session_frame(&msg.session_id, msg.session_type)); - } - Err(recovery::RuntimeResumeError::Unavailable { code, message, .. }) => { - if code == "SESSION_RESERVED" { - self.emit_fresh_agent_error( - &msg.session_id, - session_type_str(msg.session_type), - code, - &message, - ); - } else { - self.send_error(&None, code, &message); - } - } - } - } - - /// Task 10b's REBIND + ACK: if `durable` is in `cli_index` and its aliased session - /// is LIVE, flip the session's envelope stamp to the durable id and broadcast the - /// snapshot ack (stamped with the durable) announcing the session's REAL tracked - /// status — never a hardcoded "idle" (which flipped panes idle mid-turn and left - /// dead-window completions stale-busy forever). Returns `false` when the index has - /// no row or the row is stale (aliased session gone). The sessions map is never - /// re-keyed (alias, don't move -- in-flight consumers hold the placeholder key; - /// sends and kills resolve through `cli_index`). - async fn try_rebind_to_live(&self, durable: &str, session_type: &str) -> bool { - let Some(map_key) = self.cli_index.lock().await.get(durable).cloned() else { - return false; - }; - let rebound_status = { - let guard = self.sessions.lock().await; - match guard.get(&map_key) { - Some(session) => { - // Alias, don't move: flip only the envelope stamp. The status the - // ack speaks is the consumer's tracked-status fold — truth at - // ack time (a completed turn settles it; a live compaction or - // in-flight turn announces truthfully). - let previous = std::mem::replace( - &mut *session.broadcast_id.lock().expect("broadcast id lock"), - durable.to_string(), - ); - self.edges.rekeyed(&previous, durable); - Some(session.current_status()) - } - None => None, - } - }; - let Some(status) = rebound_status else { - return false; - }; - self.broadcast(&status_snapshot_frame( - durable, - durable, - &status, - session_type, - )); - true - } - /// The not-tracked resume (codex `ensure_session_resumable` analog, file-store /// flavored): boot decision (a found transcript resumes; a session with no /// transcript yet boots from its binding row's genesis, design §4.2) -> spawn @@ -5737,27 +5492,6 @@ impl FreshClaudeState { ) .await .map_err(|error| ResumeClaudeError::Transient(error.resume_detail()))?; - let session_type = session_type.to_string(); - - // Read the tracked status through the same `current_status()` helper the - // rebind arm uses (the session was registered above; the lease-revocation - // teardown already returned). Identical to the hardcoded "idle" it replaces - // for a fresh resume — truthful by construction. The None fallback covers a - // sidecar that died before this read (already EOF-evicted by its consumer): - // nothing was ever announced, so "idle" stays the truthful default. - let last_announced = self - .sessions - .lock() - .await - .get(&msg.session_id) - .map(ClaudeSession::current_status) - .unwrap_or_else(|| "idle".to_string()); - self.broadcast(&status_snapshot_frame( - &msg.session_id, - durable, - &last_announced, - &session_type, - )); Ok(()) } @@ -7476,8 +7210,10 @@ fn attach_durable_id(msg: &FreshAgentAttach) -> Option { let candidate = msg .session_ref .as_ref() + .filter(|reference| reference.provider == PROVIDER) .map(|r| r.session_id.clone()) - .or_else(|| msg.resume_session_id.clone())?; + .or_else(|| msg.resume_session_id.clone()) + .or_else(|| is_canonical_claude_uuid(&msg.session_id).then(|| msg.session_id.clone()))?; is_canonical_claude_uuid(&candidate).then_some(candidate) } @@ -8224,6 +7960,7 @@ pub use create_outcome::ClaudeCreateOutcome; mod send_outcome; pub use send_outcome::ClaudeSendOutcome; +mod attach; mod establish; mod fork; mod genesis; @@ -8502,7 +8239,7 @@ pub(crate) mod tests { async fn handle_attach_untracked_session_emits_lost_session_frame() { let (st, mut rx) = state_with_bus(); - st.handle_attach(attach_msg("does-not-exist")).await; + st.attach_for_test(attach_msg("does-not-exist")).await; let raw = rx.try_recv().expect("a lost-session frame was broadcast"); let frame: Value = serde_json::from_str(&raw).unwrap(); @@ -8527,7 +8264,7 @@ pub(crate) mod tests { msg.observed_epoch = epoch; msg.observed_generation = generation; - st.handle_attach(msg).await; + st.attach_for_test(msg).await; let raw = rx.try_recv().expect("the typed refusal frame"); let frame: Value = serde_json::from_str(&raw).unwrap(); @@ -8557,7 +8294,7 @@ pub(crate) mod tests { let mut msg = attach_msg("kilroy-gone"); msg.session_type = SessionType::Kilroy; - st.handle_attach(msg).await; + st.attach_for_test(msg).await; let frame: Value = serde_json::from_str(&rx.try_recv().unwrap()).unwrap(); assert_eq!(frame["sessionType"], "kilroy"); @@ -8565,22 +8302,20 @@ pub(crate) mod tests { assert_eq!(frame["event"]["code"], "INVALID_SESSION_ID"); } - /// Wire-shape parity with codex's tracked-and-alive row (codex.rs decision table / - /// `handle_attach_known_alive_session_emits_no_frame_regardless_of_turn_state`): - /// attaching to a session this process DOES track must broadcast nothing -- above all - /// it must never declare a live session lost (which would make the client kill and - /// re-create a healthy pane). + /// A page observing a live UUID receives its current status without + /// creating or replacing a runtime. #[tokio::test] - async fn handle_attach_tracked_session_broadcasts_nothing() { + async fn handle_attach_tracked_session_acknowledges_its_status() { let (st, mut rx) = state_with_bus(); - insert_fake_claude_session(&st, "still-alive").await; + let id = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&st, &id).await; - st.handle_attach(attach_msg("still-alive")).await; + st.attach_for_test(attach_msg(&id)).await; - assert!( - rx.try_recv().is_err(), - "tracked attach must not broadcast any frame (wire-shape parity)" - ); + let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; + assert_eq!(frame["sessionId"], id); + assert_eq!(frame["event"]["status"], "idle"); + assert_eq!(st.sessions.lock().await.len(), 1); } // ── freshAgent.attach: resume-on-attach (restart parity, Task 6) ────────────── @@ -8726,15 +8461,15 @@ pub(crate) mod tests { std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); let (st, mut rx) = state_with_bus(); - st.handle_attach(attach_msg_with_resume("client-nanoid-1", durable)) + st.attach_for_test(attach_msg_with_resume("client-nanoid-1", durable)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); - // Registered under the CLIENT's id (envelope tagging + send routing depend on it). - assert!(st.sessions.lock().await.contains_key("client-nanoid-1")); + // A legacy pane now observes the canonical runtime. + assert!(st.sessions.lock().await.contains_key(durable)); assert_eq!( st.cli_index.lock().await.get(durable), - Some(&"client-nanoid-1".to_string()) + Some(&durable.to_string()) ); // The fake received the resume boot (spawn log now records the create request). let log = std::fs::read_to_string(env.spawn_log_path()).unwrap(); @@ -8744,7 +8479,7 @@ pub(crate) mod tests { ); // Idle snapshot frame, tagged with the client's id + the durable timeline id. let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; - assert_eq!(frame["sessionId"], "client-nanoid-1"); + assert_eq!(frame["sessionId"], durable); assert_eq!(frame["event"]["status"], "idle"); assert_eq!(frame["event"]["timelineSessionId"], durable); drop(env); @@ -8757,7 +8492,7 @@ pub(crate) mod tests { std::fs::create_dir_all(home.path().join("projects")).unwrap(); std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); let (st, mut rx) = state_with_bus(); - st.handle_attach(attach_msg_with_resume( + st.attach_for_test(attach_msg_with_resume( "client-nanoid-2", "88888888-8888-4888-8888-888888888888", )) @@ -8776,14 +8511,13 @@ pub(crate) mod tests { std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); std::env::set_var("FRESHELL_CLAUDE_NODE", "/nonexistent-node-binary"); let (st, mut rx) = state_with_bus(); - st.handle_attach(attach_msg_with_resume("client-nanoid-3", durable)) + st.attach_for_test(attach_msg_with_resume("client-nanoid-3", durable)) .await; std::env::remove_var("FRESHELL_CLAUDE_NODE"); std::env::remove_var("CLAUDE_CONFIG_DIR"); - // Mirrors codex: transient => top-level error with the provider code, - // explicitly NOT INVALID_SESSION_ID. - let err = await_top_level_error(&mut rx).await; - assert!(err.contains("CLAUDE_ATTACH_RESUME_FAILED")); + let err = await_frame_of_inner_type(&mut rx, "freshAgent.error").await; + assert_eq!(err["event"]["code"], "CLAUDE_ATTACH_RESUME_FAILED"); + assert_eq!(err["sessionId"], "client-nanoid-3"); assert!(st.sessions.lock().await.is_empty()); } @@ -8791,7 +8525,7 @@ pub(crate) mod tests { async fn attach_untracked_without_any_durable_id_still_emits_lost_frame() { // The pre-parity fallback (PR #529) is preserved verbatim. let (st, mut rx) = state_with_bus(); - st.handle_attach(attach_msg("no-resume-anywhere")).await; + st.attach_for_test(attach_msg("no-resume-anywhere")).await; let frame = await_frame_of_inner_type(&mut rx, "freshAgent.error").await; assert_eq!(frame["event"]["code"], "INVALID_SESSION_ID"); } @@ -8809,41 +8543,33 @@ pub(crate) mod tests { let b = st.clone(); let m1 = attach_msg_with_resume("nano-a", durable); let m2 = attach_msg_with_resume("nano-b", durable); - tokio::join!(a.handle_attach(m1), b.handle_attach(m2)); + tokio::join!(a.attach_for_test(m1), b.attach_for_test(m2)); std::env::remove_var("CLAUDE_CONFIG_DIR"); assert_eq!(env.spawn_count(), 1, "single-flight per durable id"); drop(env); } - /// Decision-table row 3 (Task 10b pins the NEW contract): a durable id already in - /// `cli_index` whose aliased session is LIVE is REBOUND -- the envelope stamp flips - /// to the durable id and the idle snapshot ack (stamped with the durable) answers - /// the attach. The sessions map is never re-keyed (alias, don't move). + /// A legacy page observes the same canonical runtime and receives its + /// correction before its status acknowledgement. #[tokio::test] - async fn attach_with_durable_id_already_indexed_rebinds_and_acks() { + async fn attach_to_a_live_uuid_keeps_its_envelope_and_acknowledges_the_legacy_page() { let (st, mut rx) = state_with_bus(); let durable = "56565656-5656-4656-8656-565656565656"; - insert_fake_claude_session(&st, "earlier-winner").await; - st.cli_index - .lock() - .await - .insert(durable.to_string(), "earlier-winner".to_string()); - st.handle_attach(attach_msg_with_resume("late-attacher", durable)) + insert_fake_claude_session(&st, durable).await; + st.attach_for_test(attach_msg_with_resume("late-attacher", durable)) .await; let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; assert_eq!(frame["sessionId"], durable); assert_eq!(frame["event"]["status"], "idle"); assert_eq!(frame["event"]["timelineSessionId"], durable); - // Alias, don't move: the map still holds the placeholder key... let sessions = st.sessions.lock().await; let session = sessions - .get("earlier-winner") - .expect("map key never re-keyed"); - // ...and the live record's envelope stamp now reads the durable id. + .get(durable) + .expect("the canonical runtime stays registered"); assert_eq!( session.broadcast_id.lock().unwrap().as_str(), durable, - "rebind must flip the envelope stamp to the durable id" + "attaching a page preserves the runtime's canonical envelope" ); } @@ -8885,7 +8611,7 @@ pub(crate) mod tests { await_status_on_wire(&mut rx, "compacting").await; // The reconnect rescue: attach addressing the durable id → the REBIND arm. - st.handle_attach(attach_msg_with_resume("late-attacher", durable)) + st.attach_for_test(attach_msg_with_resume("late-attacher", durable)) .await; let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; @@ -8940,7 +8666,7 @@ pub(crate) mod tests { // The reconnect rescue AFTER the turn settled: the ack must speak "idle", // never the pre-completion "compacting". - st.handle_attach(attach_msg_with_resume("late-attacher-settled", durable)) + st.attach_for_test(attach_msg_with_resume("late-attacher-settled", durable)) .await; let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; @@ -8985,7 +8711,7 @@ pub(crate) mod tests { let _ = await_frame_of_inner_type(&mut rx, "freshAgent.assistant").await; // The reconnect rescue mid-turn: the ack must speak "running". - st.handle_attach(attach_msg_with_resume("late-attacher-midturn", durable)) + st.attach_for_test(attach_msg_with_resume("late-attacher-midturn", durable)) .await; let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; @@ -9012,12 +8738,12 @@ pub(crate) mod tests { std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); let (st, mut rx) = state_with_bus(); - st.handle_attach(attach_msg_with_resume("client-fresh-resume", durable)) + st.attach_for_test(attach_msg_with_resume("client-fresh-resume", durable)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; - assert_eq!(frame["sessionId"], "client-fresh-resume"); + assert_eq!(frame["sessionId"], durable); assert_eq!( frame["event"]["status"], "idle", "a freshly resumed session's ack must announce idle (the tracked default): {frame}" @@ -9045,7 +8771,7 @@ pub(crate) mod tests { .unwrap(); std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); let (st, mut rx) = state_with_bus(); - st.handle_attach(attach_msg_with_resume("client-nanoid-4", durable)) + st.attach_for_test(attach_msg_with_resume("client-nanoid-4", durable)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); let log = std::fs::read_to_string(env.spawn_log_path()).unwrap(); @@ -12202,7 +11928,7 @@ rl.on('line', (line) => { // A delayed ATTACH: the tracked-and-alive fast path is BLOCKED // with the typed error (pre-d3: the silent parity return). - st.handle_attach(freshell_protocol::FreshAgentAttach { + st.attach_for_test(freshell_protocol::FreshAgentAttach { tab_id: None, pane_id: None, provider: freshell_protocol::AgentProvider::Claude, @@ -13015,8 +12741,6 @@ rl.on('line', (line) => { // ── b8ke focused episode-2 round-1: the adoption's atomicity tests ───── - /// The fake sidecar's durable id for a create with NO resume boot. - /// A minimal thread-local tracing capture (the session_handoff /// tracing_capture pattern, trimmed to the events the e2r1 tests need). mod init_capture { @@ -15761,7 +15485,7 @@ rl.on('line', (line) => { // The attach (conn-less): resume succeeds; the row must be Bound // again even though no settings-bearing adoption write ever runs. state - .handle_attach(attach_msg_with_resume("client-lineage-attach", DURABLE)) + .attach_for_test(attach_msg_with_resume("client-lineage-attach", DURABLE)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); @@ -15827,7 +15551,7 @@ rl.on('line', (line) => { // resume parks inside its provider await. let st2 = state.clone(); let attach = tokio::spawn(async move { - st2.handle_attach(attach_msg_with_resume("client-kill-mid-resume", DURABLE)) + st2.attach_for_test(attach_msg_with_resume("client-kill-mid-resume", DURABLE)) .await; }); // Determinism: wait until the resume's create request has REACHED the @@ -15887,11 +15611,8 @@ rl.on('line', (line) => { "the newer kill's fence stands — never cleared by the stale claim" ); assert!( - fake.claim_refusals - .lock() - .unwrap() - .contains(&("claude".to_string(), DURABLE.to_string())), - "the refusal is positively logged" + fake.claim_commits.lock().unwrap().is_empty(), + "the closed pane never commits a launch claim" ); assert!( !fake @@ -16256,7 +15977,7 @@ rl.on('line', (line) => { let stall = fake.arm_post_commit_stall("claude", DURABLE); let st2 = state.clone(); let attach = tokio::spawn(async move { - st2.handle_attach(attach_msg_with_resume("client-killed-post-commit", DURABLE)) + st2.attach_for_test(attach_msg_with_resume("client-killed-post-commit", DURABLE)) .await; }); stall @@ -16844,13 +16565,13 @@ rl.on('line', (line) => { // The reconnect's late attach RIDES the fenced seat to the durable. state - .handle_attach(attach_msg_with_resume("seat-closed", DURABLE)) + .attach_for_test(attach_msg_with_resume("seat-closed", DURABLE)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); let mut saw_resume_failed = false; while let Ok(raw) = rx.try_recv() { - if raw.contains("CLAUDE_ATTACH_RESUME_FAILED") { + if raw.contains("SESSION_CLOSED") { saw_resume_failed = true; } } @@ -16864,11 +16585,8 @@ rl.on('line', (line) => { "no registration alias survives" ); assert!( - fake.claim_refusals - .lock() - .unwrap() - .contains(&("claude".to_string(), DURABLE.to_string())), - "the refusal is positively logged" + fake.claim_commits.lock().unwrap().is_empty(), + "the closed pane never commits a launch claim" ); assert!( fake.kill_tombstone_at_ms("claude", DURABLE).is_none(), @@ -16961,13 +16679,13 @@ rl.on('line', (line) => { // The NEW pane (a fresh seat) genuinely reopens the session. state - .handle_attach(attach_msg_with_resume("seat-new", DURABLE)) + .attach_for_test(attach_msg_with_resume("seat-new", DURABLE)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); assert!( - state.sessions.lock().await.contains_key("seat-new"), - "the clean-seat attach registers" + state.sessions.lock().await.contains_key(DURABLE), + "the clean-seat attach registers under the durable id" ); assert!( fake.kill_tombstone_at_ms("claude", DURABLE).is_none(), @@ -16984,9 +16702,8 @@ rl.on('line', (line) => { ); assert!( fake.alias_tombstone_records("claude", "seat-new") - .iter() - .any(|(d, _)| d == DURABLE), - "the attach registration persisted its alias record" + .is_empty(), + "the canonical runtime needs no new placeholder alias record" ); assert!( fake.alias_tombstone_records("claude", "seat-old") @@ -17029,7 +16746,7 @@ rl.on('line', (line) => { // The commit's io failure knob (Finding 5's shape). fake.set_fail_writes(true); state - .handle_attach(attach_msg_with_resume("client-commit-error", DURABLE)) + .attach_for_test(attach_msg_with_resume("client-commit-error", DURABLE)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); @@ -18440,7 +18157,7 @@ rl.on('line', (line) => { let fresh_create_durable_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; assert_eq!(created["type"], "freshAgent.created", "{created}"); - prepare_legacy_init(&st, &created["sessionId"].as_str().unwrap()).await; + prepare_legacy_init(&st, created["sessionId"].as_str().unwrap()).await; while rx.try_recv().is_ok() {} fake.set_fail_writes(true); st.adopt_session_init( @@ -18576,7 +18293,7 @@ rl.on('line', (line) => { // Drive the donor resume flow (attach to DURABLE with a transcript on disk). state - .handle_attach(attach_msg_with_resume("client-nanoid-settings", DURABLE)) + .attach_for_test(attach_msg_with_resume("client-nanoid-settings", DURABLE)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); @@ -18607,19 +18324,19 @@ rl.on('line', (line) => { let (state, _rx) = state_with_bus(); let mut msg = attach_msg_with_resume("client-nanoid-logs", DURABLE); msg.session_type = SessionType::Kilroy; - state.handle_attach(msg).await; + state.attach_for_test(msg).await; std::env::remove_var("CLAUDE_CONFIG_DIR"); let ownership_id = state .sessions .lock() .await - .get("client-nanoid-logs") + .get(DURABLE) .map(|session| session.ownership_id.clone()) - .expect("the attach resumed a session under the client's id"); + .expect("the attach resumed the canonical session"); let bound = with_message(&events, "freshagent.claude.sidecar_bound"); assert_eq!(bound.len(), 1, "{bound:?}"); - assert_eq!(bound[0].field("session_id"), Some("client-nanoid-logs")); + assert_eq!(bound[0].field("session_id"), Some(DURABLE)); assert_eq!(bound[0].field("session_type"), Some("kilroy")); assert_eq!(bound[0].field("ownership_id"), Some(ownership_id.as_str())); drop(env); @@ -18653,7 +18370,7 @@ rl.on('line', (line) => { state.set_identity_sink(settings); state - .handle_attach(attach_msg_with_resume("client-after-restart", DURABLE)) + .attach_for_test(attach_msg_with_resume("client-after-restart", DURABLE)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); @@ -18672,7 +18389,7 @@ rl.on('line', (line) => { assert_eq!(create_req["model"], "opus-x"); assert_eq!(create_req["permissionMode"], "plan"); let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; - assert_eq!(frame["sessionId"], "client-after-restart"); + assert_eq!(frame["sessionId"], DURABLE); assert!(matches!( ownership.observe("claude", DURABLE).state, freshell_ownership::OwnershipState::Live { .. } @@ -18709,7 +18426,7 @@ rl.on('line', (line) => { )); state - .handle_attach(attach_msg_with_resume( + .attach_for_test(attach_msg_with_resume( "client-nanoid-empty-resume", DURABLE, )) @@ -18756,7 +18473,7 @@ rl.on('line', (line) => { state.set_identity_sink(fake.clone()); state - .handle_attach(attach_msg_with_resume("client-nanoid-norec", DURABLE)) + .attach_for_test(attach_msg_with_resume("client-nanoid-norec", DURABLE)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); @@ -18826,7 +18543,7 @@ rl.on('line', (line) => { state.set_identity_sink(fake); state - .handle_attach(attach_msg_with_resume("client-nanoid-alarm", DURABLE)) + .attach_for_test(attach_msg_with_resume("client-nanoid-alarm", DURABLE)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); @@ -19154,7 +18871,7 @@ rl.on('line', (line) => { let home = tempfile::tempdir().unwrap(); write_fake_transcript(home.path(), durable); std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); - st.handle_attach(attach_msg_with_resume("pane-after-reload", durable)) + st.attach_for_test(attach_msg_with_resume("pane-after-reload", durable)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); assert!(st.has_live_session(durable).await, "the attach resumed it"); @@ -19201,7 +18918,7 @@ rl.on('line', (line) => { let home = tempfile::tempdir().unwrap(); write_fake_transcript(home.path(), durable); std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); - st.handle_attach(attach_msg_with_resume("pane-after-reload", durable)) + st.attach_for_test(attach_msg_with_resume("pane-after-reload", durable)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); assert!(st.has_live_session(durable).await, "the attach resumed it"); @@ -19231,12 +18948,12 @@ rl.on('line', (line) => { let _guard = CLAUDE_ENV_LOCK.lock().await; let _env = FakeClaudeSidecarEnv::install(); // The fake sidecar's session reports this durable id. - const DURABLE: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; let (st, mut rx) = state_with_bus(); st.handle_create(dedup_create_msg("req-reap-resume"), None) .await; let created = await_claude_created(&mut rx, "req-reap-resume").await; let pane_id = created["sessionId"].as_str().unwrap().to_string(); + let durable = pane_id.as_str(); // The fake never answers a plain send: the turn stays in flight. st.handle_send(send_msg(&pane_id, "a turn the sidecar dies in")) .await; @@ -19256,12 +18973,12 @@ rl.on('line', (line) => { .expect("the end-of-output path reached the exit report"); let home = tempfile::tempdir().unwrap(); - write_fake_transcript(home.path(), DURABLE); + write_fake_transcript(home.path(), durable); std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); - st.handle_attach(attach_msg_with_resume(&pane_id, DURABLE)) + st.attach_for_test(attach_msg_with_resume(&pane_id, durable)) .await; std::env::remove_var("CLAUDE_CONFIG_DIR"); - assert!(st.has_live_session(DURABLE).await, "the attach resumed it"); + assert!(st.has_live_session(durable).await, "the attach resumed it"); while rx.try_recv().is_ok() {} hold.release(); diff --git a/crates/freshell-freshagent/src/claude/attach.rs b/crates/freshell-freshagent/src/claude/attach.rs new file mode 100644 index 000000000..b0e55abde --- /dev/null +++ b/crates/freshell-freshagent/src/claude/attach.rs @@ -0,0 +1,210 @@ +//! A page attaches to a durable conversation, without changing its runtime. + +use super::*; +use freshell_terminal::FrameSink; + +impl FreshClaudeState { + /// Reply to this page only. Status, content, and attention keep their + /// ordinary broadcast path and canonical session identity. + pub async fn handle_attach(&self, mut msg: FreshAgentAttach, reply: FrameSink) { + let session_type = session_type_str(msg.session_type); + let fence = + match crate::ownership_lane::wire_fence(msg.observed_epoch, msg.observed_generation) { + Ok(fence) => fence, + Err(error) => { + attach_error(&reply, &msg, &msg.session_id, error.code(), error.message()); + return; + } + }; + let Some(candidate) = attach_durable_id(&msg) else { + reply(lost_session_frame(&msg.session_id, msg.session_type)); + return; + }; + if !is_canonical_claude_uuid(&msg.session_id) + && self.identity_sink().is_some_and(|sink| { + sink.kill_tombstone_at_ms(PROVIDER, &msg.session_id) + .is_some() + }) + { + attach_error( + &reply, + &msg, + &msg.session_id, + "SESSION_CLOSED", + "This pane was closed. Open the conversation again to continue.", + ); + return; + } + let durable = self + .identity_sink() + .and_then(|sink| sink.chain_terminus(PROVIDER, &candidate)) + .unwrap_or_else(|| candidate.clone()); + let mut previous = msg.session_id.clone(); + if durable != candidate && previous != durable { + // A durable ledger correction remains useful even when the + // subsequent owner fence refuses this delayed attach. + reply( + self.materialized_message(&previous, &durable, session_type) + .await, + ); + previous = durable.clone(); + } + msg.session_id = durable.clone(); + msg.session_ref = Some(claude_session_ref(&durable)); + msg.resume_session_id = None; + + match self.reply_live_attach(&msg, &previous, fence, &reply).await { + Ok(true) | Err(()) => return, + Ok(false) => {} + } + // A competing attach may already be booting this conversation. + // Wait for it, then inspect the runtime again before launching. + let _flight = self.wait_resume_flight(&durable).await; + match self.reply_live_attach(&msg, &previous, fence, &reply).await { + Ok(true) | Err(()) => return, + Ok(false) => {} + } + match self.resume_untracked(&msg, &durable, fence, true).await { + Ok(()) => { + // This operation advanced its own claim. The incoming + // pre-launch fence no longer names its committed owner. + match self.reply_live_attach(&msg, &previous, None, &reply).await { + Ok(true) | Err(()) => {} + Ok(false) => attach_error( + &reply, + &msg, + &previous, + "CLAUDE_ATTACH_RESUME_FAILED", + "Claude stopped while attaching. Try again.", + ), + } + } + Err(recovery::RuntimeResumeError::Lost) => { + reply(lost_session_frame(&previous, msg.session_type)); + } + Err(recovery::RuntimeResumeError::Closed) => attach_error( + &reply, + &msg, + &previous, + "SESSION_CLOSED", + "This conversation was closed. Open it again to continue.", + ), + Err(recovery::RuntimeResumeError::Unavailable { code, message, .. }) => { + attach_error(&reply, &msg, &previous, code, &message); + } + } + tracing::debug!(target: "freshell_freshagent::claude", provider = PROVIDER, + session_id = %durable, session_type, "freshagent.claude.attach_settled"); + } + + /// Hold the coordinator's observation window through the snapshot and + /// correction. A handoff cannot commit around this acknowledgement. + async fn reply_live_attach( + &self, + msg: &FreshAgentAttach, + previous: &str, + fence: Option, + reply: &FrameSink, + ) -> Result { + let Some(key) = self.resolve_session_key(&msg.session_id).await else { + return Ok(false); + }; + let operation = format!("claude-attach-{}", uuid::Uuid::new_v4()); + let guard = match fence { + Some(fence) => crate::ownership_lane::arm_adopt_guard( + &self.ownership, + PROVIDER, + &msg.session_id, + &operation, + &freshell_ownership::OwnerIdentity { + kind: freshell_ownership::RuntimeOwnerKind::FreshAgent, + terminal_id: None, + live_session_key: Some(key.clone()), + pid: None, + ownership_id: None, + }, + fence, + "freshclaude/attach", + ), + None => crate::ownership_lane::arm_attach_guard( + &self.ownership, + PROVIDER, + &msg.session_id, + &operation, + None, + "freshclaude/attach", + ), + }; + if matches!(guard, crate::ownership_lane::LaneAttachGuard::Refused) { + attach_error( + reply, + msg, + previous, + "SESSION_RESERVED", + "A lifecycle operation owns this session; retry after it settles", + ); + return Err(()); + } + let status = self + .sessions + .lock() + .await + .get(&key) + .map(ClaudeSession::current_status); + let Some(status) = status else { + return Ok(false); + }; + if previous != msg.session_id { + reply( + self.materialized_message( + previous, + &msg.session_id, + session_type_str(msg.session_type), + ) + .await, + ); + } + reply(status_snapshot_frame( + &msg.session_id, + &msg.session_id, + &status, + session_type_str(msg.session_type), + )); + drop(guard); + Ok(true) + } +} + +#[cfg(test)] +impl FreshClaudeState { + /// In-crate tests observe replies through their existing capture bus. + pub(super) async fn attach_for_test(&self, msg: FreshAgentAttach) { + let bus = self.broadcast_tx.clone(); + self.handle_attach( + msg, + Arc::new(move |frame| { + if let Ok(frame) = serde_json::to_string(&frame) { + let _ = bus.send(frame); + } + }), + ) + .await; + } +} + +fn attach_error( + reply: &FrameSink, + msg: &FreshAgentAttach, + session_id: &str, + code: &str, + message: &str, +) { + tracing::warn!(target: "freshell_freshagent::claude", provider = PROVIDER, + session_id, code, "freshagent.claude.attach_refused"); + reply(ServerMessage::FreshAgentEvent(FreshAgentEvent { + provider: PROVIDER.into(), + session_id: session_id.into(), + session_type: session_type_str(msg.session_type).into(), + event: json!({"type":"freshAgent.error", "sessionId":session_id, "code":code, "message":message}), + })); +} diff --git a/crates/freshell-freshagent/src/claude/recovery.rs b/crates/freshell-freshagent/src/claude/recovery.rs index 264d734d6..e4693ec43 100644 --- a/crates/freshell-freshagent/src/claude/recovery.rs +++ b/crates/freshell-freshagent/src/claude/recovery.rs @@ -95,10 +95,7 @@ impl FreshClaudeState { crate::ownership_lane::LaneClaim::Granted(ticket) => Some(ticket), crate::ownership_lane::LaneClaim::Unwired => None, crate::ownership_lane::LaneClaim::Adopt => { - return if self - .try_rebind_to_live(durable, session_type_str(msg.session_type)) - .await - { + return if self.resolve_session_key(durable).await.is_some() { Ok(()) } else { Err(RuntimeResumeError::reserved(None)) @@ -121,9 +118,7 @@ impl FreshClaudeState { LaunchLease::Acquired(lease) => Some(lease), refused => { return if matches!(refused, LaunchLease::BoundLive { .. }) - && self - .try_rebind_to_live(durable, session_type_str(msg.session_type)) - .await + && self.resolve_session_key(durable).await.is_some() { Ok(()) } else { diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index f81a612bc..3256f994a 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -1974,8 +1974,9 @@ async fn handle_client_text( } freshell_protocol::AgentProvider::Claude => { let fresh_claude = state.fresh_claude.clone(); + let reply = conn_sink.clone(); tokio::spawn( - async move { fresh_claude.handle_attach(attach).await } + async move { fresh_claude.handle_attach(attach, reply).await } .instrument(tracing::Span::current()), ); } diff --git a/crates/freshell-ws/tests/freshagent_claude_attach.rs b/crates/freshell-ws/tests/freshagent_claude_attach.rs index 5c7f1ca61..e223174dc 100644 --- a/crates/freshell-ws/tests/freshagent_claude_attach.rs +++ b/crates/freshell-ws/tests/freshagent_claude_attach.rs @@ -375,7 +375,7 @@ async fn claude_attach_with_resumable_transcript_resumes_and_emits_snapshot_over }) .await; - assert_eq!(frame["sessionId"], "gone-after-restart"); + assert_eq!(frame["sessionId"], durable); assert_eq!(frame["sessionType"], "freshclaude"); assert_eq!(frame["event"]["status"], "idle"); assert_eq!(frame["event"]["timelineSessionId"], durable); @@ -439,21 +439,19 @@ async fn claude_attach_with_an_unreadable_store_is_transient_not_lost() { ) .await; - // The FIRST answer is decisive: either the lost frame for this pane or the - // transient top-level error. + // A transient failure stays scoped to this pane and is distinct from + // the permanent INVALID_SESSION_ID lost-session response. let frame = await_frame(&mut ws, Duration::from_secs(15), |v| { (v["type"] == "freshAgent.event" && v["sessionId"] == "pane-with-unreadable-store") || v["type"] == "error" }) .await; assert_eq!( - frame["type"], "error", + frame["event"]["type"], "freshAgent.error", "an unreadable store must not mark the session lost: {frame}" ); assert!( - frame["message"] - .as_str() - .is_some_and(|m| m.starts_with("CLAUDE_ATTACH_RESUME_FAILED")), + frame["event"]["code"] == "CLAUDE_ATTACH_RESUME_FAILED", "the attach answers with the transient resume failure: {frame}" ); diff --git a/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs b/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs index ecd0fdf86..5e15503e6 100644 --- a/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs +++ b/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs @@ -1,403 +1,96 @@ -//! Retire-on-kill round 6 (focused-ep5-r5 Finding 2) — the restart-boundary -//! integration pins. In-memory claude placeholder→durable alias tombstones -//! used to die with the server process, so the finding's exact interleaving -//! was: panes reconnect after a restart and resume their durable session -//! (slow); the user closes the pane before the resume re-registers the alias; -//! the close names only the bare placeholder and fences/retires nothing -//! durable; the resume then commits the durable row Bound and registers a -//! session for a pane that no longer exists — and the next recovery offers -//! it. -//! -//! The repair has two halves, pinned here across a REAL ledger reload (a -//! fresh `PaneLedger` over the same root + a fresh `FreshClaudeState` IS the -//! restart — every process-local map is empty, only the durable store -//! survives): -//! -//! 1. Alias tombstones are durable (the ledger's `alias-tombstones/` -//! subtree, minted at registration/adoption and demoted at eviction), so a -//! post-restart kill naming the bare placeholder still resolves, fences, -//! and retires the durable row. (Test 1 — the finding's own order.) -//! 2. The claim commit consults the placeholder's fence: a close recorded -//! under the one-shot seat blocks the commit exactly like one recorded -//! under the durable id — the brief's literal pin: a restart boundary -//! BETWEEN the fence write and the claim commit leaves the commit blocked -//! and the row Retired. (Test 2.) -//! -//! Harness note: these tests drive `FreshClaudeState` DIRECTLY (no socket) — -//! the create/kill/attach handlers are public; the production -//! `spawn_sidecar()` is swapped by the SAME `FRESHELL_CLAUDE_SIDECAR` / -//! `FRESHELL_CLAUDE_NODE` env overrides `freshagent_claude_kill_interrupt.rs` -//! uses — so the alias-mint, kill, and attach lanes all run their real code -//! against a REAL on-disk ledger, through the shared `common::ledger_sink` -//! sink, which mirrors `freshell-server/src/identity_sink.rs`'s adapter (the ws -//! test crate cannot depend on freshell-server). +//! Read compatibility for placeholder aliases persisted by older servers. +//! New runtimes use UUIDs; seed the old on-disk records, then exercise the +//! production sidecar and the actual kill/attach paths across ledger reloads. mod common; -use std::sync::Arc; - -use serde_json::Value; - -use common::ledger_sink::LedgerSink; -use freshell_protocol::{ - AgentProvider, FreshAgentAttach, FreshAgentCreate, FreshAgentKill, SessionLocator, SessionType, -}; -use freshell_ws::pane_ledger::{PaneLedger, RetiredReason, RowState}; - -/// Serializes every test in this file (process-global env vars mutate). -static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - -/// A minimal scripted fake claude sidecar (production wire protocol only): -/// `create` answers `created` with a fresh placeholder and then emits -/// `sdk.session.init` echoing the resume boot's id as the durable cli id (the -/// resume-continuity shape) so the ADOPTION path runs its real fold — -/// cli_index insert, binding write, and (round 6) the alias-tombstone mint -/// write. `shutdown` exits (the kill's graceful teardown drives it). -const FAKE_SIDECAR: &str = r#" -import fs from 'node:fs' -import readline from 'node:readline' - -let counter = 0 -const rl = readline.createInterface({ input: process.stdin, terminal: false }) -rl.on('line', (line) => { - const trimmed = line.trim() - if (!trimmed) return - let msg - try { - msg = JSON.parse(trimmed) - } catch { - return - } - if (msg.type === 'create') { - counter += 1 - const sessionId = `fake-claude-kar-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', sessionId, protocol: 2 }) + '\n') - const cliSessionId = msg.boot?.resume || '00000000-0000-4000-8000-0000000000ff' - console.log(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] })) - console.log(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' })) - } else if (msg.type === 'shutdown') { - process.exit(0) - } -}) -"#; - -/// One test's temp root: the claude store (fake transcript), the fake -/// sidecar script, and the pane-ledger root (the durable store that survives -/// the scripted restart). -struct Rig { - dir: tempfile::TempDir, -} -impl Rig { - fn install(&self) { - let script = self.dir.path().join("fake-sidecar.mjs"); - std::fs::write(&script, FAKE_SIDECAR).expect("write fake sidecar"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("CLAUDE_CONFIG_DIR", self.dir.path().join("claude-store")); - } - fn write_transcript(&self, durable: &str) { - let dir = self - .dir - .path() - .join("claude-store") - .join("projects") - .join("-t"); - std::fs::create_dir_all(&dir).unwrap(); - std::fs::write( - dir.join(format!("{durable}.jsonl")), - r#"{"type":"user","cwd":"/tmp","message":{"role":"user","content":[{"type":"text","text":"hi"}]}}"#, - ) +use common::claude_rig::{ClaudeRig, RigKind, RigOptions}; +use freshell_ws::pane_ledger::{RetiredReason, RowState}; +use serde_json::json; + +async fn legacy_close_survives_restart(close_before_restart: bool) { + let mut rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut old_server = rig.connect().await; + let durable = old_server.create("legacy-record-source").await; + rig.converse(&mut old_server, &durable, &["saved before the upgrade"]) + .await; + let placeholder = "persisted-old-pane-placeholder"; + rig.pane_ledger + .as_ref() + .unwrap() + .record_alias_tombstone("claude", placeholder, &durable, 1_000) .unwrap(); + // The old runtime is gone. The alias is now exclusively on disk, as + // it is when a server upgrade first reads an older pane's saved state. + rig.restart().await; + assert!(rig + .pane_ledger + .as_ref() + .unwrap() + .alias_tombstone_records("claude", placeholder) + .iter() + .any(|(id, _)| id == &durable)); + if close_before_restart { + rig.connect().await.kill(placeholder).await; + rig.restart().await; } - fn ledger_root(&self) -> std::path::PathBuf { - self.dir.path().join("pane-ledger") - } -} -impl Drop for Rig { - fn drop(&mut self) { - std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); - std::env::remove_var("FRESHELL_CLAUDE_NODE"); - std::env::remove_var("CLAUDE_CONFIG_DIR"); - } -} - -/// One process generation: a fresh `FreshClaudeState` over a REAL ledger -/// rooted at `root` (a second generation over the same root IS the restart). -fn new_generation( - root: &std::path::Path, -) -> ( - freshell_freshagent::FreshClaudeState, - Arc, - tokio::sync::broadcast::Receiver, -) { - let (tx, rx) = tokio::sync::broadcast::channel::(64); - let st = freshell_freshagent::FreshClaudeState::new(Arc::new(tx)); - let ledger = Arc::new(PaneLedger::new(Some(root.to_path_buf()))); - st.set_identity_sink(Arc::new(LedgerSink::new(ledger.clone()))); - (st, ledger, rx) -} - -fn create_msg_resume(request_id: &str, durable: &str) -> FreshAgentCreate { - FreshAgentCreate { - pane_id: None, - naming_handle: None, - observed_epoch: None, - observed_generation: None, - request_id: request_id.to_string(), - session_type: SessionType::Freshclaude, - provider: Some(AgentProvider::Claude), - cwd: Some("/t".to_string()), // settings-bearing: the adoption writes the row - legacy_restore_context: None, - resume_session_id: None, - session_ref: Some(SessionLocator { - provider: "claude".to_string(), - session_id: durable.to_string(), - }), - model: None, - model_selection: None, - permission_mode: None, - sandbox: None, - effort: None, - plugins: None, - tab_id: None, - } -} - -fn attach_msg(seat: &str, durable: &str) -> FreshAgentAttach { - FreshAgentAttach { - tab_id: None, - pane_id: None, - observed_epoch: None, - observed_generation: None, - provider: AgentProvider::Claude, - session_id: seat.to_string(), - session_type: SessionType::Freshclaude, - cwd: None, - resume_session_id: None, - session_ref: Some(SessionLocator { - provider: "claude".to_string(), - session_id: durable.to_string(), - }), - } -} - -fn kill_msg(session_id: &str) -> FreshAgentKill { - FreshAgentKill { - observed_epoch: None, - observed_generation: None, - provider: AgentProvider::Claude, - session_id: session_id.to_string(), - session_type: SessionType::Freshclaude, - cwd: None, + let mut client = rig.connect().await; + if !close_before_restart { + client.kill(placeholder).await; } -} - -/// Bounded drain of the broadcast receiver until a frame containing `needle` -/// arrives (the lane answers always broadcast through this bus). -async fn await_frame_containing( - rx: &mut tokio::sync::broadcast::Receiver, - needle: &str, -) -> Value { - tokio::time::timeout(std::time::Duration::from_secs(15), async { - loop { - let raw = rx.recv().await.expect("bus frame"); - if raw.contains(needle) { - return serde_json::from_str(&raw).expect("frame is JSON"); - } - } - }) - .await - .unwrap_or_else(|_| panic!("a frame containing {needle} within budget")) -} - -/// Bounded wait for a condition (the consumer's adoption fold runs on its -/// own task; the deadline poll is the suite convention). -async fn await_condition(what: &str, cond: impl Fn() -> bool) { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - if cond() { - return; - } + let ledger = rig.pane_ledger.as_ref().unwrap(); + let row = ledger.load_binding("claude", &durable).unwrap(); + assert_eq!(row.state, RowState::Retired); + assert_eq!(row.retired_reason, Some(RetiredReason::Closed)); + for id in [placeholder, durable.as_str()] { assert!( - tokio::time::Instant::now() < deadline, - "{what} (never happened)" + ledger.kill_tombstone_at("claude", id).is_some(), + "missing close fence for {id}" ); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; } -} -/// Test 1 — the finding's own order across the restart: create-with-resume -/// in generation 1 (the row lands Bound, the alias mapping is persisted at -/// registration/adoption); "restart" (generation 2 over the same durable -/// root — every process-local map is empty); the user THEN closes the pane -/// by its bare placeholder. The kill must resolve the PERSISTED alias, so -/// the durable row ends Retired(Closed) with its fence standing. And the -/// reconnect's late attach (riding the closed seat) must be refused — -/// nothing registers, the row never revives. -#[tokio::test(flavor = "multi_thread")] -async fn a_kill_naming_the_bare_placeholder_after_a_restart_retires_the_durable_row_and_blocks_the_late_attach( -) { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let rig = Rig { - dir: tempfile::tempdir().expect("temp dir"), - }; - rig.install(); - let durable = "a1a1a1a1-a1a1-41a1-81a1-a1a1a1a1a1a1".to_string(); - rig.write_transcript(&durable); - let root = rig.ledger_root(); - - // Generation 1: the session lives; the durable alias record is minted. - let placeholder = { - let (st1, ledger1, mut rx1) = new_generation(&root); - st1.handle_create(create_msg_resume("req-kar-1", &durable), None) - .await; - let created = await_frame_containing(&mut rx1, "\"freshAgent.created\"").await; - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - let ledger1b = ledger1.clone(); - let dur = durable.clone(); - let ph = placeholder.clone(); - await_condition("the adoption row + alias record", move || { - ledger1b - .load_binding("claude", &dur) - .is_some_and(|r| r.state == RowState::Bound) - && !ledger1b.alias_tombstone_records("claude", &ph).is_empty() + client + .send(json!({ + "type":"freshAgent.attach", "provider":"claude", "sessionType":"freshclaude", + "sessionId":placeholder, "sessionRef":{"provider":"claude", "sessionId":durable}, + })) + .await; + let error = client + .wait_for("a closed legacy pane refusal", |frame| { + frame["event"]["type"] == "freshAgent.error" + && frame["event"]["code"] == "SESSION_CLOSED" }) .await; - placeholder - // generation 1 drops here — the "restart" - }; - - // Generation 2: no process-local mapping anywhere. - let (st2, ledger2, mut rx2) = new_generation(&root); - assert!( - ledger2 - .alias_tombstone_records("claude", &placeholder) - .iter() - .any(|(d, _)| d == &durable), - "the persisted alias record survived the scripted restart" - ); - - // The user closes the pane by its bare placeholder NOW (the finding's - // window — before any resume re-registers the alias in memory). - st2.handle_kill(kill_msg(&placeholder)).await; - - let row = ledger2 - .load_binding("claude", &durable) - .expect("the durable row"); + assert_eq!(error["sessionId"], placeholder); + assert!(!rig.fresh_claude.has_live_session(&durable).await); assert_eq!( - row.state, - RowState::Retired, - "the close retired the durable row" - ); - assert_eq!(row.retired_reason, Some(RetiredReason::Closed)); - assert!( - ledger2.kill_tombstone_at("claude", &durable).is_some(), - "the durable close fence landed" - ); - assert!( - ledger2.kill_tombstone_at("claude", &placeholder).is_some(), - "the placeholder's own fence landed too" + ledger.load_binding("claude", &durable).unwrap().state, + RowState::Retired ); + assert!(ledger.kill_tombstone_at("claude", &durable).is_some()); - // The reconnect's late attach (riding the closed seat) must be refused: - // no session registers, the row never revives. - st2.handle_attach(attach_msg(&placeholder, &durable)).await; - let failed = await_frame_containing(&mut rx2, "CLAUDE_ATTACH_RESUME_FAILED").await; - assert_eq!(failed["type"], "error"); - let row = ledger2 - .load_binding("claude", &durable) - .expect("the durable row"); - assert_eq!( - row.state, - RowState::Retired, - "the row stays Retired — the phantom claim never revived it" - ); - assert!( - ledger2.kill_tombstone_at("claude", &durable).is_some(), - "the fence still stands" - ); - assert!( - !st2.has_live_session(&durable).await, - "nothing live registered for the closed pane" - ); -} - -/// Test 2 — the brief's literal pin: the restart boundary sits BETWEEN the -/// fence write and the claim commit. Generation 1 closes the pane (the kill -/// fences placeholder AND durable and retires the row — the live generation -/// resolves its own aliases); the restart then wipes every process-local -/// alias; the late attach's commit must be blocked (a fence recorded under -/// the one-shot seat blocks exactly like one under the durable), and the row -/// stays Retired. -#[tokio::test(flavor = "multi_thread")] -async fn a_close_fenced_before_the_restart_blocks_the_late_claim_and_the_row_stays_retired() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let rig = Rig { - dir: tempfile::tempdir().expect("temp dir"), - }; - rig.install(); - let durable = "a2a2a2a2-a2a2-42a2-82a2-a2a2a2a2a2a2".to_string(); - rig.write_transcript(&durable); - let root = rig.ledger_root(); - - // Generation 1: create-with-resume lives, then the close lands. - let placeholder = { - let (st1, ledger1, mut rx1) = new_generation(&root); - st1.handle_create(create_msg_resume("req-kar-2", &durable), None) - .await; - let created = await_frame_containing(&mut rx1, "\"freshAgent.created\"").await; - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - let ledger1b = ledger1.clone(); - let dur = durable.clone(); - let ph = placeholder.clone(); - await_condition("the adoption row + alias record", move || { - ledger1b - .load_binding("claude", &dur) - .is_some_and(|r| r.state == RowState::Bound) - && !ledger1b.alias_tombstone_records("claude", &ph).is_empty() - }) + // An explicit new open of the durable conversation is still allowed. + // A closed legacy pane and a new open are distinct user actions. + let mut reopened = rig.connect().await; + reopened.attach(&durable).await; + rig.converse(&mut reopened, &durable, &["explicitly reopened"]) .await; + rig.fresh_claude.shutdown().await; +} - // THE FENCE WRITE (pre-restart): the user closes the pane. - st1.handle_kill(kill_msg(&placeholder)).await; - let row = ledger1 - .load_binding("claude", &durable) - .expect("the durable row"); - assert_eq!( - row.state, - RowState::Retired, - "pre-restart: the close retired the row" - ); - assert!(ledger1.kill_tombstone_at("claude", &durable).is_some()); - placeholder - }; - - // ── the restart boundary: every in-memory alias/fence-holder is gone ── - let (st2, ledger2, mut rx2) = new_generation(&root); - assert!( - ledger2.kill_tombstone_at("claude", &placeholder).is_some(), - "the seat's fence is durable across the restart" - ); +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_kill_by_legacy_placeholder_after_restart_closes_its_durable_row() { + legacy_close_survives_restart(false).await; +} - // The claim commit lands now — it must be BLOCKED, the row stays Retired. - st2.handle_attach(attach_msg(&placeholder, &durable)).await; - let failed = await_frame_containing(&mut rx2, "CLAUDE_ATTACH_RESUME_FAILED").await; - assert_eq!(failed["type"], "error"); - let row = ledger2 - .load_binding("claude", &durable) - .expect("the durable row"); - assert_eq!( - row.state, - RowState::Retired, - "the row stays Retired across the restart boundary" - ); - assert_eq!(row.retired_reason, Some(RetiredReason::Closed)); - assert!( - ledger2.kill_tombstone_at("claude", &durable).is_some(), - "the durable fence is never cleared by the blocked commit" - ); - assert!( - !st2.has_live_session(&durable).await, - "nothing live registered for the closed pane" - ); +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_legacy_close_written_before_restart_still_blocks_the_late_attach() { + legacy_close_survives_restart(true).await; } diff --git a/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs b/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs index 7b3e98bc5..bb84211f0 100644 --- a/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs +++ b/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs @@ -55,6 +55,7 @@ async fn rig_with( hosted_claude: hosted, env: vec![("KILROY_ENABLED".into(), "1".into())], freshell_mcp: Some(Arc::clone(&cell)), + ..RigOptions::default() }, ) .await; diff --git a/crates/freshell-ws/tests/freshagent_claude_observers.rs b/crates/freshell-ws/tests/freshagent_claude_observers.rs new file mode 100644 index 000000000..bc0da3de2 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_observers.rs @@ -0,0 +1,108 @@ +//! Attaching another page observes one canonical runtime without resetting peers. + +mod common; + +use common::claude_rig::{ClaudeRig, RigKind, RigOptions}; +use serde_json::json; +use std::time::Duration; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn attaching_mid_turn_acknowledges_running_only_to_the_requester() { + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut first = rig.connect().await; + let session = first.create("create-observers").await; + let mut second = rig.connect().await; + first.send_text(&session, "hang", "HANG").await; + first.wait_for_event("freshAgent.stream").await; + second + .send(json!({ + "type": "freshAgent.attach", "provider": "claude", "sessionType": "freshclaude", + "sessionId": session, "sessionRef": {"provider":"claude", "sessionId":session}, + })) + .await; + let ack = second.wait_for_event("freshAgent.session.snapshot").await; + assert_eq!(ack["sessionId"], session); + assert_eq!(ack["event"]["status"], "running"); + first.read_for(Duration::from_millis(100)).await; + assert_eq!( + first.events_seen("freshAgent.session.snapshot"), + 0, + "another page's attach must not reset this page's live stream" + ); + + // Finish the held turn, then prove both observers receive the next + // stream, status, result and completion under the same durable UUID. + first.send(json!({ + "type":"freshAgent.interrupt", "provider":"claude", "sessionType":"freshclaude", "sessionId":session, + })).await; + first.wait_for_event("freshAgent.result").await; + first.read_for(Duration::from_millis(100)).await; + second.read_for(Duration::from_millis(100)).await; + first.frames.clear(); + second.frames.clear(); + first + .send_text(&session, "next", "both pages receive this turn") + .await; + for page in [&mut first, &mut second] { + page.wait_for_event("freshAgent.turn.complete").await; + for event in [ + "freshAgent.stream", + "freshAgent.status", + "freshAgent.result", + ] { + assert!( + page.frames.iter().any(|frame| { + frame["sessionId"] == session && frame["event"]["type"] == event + }), + "the observer missed {event}: {:?}", + page.frames + ); + } + assert_eq!(page.events_seen("freshAgent.turn.complete"), 1); + } + rig.fresh_claude.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_legacy_placeholder_attach_materializes_and_resumes_the_durable_uuid() { + let mut rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut first = rig.connect().await; + let session = first.create("create-legacy-observer").await; + rig.converse(&mut first, &session, &["before the restart"]) + .await; + rig.restart().await; + let mut legacy = rig.connect().await; + legacy.send(json!({ + "type": "freshAgent.attach", "provider":"claude", "sessionType":"freshclaude", + "sessionId":"legacy-placeholder", "sessionRef":{"provider":"claude", "sessionId":session}, + })).await; + legacy.wait_for_event("freshAgent.session.snapshot").await; + let materialized = legacy + .frames + .iter() + .position(|frame| { + frame["type"] == "freshAgent.session.materialized" + && frame["previousSessionId"] == "legacy-placeholder" + && frame["sessionId"] == session + }) + .expect("an open legacy pane must learn the canonical UUID"); + let ack = legacy + .frames + .iter() + .position(|frame| { + frame["sessionId"] == session && frame["event"]["type"] == "freshAgent.session.snapshot" + }) + .expect("the migrated pane must receive its attach acknowledgement"); + assert!(materialized < ack); + assert!(rig.fresh_claude.has_live_session(&session).await); + rig.converse(&mut legacy, &session, &["after legacy migration"]) + .await; + rig.fresh_claude.shutdown().await; +} From b294d086e34d79e489102eb4a3a662c6dd26918c Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:25:39 -0700 Subject: [PATCH 029/100] fix(agent-api): preserve pending pane publications across layout sync --- .../freshell-freshagent/src/layout_store.rs | 82 ++++++- .../src/layout_store/publication.rs | 107 +++++++++ crates/freshell-freshagent/src/lib.rs | 2 +- crates/freshell-freshagent/src/pane_ops.rs | 6 +- .../src/rest_lane_publication_tests.rs | 217 ++++++++++++++++++ crates/freshell-ws/src/terminal.rs | 10 +- .../tests/rest_freshclaude_lane.rs | 62 +++++ 7 files changed, 482 insertions(+), 4 deletions(-) create mode 100644 crates/freshell-freshagent/src/layout_store/publication.rs create mode 100644 crates/freshell-freshagent/src/rest_lane_publication_tests.rs diff --git a/crates/freshell-freshagent/src/layout_store.rs b/crates/freshell-freshagent/src/layout_store.rs index 8f55bd932..88fbd7fe0 100644 --- a/crates/freshell-freshagent/src/layout_store.rs +++ b/crates/freshell-freshagent/src/layout_store.rs @@ -32,6 +32,8 @@ use content::{migrate_legacy_fresh_agent_content, migrate_legacy_fresh_agent_nod mod persist; use persist::{load_persisted_clients, persist_locked}; +mod publication; + /// One ORDERED tab row (`UiSnapshot.tabs`, `layout-store.ts:7`). #[derive(Clone, Debug, Default)] pub struct TabRow { @@ -190,6 +192,8 @@ struct LayoutInner { /// In-memory only (a transient reconnect-reconciliation aid): the /// persist format serializes the client snapshots, not the stamps. recovery_stamps: std::collections::HashMap, + /// Ordered server creations not yet observed in a browser layout sync. + pending_publications: Vec, /// kata b8ke Task 10 (round-1 review, DURABILITY): `Some` → every /// mutation that changes the snapshot set rewrites the multi-client /// snapshot to this path (atomic temp+rename) and construction loads @@ -492,8 +496,10 @@ impl LayoutStore { seed_pane_title(&mut snapshot, &tab_id, &pane_id, &content); } } - let incoming_pane_ids = pane_ids_of(&snapshot); + let observed_pane_ids = pane_ids_of(&snapshot); let mut inner = self.lock(); + publication::preserve(&mut inner, &mut snapshot, &observed_pane_ids); + let incoming_pane_ids = pane_ids_of(&snapshot); // b8ke ext r25 F2: THE SERVER RECOVERY'S CONTENT AUTHORITY. A // pane mutated through an authoritative recovery path (an // `attach_pane_content` write-through — the REST/MCP respawn and @@ -519,6 +525,9 @@ impl LayoutStore { let mut stale_pane_writes: Vec = Vec::new(); let mut released_stamps: Vec = Vec::new(); for (pane_id, stamp) in inner.recovery_stamps.iter() { + if !observed_pane_ids.contains(pane_id) { + continue; + } let Some(incoming_content) = pane_content_in_snapshot(&snapshot, pane_id) else { // The pane is not in this sync — the stamp stays (a // later sync may still carry the stale copy). @@ -717,6 +726,14 @@ impl LayoutStore { /// `ensureSnapshot` + append an ordered tab with a single terminal leaf and /// a seeded title (`createTab`, `layout-store.ts:431-460`). pub fn create_tab(&self, title: Option<&str>) -> (String, String) { + self.create_tab_with_publication(title, false) + } + + pub(crate) fn create_pending_tab(&self, title: Option<&str>) -> (String, String) { + self.create_tab_with_publication(title, true) + } + + fn create_tab_with_publication(&self, title: Option<&str>, pending: bool) -> (String, String) { let tab_id = Uuid::new_v4().to_string(); let pane_id = Uuid::new_v4().to_string(); // `buildContent({})` (`layout-store.ts:317-325`): a detached terminal pane. @@ -754,6 +771,17 @@ impl LayoutStore { if let Some(tab) = snapshot.tabs.iter_mut().find(|t| t.id == tab_id) { tab.name_source = name_source; } + if pending { + inner + .pending_publications + .push(publication::PendingPublication { + tab_id: tab_id.clone(), + pane_id: pane_id.clone(), + placement: publication::Placement::Tab, + published: false, + create_request_id: None, + }); + } persist_locked(&inner); (tab_id, pane_id) } @@ -813,6 +841,9 @@ impl LayoutStore { /// the tab. pub fn close_tab(&self, tab_id: &str) -> RenameOutcome { let mut inner = self.lock(); + inner + .pending_publications + .retain(|entry| entry.tab_id != tab_id); if inner.clients.is_empty() { return RenameOutcome::failed("no layout snapshot"); } @@ -1053,6 +1084,23 @@ impl LayoutStore { &self, pane_id: &str, direction: &str, + ) -> Result<(String, String), &'static str> { + self.split_pane_with_publication(pane_id, direction, false) + } + + pub(crate) fn split_pending_pane( + &self, + pane_id: &str, + direction: &str, + ) -> Result<(String, String), &'static str> { + self.split_pane_with_publication(pane_id, direction, true) + } + + fn split_pane_with_publication( + &self, + pane_id: &str, + direction: &str, + pending: bool, ) -> Result<(String, String), &'static str> { let mut inner = self.lock(); let new_pane_id = Uuid::new_v4().to_string(); @@ -1098,6 +1146,21 @@ impl LayoutStore { } match first { Some(tab_id) => { + if pending { + inner + .pending_publications + .push(publication::PendingPublication { + tab_id: tab_id.clone(), + pane_id: new_pane_id.clone(), + placement: publication::Placement::Split { + source_pane_id: pane_id.to_string(), + split_node_id, + direction: direction.to_string(), + }, + published: false, + create_request_id: None, + }); + } persist_locked(&inner); Ok((tab_id, new_pane_id)) } @@ -1110,6 +1173,9 @@ impl LayoutStore { /// updates that arrived while creation awaited the provider. pub(crate) fn rollback_split(&self, pane_id: &str) -> bool { let mut inner = self.lock(); + inner + .pending_publications + .retain(|entry| entry.pane_id != pane_id); let mut removed = false; for snapshot in inner.snapshots_mut() { let tab_id = snapshot.layouts.iter_mut().find_map(|(tab_id, root)| { @@ -1257,6 +1323,17 @@ impl LayoutStore { found = true; } if found { + if let Some(pending) = inner + .pending_publications + .iter_mut() + .find(|entry| entry.pane_id == pane_id) + { + pending.published = true; + pending.create_request_id = normalized + .get("createRequestId") + .and_then(Value::as_str) + .map(str::to_string); + } // b8ke ext r25 F2: record the recovery stamp ONLY when the // write actually changed the pane's content — an idempotent // re-attach (the same content) stamps nothing. @@ -1311,6 +1388,9 @@ impl LayoutStore { // b8ke ext r25 F2: a closed pane's recovery stamp is dead // weight — the pane no longer exists to protect. inner.recovery_stamps.remove(pane_id); + inner + .pending_publications + .retain(|entry| entry.pane_id != pane_id); persist_locked(&inner); } first.unwrap_or(Err("pane not found")) diff --git a/crates/freshell-freshagent/src/layout_store/publication.rs b/crates/freshell-freshagent/src/layout_store/publication.rs new file mode 100644 index 000000000..19ac4c555 --- /dev/null +++ b/crates/freshell-freshagent/src/layout_store/publication.rs @@ -0,0 +1,107 @@ +//! Keep a server-created pane addressable until the browser observes its +//! announcement. Merge only that creation into an older browser layout. + +use super::*; + +pub(super) struct PendingPublication { + pub tab_id: String, + pub pane_id: String, + pub placement: Placement, + pub published: bool, + pub create_request_id: Option, +} + +pub(super) enum Placement { + Tab, + Split { + source_pane_id: String, + split_node_id: String, + direction: String, + }, +} + +pub(super) fn preserve( + inner: &mut LayoutInner, + incoming: &mut UiSnapshot, + observed_panes: &std::collections::HashSet, +) { + let mut pending = std::mem::take(&mut inner.pending_publications); + pending.retain(|entry| !(entry.published && observed_panes.contains(&entry.pane_id))); + for entry in &pending { + if pane_content_in_snapshot(incoming, &entry.pane_id).is_some() { + continue; + } + let Some(current) = inner + .snapshots() + .find(|snapshot| pane_content_in_snapshot(snapshot, &entry.pane_id).is_some()) + else { + continue; + }; + let Some(content) = pane_content_in_snapshot(current, &entry.pane_id) else { + continue; + }; + let leaf = PaneNode::Leaf { + id: entry.pane_id.clone(), + content: content.clone(), + }; + let merged = match &entry.placement { + Placement::Tab => { + if incoming.tabs.iter().any(|tab| tab.id == entry.tab_id) { + false + } else if let Some(tab) = current.tabs.iter().find(|tab| tab.id == entry.tab_id) { + incoming.tabs.push(tab.clone()); + incoming.layouts.insert(entry.tab_id.clone(), leaf); + incoming + .active_pane + .insert(entry.tab_id.clone(), entry.pane_id.clone()); + true + } else { + false + } + } + Placement::Split { + source_pane_id, + split_node_id, + direction, + } => { + let Some(root) = incoming.layouts.get_mut(&entry.tab_id) else { + continue; + }; + let Some(source) = root.find_leaf(source_pane_id).cloned() else { + continue; + }; + replace_node( + root, + source_pane_id, + &PaneNode::Split { + id: split_node_id.clone(), + direction: direction.clone(), + sizes: [50.0, 50.0], + children: Box::new([source, leaf]), + }, + ) + } + }; + if merged { + seed_pane_title(incoming, &entry.tab_id, &entry.pane_id, &content); + tracing::debug!( + event = "agent_api.layout_creation_preserved", + tab_id = %entry.tab_id, + pane_id = %entry.pane_id, + published = entry.published, + "preserved a server-created pane across a pre-announcement layout sync" + ); + } + } + inner.pending_publications = pending; +} + +impl LayoutStore { + /// `pane.closed` is acknowledged before the browser removes its leaf. + /// Release publication authority first so its next layout sync can close it. + pub fn release_pane_publication(&self, create_request_id: &str) { + self.lock() + .pending_publications + .retain(|entry| entry.create_request_id.as_deref() != Some(create_request_id)); + } +} diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index 66cf582cc..8030fc2b9 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -5077,7 +5077,7 @@ async fn create_lane_agent_tab( mut request: rest_lane::RestLaneCreate, name: Option, ) -> Response { - let (tab_id, pane_id) = state.layout.create_tab(name.as_deref()); + let (tab_id, pane_id) = state.layout.create_pending_tab(name.as_deref()); request.tab_id = Some(tab_id.clone()); request.pane_id = Some(pane_id.clone()); let created = match gateway.create_agent(request.clone()).await { diff --git a/crates/freshell-freshagent/src/pane_ops.rs b/crates/freshell-freshagent/src/pane_ops.rs index 625c9f6bb..4227e4021 100644 --- a/crates/freshell-freshagent/src/pane_ops.rs +++ b/crates/freshell-freshagent/src/pane_ops.rs @@ -175,7 +175,7 @@ pub(crate) async fn split_pane( .unwrap_or("horizontal") .to_string(); - let (tab_id, new_pane_id) = match state.layout.split_pane(&pane_id, &direction) { + let (tab_id, new_pane_id) = match state.layout.split_pending_pane(&pane_id, &direction) { Ok(split) => split, // Node: `if (!result?.tabId || !result?.newPaneId) res.json(approx( // result, 'pane split requested; not applied'))` (router.ts:1312-1314). @@ -1270,3 +1270,7 @@ mod store_tests; #[cfg(test)] #[path = "rest_lane_http_tests.rs"] mod rest_lane_http_tests; + +#[cfg(test)] +#[path = "rest_lane_publication_tests.rs"] +mod rest_lane_publication_tests; diff --git a/crates/freshell-freshagent/src/rest_lane_publication_tests.rs b/crates/freshell-freshagent/src/rest_lane_publication_tests.rs new file mode 100644 index 000000000..05682491e --- /dev/null +++ b/crates/freshell-freshagent/src/rest_lane_publication_tests.rs @@ -0,0 +1,217 @@ +use super::tests::{app, get, post, state_with_registry}; +use crate::rest_lane::*; +use axum::http::StatusCode; +use serde_json::json; +use std::sync::Arc; + +struct SyncDuringCreateLane { + layout: crate::layout_store::LayoutStore, + browser_sync: freshell_protocol::UiLayoutSync, +} + +#[async_trait::async_trait] +impl FreshAgentRestLane for SyncDuringCreateLane { + async fn create_agent( + self: Arc, + request: RestLaneCreate, + ) -> Result { + assert!(self + .layout + .get_pane_snapshot(request.pane_id.as_deref().unwrap()) + .is_some()); + // A browser can publish its already-scheduled layout before it has + // received the create announcement, while the runtime launch awaits. + self.layout + .update_from_ui(&self.browser_sync, "creating-browser"); + Ok(RestLaneCreated { + session_id: "created-during-sync".into(), + ..Default::default() + }) + } + async fn send_agent(&self, request: RestLaneSend) -> Result { + Ok(RestLaneSendResult { + session_id: request.session_id, + completed: true, + submitted_turn_id: Some("submitted-after-create".into()), + noop: false, + }) + } +} + +#[tokio::test] +async fn an_announced_rest_split_is_discoverable_after_a_browser_sync_during_runtime_create() { + let state = state_with_registry(); + let payload = json!({ + "tabs":[{"id":"current-tab"}], "activeTabId":"current-tab", "timestamp":1, + "layouts":{"current-tab":{"type":"split", "id":"existing-ancestor", "direction":"horizontal", "sizes":[40,60], "children":[ + {"type":"leaf", "id":"source-pane", "content":{"kind":"terminal", "mode":"shell"}}, + {"type":"leaf", "id":"sibling-pane", "content":{"kind":"browser", "url":"https://before.example"}}, + ]}}, "activePane":{"current-tab":"source-pane"}, + }); + let initial: freshell_protocol::UiLayoutSync = serde_json::from_value(payload.clone()).unwrap(); + state.layout.update_from_ui(&initial, "creating-browser"); + let mut during_create = payload; + during_create["layouts"]["current-tab"]["direction"] = json!("vertical"); + during_create["layouts"]["current-tab"]["children"][1]["content"]["url"] = + json!("https://changed-during-create.example"); + let lane = Arc::new(SyncDuringCreateLane { + layout: state.layout.clone(), + browser_sync: serde_json::from_value(during_create).unwrap(), + }); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let mut events = state.broadcast_tx.subscribe(); + let router = app(state.clone()); + let (status, created) = post( + router.clone(), + "/api/panes/source-pane/split", + json!({"agent":"kilroy"}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{created}"); + let pane = created["data"]["paneId"].as_str().unwrap(); + // An already-sent pre-announcement sync can arrive just after completion too. + state + .layout + .update_from_ui(&lane.browser_sync, "creating-browser"); + let (_, listed) = get(router.clone(), "/api/panes?tabId=current-tab", true).await; + assert!( + listed["data"]["panes"] + .as_array() + .unwrap() + .iter() + .any(|row| row["id"] == pane), + "an announced pane must be immediately discoverable: {listed}" + ); + let (_, snapshot) = get(router.clone(), "/api/layout/snapshot", true).await; + assert_eq!( + snapshot["data"]["layouts"]["current-tab"]["id"], + "existing-ancestor" + ); + assert_eq!( + snapshot["data"]["layouts"]["current-tab"]["direction"], + "vertical" + ); + assert_eq!( + state + .layout + .get_pane_snapshot("sibling-pane") + .unwrap() + .pane_content + .unwrap()["url"], + "https://changed-during-create.example" + ); + let (status, sent) = post( + router.clone(), + &format!("/api/panes/{pane}/send-keys"), + json!({"data":"an immediate external prompt"}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{sent}"); + assert_eq!(sent["data"]["sessionId"], "created-during-sync"); + assert!( + events.try_recv().is_ok(), + "the successful split is announced" + ); + let (status, closed) = post(router, &format!("/api/panes/{pane}/close"), json!({}), true).await; + assert_eq!(status, StatusCode::OK, "{closed}"); + state + .layout + .update_from_ui(&lane.browser_sync, "creating-browser"); + assert!( + state.layout.get_pane_snapshot(pane).is_none(), + "an explicit close releases publication authority" + ); +} + +#[tokio::test] +async fn an_announced_rest_tab_is_discoverable_after_stale_syncs_during_and_after_create() { + let state = state_with_registry(); + let browser_sync: freshell_protocol::UiLayoutSync = serde_json::from_value(json!({ + "tabs":[{"id":"current-tab"}], "activeTabId":"current-tab", "timestamp":1, + "layouts":{"current-tab":{"type":"leaf", "id":"source-pane", "content":{"kind":"terminal", "mode":"shell"}}}, + "activePane":{"current-tab":"source-pane"}, + })).unwrap(); + state + .layout + .update_from_ui(&browser_sync, "creating-browser"); + let lane = Arc::new(SyncDuringCreateLane { + layout: state.layout.clone(), + browser_sync, + }); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state.clone()); + let (status, created) = post( + router.clone(), + "/api/tabs", + json!({"agent":"claude", "name":"Background conversation"}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{created}"); + let tab = created["data"]["tabId"].as_str().unwrap(); + let pane = created["data"]["paneId"].as_str().unwrap(); + state + .layout + .update_from_ui(&lane.browser_sync, "creating-browser"); + let (_, listed) = get(router.clone(), "/api/tabs", true).await; + assert!( + listed["data"]["tabs"] + .as_array() + .unwrap() + .iter() + .any(|row| row["id"] == tab), + "the announced tab remains discoverable: {listed}" + ); + assert_eq!( + listed["data"]["activeTabId"], "current-tab", + "publication stays focus neutral" + ); + let (_, listed) = get(router.clone(), &format!("/api/panes?tabId={tab}"), true).await; + assert_eq!(listed["data"]["panes"][0]["id"], pane); + state.layout.close_tab(tab); + state + .layout + .update_from_ui(&lane.browser_sync, "creating-browser"); + assert!( + state.layout.get_pane_snapshot(pane).is_none(), + "a closed background tab must not reappear" + ); +} + +#[tokio::test] +async fn observing_the_created_leaf_releases_publication_authority_for_later_layout_edits() { + let state = state_with_registry(); + let original: freshell_protocol::UiLayoutSync = serde_json::from_value(json!({ + "tabs":[{"id":"current-tab"}], "activeTabId":"current-tab", "timestamp":1, + "layouts":{"current-tab":{"type":"leaf", "id":"source-pane", "content":{"kind":"terminal", "mode":"shell"}}}, + "activePane":{"current-tab":"source-pane"}, + })).unwrap(); + state.layout.update_from_ui(&original, "creating-browser"); + state + .set_claude_rest_lane(Arc::new(SyncDuringCreateLane { + layout: state.layout.clone(), + browser_sync: original.clone(), + })) + .unwrap(); + let (status, created) = post( + app(state.clone()), + "/api/tabs", + json!({"agent":"claude"}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{created}"); + let pane = created["data"]["paneId"].as_str().unwrap(); + let mut observed = state.layout.get_normalized_snapshot(None); + observed["timestamp"] = json!(2); + state.layout.update_from_ui( + &serde_json::from_value(observed).unwrap(), + "creating-browser", + ); + // Once the browser observed the leaf, subsequent layout edits are its + // authority again. The publication must not keep forcing the old tab back. + state.layout.update_from_ui(&original, "creating-browser"); + assert!(state.layout.get_pane_snapshot(pane).is_none()); +} diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index f81a612bc..a49eaf82c 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -9700,8 +9700,16 @@ async fn handle_pane_closed(closed: &PaneClosed, state: &WsState, ws_tx: &mut Ws }) }; match result { - Ok(()) => send(ws_tx, &answer(true, None)).await, + Ok(()) => { + state + .layout + .release_pane_publication(&closed.create_request_id); + send(ws_tx, &answer(true, None)).await + } Err(err) if err.is_persisted() => { + state + .layout + .release_pane_publication(&closed.create_request_id); // The record IS durable despite the reported error — the close // evidence stands, so the answer is success; loud, structured, // never silent. diff --git a/crates/freshell-ws/tests/rest_freshclaude_lane.rs b/crates/freshell-ws/tests/rest_freshclaude_lane.rs index 6f62f9afe..c46fe7082 100644 --- a/crates/freshell-ws/tests/rest_freshclaude_lane.rs +++ b/crates/freshell-ws/tests/rest_freshclaude_lane.rs @@ -346,6 +346,68 @@ async fn rest_recovery_does_not_reopen_an_explicitly_closed_session() { rig.fresh_claude.shutdown().await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_browser_close_before_its_debounce_releases_the_rest_split_publication() { + let rig = ClaudeRig::start_with_options(RigKind::Sim, options()).await; + let mut browser = rig.connect().await; + let original = create(&rig, "claude").await; + let source = original["paneId"].as_str().unwrap(); + let tab = original["tabId"].as_str().unwrap(); + let (_, snapshot) = get(&rig, "/api/layout/snapshot").await; + let snapshot: Value = serde_json::from_str(&snapshot).unwrap(); + let before_split = json!({ + "type":"ui.layout.sync", "tabs":snapshot["data"]["tabs"], "activeTabId":tab, + "layouts":snapshot["data"]["layouts"], "activePane":snapshot["data"]["activePane"], "timestamp":1, + }); + browser.send(before_split.clone()).await; + browser.send(json!({"type":"ping"})).await; + browser + .wait_for("initial layout sync", |frame| frame["type"] == "pong") + .await; + let (status, split) = rig + .post_json( + &format!("/api/panes/{source}/split"), + &json!({"agent":"kilroy"}), + ) + .await; + assert_eq!(status, 200, "{split}"); + let pane = split["data"]["paneId"].as_str().unwrap(); + let announced = browser + .wait_for("the unobserved split announcement", |frame| { + frame["command"] == "pane.split" + }) + .await; + let create_id = announced["payload"]["newContent"]["createRequestId"] + .as_str() + .unwrap(); + browser + .send(json!({"type":"pane.closed", "createRequestId":create_id})) + .await; + let closed = browser + .wait_for("the close acknowledgement", |frame| { + frame["type"] == "pane.closed.result" && frame["createRequestId"] == create_id + }) + .await; + assert_eq!(closed["success"], true, "{closed}"); + browser.frames.clear(); + browser.send(before_split).await; + browser.send(json!({"type":"ping"})).await; + browser + .wait_for("closed layout sync", |frame| frame["type"] == "pong") + .await; + let (status, listed) = get(&rig, &format!("/api/panes?tabId={tab}")).await; + assert_eq!(status, 200, "{listed}"); + let listed: Value = serde_json::from_str(&listed).unwrap(); + assert_eq!( + listed["data"]["panes"].as_array().unwrap().len(), + 1, + "{listed}" + ); + assert_eq!(listed["data"]["panes"][0]["id"], source); + assert_ne!(listed["data"]["panes"][0]["id"], pane); + rig.fresh_claude.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn rest_recovery_reports_unreadable_storage_as_retryable_and_can_retry_after_repair() { let rig = ClaudeRig::start_with_options(RigKind::Sim, options()).await; From 5676fd3fcbf0b976d9c2df102184b0eefbfdd423 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:39:50 -0700 Subject: [PATCH 030/100] feat(freshclaude): report turns interrupted by restart --- crates/freshell-freshagent/src/claude.rs | 24 +++ .../freshell-freshagent/src/claude/attach.rs | 20 ++- .../src/claude/restart_notice.rs | 93 ++++++++++ .../freshell-freshagent/src/identity_sink.rs | 63 +++++++ crates/freshell-freshagent/src/lib.rs | 2 +- crates/freshell-server/src/identity_sink.rs | 114 +++++++++++- .../src/recovery_inventory_tests.rs | 1 + crates/freshell-ws/src/pane_ledger.rs | 67 +++++++ crates/freshell-ws/src/pane_ledger_tests.rs | 38 ++++ .../freshell-ws/tests/common/ledger_sink.rs | 35 +++- .../tests/freshagent_claude_lifecycle.rs | 168 ++++++++++++++++++ 11 files changed, 619 insertions(+), 6 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude/restart_notice.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index dac240bac..dff18e017 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -1940,6 +1940,23 @@ impl FreshClaudeState { let mut guard = self.sessions.lock().await; guard.drain().map(|(_, s)| s).collect() }; + let interrupted_ids: Vec = drained + .iter() + .filter(|session| session.ledger.lock().expect("turn ledger lock").busy()) + .map(|session| { + session + .cli_session_id + .as_deref() + .unwrap_or(&session.sidecar_session_id) + .to_string() + }) + .collect(); + futures_util::future::join_all( + interrupted_ids + .iter() + .map(|session_id| self.mark_turn_interrupted_for_shutdown(session_id)), + ) + .await; let confirm_rounds = self.tree_confirm_rounds(); // One task per session: each teardown's synchronous `/proc` scans run // on the runtime's workers in parallel, so no session's shutdown @@ -2023,6 +2040,8 @@ impl FreshClaudeState { name_ref, session_name, })); + self.notify_turn_interrupted_after_ack(&cached.session_id, session_type) + .await; return ClaudeCreateOutcome::Created { session_id: cached.session_id, session_ref: cached.session_ref, @@ -2443,6 +2462,8 @@ impl FreshClaudeState { name_ref, session_name, })); + self.notify_turn_interrupted_after_ack(&launch_id, session_type) + .await; // C17: the server says the new session's status itself (no sidecar // status reaches a client any more). self.announce_claude_status(&created, session_type).await; @@ -2548,6 +2569,8 @@ impl FreshClaudeState { name_ref, session_name, })); + self.notify_turn_interrupted_after_ack(durable, session_type) + .await; self.announce_claude_status(durable, session_type).await; ClaudeCreateOutcome::Created { session_id: durable.to_string(), @@ -7972,6 +7995,7 @@ mod genesis; mod history; mod pending_runtime; mod rekey; +mod restart_notice; use pending_runtime::{is_pending_frame, PendingResponse}; mod rollback; mod sidecar_writer; diff --git a/crates/freshell-freshagent/src/claude/attach.rs b/crates/freshell-freshagent/src/claude/attach.rs index b0e55abde..94ef7e018 100644 --- a/crates/freshell-freshagent/src/claude/attach.rs +++ b/crates/freshell-freshagent/src/claude/attach.rs @@ -54,14 +54,24 @@ impl FreshClaudeState { msg.resume_session_id = None; match self.reply_live_attach(&msg, &previous, fence, &reply).await { - Ok(true) | Err(()) => return, + Ok(true) => { + self.notify_turn_interrupted_after_ack(&msg.session_id, session_type) + .await; + return; + } + Err(()) => return, Ok(false) => {} } // A competing attach may already be booting this conversation. // Wait for it, then inspect the runtime again before launching. let _flight = self.wait_resume_flight(&durable).await; match self.reply_live_attach(&msg, &previous, fence, &reply).await { - Ok(true) | Err(()) => return, + Ok(true) => { + self.notify_turn_interrupted_after_ack(&msg.session_id, session_type) + .await; + return; + } + Err(()) => return, Ok(false) => {} } match self.resume_untracked(&msg, &durable, fence, true).await { @@ -69,7 +79,11 @@ impl FreshClaudeState { // This operation advanced its own claim. The incoming // pre-launch fence no longer names its committed owner. match self.reply_live_attach(&msg, &previous, None, &reply).await { - Ok(true) | Err(()) => {} + Ok(true) => { + self.notify_turn_interrupted_after_ack(&msg.session_id, session_type) + .await; + } + Err(()) => {} Ok(false) => attach_error( &reply, &msg, diff --git a/crates/freshell-freshagent/src/claude/restart_notice.rs b/crates/freshell-freshagent/src/claude/restart_notice.rs new file mode 100644 index 000000000..a9cc1eec9 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/restart_notice.rs @@ -0,0 +1,93 @@ +//! Durable one-shot notice for a freshclaude turn stopped by server restart. + +use super::{FreshClaudeState, PROVIDER}; +use std::time::Duration; + +const SHUTDOWN_MARK_BUDGET: Duration = Duration::from_millis(500); +const RESTART_INTERRUPTED_CODE: &str = "TURN_INTERRUPTED_BY_RESTART"; +const RESTART_INTERRUPTED_MESSAGE: &str = + "The server restarted while this turn was running. The turn was stopped. Send a message to continue."; + +impl FreshClaudeState { + /// Persist an owed turn before its live sidecar is torn down. Shutdown + /// cannot wait indefinitely on disk I/O, so a slow or failed marker write + /// is logged and the server continues stopping. + pub(super) async fn mark_turn_interrupted_for_shutdown(&self, session_id: &str) { + let Some(sink) = self.identity_sink() else { + return; + }; + let at_ms = crate::rollback_record::now_ms(); + match tokio::time::timeout( + SHUTDOWN_MARK_BUDGET, + sink.mark_turn_interrupted(PROVIDER, session_id, at_ms), + ) + .await + { + Ok(Ok(true)) => tracing::info!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.restart_interrupt_persisted", + session_id, + at_ms, + "an owed turn was recorded for the next attach" + ), + Ok(Ok(false)) => tracing::warn!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.restart_interrupt_row_missing", + session_id, + "an owed turn had no bound fresh-agent row for its restart marker" + ), + Ok(Err(error)) => tracing::error!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.restart_interrupt_persist_failed", + session_id, + error = %error, + "the restart interruption marker could not be persisted" + ), + Err(_) => tracing::error!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.restart_interrupt_persist_timed_out", + session_id, + budget_ms = SHUTDOWN_MARK_BUDGET.as_millis() as u64, + "the restart interruption marker exceeded its shutdown budget" + ), + } + } + + /// Consume and report the marker only after the create/attach acknowledgement + /// has been sent. The ledger serializes the consume, so concurrent + /// acknowledgements can produce at most one notice. + pub(super) async fn notify_turn_interrupted_after_ack( + &self, + session_id: &str, + session_type: &str, + ) { + let Some(sink) = self.identity_sink() else { + return; + }; + match sink.take_turn_interrupted(PROVIDER, session_id).await { + Ok(Some(at_ms)) => { + self.emit_fresh_agent_error( + session_id, + session_type, + RESTART_INTERRUPTED_CODE, + RESTART_INTERRUPTED_MESSAGE, + ); + tracing::info!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.restart_interrupt_notified", + session_id, + interrupted_at_ms = at_ms, + "the interrupted-turn notice followed a successful acknowledgement" + ); + } + Ok(None) => {} + Err(error) => tracing::error!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.restart_interrupt_consume_failed", + session_id, + error = %error, + "the restart interruption marker was retained for a later acknowledgement" + ), + } + } +} diff --git a/crates/freshell-freshagent/src/identity_sink.rs b/crates/freshell-freshagent/src/identity_sink.rs index e694a7a26..96c694da8 100644 --- a/crates/freshell-freshagent/src/identity_sink.rs +++ b/crates/freshell-freshagent/src/identity_sink.rs @@ -317,6 +317,17 @@ impl FreshAgentBindingUpsert { pub type SinkWrite = std::pin::Pin> + Send + 'static>>; +/// The awaited shutdown-marker write. `false` means the durable binding row +/// was absent or no longer belonged to a live fresh-agent session. +pub type SinkMarkTurnInterruptedWrite = + std::pin::Pin> + Send + 'static>>; + +/// The awaited one-shot shutdown-marker consumption. The timestamp is +/// returned only after the cleared marker is durable. +pub type SinkTakeTurnInterruptedWrite = std::pin::Pin< + Box>> + Send + 'static>, +>; + /// Delta-r6-r4 (focused-episode-6 round 3, Finding 3): the close envelope's /// failure classes — this crate's mirror of /// `freshell_ws::pane_ledger::CloseEnvelopeError` (it cannot see the ledger; @@ -429,6 +440,23 @@ pub type SinkAliasClearWrite = std::pin::Pin< 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; + /// Persist a freshclaude shutdown interruption marker on an existing + /// bound fresh-agent row. Called only for owed live sessions and bounded + /// by the caller during server shutdown. + fn mark_turn_interrupted( + &self, + provider: &str, + session_id: &str, + at_ms: i64, + ) -> SinkMarkTurnInterruptedWrite; + /// Atomically clear and return a shutdown interruption marker. Call only + /// after the next successful create/attach acknowledgement; an error + /// leaves the durable marker available for a later retry. + fn take_turn_interrupted( + &self, + provider: &str, + session_id: &str, + ) -> SinkTakeTurnInterruptedWrite; 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. @@ -739,6 +767,8 @@ pub(crate) struct FakeIdentitySink { /// bindings never enter (and a blank rewrite removes the key, matching /// the ledger's full-snapshot replace). pub recorded: std::sync::Mutex>, + /// The optional persisted-restart marker, keyed like a binding row. + pub turn_interrupted: std::sync::Mutex>, /// Focused-ep1-r4 Finding 2: the row's CURRENT provenance stamps, /// atomically applied/preserved on every `record_binding` — the ledger /// merge's (`pane_ledger.rs`) in-memory twin, backing `load_provenance`. @@ -1587,6 +1617,39 @@ impl PaneIdentitySink for FakeIdentitySink { } self.write_result() } + fn mark_turn_interrupted( + &self, + provider: &str, + session_id: &str, + at_ms: i64, + ) -> SinkMarkTurnInterruptedWrite { + let result = if self.fail_writes.load(std::sync::atomic::Ordering::SeqCst) { + Err(std::io::Error::other("fake write failure")) + } else { + self.turn_interrupted + .lock() + .unwrap() + .insert((provider.into(), session_id.into()), at_ms); + Ok(true) + }; + Box::pin(std::future::ready(result)) + } + fn take_turn_interrupted( + &self, + provider: &str, + session_id: &str, + ) -> SinkTakeTurnInterruptedWrite { + let result = if self.fail_writes.load(std::sync::atomic::Ordering::SeqCst) { + Err(std::io::Error::other("fake write failure")) + } else { + Ok(self + .turn_interrupted + .lock() + .unwrap() + .remove(&(provider.into(), session_id.into()))) + }; + Box::pin(std::future::ready(result)) + } fn load_settings(&self, provider: &str, session_id: &str) -> Option { self.settings .lock() diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index a68ba1f09..e1c8b479f 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -113,7 +113,7 @@ pub use identity_sink::{ BindProvenance, ClaimCommit, ClaudeGenesis, CloseAnswer, FreshAgentBindingUpsert, FreshAgentSettings, GenesisUpdate, PaneIdentitySink, ProvenanceUpdate, RecordedGenesis, SharedPaneIdentitySink, SinkAliasClearWrite, SinkCloseError, SinkCloseWrite, SinkCommitWrite, - SinkWrite, + SinkMarkTurnInterruptedWrite, SinkTakeTurnInterruptedWrite, SinkWrite, }; pub use opencode_ws::FreshOpencodeState; pub use rollback_record::{ diff --git a/crates/freshell-server/src/identity_sink.rs b/crates/freshell-server/src/identity_sink.rs index 759b09b28..6bbd19ba5 100644 --- a/crates/freshell-server/src/identity_sink.rs +++ b/crates/freshell-server/src/identity_sink.rs @@ -5,7 +5,7 @@ use freshell_freshagent::{ BindProvenance, ClaimCommit, FreshAgentBindingUpsert, FreshAgentSettings, PaneIdentitySink, RecordedGenesis, RollbackRecord, SinkAliasClearWrite, SinkCloseError, SinkCloseWrite, - SinkCommitWrite, SinkWrite, + SinkCommitWrite, SinkMarkTurnInterruptedWrite, SinkTakeTurnInterruptedWrite, SinkWrite, }; use freshell_ws::pane_ledger::{CloseEnvelopeError, FreshAgentBindingWrite, PaneLedger}; use std::sync::Arc; @@ -409,6 +409,39 @@ impl PaneIdentitySink for LedgerIdentitySink { }) } + fn mark_turn_interrupted( + &self, + provider: &str, + session_id: &str, + at_ms: i64, + ) -> SinkMarkTurnInterruptedWrite { + let ledger = self.ledger.clone(); + let (provider, session_id) = (provider.to_string(), session_id.to_string()); + Box::pin(async move { + tokio::task::spawn_blocking(move || { + ledger.mark_fresh_agent_turn_interrupted(&provider, &session_id, at_ms) + }) + .await + .map_err(std::io::Error::other)? + }) + } + + fn take_turn_interrupted( + &self, + provider: &str, + session_id: &str, + ) -> SinkTakeTurnInterruptedWrite { + let ledger = self.ledger.clone(); + let (provider, session_id) = (provider.to_string(), session_id.to_string()); + Box::pin(async move { + tokio::task::spawn_blocking(move || { + ledger.take_fresh_agent_turn_interrupted(&provider, &session_id) + }) + .await + .map_err(std::io::Error::other)? + }) + } + fn load_settings(&self, provider: &str, session_id: &str) -> Option { // Reads are memory-only against the write-through index — safe inline. let row = self.ledger.load_binding(provider, session_id)?; @@ -835,6 +868,85 @@ mod tests { assert_eq!(row.pane_kind.as_deref(), Some("fresh-agent")); } + #[tokio::test(flavor = "multi_thread")] + async fn fresh_agent_restart_marker_survives_reload_and_is_consumed_once() { + 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.clone(), None); + assert!(!sink + .mark_turn_interrupted("claude", "missing", 1_000) + .await + .expect("marking a missing row is a no-op")); + sink.record_binding(FreshAgentBindingUpsert { + genesis: freshell_freshagent::GenesisUpdate::Keep, + name_transition: None, + provider: "claude".into(), + session_id: "session-restart".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::default(), + }) + .await + .expect("binding write succeeds"); + let updated_at = ledger + .load_binding("claude", "session-restart") + .expect("binding exists") + .updated_at; + assert!(sink + .mark_turn_interrupted("claude", "session-restart", 2_000) + .await + .expect("marker write succeeds")); + + let restarted = LedgerIdentitySink::new( + Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + tmp.path().to_path_buf(), + ))), + None, + ); + let marked = restarted + .ledger + .load_binding("claude", "session-restart") + .expect("binding survives reload"); + assert_eq!(marked.turn_interrupted_at, Some(2_000)); + assert_eq!(marked.updated_at, updated_at); + assert_eq!( + restarted + .take_turn_interrupted("claude", "session-restart") + .await + .expect("consume succeeds"), + Some(2_000) + ); + assert_eq!( + restarted + .take_turn_interrupted("claude", "session-restart") + .await + .expect("second consume succeeds"), + None + ); + let after_consume = LedgerIdentitySink::new( + Arc::new(freshell_ws::pane_ledger::PaneLedger::new(Some( + tmp.path().to_path_buf(), + ))), + None, + ); + assert_eq!( + after_consume + .ledger + .load_binding("claude", "session-restart") + .expect("binding remains") + .turn_interrupted_at, + None + ); + } + /// 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. diff --git a/crates/freshell-server/src/recovery_inventory_tests.rs b/crates/freshell-server/src/recovery_inventory_tests.rs index deefc88ad..3ef8b1f41 100644 --- a/crates/freshell-server/src/recovery_inventory_tests.rs +++ b/crates/freshell-server/src/recovery_inventory_tests.rs @@ -135,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, + turn_interrupted_at: None, plugins: None, } } diff --git a/crates/freshell-ws/src/pane_ledger.rs b/crates/freshell-ws/src/pane_ledger.rs index 4a8f66f34..63ab8e401 100644 --- a/crates/freshell-ws/src/pane_ledger.rs +++ b/crates/freshell-ws/src/pane_ledger.rs @@ -947,6 +947,12 @@ pub struct BindingRow { /// excludes such rows exactly like unattributed ones. #[serde(default, skip_serializing_if = "Option::is_none")] pub last_attributed_at: Option, + /// A freshclaude turn that was still owed when the server shut down. + /// The next successful create/attach consumes this marker and reports the + /// interruption once. Optional under LEDGER_VERSION 1 so older rows keep + /// loading unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_interrupted_at: Option, } /// Evidence that identity establishment was in flight (G1: never a binding). @@ -2296,6 +2302,7 @@ impl PaneLedger { device_id, tab_key, last_attributed_at, + turn_interrupted_at: None, }; self.write_binding(root, index, &row)?; // new bound row FIRST (pinned) @@ -2822,6 +2829,9 @@ impl PaneLedger { permission_mode: w.permission_mode.map(str::to_string), effort: w.effort.map(str::to_string), plugins: w.plugins.map(<[String]>::to_vec), + // A rebind during recovery must not erase a shutdown marker + // before a create or attach can acknowledge the interruption. + turn_interrupted_at: existing.and_then(|row| row.turn_interrupted_at), // 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 @@ -4637,6 +4647,63 @@ impl PaneLedger { Ok(true) } + /// Record that a live fresh-agent turn was interrupted by server shutdown. + /// This updates only the optional marker on an existing bound fresh-agent + /// row; it never creates or revives an identity row. `Ok(false)` means the + /// row was unavailable or no longer bound. + pub fn mark_fresh_agent_turn_interrupted( + &self, + provider: &str, + session_id: &str, + at_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(mut row) = index.bindings.get(&key).cloned() else { + return Ok(false); + }; + if row.pane_kind.as_deref() != Some("fresh-agent") || row.state != RowState::Bound { + return Ok(false); + } + if row.turn_interrupted_at == Some(at_ms) { + return Ok(true); + } + row.turn_interrupted_at = Some(at_ms); + self.write_binding(root, &mut index, &row)?; + Ok(true) + } + + /// Atomically read and clear the restart marker on an existing bound + /// fresh-agent row. The marker is returned only after the cleared row is + /// durably written, so callers can emit one notice after their create or + /// attach acknowledgement without racing another acknowledgement. + pub fn take_fresh_agent_turn_interrupted( + &self, + provider: &str, + session_id: &str, + ) -> std::io::Result> { + let Some(root) = &self.root else { + return Ok(None); + }; + let mut index = self.guard(); + let key = (provider.to_string(), session_id.to_string()); + let Some(mut row) = index.bindings.get(&key).cloned() else { + return Ok(None); + }; + if row.pane_kind.as_deref() != Some("fresh-agent") || row.state != RowState::Bound { + return Ok(None); + } + let Some(at_ms) = row.turn_interrupted_at else { + return Ok(None); + }; + row.turn_interrupted_at = None; + self.write_binding(root, &mut index, &row)?; + Ok(Some(at_ms)) + } + /// Fresh-agent Task 3 (`was_recorded` rekeying): true iff a FRESH-AGENT /// binding row carrying a SETTINGS-BEARING snapshot exists for this key — /// at least one of model/sandbox/permission_mode/effort/cwd/plugins set (the exact diff --git a/crates/freshell-ws/src/pane_ledger_tests.rs b/crates/freshell-ws/src/pane_ledger_tests.rs index e3fbc4ef2..4db3edc69 100644 --- a/crates/freshell-ws/src/pane_ledger_tests.rs +++ b/crates/freshell-ws/src/pane_ledger_tests.rs @@ -4826,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, + turn_interrupted_at: None, plugins: None, }; write_row_atomic( @@ -5220,6 +5221,42 @@ fn old_terminal_rows_without_settings_fields_still_deserialize() { let row: BindingRow = serde_json::from_str(json).expect("old row must parse"); assert_eq!(row.pane_kind, None); assert_eq!(row.model, None); + assert_eq!(row.turn_interrupted_at, None); +} + +#[test] +fn fresh_agent_restart_marker_survives_reload_and_is_consumed_atomically() { + let root = temp_root("turn-interrupted"); + let ledger = PaneLedger::new(Some(root.clone())); + ledger + .record_fresh_agent_binding(&fa_write("claude", "session-restart", 1_000)) + .expect("fresh-agent binding"); + assert!(ledger + .mark_fresh_agent_turn_interrupted("claude", "session-restart", 2_000) + .expect("mark turn interrupted")); + + let reloaded = PaneLedger::new(Some(root.clone())); + let marked = reloaded + .load_binding("claude", "session-restart") + .expect("binding after reload"); + assert_eq!(marked.turn_interrupted_at, Some(2_000)); + assert_eq!( + marked.updated_at, 1_000, + "the marker is not an identity rebind" + ); + assert_eq!( + reloaded + .take_fresh_agent_turn_interrupted("claude", "session-restart") + .expect("consume turn marker"), + Some(2_000) + ); + + let consumed = PaneLedger::new(Some(root.clone())) + .load_binding("claude", "session-restart") + .expect("binding after marker consumption reload"); + assert_eq!(consumed.turn_interrupted_at, None); + assert_eq!(consumed.updated_at, 1_000); + std::fs::remove_dir_all(&root).ok(); } #[test] @@ -8485,6 +8522,7 @@ fn close_record_covers_row_is_the_shared_recovery_predicate() { device_id: None, tab_key: None, last_attributed_at: None, + turn_interrupted_at: None, plugins: None, } } diff --git a/crates/freshell-ws/tests/common/ledger_sink.rs b/crates/freshell-ws/tests/common/ledger_sink.rs index 9c411434f..68cb826ac 100644 --- a/crates/freshell-ws/tests/common/ledger_sink.rs +++ b/crates/freshell-ws/tests/common/ledger_sink.rs @@ -16,7 +16,7 @@ use freshell_freshagent::identity_sink::SinkCloseError; use freshell_freshagent::{ BindProvenance, ClaimCommit, FreshAgentBindingUpsert, FreshAgentSettings, PaneIdentitySink, ProvenanceUpdate, RollbackRecord, SinkAliasClearWrite, SinkCloseWrite, SinkCommitWrite, - SinkWrite, + SinkMarkTurnInterruptedWrite, SinkTakeTurnInterruptedWrite, SinkWrite, }; use freshell_ws::pane_ledger::{ ClaimCommitOutcome, CloseEnvelopeError, FreshAgentBindingWrite, PaneLedger, ProvenancePolicy, @@ -202,6 +202,39 @@ impl PaneIdentitySink for LedgerSink { }) } + fn mark_turn_interrupted( + &self, + provider: &str, + session_id: &str, + at_ms: i64, + ) -> SinkMarkTurnInterruptedWrite { + let ledger = self.ledger.clone(); + let (provider, session_id) = (provider.to_string(), session_id.to_string()); + Box::pin(async move { + tokio::task::spawn_blocking(move || { + ledger.mark_fresh_agent_turn_interrupted(&provider, &session_id, at_ms) + }) + .await + .map_err(std::io::Error::other)? + }) + } + + fn take_turn_interrupted( + &self, + provider: &str, + session_id: &str, + ) -> SinkTakeTurnInterruptedWrite { + let ledger = self.ledger.clone(); + let (provider, session_id) = (provider.to_string(), session_id.to_string()); + Box::pin(async move { + tokio::task::spawn_blocking(move || { + ledger.take_fresh_agent_turn_interrupted(&provider, &session_id) + }) + .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)?; if row.pane_kind.as_deref() != Some("fresh-agent") { diff --git a/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs b/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs index f9c9f4231..203aed15c 100644 --- a/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs +++ b/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs @@ -536,6 +536,174 @@ async fn a_resume_create_answers_created_with_its_session_ref_once_the_cli_boote assert!(rig.ledger_rows("prompt").len() == 1, "no prompt was needed"); } +/// A restart records an owed turn in the persisted binding row. The first +/// successful attach after the ledger is reloaded reports that interruption +/// once, after its snapshot acknowledgement, and does not manufacture an +/// attention edge. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_owed_turn_is_reported_once_after_attach_across_a_server_restart() { + let program = + json!({ "rules": [{ "on": "msg:prompt", "emit": [{ "kind": "hang" }] }] }).to_string(); + let mut rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + env: vec![("FRESHELL_CLAUDE_SIM_PROGRAM".into(), program)], + ..RigOptions::default() + }, + ) + .await; + let mut first = rig.connect().await; + let session_id = first.create("create-restart-busy").await; + first + .send_text( + &session_id, + "send-restart-busy", + "a turn interrupted by restart", + ) + .await; + first.wait_for_event("freshAgent.session.init").await; + + let row_before_restart = rig + .pane_ledger + .as_ref() + .expect("durable ledger enabled") + .load_binding("claude", &session_id) + .expect("fresh-agent binding exists before restart"); + assert!( + serde_json::to_value(&row_before_restart) + .expect("binding serializes") + .get("turnInterruptedAt") + .is_none(), + "a live turn is not marked interrupted before shutdown" + ); + + rig.restart().await; + + let row_after_reload = rig + .pane_ledger + .as_ref() + .expect("restarted rig reloads its durable ledger") + .load_binding("claude", &session_id) + .expect("fresh-agent binding survives restart"); + assert!( + serde_json::to_value(&row_after_reload) + .expect("binding serializes") + .get("turnInterruptedAt") + .is_some_and(|value| value.as_i64().is_some()), + "shutdown persisted the owed turn marker before tearing down the sidecar" + ); + + let mut after = rig.connect().await; + after.attach(&session_id).await; + let notice = after + .wait_for("restart interruption notice", |frame| { + frame["type"] == "freshAgent.event" + && frame["event"]["type"] == "freshAgent.error" + && frame["event"]["code"] == "TURN_INTERRUPTED_BY_RESTART" + }) + .await; + assert_eq!(notice["sessionId"], session_id, "{notice}"); + assert_eq!(notice["event"]["sessionId"], session_id, "{notice}"); + let acknowledgement_at = after + .frames + .iter() + .position(|frame| { + frame["type"] == "freshAgent.event" + && frame["event"]["type"] == "freshAgent.session.snapshot" + && frame["event"]["sessionId"] == session_id + }) + .expect("the attach was acknowledged"); + let notice_at = after + .frames + .iter() + .position(|frame| frame["event"]["code"] == "TURN_INTERRUPTED_BY_RESTART") + .expect("the restart notice was delivered"); + assert!( + acknowledgement_at < notice_at, + "the interruption notice follows the attach acknowledgement: {:?}", + after.frames + ); + assert_eq!(after.events_seen("freshAgent.turn.complete"), 0); + assert_eq!(after.events_seen("freshAgent.turn.waiting"), 0); + + let cleared = rig + .pane_ledger + .as_ref() + .expect("durable ledger remains available") + .load_binding("claude", &session_id) + .expect("binding remains after resume"); + assert!( + serde_json::to_value(&cleared) + .expect("binding serializes") + .get("turnInterruptedAt") + .is_none(), + "delivering the restart notice consumes its persisted marker" + ); + + after.attach(&session_id).await; + after.read_for(QUIET).await; + assert_eq!( + after + .frames + .iter() + .filter(|frame| frame["event"]["code"] == "TURN_INTERRUPTED_BY_RESTART") + .count(), + 1, + "a second attach does not repeat the consumed notice: {:?}", + after.frames + ); +} + +/// An idle runtime has no owed work at shutdown, so a later attach does not +/// report an interruption. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_idle_runtime_does_not_receive_a_restart_interruption_notice() { + let mut rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut first = rig.connect().await; + let session_id = first.create("create-restart-idle").await; + + rig.restart().await; + let row_after_reload = rig + .pane_ledger + .as_ref() + .expect("durable ledger enabled") + .load_binding("claude", &session_id) + .expect("fresh-agent binding survives restart"); + assert!( + serde_json::to_value(&row_after_reload) + .expect("binding serializes") + .get("turnInterruptedAt") + .is_none(), + "shutdown does not mark an idle session" + ); + + let mut after = rig.connect().await; + after.attach(&session_id).await; + after.read_for(QUIET).await; + assert_eq!( + after + .frames + .iter() + .filter(|frame| frame["type"] == "freshAgent.event" + && frame["event"]["type"] == "freshAgent.error" + && frame["event"]["code"] == "TURN_INTERRUPTED_BY_RESTART") + .count(), + 0, + "an idle session does not get an interruption notice: {:?}", + after.frames + ); + assert_eq!(after.events_seen("freshAgent.turn.complete"), 0); + assert_eq!(after.events_seen("freshAgent.turn.waiting"), 0); +} + // ── Registration: a session is in the map before its consumer reads a line ──────── /// How long the server is made to take between starting a session's consumer and From 534e173f843231a2f5f9e2ce4cae301109308449 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:42:11 -0700 Subject: [PATCH 031/100] fix(freshclaude): retain native name on create replay --- crates/freshell-freshagent/src/claude.rs | 9 +++++++++ .../src/claude/tests/create_binding_tests.rs | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index fc14f45ee..ea4df30cb 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -1891,6 +1891,15 @@ impl FreshClaudeState { freshell_protocol::session_names::SessionNameRef, freshell_protocol::session_names::SessionNameRecord, )> { + if let Some(projection) = crate::naming::session_projection( + &self.naming(), + freshell_protocol::session_names::NamedProvider::Claude, + session_id, + ) + .await + { + return Some(projection); + } let handle = self.naming_handles.lock().await.get(session_id).cloned()?; let sink = self.naming(); let target = freshell_protocol::session_names::SessionNameRef::Pending { id: handle }; diff --git a/crates/freshell-freshagent/src/claude/tests/create_binding_tests.rs b/crates/freshell-freshagent/src/claude/tests/create_binding_tests.rs index 3e92d56a7..3e1006f0c 100644 --- a/crates/freshell-freshagent/src/claude/tests/create_binding_tests.rs +++ b/crates/freshell-freshagent/src/claude/tests/create_binding_tests.rs @@ -61,6 +61,15 @@ async fn create_binding_and_name_are_durable_before_live_and_created() { } ); assert!(state.naming_handles.lock().await.get(&id).is_none()); + let mut replay = dedup_create_msg("create-bound-before-live"); + replay.naming_handle = Some("pre-create-name".into()); + state.handle_create(replay, None).await; + let replayed = await_claude_created(&mut rx, "create-bound-before-live").await; + assert_eq!( + replayed["nameRef"], created["nameRef"], + "a replay adopts the durable name identity" + ); + assert_eq!(replayed["sessionName"], created["sessionName"]); assert!(identity .geneses .lock() From 99719fb9e9832b0d885e6361a4c1c34a2e924425 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:45:11 -0700 Subject: [PATCH 032/100] test(e2e): run Claude browser scenarios through production sidecar --- .../fixtures/fake-claude-sidecar.mjs | 174 ----- .../providers/fake-claude-sdk-sidecar.mjs | 546 ---------------- .../fixtures/providers/fixture-core.mjs | 9 +- test/e2e-browser/helpers/claude-sim-lane.ts | 49 +- .../fake-claude-sdk-sidecar-control.test.ts | 609 ------------------ .../helpers/unified-agent-names.ts | 8 +- .../specs/fresh-agent-control-rust.spec.ts | 520 ++++++--------- .../fresh-agent-error-turn-attention.spec.ts | 127 +--- ...fresh-agent-hidden-approval-reveal.spec.ts | 77 +-- .../fresh-agent-mobile-attention.spec.ts | 96 +-- .../freshagent-default-model-rust.spec.ts | 76 +-- ...hagent-live-model-convergence-rust.spec.ts | 28 +- .../freshagent-settings-resume-rust.spec.ts | 46 +- .../specs/freshclaude-first-send-rust.spec.ts | 70 +- ...shclaude-identity-persistence-rust.spec.ts | 129 +++- .../freshclaude-restart-parity-rust.spec.ts | 63 +- ...freshclaude-zero-turn-restart-rust.spec.ts | 58 +- .../harness-03-provider-fixtures.spec.ts | 182 +----- .../specs/hidden-pane-rebind-rust.spec.ts | 40 +- .../e2e-browser/specs/mcp-bridge-rust.spec.ts | 22 +- .../specs/reconcile-completion-rust.spec.ts | 65 +- .../specs/reconnect-revive-rust.spec.ts | 58 +- .../specs/recover-my-panes-rust.spec.ts | 14 +- .../specs/restore-contract-wall-rust.spec.ts | 20 +- .../specs/wavea-interactions-rust.spec.ts | 26 +- 25 files changed, 634 insertions(+), 2478 deletions(-) delete mode 100644 test/e2e-browser/fixtures/fake-claude-sidecar.mjs delete mode 100755 test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs delete mode 100644 test/e2e-browser/helpers/fake-claude-sdk-sidecar-control.test.ts diff --git a/test/e2e-browser/fixtures/fake-claude-sidecar.mjs b/test/e2e-browser/fixtures/fake-claude-sidecar.mjs deleted file mode 100644 index a890dd3c6..000000000 --- a/test/e2e-browser/fixtures/fake-claude-sidecar.mjs +++ /dev/null @@ -1,174 +0,0 @@ -#!/usr/bin/env node -// Fake Claude SDK-bridge sidecar for e2e (freshclaude). Enabled via the -// production env seam: FRESHELL_CLAUDE_SIDECAR=. Speaks the -// newline-JSON stdio protocol from crates/freshell-freshagent/src/claude.rs: -// in : {"type":"create",protocol:2,requestId,cwd,model,permissionMode,effort, -// boot:{kind:'fresh'}|{kind:'resume',resume}} (protocol v2, crates/freshell-claude-sidecar/boot.mjs) -// {"type":"send",sessionId,text} {"type":"interrupt",sessionId} {"type":"shutdown"} -// out: {"type":"created","sessionId",protocol:2} FIRST (any earlier sdk.* line is -// DISCARDED by read_created, claude_handshake.rs; 45s budget claude.rs), -// then sdk.* event lines (renamed sdk.X -> freshAgent.X server-side). -// FIELD shapes come from the REAL sidecar (crates/freshell-claude-sidecar/ -// index.mjs:15-30) + the client consumer (src/lib/fresh-agent-ws.ts:195-284): -// - sdk.assistant content MUST be an ARRAY of blocks (fresh-agent-ws.ts:260-265); -// - no attention edge: the server rings each turn from its sdk.result (C18); -// - sdk.session.init cliSessionId MUST be a canonical UUID -// (shared/session-contract.ts:34) or no durable sessionRef ever lands. -// The process MUST stay alive (no EOF) until shutdown/kill -- an early exit -// stops the server's consumer. -// FAKE_CLAUDE_SIDECAR_HOLD_TURN=1 -> a send starts running and never -// completes (busy-restart wedge scenario). -// NEW knobs (Task 7): -// - FAKE_CLAUDE_SIDECAR_LOG= -> append request log (JSONL: {pid, t, msg}) -// - FAKE_CLAUDE_SIDECAR_HOLD_TURN_ONCE_MARKER= -> first send wedges, rest work -// - a resume boot on create -> cliSessionId uses that (resume continuity) -// - transcripts: create ensures, each send appends to /projects/-fixture/.jsonl -// - FAKE_CLAUDE_SIDECAR_INIT_AT=turn -> report the session (sdk.session.init) -// at the start of every turn and never at create, the REAL CLI's order: it -// emits system/init only once a prompt runs, so a new pane has no durable id -// (and the server no owner record for it) until its first message runs. -// Default (unset or 'create'): report once, right after `created`. -import readline from 'node:readline' -import fs from 'node:fs' -import path from 'node:path' -import os from 'node:os' -import { randomUUID } from 'node:crypto' - -const HOLD_TURN = process.env.FAKE_CLAUDE_SIDECAR_HOLD_TURN === '1' -const HOLD_ONCE_MARKER = process.env.FAKE_CLAUDE_SIDECAR_HOLD_TURN_ONCE_MARKER -// RANDOM canonical UUID per sidecar PROCESS (council follow-up, PR #562/#563 -// close-out): the old static 44444444-... default made every resume-less -// create in every sidecar process mint the SAME id, so a regression that -// silently lost the durable identity across a restart would be re-stamped -// with the identical constant and collide onto the same transcript file -- -// structurally collision-blind. With a per-process random default, any -// identity-losing bare create after a restart (new sidecar process) mints a -// DIFFERENT id and the identity assertions in the specs go red. The env -// override remains for specs that need a caller-chosen id. -const CLI_SESSION_ID = process.env.FAKE_CLAUDE_SIDECAR_CLI_SESSION_ID ?? randomUUID() -const REQUEST_LOG = process.env.FAKE_CLAUDE_SIDECAR_LOG -const INIT_AT_TURN = process.env.FAKE_CLAUDE_SIDECAR_INIT_AT === 'turn' - -// sessionId (bridge nanoid) -> { cliSessionId (durable uuid), cwd } -const sessions = new Map() - -function emit(obj) { - process.stdout.write(`${JSON.stringify(obj)}\n`) -} - -function logRequest(msg) { - if (!REQUEST_LOG) return - fs.mkdirSync(path.dirname(REQUEST_LOG), { recursive: true }) - fs.appendFileSync(REQUEST_LOG, `${JSON.stringify({ pid: process.pid, t: Date.now(), msg })}\n`) -} - -function claudeHome() { - // Mirror the REAL CLI's resolution (CLAUDE_CONFIG_DIR first -- ledger A3), then - // the freshell-legacy CLAUDE_HOME the harness sets, then ~/.claude -- the same - // candidate order as the Rust claude_home_candidates(). - return ( - process.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_HOME || path.join(os.homedir(), '.claude') - ) -} - -function transcriptPath(cliSessionId) { - const dir = path.join(claudeHome(), 'projects', '-fixture') - fs.mkdirSync(dir, { recursive: true }) - return path.join(dir, `${cliSessionId}.jsonl`) -} - -// Claude-code transcript line shape (what the Rust snapshot adapter parses). -// `cwd` is load-bearing: the attach arm resumes with the transcript's ORIGINAL -// cwd (ledger A15 -- real lines carry cwd on 100% of user/assistant lines). -// A user line takes the send's own uuid when the server gave one, as the real -// CLI writes the prompt under its SDKUserMessage's uuid (the server's -// submittedTurnId). -function appendTranscript(cliSessionId, role, text, cwd, uuid) { - const line = { - type: role, - ...(typeof uuid === 'string' && uuid ? { uuid } : {}), - timestamp: new Date().toISOString(), - cwd: cwd ?? process.cwd(), - message: { role, content: [{ type: 'text', text }] }, - } - fs.appendFileSync(transcriptPath(cliSessionId), `${JSON.stringify(line)}\n`) -} - -function emitSessionInit(sessionId, { cliSessionId, cwd, model }) { - emit({ - type: 'sdk.session.init', - sessionId, - cliSessionId, - model, - cwd, - tools: [], - }) -} - -const rl = readline.createInterface({ input: process.stdin }) -rl.on('line', (line) => { - let msg - try { - msg = JSON.parse(line) - } catch { - return - } - logRequest(msg) - if (msg.type === 'create') { - const sessionId = msg.sessionId ?? `fc-e2e-${process.pid}-${Date.now()}` - // Resume continuity: a resumed session keeps its durable id (what the real - // CLI's transcript filename stem provides across restarts). - const resumeSessionId = msg.boot?.resume - const cliSessionId = resumeSessionId ?? msg.sessionId ?? CLI_SESSION_ID - const cwd = msg.cwd ?? process.cwd() - sessions.set(sessionId, { cliSessionId, cwd, model: msg.model ?? 'claude-opus-4-6', effort: msg.effort, permissionMode: msg.permissionMode }) - // Ensure the transcript file EXISTS from create (the attach arm's - // transcript-present gate reads it before any send happens post-restart). - fs.closeSync(fs.openSync(transcriptPath(cliSessionId), 'a')) - emit({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) - if (!INIT_AT_TURN) { - emitSessionInit(sessionId, { cliSessionId, cwd, model: msg.model ?? 'claude-opus-4-6' }) - } - if (resumeSessionId) { - emit({ type: 'sdk.session.snapshot', sessionId, messages: [] }) - } - emit({ type: 'sdk.status', sessionId, status: 'idle' }) - } else if (msg.type === 'configure') { - const session = sessions.get(msg.sessionId) - if (!session) { - emit({ type: 'sdk.configured', sessionId: msg.sessionId, requestId: msg.requestId, ok: false, message: 'Session not found' }) - return - } - for (const key of ['model', 'effort', 'permissionMode', 'cwd']) { - if (msg.settings?.[key] != null || (key === 'effort' && msg.settings?.effort === null)) session[key] = msg.settings[key] - } - emit({ type: 'sdk.configured', sessionId: msg.sessionId, requestId: msg.requestId, ok: true, settings: session }) - } else if (msg.type === 'send') { - const { cliSessionId, cwd, model, effort, permissionMode } = sessions.get(msg.sessionId) ?? { cliSessionId: CLI_SESSION_ID } - emit({ type: 'sdk.status', sessionId: msg.sessionId, status: 'running' }) - if (INIT_AT_TURN) emitSessionInit(msg.sessionId, { cliSessionId, cwd, model }) - appendTranscript(cliSessionId, 'user', msg.text, cwd, msg.uuid) - const holdOnce = HOLD_ONCE_MARKER && !fs.existsSync(HOLD_ONCE_MARKER) - if (holdOnce) { - fs.mkdirSync(path.dirname(HOLD_ONCE_MARKER), { recursive: true }) - fs.writeFileSync(HOLD_ONCE_MARKER, '1') - return // wedged: running forever (busy-restart scenario) - } - if (HOLD_TURN) return - const text = process.env.FAKE_CLAUDE_SIDECAR_ECHO_SETTINGS === '1' - ? `Fixture claude turn (${model}, ${effort ?? 'default'}, ${permissionMode ?? 'default'})` - : 'Fixture claude turn' - appendTranscript(cliSessionId, 'assistant', text, cwd) - emit({ - type: 'sdk.assistant', - sessionId: msg.sessionId, - content: [{ type: 'text', text }], - model, - }) - // A turn ends the way the real sidecar ends it: its result, which the - // server rings (C18). No per-turn idle (the real sidecar sends no idle at all). - emit({ type: 'sdk.result', sessionId: msg.sessionId, result: 'success', subtype: 'success', isError: false, terminalReason: 'completed', numTurns: 1, durationMs: 1, costUsd: 0, usage: { input_tokens: 1, output_tokens: 1 } }) - } else if (msg.type === 'shutdown') { - process.exit(0) - } -}) diff --git a/test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs b/test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs deleted file mode 100755 index d2ce0ae85..000000000 --- a/test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs +++ /dev/null @@ -1,546 +0,0 @@ -#!/usr/bin/env node -// HARNESS-03 deterministic fake Claude-SDK bridge sidecar — covers the -// checklist's "Kilroy/Claude-SDK" entry with ONE executable: both kilroy and -// freshclaude ride the claude provider's sidecar protocol -// (crates/freshell-freshagent/src/claude.rs), differing only in the -// sessionType flavour — select it via FRESHELL_FAKE_PROVIDER (default -// 'kilroy'). -// -// Wire protocol (mirrors crates/freshell-claude-sidecar/index.mjs and the -// realism notes in fixtures/fake-claude-sidecar.mjs): -// in : {"type":"create",protocol:2,requestId,cwd,model,permissionMode,effort, -// boot:{kind:'fresh'}|{kind:'resume',resume}|{kind:'fork',resume,resumeSessionAt?,dropsTurn?}} -// (protocol v2, crates/freshell-claude-sidecar/boot.mjs; kata 1wxv fork-at-point: -// a fork boot of `resume` at resumeSessionAt -// mints a NEW durable cliSessionId and writes the parent's transcript PREFIX -// through the addressed uuid verbatim; a dropsTurn guard that is NOT the -// raw-chain successor of the resume point refuses with the SDK-documented -// "Resume rejected by --resume-drops-turn:" prefix — NO init, NO durable session; -// plain resume keeps the same-id behavior) -// {"type":"send",sessionId,uuid,text} {"type":"interrupt",sessionId,stopId} {"type":"shutdown"} -// {"type":"permission.respond",sessionId,requestId,decision} -// {"type":"question.respond",sessionId,requestId,answers} -// out: {"type":"created",requestId,sessionId,protocol:2} FIRST (claude_handshake.rs -// discards any earlier line), then sdk.* frames: -// sdk.session.init {cliSessionId: CANONICAL UUID}, sdk.status, -// sdk.assistant (content MUST be an ARRAY), sdk.result on EVERY turn -// result with the facts the server's verdict reads (subtype, isError, -// terminalReason: aborted_streaming for a stopped turn, numTurns), -// sdk.permission.request / sdk.question.request (server -// sdk-bridge-types.ts shapes), sdk.permission.cancelled / -// sdk.question.cancelled (interrupt-time pending cancellation, the real -// sidecar's cancelPending), sdk.interrupt_settled {stopId, ok}, -// sdk.session.snapshot (resume). -// -// Like the real sidecar (C18) it mints NO attention edge: the server rings -// every turn end from its result (minus the one the user's own Stop landed -// on), mints the waiting edge from the first pending request, and rings the -// death of an owed turn. -// -// Turn semantics: `send` ALWAYS opens with sdk.status running (bookkeeping the -// real bridge performs unconditionally); a matching program rule then owns -// the turn; when no rule emitted completion/crash — AND no approval/question -// parked the turn — the canned assistant+result success turn closes it. Like -// the real sidecar, a turn ends with its result alone: there is no per-turn -// idle (the real sidecar sends no idle at all). -// A turn that raised an approval/question PARKS: it stays open -// until a matching permission.respond/question.respond (or interrupt) arrives, -// exactly like the real sidecar's parked canUseTool promise. -// -// Respond arms (AGENT-05/06 e2e): permission.respond/question.respond are -// routed into the program engine (decision points `msg:permission.respond` / -// `msg:question.respond` — e.g. a `kind:'completion'` emission continues the -// turn). Each respond REMOVES the tracked pending entry. Unknown requestIds -// are lose-safely ignored (the Rust dispatch validates against its own -// pending set first). -// -// Interrupt semantics: every parked entry gets ONE sdk.permission.cancelled / -// sdk.question.cancelled frame. While a turn is owed a result the settle is -// ok:true (echoing the Stop's id) and the interrupted turn's own aborted -// sdk.result follows (the real SDK contract orders the receipt BEFORE the -// result — sdk.d.ts:3765). With NOTHING owed the settle is STILL ok:true — -// the SDK contract documents RESOLUTION, not rejection, for interrupting with -// nothing in flight (sdk.d.ts:2384-2394). Either way it is NEVER an sdk.exit -// (the real sidecar keeps the session alive across interrupt). -// -// Raw-stdin audit (AGENT-05/06 e2e): when FRESHELL_FAKE_STDIN= is set, -// EVERY raw stdin line is appended there as a JSONL row {t, pid, line} — -// the spec's ground truth for "the exact respond/compact frames the Rust -// server wrote to the sidecar" and for zero-before-click proofs. -// (FRESHELL_FAKE_* keys are auto-recorded into the launch ledger.) -// -// The `stream-error` lane models the real sidecar's end of stream (its -// consumeStream finally): cancel the parked cards, then -// `sdk.stream.ended {reason, message}` as the LAST line and exit code 3 (each -// sidecar hosts one session, so its stream's end is the sidecar's end). The -// Rust server reports the exit (SIDECAR_EXITED) and rings the cut-off turn. -// The `crash` lane stays as-is (a real Node death screams no protocol frame -// and exits). -// -// Wire audit (D1-F2): every OUTBOUND frame is ALSO recorded into the -// FRESHELL_FAKE_EVENTS ledger as a `{t,pid,provider,kind:'wire',frame}` row, -// so specs assert on what actually crossed stdout (e.g. the result a -// denied/errored turn ended with) instead of trusting the program emission -// ledger alone. Program rows and wire rows -// coexist there; filter on `kind === 'wire'` for wire truth (event-kind -// consumers see no shape change). -// -// Transcript realism (AGENT-05 reload-while-pending): the cards render -// EXCLUSIVELY from the REST snapshot, which 404s without a durable transcript -// — so `create` ensures an EMPTY JSONL transcript at -// /projects//.jsonl, every `send` -// appends one {"type":"user", cwd, message:{role,content:[{type:'text',text}]}} -// entry, and every completion appends the matching assistant entry. The line -// shape mirrors `parse_transcript_turns`' accepted shape -// (crates/freshell-freshagent/src/claude_snapshot.rs:367-420); the cwd -// mangling (`[^A-Za-z0-9]` → '-') mirrors the real CLI's project-dir slug — -// though the Rust locator scans EVERY projects dir, so only the filename -// portion (the canonical cliSessionId) is load-bearing. claudeHome resolves -// CLAUDE_CONFIG_DIR > CLAUDE_HOME > ~/.claude, the same candidate order the -// Rust server uses; the sidecar inherits the harness's isolated HOME. -// -// The process stays alive until `shutdown` (exit 0), a scripted `crash` -// (exit code), a `stream-error` (exit 3), or kill. -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { randomUUID } from 'node:crypto' -import readline from 'node:readline' -import { appendJsonl, appendLaunchLedger, EVENTS_ENV, FixtureEngine, keepAlive, loadProgram } from './fixture-core.mjs' - -const provider = process.env.FRESHELL_FAKE_PROVIDER ?? 'kilroy' -/** The real sidecar's exit code after its SDK stream ends (stream-end.mjs). */ -const STREAM_ENDED_EXIT_CODE = 3 -/** Set by the stream-error lane: the fake is exiting and reads no more requests. */ -let exiting = false -/** The result facts the real sidecar forwards for a turn a Stop landed on (T11). */ -const STOPPED_RESULT = Object.freeze({ subtype: 'error_during_execution', isError: true, terminalReason: 'aborted_streaming', numTurns: 1 }) - -/** The result facts of a turn that ended with `subtype` on its own. */ -function endedResult(subtype) { - return subtype === 'success' - ? { subtype, isError: false, terminalReason: 'completed', numTurns: 1 } - : { subtype, isError: true, numTurns: 1 } -} -const env = process.env -appendLaunchLedger({ provider, argv: process.argv.slice(2), env }) -const program = loadProgram(env) - -// AGENT-05/06 e2e audit: one JSONL row per RAW stdin line, before parsing — -// a malformed line is audited too, and the spec never has to trust the -// fixture's own parse to prove what the server wrote. -const STDIN_LOG = env.FRESHELL_FAKE_STDIN - -// bridge sessionId -> { cliSessionId, cwd, pending, pendingEntries } -const sessions = new Map() -let activeSessionId = null -let createCounter = 0 - -function emit(obj) { - // Wire audit (D1-F2, see the header): record the ACTUAL outbound frame so - // specs assert on wire truth (what crossed stdout), not just program intent. - appendJsonl(env[EVENTS_ENV], { t: Date.now(), pid: process.pid, provider, kind: 'wire', frame: obj }) - process.stdout.write(`${JSON.stringify(obj)}\n`) -} - -function claudeHome() { - // Candidate order matches the Rust `claude_home_candidates` (ledger A3). - return ( - env.CLAUDE_CONFIG_DIR || env.CLAUDE_HOME || path.join(os.homedir(), '.claude') - ) -} - -function mangleCwd(cwd) { - return String(cwd ?? '').replace(/[^A-Za-z0-9]/g, '-') -} - -function transcriptPath(cliSessionId, cwd) { - return path.join(claudeHome(), 'projects', mangleCwd(cwd), `${cliSessionId}.jsonl`) -} - -// kata 1wxv Task 7 (fork-at-point): the rollback resume math runs over the RAW -// parentUuid chain, so every appended transcript line now carries a real -// uuid + parentUuid backbone, chained per cliSessionId (the real CLI's shape). -const lastUuidBySession = new Map() - -/** - * Append one transcript line in parse_transcript_turns' accepted shape. A - * user line takes the send's own uuid when the server gave one: the real CLI - * writes the prompt under the uuid of its SDKUserMessage, which is the turn - * id the server announced as submittedTurnId. - */ -function appendTranscript(cliSessionId, cwd, role, text, transcriptOverride, lineUuid) { - const parentUuid = lastUuidBySession.get(cliSessionId) ?? null - const uuid = typeof lineUuid === 'string' && lineUuid ? lineUuid : randomUUID() - lastUuidBySession.set(cliSessionId, uuid) - const line = { - type: role, - uuid, - parentUuid, - timestamp: new Date().toISOString(), - cwd: cwd ?? process.cwd(), - message: { role, content: [{ type: 'text', text }] }, - } - appendJsonl(transcriptOverride ?? transcriptPath(cliSessionId, cwd), line) -} - -// ── pending-request tracking (mirrors the real sidecar's permission-channel) ── -function trackPending(sessionId, kind, requestId) { - const st = sessions.get(sessionId) - if (!st) return - st.pendingEntries.push({ kind, requestId }) -} - -/** - * Resolve ONE parked request (permission.respond/question.respond): drop the - * tracked entry. Unknown requestIds are a lose-safely no-op. - */ -function resolvePending(sessionId, kind, requestId) { - const st = sessions.get(sessionId) - if (!st) return - const idx = st.pendingEntries.findIndex( - (entry) => entry.kind === kind && entry.requestId === String(requestId), - ) - if (idx === -1) return - st.pendingEntries.splice(idx, 1) -} - -async function render(event) { - const { kind, data } = event - const sessionId = data.sessionId ?? activeSessionId - switch (kind) { - case 'session': - emit({ - type: 'sdk.session.init', - sessionId, - cliSessionId: data.cliSessionId, - model: data.model ?? 'fixture-model', - cwd: data.cwd ?? process.cwd(), - tools: [], - }) - break - case 'resume': - emit({ type: 'sdk.session.snapshot', sessionId, messages: data.messages ?? [] }) - break - case 'activity': - emit({ type: 'sdk.status', sessionId, status: data.status ?? 'running' }) - break - case 'approval': { - const input = typeof data.input === 'object' && data.input !== null ? data.input : { command: data.input } - const requestId = String(data.id ?? `perm-${randomUUID()}`) - trackPending(sessionId, 'permission', requestId) - emit({ - type: 'sdk.permission.request', - sessionId, - requestId, - subtype: 'can_use_tool', - tool: { name: data.tool ?? 'Bash', input }, - }) - break - } - case 'question': { - const questions = Array.isArray(data.questions) - ? data.questions - : [{ question: data.text ?? '', header: 'Fixture', options: [], multiSelect: false }] - const requestId = String(data.id ?? `q-${randomUUID()}`) - trackPending(sessionId, 'question', requestId) - emit({ - type: 'sdk.question.request', - sessionId, - requestId, - questions, - }) - break - } - case 'completion': { - const st = sessions.get(sessionId) - // Real-sidecar parity (index.mjs handleSdkMessage drops the whole - // message for a missing session): a completion targeting a session - // that no longer exists (e.g. after the stream-error lane's teardown) - // is dropped ENTIRELY — no assistant, no result, no idle. - // The fake must not invent frames the real sidecar never emits. - if (!st) return - emit({ - type: 'sdk.assistant', - sessionId, - content: [{ type: 'text', text: data.text ?? 'Fixture turn' }], - model: st.settings.model ?? 'fixture-model', - }) - appendTranscript(st.cliSessionId, st.cwd, 'assistant', data.text ?? 'Fixture turn', st.transcriptOverride) - // An in-flight interrupt deferred to this scripted completion: the - // interrupted turn's own terminal result is the CLI's aborted shape - // (T11 — an interrupted turn never ends 'success'). - const facts = st.interrupted ? STOPPED_RESULT : endedResult(data.subtype ?? 'success') - st.interrupted = false - st.pendingResults = Math.max(0, st.pendingResults - 1) - // The facts the server's verdict reads: it rings the turn (or not, for - // the user's own Stop) and shows a failed turn's banner. - emit({ type: 'sdk.result', sessionId, result: facts.subtype, ...facts }) - break - } - case 'stream-error': { - // The real sidecar's end of stream (index.mjs consumeStream finally): - // cancel every parked entry, then sdk.stream.ended as the LAST line and - // exit code 3 (no stream-end status: the server reports the ended - // session as exited, and rings a cut-off turn). The separate `crash` - // lane deliberately stays different: no protocol frame + exit. - const st = sessions.get(sessionId) - if (st) { - for (const entry of st.pendingEntries.splice(0)) { - emit({ - type: entry.kind === 'question' ? 'sdk.question.cancelled' : 'sdk.permission.cancelled', - sessionId, - requestId: entry.requestId, - }) - } - } - if (st) sessions.delete(sessionId) - emit({ - type: 'sdk.stream.ended', - sessionId, - reason: data.reason ?? 'stream_error', - message: data.message ?? 'Fixture stream error', - }) - exiting = true - process.stdout.write('', () => process.exit(STREAM_ENDED_EXIT_CODE)) - break - } - case 'marker': - if (data.signal === 'interrupt') { - emit({ type: 'sdk.status', sessionId, status: 'idle' }) - } - break - case 'crash': - // A real crash screams no protocol frame; the ledger holds the record. - break - default: - break - } -} - -const engine = new FixtureEngine({ - provider, - program, - env, - write: (event) => render(event), -}) - -const rl = readline.createInterface({ input: process.stdin }) -rl.on('line', (line) => { - appendJsonl(STDIN_LOG, { t: Date.now(), pid: process.pid, line }) - // Like the real sidecar: once its stream ended, sdk.stream.ended stays the last line. - if (exiting) return - void handleInput(line).catch((err) => { - emit({ type: 'sdk.error', sessionId: activeSessionId, message: String(err?.message ?? err) }) - }) -}) - -async function handleInput(line) { - let msg - try { - msg = JSON.parse(line) - } catch { - return - } - if (msg.type === 'create') { - createCounter += 1 - const sessionId = msg.sessionId ?? `${provider}-fake-${process.pid}-${createCounter}` - activeSessionId = sessionId - // kata 1wxv Task 7 (fork-at-point, s2rk correction): a `forkSession:true` - // create mints a NEW durable cliSessionId — real `claude --fork-session` - // NEVER reuses the parent's id; plain resume keeps the same-id behavior. - // A PATH-shaped resumeSessionId (the server's cwd-gone fallback lane: - // `--resume .jsonl` bypasses the CLI's project-slug scoping) names - // the transcript file to CONTINUE — the session's durable id stays the - // file's stem, and every record lands in THAT file (never a - // path-flattened `<...>.jsonl.jsonl` phantom). - // Protocol v2 (crates/freshell-claude-sidecar/boot.mjs): the boot names - // the conversation to start. - const boot = msg.boot ?? {} - const resumeSessionId = typeof boot.resume === 'string' ? boot.resume : undefined - const forking = boot.kind === 'fork' - const resumeRaw = resumeSessionId ?? null - const resumeIsPath = resumeRaw !== null - && (resumeRaw.includes('/') || resumeRaw.endsWith('.jsonl')) - const resumePath = !forking && resumeIsPath ? resumeRaw : null - const cliSessionId = msg.sessionId ?? (forking - ? randomUUID() - : (resumePath - ? path.basename(resumePath).replace(/\.jsonl$/, '') - : (resumeRaw ?? program.sessionId ?? randomUUID()))) - const cwd = msg.cwd ?? process.cwd() - sessions.set(sessionId, { cliSessionId, cwd, pendingEntries: [], - transcriptOverride: resumePath, - settings: { model: msg.model, effort: msg.effort, permissionMode: msg.permissionMode, cwd }, - // Turn bookkeeping: the sends owed a result (an interrupt ends the - // owed turn with its aborted result) and the in-flight/interrupt - // render flags. - pendingResults: 0, sendInFlight: false, interrupted: false }) - // A durable transcript EXISTS from create on (the reload-while-pending - // snapshot route reads it before any turn completes) — touch, no bogus row. - // A path-resumed session's transcript IS the named file. - const transcript = resumePath ?? transcriptPath(cliSessionId, cwd) - fs.mkdirSync(path.dirname(transcript), { recursive: true }) - if (forking && resumeSessionId) { - // created FIRST — a real consumer discards anything earlier. The - // resumeDropsTurn refusal watch runs BEFORE any durable state moves: - // the guard must name the RAW-chain successor of the resume point (the - // SDK-armed discard guard); anything else refuses with the SDK's - // documented prefix and NO sdk.session.init / durable session is ever - // minted (freshell retries ONCE with the guard omitted). - emit({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) - const parentPath = transcriptPath(resumeSessionId, cwd) - const parentLines = fs.existsSync(parentPath) - ? fs.readFileSync(parentPath, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l)) - : [] - const cut = typeof boot.resumeSessionAt === 'string' - ? parentLines.findIndex((l) => l.uuid === boot.resumeSessionAt) - : parentLines.length - 1 - if (typeof boot.dropsTurn === 'string') { - const successor = cut >= 0 ? (parentLines[cut + 1]?.uuid ?? null) : null - if (boot.dropsTurn !== successor) { - emit({ - type: 'sdk.error', - sessionId, - message: `Resume rejected by --resume-drops-turn: ${boot.dropsTurn} is not the raw-chain successor of the resume point (drop-guard mismatch)`, - }) - return - } - } - // The child file is the parent's transcript PREFIX through - // resumeSessionAt, uuids preserved verbatim (a real fork keeps original - // message ids), so freshell's transcript readers see a real durable - // JSONL; the chain cursor seeds onto the fork point's uuid. - const prefix = parentLines.slice(0, cut < 0 ? undefined : cut + 1) - const lastPrefixUuid = prefix.length > 0 ? prefix[prefix.length - 1]?.uuid : null - fs.writeFileSync(transcript, prefix.map((l) => JSON.stringify(l)).join('\n') + (prefix.length ? '\n' : '')) - if (typeof lastPrefixUuid === 'string') lastUuidBySession.set(cliSessionId, lastPrefixUuid) - } else { - fs.closeSync(fs.openSync(transcript, 'a')) - // created FIRST — a real consumer discards anything earlier. - emit({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) - // A plain resume CONTINUES the parent's chain: seed the uuid cursor from - // the transcript tail so the next appended line's parentUuid is right. - if (typeof resumeSessionId === 'string' && fs.existsSync(transcript)) { - const lines = fs.readFileSync(transcript, 'utf8').split('\n').filter(Boolean) - const last = lines.length > 0 ? JSON.parse(lines[lines.length - 1]) : null - if (typeof last?.uuid === 'string') lastUuidBySession.set(cliSessionId, last.uuid) - } - } - const emitted = await engine.handleMessage(msg) - if (emitted.has('crash')) return - if (!emitted.has('session')) { - await engine.emitEvent( - 'session', - { cliSessionId, model: msg.model ?? 'fixture-model', cwd }, - 'msg:create:default', - ) - } - if (resumeSessionId) { - await engine.emitResume(cliSessionId) - } - emit({ type: 'sdk.status', sessionId, status: 'idle' }) - } else if (msg.type === 'configure') { - const st = sessions.get(msg.sessionId) - if (!st) { - emit({ type: 'sdk.configured', sessionId: msg.sessionId, requestId: msg.requestId, ok: false, message: 'Session not found' }) - return - } - for (const key of ['model', 'effort', 'permissionMode', 'cwd']) { - if (msg.settings?.[key] != null || (key === 'effort' && msg.settings?.effort === null)) st.settings[key] = msg.settings[key] - } - st.cwd = st.settings.cwd - emit({ type: 'sdk.configured', sessionId: msg.sessionId, requestId: msg.requestId, ok: true, settings: st.settings }) - } else if (msg.type === 'send') { - activeSessionId = msg.sessionId ?? activeSessionId - const st = sessions.get(msg.sessionId) - if (st) appendTranscript(st.cliSessionId, st.cwd, 'user', msg.text, st.transcriptOverride, msg.uuid) - if (st) { - // The send is owed a result; sendInFlight marks the awaited - // program-handling window, so an interrupt arriving during a delayed - // scripted emission defers to that completion instead of synthesizing - // a second terminal frame. - st.pendingResults += 1 - st.sendInFlight = true - } - try { - // Turn-open bookkeeping is unconditional (the real bridge always goes busy). - await engine.emitEvent('activity', { status: 'running' }, 'msg:send:open') - const emitted = await engine.handleMessage(msg) - if (emitted.has('crash')) return - // A turn that raised an approval/question PARKS until the matching - // respond (or an interrupt) arrives — the canned success turn must NOT - // close it early, or the card would clear with an invented resolution. - if (emitted.has('completion') || emitted.has('approval') || emitted.has('question')) return - await engine.emitEvent('completion', { subtype: 'success' }, 'msg:send:default') - } finally { - if (st) st.sendInFlight = false - } - } else if ( - msg.type === 'permission.respond' - || msg.type === 'question.respond' - ) { - activeSessionId = msg.sessionId ?? activeSessionId - const kind = msg.type === 'question.respond' ? 'question' : 'permission' - resolvePending(msg.sessionId, kind, msg.requestId) - const emitted = await engine.handleMessage(msg) - if (emitted.has('crash')) return - } else if (msg.type === 'interrupt') { - activeSessionId = msg.sessionId ?? activeSessionId - const st = sessions.get(msg.sessionId) - const settled = (fields) => emit({ type: 'sdk.interrupt_settled', sessionId: msg.sessionId, stopId: msg.stopId ?? null, ...fields }) - if (!st) { - // Real-sidecar parity (index.mjs handleInterrupt): a session this - // sidecar does not host settles ok:false. - settled({ ok: false, message: 'Claude session is no longer available.' }) - return - } - // Mirror the real sidecar's interrupt path (index.mjs — the transport is - // still open, so cancelPending emits one cancel frame per parked entry, - // never a fabricated user respond), then NO sdk.exit: interrupt ends the - // TURN, never the session (AGENT-03). - for (const entry of st.pendingEntries.splice(0)) { - emit({ - type: entry.kind === 'question' ? 'sdk.question.cancelled' : 'sdk.permission.cancelled', - sessionId: msg.sessionId, - requestId: entry.requestId, - }) - } - if (st.pendingResults > 0) { - // A turn is owed: the receipt comes BEFORE the stopped turn's own result - // (sdk.d.ts:3765). Force that result to the aborted shape BEFORE program - // rules run, so a completion scripted on msg:interrupt renders the - // interrupted turn truthfully. - st.interrupted = true - settled({ ok: true, stillQueued: [] }) - const emitted = await engine.handleMessage(msg) - if (emitted.has('crash')) return - // The program ended the interrupted turn itself (its completion render - // already took the aborted shape). - if (emitted.has('completion')) return - // A delayed scripted completion is still mid-flight: let it land. - if (st.sendInFlight) return - // Parked/plain in-flight turn: nothing else will end it, so the - // interrupted turn's own aborted result follows here. The counter goes - // to ZERO: the fake models ONE in-flight turn, so the interrupt ends - // everything owed. - st.pendingResults = 0 - st.interrupted = false - emit({ type: 'sdk.result', sessionId: msg.sessionId, result: STOPPED_RESULT.subtype, ...STOPPED_RESULT }) - return - } - // Nothing owed: the SDK RESOLVES an idle-session interrupt - // (sdk.d.ts:2384-2394), so the settle lands ok:true. The receipt must - // precede any of the frames below in stream order. - settled({ ok: true, stillQueued: [] }) - const emitted = await engine.handleMessage(msg) - if (emitted.has('crash')) return - if (!emitted.has('activity') && !emitted.has('marker')) { - await engine.emitEvent('activity', { status: 'idle' }, 'msg:interrupt:idle') - } - } else if (msg.type === 'shutdown') { - process.exit(0) - } -} - -keepAlive() diff --git a/test/e2e-browser/fixtures/providers/fixture-core.mjs b/test/e2e-browser/fixtures/providers/fixture-core.mjs index 2764ca982..e37a2e9d0 100644 --- a/test/e2e-browser/fixtures/providers/fixture-core.mjs +++ b/test/e2e-browser/fixtures/providers/fixture-core.mjs @@ -1,10 +1,9 @@ // HARNESS-03 — deterministic provider-fixture core engine. // -// Shared engine behind the seven fake provider executables in this directory -// (fake-claude / fake-gemini / fake-kimi / fake-amplifier terminal CLIs, -// fake-claude-sdk-sidecar for kilroy/freshclaude, fake-codex-app-server, -// fake-opencode-server). It provides two uniform, provider-independent -// observability surfaces plus a scriptable event engine: +// Shared engine behind the six remaining fake provider executables in this +// directory (fake-claude / fake-gemini / fake-kimi / fake-amplifier terminal +// CLIs, fake-codex-app-server, fake-opencode-server). It provides two uniform, +// provider-independent observability surfaces plus a scriptable event engine: // // - Launch ledger (env FRESHELL_FAKE_LEDGER): one JSONL row per process // launch — { t, pid, provider, argv, cwd, env } where `env` is STRICTLY diff --git a/test/e2e-browser/helpers/claude-sim-lane.ts b/test/e2e-browser/helpers/claude-sim-lane.ts index 19842bf11..4d304b330 100644 --- a/test/e2e-browser/helpers/claude-sim-lane.ts +++ b/test/e2e-browser/helpers/claude-sim-lane.ts @@ -37,6 +37,28 @@ export const CLAUDE_CLI_SIMULATOR = path.resolve(__dirname, '../../fixtures/clau export type ClaudeSimFlavour = 'freshclaude' | 'kilroy' +/** Environment for custom RustServer fixtures that use the production sidecar and CLI simulator. */ +export function claudeSimulatorServerEnv(input: { + ledgerPath: string + flavour?: ClaudeSimFlavour + program?: unknown + extra?: Record +}): Record { + return { + FRESHELL_CLAUDE_SIDECAR: CLAUDE_SIDECAR, + FRESHELL_CLAUDE_NODE: process.execPath, + FRESHELL_CLAUDE_SDK_QUERY_MODULE: CLAUDE_CLI_SIMULATOR, + FRESHELL_CLAUDE_SIM_LEDGER: input.ledgerPath, + // An empty program preserves the simulator's built-in prompt markers + // (HANG, SLOW, ERROR_TURN, and friends) while preventing an inherited + // runner program from changing the test. Scenario-specific programs are + // supplied explicitly by the caller. + FRESHELL_CLAUDE_SIM_PROGRAM: JSON.stringify(input.program ?? { rules: [] }), + ...(input.flavour === 'kilroy' ? { KILROY_ENABLED: '1' } : {}), + ...input.extra, + } +} + /** One program emission (see test/fixtures/claude-cli-sim/program.mjs for the kinds). */ export type ClaudeSimEmission = { kind: string, delayMs?: number, data?: Record } export type ClaudeSimRule = { on: string, match?: Record, once?: boolean, emit: ClaudeSimEmission[] } @@ -65,6 +87,21 @@ export interface ClaudeSimLaneOptions { /** A row of the simulator's ledger (FRESHELL_CLAUDE_SIM_LEDGER). */ export type ClaudeSimLedgerRow = { kind: string, [key: string]: unknown } +/** Read the simulator's append-only JSONL ledger, tolerating a partial last write. */ +export function readClaudeSimLedger(file: string): ClaudeSimLedgerRow[] { + if (!existsSync(file)) return [] + return readFileSync(file, 'utf8') + .split('\n') + .filter(Boolean) + .flatMap((line) => { + try { + return [JSON.parse(line) as ClaudeSimLedgerRow] + } catch { + return [] + } + }) +} + export interface ClaudeSimPane { paneRoot: Locator tabId: string @@ -214,17 +251,7 @@ export class ClaudeSimLane { /** What the simulated CLI recorded so far (its ledger). */ ledger(): ClaudeSimLedgerRow[] { - if (!existsSync(this.ledgerPath)) return [] - return readFileSync(this.ledgerPath, 'utf8') - .split('\n') - .filter(Boolean) - .flatMap((line) => { - try { - return [JSON.parse(line) as ClaudeSimLedgerRow] - } catch { - return [] - } - }) + return readClaudeSimLedger(this.ledgerPath) } /** Attach the server's output to a failed test's report. */ diff --git a/test/e2e-browser/helpers/fake-claude-sdk-sidecar-control.test.ts b/test/e2e-browser/helpers/fake-claude-sdk-sidecar-control.test.ts deleted file mode 100644 index af9dcfddb..000000000 --- a/test/e2e-browser/helpers/fake-claude-sdk-sidecar-control.test.ts +++ /dev/null @@ -1,609 +0,0 @@ -// Unit coverage for the AGENT-04/05/06 arms of the HARNESS-03 fake claude-SDK -// sidecar (`test/e2e-browser/fixtures/providers/fake-claude-sdk-sidecar.mjs`): -// permission.respond/question.respond pending-resolution, interrupt-time -// pending cancellation (cancel frames, never an sdk.exit), the result facts -// the server's verdict reads (C18: the fake mints no attention edge, like the -// real sidecar), the parked-turn rule (no canned completion while parked), the -// stream-error lane's exit (the real sidecar's end of stream), the -// FRESHELL_FAKE_STDIN raw-stdin audit, and the durable-transcript writes the -// reload-while-pending snapshot route depends on. Process/wire-level contract -// for all seven fixtures stays in specs/harness-03-provider-fixtures.spec.ts; -// this file pins only the new arms, driven through real child processes with -// per-test tmp HOMEs. -import { spawn, type ChildProcess } from 'node:child_process' -import fs from 'node:fs' -import os from 'node:os' -import path from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' - -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) -const FIXTURE = path.resolve(__dirname, '../fixtures/providers/fake-claude-sdk-sidecar.mjs') - -let tmp: string -beforeEach(() => { - tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'fake-claude-sidecar-arms-')) -}) -afterEach(() => { - fs.rmSync(tmp, { recursive: true, force: true }) -}) - -type Launched = { - proc: ChildProcess - stdoutLines: () => any[] - send: (msg: Record) => void - waitLine: (pred: (o: any) => boolean, what: string) => Promise - stop: () => Promise -} - -function launch(program: unknown, extraEnv: Record = {}, fixture = FIXTURE): Launched { - const proc = spawn(process.execPath, [fixture], { - env: { - ...process.env, - HOME: tmp, - FRESHELL_FAKE_PROVIDER: 'freshclaude', - FRESHELL_FAKE_PROGRAM: JSON.stringify(program), - ...extraEnv, - }, - stdio: ['pipe', 'pipe', 'inherit'], - }) - let killed = false - const parsed: any[] = [] - let carry = '' - proc.stdout!.on('data', (chunk) => { - carry += String(chunk) - let idx - while ((idx = carry.indexOf('\n')) !== -1) { - const line = carry.slice(0, idx) - carry = carry.slice(idx + 1) - try { - parsed.push(JSON.parse(line)) - } catch { - // non-JSON stdout is not part of this protocol; ignore - } - } - }) - return { - proc, - stdoutLines: () => parsed.slice(), - send: (msg) => proc.stdin!.write(`${JSON.stringify(msg)}\n`), - waitLine: async (pred, what) => { - const deadline = Date.now() + 10_000 - for (;;) { - const hit = parsed.find(pred) - if (hit) return hit - if (Date.now() > deadline) { - throw new Error(`fake sidecar: timed out waiting for ${what}; saw ${JSON.stringify(parsed.map((o) => o?.type))}`) - } - await new Promise((resolve) => setTimeout(resolve, 25)) - } - }, - stop: async () => { - if (proc.exitCode !== null || killed) return - killed = true - proc.kill('SIGKILL') - }, - } -} - -function readJsonl(filePath: string): any[] { - if (!fs.existsSync(filePath)) return [] - return fs - .readFileSync(filePath, 'utf8') - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)) -} - -const RAISE_PROGRAM = { - rules: [ - { - on: 'msg:send', - match: { text: 'RAISE_PERMISSION' }, - emit: [ - { kind: 'approval', data: { id: 'req-perm-1', tool: 'Bash', input: { command: 'ls' } } }, - ], - }, - { - // A DISTINCT second id: the real sidecar nanoids each raise, so a - // concurrent-parking probe never reuses the first request's id. - on: 'msg:send', - match: { text: 'RAISE_PERMISSION_2' }, - emit: [ - { kind: 'approval', data: { id: 'req-perm-2', tool: 'Bash', input: { command: 'ls -la' } } }, - ], - }, - { - on: 'msg:permission.respond', - match: { decision: { behavior: 'allow' } }, - emit: [{ kind: 'completion', data: { subtype: 'success' } }], - }, - { - on: 'msg:send', - match: { text: 'RAISE_QUESTION' }, - emit: [ - { - kind: 'question', - data: { - id: 'req-q-1', - questions: [ - { - question: 'Pick one', - header: 'Fixture', - options: [{ label: 'A', description: 'alpha' }, { label: 'B', description: 'beta' }], - multiSelect: false, - }, - ], - }, - }, - ], - }, - { - on: 'msg:question.respond', - emit: [{ kind: 'completion', data: { subtype: 'success' }, delayMs: 10 }], - }, - ], -} - -describe('fake-claude-sdk-sidecar respond/interrupt arms (AGENT-05/06 fixture)', () => { - it( - 'responds resolve the parked requests; the fake mints no waiting or completion edge (the server does, C18)', - async () => { - const stdinLog = path.join(tmp, 'stdin.jsonl') - const fx = launch(RAISE_PROGRAM, { FRESHELL_FAKE_STDIN: stdinLog }) - try { - fx.send({ type: 'create', requestId: 'req-1', cwd: tmp }) - const created = await fx.waitLine((o) => o.type === 'created', 'created') - const sessionId = created.sessionId as string - - fx.send({ type: 'send', sessionId, text: 'RAISE_PERMISSION' }) - const request = await fx.waitLine((o) => o.type === 'sdk.permission.request', 'sdk.permission.request') - expect(request).toMatchObject({ - sessionId, - requestId: 'req-perm-1', - subtype: 'can_use_tool', - tool: { name: 'Bash', input: { command: 'ls' } }, - }) - // Parked: NO result may arrive before the respond. - expect(fx.stdoutLines().some((o) => o.type === 'sdk.result')).toBe(false) - - // A second CONCURRENT raise (distinct request id, like the real - // sidecar's nanoid mint) while parked. - fx.send({ type: 'send', sessionId, text: 'RAISE_PERMISSION_2' }) - await fx.waitLine((o) => o.type === 'sdk.permission.request' && o.requestId === 'req-perm-2', 'second permission.request') - - // Each respond resolves its own parked request and its turn ends. - fx.send({ type: 'permission.respond', sessionId, requestId: 'req-perm-1', decision: { behavior: 'allow' } }) - await fx.waitLine((o) => o.type === 'sdk.result', 'turn result after allow') - fx.send({ type: 'permission.respond', sessionId, requestId: 'req-perm-2', decision: { behavior: 'allow' } }) - await fx.waitLine( - (o) => o.type === 'sdk.result' && fx.stdoutLines().filter((r) => r.type === 'sdk.result').length >= 2, - 'second turn result', - ) - fx.send({ type: 'send', sessionId, text: 'RAISE_PERMISSION' }) - await fx.waitLine( - (o) => o.type === 'sdk.permission.request' && fx.stdoutLines().filter((r) => r.type === 'sdk.permission.request').length === 3, - 'third permission.request', - ) - // The server folds these requests and results into its own edges. - expect( - fx.stdoutLines().filter((o) => o.type === 'sdk.turn.waiting' || o.type === 'sdk.turn.complete'), - 'the fake mints no attention edge', - ).toEqual([]) - - // Raw-stdin audit: both respond frames landed verbatim in the - // fixture's log (exactly the frames this test wrote — zero fabricated). - const stdinRows = readJsonl(stdinLog) - const responds = stdinRows - .map((row) => { - try { - return JSON.parse(row.line) - } catch { - return null - } - }) - .filter((m) => m?.type === 'permission.respond') - expect(responds).toHaveLength(2) - expect(responds[0]).toMatchObject({ sessionId, requestId: 'req-perm-1', decision: { behavior: 'allow' } }) - expect(responds[1]).toMatchObject({ sessionId, requestId: 'req-perm-2', decision: { behavior: 'allow' } }) - } finally { - await fx.stop() - } - }, - ) - - it('a respond for a NEVER-raised requestId does not poison the pending ledger (task-008-review N-2)', async () => { - const fx = launch(RAISE_PROGRAM) - try { - fx.send({ type: 'create', requestId: 'req-1', cwd: tmp }) - const created = await fx.waitLine((o) => o.type === 'created', 'created') - const sessionId = created.sessionId as string - - fx.send({ type: 'send', sessionId, text: 'RAISE_PERMISSION' }) - await fx.waitLine((o) => o.type === 'sdk.permission.request', 'permission.request') - - // Resolve a requestId that was NEVER raised. (deny matches no - // RAISE_PROGRAM rule, so this respond emits nothing else; only the - // resolvePending ledger bookkeeping runs.) - fx.send({ type: 'permission.respond', sessionId, requestId: 'req-never-raised', decision: { behavior: 'deny' } }) - - // The ledger must still hold the real parked request: an interrupt - // cancels exactly it, and nothing for the foreign id. - fx.send({ type: 'interrupt', sessionId, stopId: 'stop-1' }) - await fx.waitLine((o) => o.type === 'sdk.interrupt_settled', 'the settle') - expect( - fx.stdoutLines().filter((o) => o.type === 'sdk.permission.cancelled').map((o) => o.requestId), - 'the foreign respond must not have resolved the real parked request', - ).toEqual(['req-perm-1']) - } finally { - await fx.stop() - } - }) - - it('interrupt cancels every parked entry with cancel frames and never emits sdk.exit', async () => { - const stdinLog = path.join(tmp, 'stdin.jsonl') - const fx = launch(RAISE_PROGRAM, { FRESHELL_FAKE_STDIN: stdinLog }) - try { - fx.send({ type: 'create', requestId: 'req-1', cwd: tmp }) - const created = await fx.waitLine((o) => o.type === 'created', 'created') - const sessionId = created.sessionId as string - - fx.send({ type: 'send', sessionId, text: 'RAISE_PERMISSION' }) - await fx.waitLine((o) => o.type === 'sdk.permission.request', 'permission.request') - fx.send({ type: 'send', sessionId, text: 'RAISE_QUESTION' }) - await fx.waitLine((o) => o.type === 'sdk.question.request', 'question.request') - - fx.send({ type: 'interrupt', sessionId, stopId: 'stop-1' }) - const cancelledPermission = await fx.waitLine((o) => o.type === 'sdk.permission.cancelled', 'sdk.permission.cancelled') - expect(cancelledPermission).toMatchObject({ sessionId, requestId: 'req-perm-1' }) - const cancelledQuestion = await fx.waitLine((o) => o.type === 'sdk.question.cancelled', 'sdk.question.cancelled') - expect(cancelledQuestion).toMatchObject({ sessionId, requestId: 'req-q-1' }) - // The receipt echoes the Stop's id, and the interrupted turn ends with - // its own aborted result, like the real CLI's (T11). - expect(await fx.waitLine((o) => o.type === 'sdk.interrupt_settled', 'the settle')).toMatchObject({ sessionId, stopId: 'stop-1', ok: true }) - const stopped = await fx.waitLine((o) => o.type === 'sdk.result' && o.result === 'error_during_execution', 'the interrupted turn result') - expect(stopped).toMatchObject({ subtype: 'error_during_execution', isError: true, terminalReason: 'aborted_streaming' }) - - // AGENT-03 separation: interrupt ends the turn, never the session. - expect(fx.stdoutLines().some((o) => o.type === 'sdk.exit')).toBe(false) - - // Zero fabricated decisions: the stdin audit contains NO respond frame. - const stdinRows = readJsonl(stdinLog) - expect( - stdinRows.filter((row) => /"(permission|question)\.respond"/.test(String(row.line))), - 'interrupt must never invent a user decision', - ).toEqual([]) - - // The session is still usable: a plain send completes normally. - fx.send({ type: 'send', sessionId, text: 'plain follow-up' }) - await fx.waitLine((o) => o.type === 'sdk.result' && o.result === 'success', 'post-interrupt completion') - } finally { - await fx.stop() - } - }) - - it('a deny/errored completion emits sdk.result with its facts, no edge (the server rings it) and no per-turn idle', async () => { - const eventsLog = path.join(tmp, 'events.jsonl') - const fx = launch({ - rules: [ - { - on: 'msg:send', - match: { text: 'RAISE_PERMISSION' }, - emit: [ - { kind: 'approval', data: { id: 'req-perm-1', tool: 'Bash', input: { command: 'ls' } } }, - ], - }, - { - on: 'msg:permission.respond', - match: { decision: { behavior: 'deny' } }, - emit: [{ kind: 'completion', data: { subtype: 'error', text: 'The user denied this request.' } }], - }, - ], - }, { FRESHELL_FAKE_EVENTS: eventsLog }) - try { - fx.send({ type: 'create', requestId: 'req-1', cwd: tmp }) - const created = await fx.waitLine((o) => o.type === 'created', 'created') - const sessionId = created.sessionId as string - - fx.send({ type: 'send', sessionId, text: 'RAISE_PERMISSION' }) - await fx.waitLine((o) => o.type === 'sdk.permission.request', 'permission.request') - // Parked: NO completion frame may arrive before the respond. - expect(fx.stdoutLines().some((o) => o.type === 'sdk.turn.complete')).toBe(false) - - fx.send({ type: 'permission.respond', sessionId, requestId: 'req-perm-1', decision: { behavior: 'deny' } }) - // The errored sdk.result is the terminal marker for the deny - // continuation: it carries the facts the server's verdict reads. - const errored = await fx.waitLine((o) => o.type === 'sdk.result' && o.result === 'error', 'errored sdk.result') - expect(errored).toMatchObject({ sessionId, subtype: 'error', isError: true }) - const out = fx.stdoutLines() - const resultIdx = out.findIndex( - (o) => o.type === 'sdk.result' && o.result === 'error' && o.sessionId === sessionId, - ) - expect(resultIdx, 'the denied turn produced an errored sdk.result').toBeGreaterThanOrEqual(0) - // Like the real sidecar, the turn ends with its result alone: no idle - // follows it (the real sidecar sends no idle at all). - expect( - out.filter((o, i) => i > resultIdx && o.type === 'sdk.status' && o.sessionId === sessionId && o.status === 'idle'), - 'no per-turn idle after the result', - ).toEqual([]) - expect( - out.some((o) => o.type === 'sdk.assistant' && o.sessionId === sessionId - && o.content?.[0]?.text?.includes('denied')), - 'the denial assistant frame arrived', - ).toBe(true) - - // The outbound wire audit records exactly what went over stdout. - const wires = readJsonl(eventsLog).filter((r) => r.kind === 'wire') - expect( - wires.filter((r) => r.frame?.type === 'sdk.result' && r.frame?.sessionId === sessionId), - 'the wire audit shows the errored result on stdout', - ).toHaveLength(1) - - // A plain turn still completes the positive way (fidelity pin). - fx.send({ type: 'send', sessionId, text: 'plain follow-up' }) - const success = await fx.waitLine((o) => o.type === 'sdk.result' && o.result === 'success', 'the follow-up success result') - expect(success).toMatchObject({ subtype: 'success', isError: false, terminalReason: 'completed' }) - expect( - fx.stdoutLines().filter((o) => o.type === 'sdk.turn.complete' || o.type === 'sdk.turn.waiting'), - 'the fake mints no attention edge', - ).toEqual([]) - } finally { - await fx.stop() - } - }) - - it('an interrupt on an idle session settles ok:true (SDK resolution) with its stopId, and the next turn runs', async () => { - const fx = launch({ rules: [] }) - try { - fx.send({ type: 'create', requestId: 'req-1', cwd: tmp }) - const created = await fx.waitLine((o) => o.type === 'created', 'created') - const sessionId = created.sessionId as string - await fx.waitLine((o) => o.type === 'sdk.status' && o.status === 'idle', 'initial idle') - - // Nothing awaits a terminal frame. The real SDK RESOLVES an - // idle-session interrupt (sdk.d.ts:2384-2394 — resolution, not - // rejection; the ok:false 'no in-flight SDK query' shape fires only - // when the SDK surface lacks the interrupt method entirely), so the - // real sidecar settles ok:true. The fake must mirror that shape. - fx.send({ type: 'interrupt', sessionId, stopId: 'stop-idle' }) - const settle = await fx.waitLine((o) => o.type === 'sdk.interrupt_settled', 'idle interrupt settle') - expect(settle).toMatchObject({ sessionId, stopId: 'stop-idle', ok: true }) - - fx.send({ type: 'send', sessionId, text: 'next unrelated turn' }) - await fx.waitLine((o) => o.type === 'sdk.result' && o.result === 'success', 'the next turn runs') - } finally { - await fx.stop() - } - }) - - it('an interrupt against a dead session settles ok:false, never a fabricated ok:true (real-sidecar parity)', async () => { - const fx = launch({ rules: [] }) - try { - // No create ever ran: the session id is unknown. The REAL sidecar's - // handleInterrupt refuses it: its receipt is ok:false, echoing the - // Stop's id (index.mjs). The kilroy fake mirrors this; the claude fake - // must too. - fx.send({ type: 'interrupt', sessionId: 'never-created', stopId: 'stop-x' }) - const settle = await fx.waitLine((o) => o.type === 'sdk.interrupt_settled', 'dead-session interrupt settle') - expect(settle).toMatchObject({ sessionId: 'never-created', stopId: 'stop-x', ok: false }) - const out = fx.stdoutLines() - expect(out.filter((o) => o.type === 'sdk.error'), 'no error frame for a dead session').toEqual([]) - expect(out.filter((o) => o.type === 'sdk.status'), 'no fabricated activity for a dead session').toEqual([]) - } finally { - await fx.stop() - } - }) - - it('the stream-error lane ends the sidecar like the real one: sdk.stream.ended last, exit code 3, no edge', async () => { - const fx = launch({ - rules: [ - { on: 'msg:send', match: { text: 'DIE' }, emit: [{ kind: 'stream-error' }] }, - ], - }) - try { - fx.send({ type: 'create', requestId: 'req-1', cwd: tmp }) - const created = await fx.waitLine((o) => o.type === 'created', 'created') - const sessionId = created.sessionId as string - const exited = new Promise((resolve) => fx.proc.once('exit', (code) => resolve(code))) - - // The stream ends mid-turn: the end report, then the exit. The server - // rings the cut-off turn (C18), not the fake. - fx.send({ type: 'send', sessionId, text: 'DIE' }) - expect(await exited).toBe(3) - const out = fx.stdoutLines() - expect(out.at(-1)).toEqual({ type: 'sdk.stream.ended', sessionId, reason: 'stream_error', message: 'Fixture stream error' }) - expect(out.filter((o) => o.type === 'sdk.turn.complete'), 'the fake mints no edge').toEqual([]) - expect(out.filter((o) => o.type === 'sdk.result'), 'the turn ended with no result').toEqual([]) - expect(out.filter((o) => o.type === 'sdk.error'), 'the end report carries the cause; no error frame repeats it').toEqual([]) - const turnOpened = out.findLastIndex((o) => o.type === 'sdk.status' && o.status === 'running') - expect(turnOpened, 'the send opened its turn').toBeGreaterThanOrEqual(0) - expect( - out.slice(turnOpened + 1).filter((o) => o.type === 'sdk.status'), - 'no stream-end status: the server reports the ended session as exited', - ).toEqual([]) - } finally { - await fx.stop() - } - }) - - it('writes the durable claude transcript the snapshot route reads (create-touch, user on send, assistant on completion)', async () => { - const fx = launch(RAISE_PROGRAM) - try { - fx.send({ type: 'create', requestId: 'req-1', cwd: tmp }) - await fx.waitLine((o) => o.type === 'sdk.session.init', 'sdk.session.init') - const created = fx.stdoutLines().find((o) => o.type === 'created') - const init = fx.stdoutLines().find((o) => o.type === 'sdk.session.init') - const cliSessionId = init.cliSessionId as string - expect(cliSessionId).toMatch(/^[0-9a-f-]{36}$/) - const transcript = path.join(tmp, '.claude', 'projects', tmp.replace(/[^A-Za-z0-9]/g, '-'), `${cliSessionId}.jsonl`) - expect(fs.existsSync(transcript), 'create must ensure the durable transcript file').toBe(true) - - fx.send({ type: 'send', sessionId: created.sessionId, text: 'hello durable world' }) - await fx.waitLine((o) => o.type === 'sdk.result', 'completion') - - const lines = readJsonl(transcript) - expect(lines.map((l) => l.type)).toEqual(['user', 'assistant']) - expect(lines[0]).toMatchObject({ type: 'user', cwd: tmp, message: { role: 'user', content: [{ type: 'text', text: 'hello durable world' }] } }) - expect(lines[1].message.role).toBe('assistant') - } finally { - await fx.stop() - } - }) -}) - -describe('fake-claude-sdk-sidecar fork-at-point arm (kata 1wxv e2e fixture)', () => { - /** The fixture's transcript dir for a cwd under this test's tmp HOME. */ - function transcriptFor(cliSessionId: string, cwd: string): string { - return path.join(tmp, '.claude', 'projects', cwd.replace(/[^A-Za-z0-9]/g, '-'), `${cliSessionId}.jsonl`) - } - - /** Drive ORIG through two complete turns so its transcript holds u1/a1/u2/a2. */ - async function seedTwoTurns(fx: Launched): Promise<{ sessionId: string; cliSessionId: string }> { - fx.send({ type: 'create', requestId: 'req-orig', cwd: tmp }) - const created = await fx.waitLine((o) => o.type === 'created' && o.requestId === 'req-orig', 'created ORIG') - const init = await fx.waitLine( - (o) => o.type === 'sdk.session.init' && o.sessionId === created.sessionId, - 'sdk.session.init ORIG', - ) - fx.send({ type: 'send', sessionId: created.sessionId, text: 'turn one' }) - await fx.waitLine((o) => o.type === 'sdk.result', 'turn one completion') - fx.send({ type: 'send', sessionId: created.sessionId, text: 'turn two' }) - await fx.waitLine( - (o) => o.type === 'sdk.result' && fx.stdoutLines().filter((r) => r.type === 'sdk.result').length >= 2, - 'turn two completion', - ) - return { sessionId: created.sessionId as string, cliSessionId: init.cliSessionId as string } - } - - it('a fork boot mints a fresh cliSessionId and seeds the transcript prefix (s2rk correction)', async () => { - const fx = launch({ rules: [] }) - try { - const { cliSessionId: ORIG } = await seedTwoTurns(fx) - const parentLines = readJsonl(transcriptFor(ORIG, tmp)) - expect(parentLines.map((l) => l.type)).toEqual(['user', 'assistant', 'user', 'assistant']) - // Every chain line carries a uuid + parentUuid backbone (the rollback - // resume math runs over the RAW parentUuid chain). - expect(parentLines.every((l) => typeof l.uuid === 'string' && l.uuid.length > 0)).toBe(true) - expect(parentLines[0].parentUuid ?? null).toBe(null) - expect(parentLines[1].parentUuid).toBe(parentLines[0].uuid) - expect(parentLines[2].parentUuid).toBe(parentLines[1].uuid) - - // Fork at a1 (keep through-and-including a1); the dropsTurn guard - // is the RAW-chain successor of the resume point (u2). - fx.send({ - type: 'create', - protocol: 2, - requestId: 'req-fork', - cwd: tmp, - boot: { kind: 'fork', resume: ORIG, resumeSessionAt: parentLines[1].uuid, dropsTurn: parentLines[2].uuid }, - }) - const forkCreated = await fx.waitLine((o) => o.type === 'created' && o.requestId === 'req-fork', 'created fork') - const forkInit = await fx.waitLine( - (o) => o.type === 'sdk.session.init' && o.sessionId === forkCreated.sessionId, - 'sdk.session.init fork', - ) - const CHILD = forkInit.cliSessionId as string - expect(CHILD, 'real claude --fork-session mints a NEW durable id').not.toBe(ORIG) - expect(CHILD).toMatch(/^[0-9a-f-]{36}$/) - - // The child file is the parent's transcript PREFIX through a1, uuids - // preserved verbatim (fork prefix retention) — visible to freshell's - // transcript readers as a real durable JSONL. - const childLines = readJsonl(transcriptFor(CHILD, tmp)) - expect(childLines).toEqual(parentLines.slice(0, 2)) - - // A follow-up plain-resume create still keeps ORIG (no same-id divergence - // regression on the non-fork arm). - fx.send({ type: 'create', protocol: 2, requestId: 'req-plain', cwd: tmp, boot: { kind: 'resume', resume: ORIG } }) - const plainCreated = await fx.waitLine((o) => o.type === 'created' && o.requestId === 'req-plain', 'created plain resume') - const plainInit = await fx.waitLine( - (o) => o.type === 'sdk.session.init' && o.sessionId === plainCreated.sessionId, - 'sdk.session.init plain resume', - ) - expect(plainInit.cliSessionId).toBe(ORIG) - } finally { - await fx.stop() - } - }) - - it('a resume boot keeps the existing same-cliSessionId behavior', async () => { - const fx = launch({ rules: [] }) - try { - const { cliSessionId: ORIG } = await seedTwoTurns(fx) - fx.send({ type: 'create', protocol: 2, requestId: 'req-resume', cwd: tmp, boot: { kind: 'resume', resume: ORIG } }) - const created = await fx.waitLine((o) => o.type === 'created' && o.requestId === 'req-resume', 'created resume') - const init = await fx.waitLine( - (o) => o.type === 'sdk.session.init' && o.sessionId === created.sessionId, - 'sdk.session.init resume', - ) - expect(init.cliSessionId).toBe(ORIG) - // The untouched original transcript is never rewritten by a plain resume. - expect(readJsonl(transcriptFor(ORIG, tmp)).map((l) => l.type)).toEqual([ - 'user', - 'assistant', - 'user', - 'assistant', - ]) - } finally { - await fx.stop() - } - }) - - it('a dropsTurn guard that is NOT the raw-chain successor of the resume point refuses with the SDK prefix', async () => { - const fx = launch({ rules: [] }) - try { - const { cliSessionId: ORIG } = await seedTwoTurns(fx) - const parentLines = readJsonl(transcriptFor(ORIG, tmp)) - // Fork at u1: the successor is a1; a guard naming anything else is the - // SDK's refused-completion contract (freshell retries once guard-less). - fx.send({ - type: 'create', - protocol: 2, - requestId: 'req-refused', - cwd: tmp, - // dropsTurn a2 — NOT the successor of u1 - boot: { kind: 'fork', resume: ORIG, resumeSessionAt: parentLines[0].uuid, dropsTurn: parentLines[3].uuid }, - }) - // This fake still reports the refusal after its placeholder `created`, the - // shape from before sidecar protocol v2 (the real sidecar now answers - // create.failed{FORK_GUARD_REFUSED} instead: test/unit/claude-sidecar/ - // boot-handshake.test.ts); the server's rollback treats both as the guard - // refusal. - await fx.waitLine((o) => o.type === 'created' && o.requestId === 'req-refused', 'created refused fork') - const refusal = await fx.waitLine( - (o) => typeof o?.message === 'string' && o.message.startsWith('Resume rejected by --resume-drops-turn:'), - 'resume-drops-turn refusal line', - ) - expect(refusal.message).toContain(parentLines[3].uuid) - // The refused fork must never mint a DURABLE session: the only - // sdk.session.init on the whole wire is ORIG's own (from seedTwoTurns). - expect( - fx.stdoutLines().filter((o) => o.type === 'sdk.session.init'), - 'no sdk.session.init ever lands for a refused fork', - ).toHaveLength(1) - } finally { - await fx.stop() - } - }) -}) - -describe('create-time UUID contract shared by browser Claude fixtures', () => { - it.each([ - ['SDK fixture', FIXTURE], - ['simple fixture', path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs')], - ])('%s keeps the requested UUID on created, init, and its transcript', async (_name, fixture) => { - const sessionId = '11111111-1111-4111-8111-111111111111' - const config = path.join(tmp, 'claude') - const fx = launch({}, { CLAUDE_CONFIG_DIR: config }, fixture) - try { - fx.send({ type: 'create', protocol: 2, sessionId, requestId: 'uuid-create', cwd: tmp, boot: { kind: 'fresh' } }) - expect(await fx.waitLine((frame) => frame.type === 'created', 'created')).toMatchObject({ sessionId }) - expect(await fx.waitLine((frame) => frame.type === 'sdk.session.init', 'init')).toMatchObject({ sessionId, cliSessionId: sessionId }) - fx.send({ type: 'send', sessionId, uuid: '22222222-2222-4222-8222-222222222222', text: 'hello' }) - await fx.waitLine((frame) => frame.type === 'sdk.result', 'result') - const files = fs.readdirSync(config, { recursive: true }).map(String) - expect(files.some((file) => file.endsWith(`${sessionId}.jsonl`))).toBe(true) - } finally { await fx.stop() } - }) -}) diff --git a/test/e2e-browser/helpers/unified-agent-names.ts b/test/e2e-browser/helpers/unified-agent-names.ts index d1e2fcde1..97aa97281 100644 --- a/test/e2e-browser/helpers/unified-agent-names.ts +++ b/test/e2e-browser/helpers/unified-agent-names.ts @@ -12,6 +12,7 @@ import type { E2eServerInfo } from './server-fixture-support.js' import { selectShellFromPicker } from './test-harness.js' import { openPanePicker } from './pane-picker.js' import type { SessionNameRef } from '../../../shared/session-names.js' +import { claudeSimulatorServerEnv } from './claude-sim-lane.js' /** * UNIFIED AGENT NAMES (plan Task 8) — the shared six-mode journey helper. @@ -263,10 +264,9 @@ export async function modeFixture(mode: UnifiedAgentMode): Promise provider: 'claude', fresh: true, pickerLabel: /^Freshclaude$/, - env: async () => ({ - FRESHELL_CLAUDE_SIDECAR: path.join(providers, 'fake-claude-sdk-sidecar.mjs'), - FRESHELL_CLAUDE_NODE: process.execPath, - FRESHELL_FAKE_PROVIDER: 'freshclaude', + env: async (root) => claudeSimulatorServerEnv({ + ledgerPath: path.join(root, 'claude-sim-ledger.jsonl'), + flavour: 'freshclaude', }), } case 'freshcodex': 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 6ab0808c0..ee6aa39f8 100644 --- a/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts @@ -1,15 +1,15 @@ /** * FRESH-AGENT CONTROL SURFACES — PW-RUST e2e validation (AGENT-04/05/06/07/24). * - * Drives the REAL Rust server + real browser UI against hermetic provider - * fakes and pins the four control surfaces end-to-end: + * Drives the REAL Rust server + real browser UI against the production Claude + * sidecar over the CLI simulator, plus the existing Codex/OpenCode fixtures, + * and pins the control surfaces end-to-end: * * AGENT-05 (approval responses + cancellation): Allow/Deny clicks must - * write the EXACT `permission.respond` frame to the sidecar - * stdin (FRESHELL_FAKE_STDIN raw audit log) — and provably ZERO frames - * before the click; reload-while-pending restores exactly one card from - * the REST snapshot overlay; the composer Stop button cancels without - * inventing a user decision. + * Allow/Deny clicks reach the simulated CLI's real canUseTool callback — + * and provably no decision exists before the click; reload-while-pending + * restores exactly one card from the REST snapshot overlay; composer Stop + * cancels without inventing a user decision. * AGENT-06 (question responses): single-choice / multi-select / Other * answers land in `question.respond` keyed by QUESTION TEXT, and the turn * continues. @@ -31,35 +31,6 @@ * 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 - * compact content is out of scope for shape tests by design (the real-provider - * contract suites are opt-in per repo policy). - * - * ── PRE-FIX NEGATIVE (mutation-check recipe) ──────────────────────────────── - * The spec must fail loudly without the response wiring. Prove it by pointing - * the claude lane at a NO-OP stub that answers create but never raises a card - * (NOTE: the stub must be .cjs — a require()-based stub in a .mjs file crashes - * at import under NodeNext, which would fail the run for the WRONG reason): - * 1. Write /tmp/noop-sidecar.cjs: - * const rl = require('readline').createInterface({ input: process.stdin }) - * rl.on('line', (line) => { - * let m; try { m = JSON.parse(line) } catch { return } - * if (m.type === 'create') { - * const id = 'noop-' + process.pid - * process.stdout.write(JSON.stringify({ type:'created', requestId:m.requestId, sessionId:id })+'\n') - * process.stdout.write(JSON.stringify({ type:'sdk.session.init', sessionId:id, cliSessionId:'66666666-6666-4666-8666-666666666666', model:'noop', cwd:m.cwd||process.cwd(), tools:[] })+'\n') - * process.stdout.write(JSON.stringify({ type:'sdk.status', sessionId:id, status:'idle' })+'\n') - * } - * }) - * process.stdin.resume() - * 2. FRESHELL_FAKE_NOOP=1 pnpm exec playwright test --config test/e2e-browser/playwright.config.ts \ - * Expected: the approval-allow test fails — the approval card never renders - * (timeout waiting for role=alert "Permission request for Bash"), proving - * the assertion chain is not vacuous. (Recorded in the Task-8 report; with - * the Tasks 1-6 server wiring REMOVED the failure is instead the click - * producing zero `permission.respond` lines in the sidecar stdin audit.) - * * Donors (helpers copied, not imported, per this suite's per-spec-ownership * convention): freshclaude-identity-persistence-rust.spec.ts (boot/identity * plumbing), freshagent-settings-resume-rust.spec.ts (CODEX_CMD/OPENCODE_CMD @@ -81,11 +52,11 @@ import type { E2eServerInfo } from '../helpers/server-fixture-support.js'; import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' import { WS_PROTOCOL_VERSION } from '../../../shared/ws-version.js' +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.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') const CODEX_APP_SERVER_FIXTURE = path.resolve( __dirname, '../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs', @@ -95,26 +66,21 @@ 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 (the shared claude/kilroy lane) ───────────────────────── -// Text-driven raises; responds continue the turn: allow → success completion, -// deny → a NON-SUCCESS closure that rings the unified attention edge — a deny -// is an unwitnessed-error turn end and rings exactly like any other turn end -// (the edge is outcome-agnostic; only a user-initiated interrupt stays -// silent), question answers → success completion. Compact emits the compacting -// status then completes after a pollable window. Crash exits the process -// mid-turn — no protocol frames, like a real sidecar death (the Rust -// unrequested-death synthesis rings for it, never the fake). +// ── Simulator program (the shared Claude/Kilroy lane) ─────────────────────── +// Text-driven permission and question requests. The simulator's real +// canUseTool callback handles the user's answer; its local /compact command +// and process-death behavior supply the remaining lifecycle events. const 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:send', + on: 'msg:prompt', match: { text: 'RAISE_QUESTION' }, emit: [ { @@ -137,7 +103,7 @@ const CLAUDE_PROGRAM = { ], }, { - on: 'msg:send', + on: 'msg:prompt', match: { text: 'RAISE_QUESTION_MULTI' }, emit: [ { @@ -161,7 +127,7 @@ const CLAUDE_PROGRAM = { ], }, { - on: 'msg:send', + on: 'msg:prompt', match: { text: 'RAISE_QUESTION_OTHER' }, emit: [ { @@ -180,41 +146,10 @@ const CLAUDE_PROGRAM = { }, ], }, - { - on: 'msg:permission.respond', - match: { decision: { behavior: 'allow' } }, - emit: [{ kind: 'completion', data: { subtype: 'success' } }], - }, - { - on: 'msg:permission.respond', - match: { decision: { behavior: 'deny' } }, - emit: [ - { - kind: 'completion', - delayMs: 20, - data: { subtype: 'error', text: 'The user denied this request.' }, - }, - ], - }, - { - on: 'msg:question.respond', - emit: [{ kind: 'completion', delayMs: 10, data: { subtype: 'success' } }], - }, - { - on: 'msg:send', - match: { text: '/compact focus the diff' }, - emit: [ - { kind: 'activity', data: { status: 'compacting' } }, - // The compacting indicator assertion polls attribute state; under - // full-suite multi-worker load the page's rAF polling can stall beyond a - // few hundred ms, so the hold must comfortably outlast that (1500ms). - { kind: 'completion', delayMs: 1500, data: { subtype: 'success' } }, - ], - }, { // The 3s delay is the window in which the crash lane moves the user to // another tab, so the crash ends a turn nobody is watching. - on: 'msg:send', + on: 'msg:prompt', match: { text: 'CRASH_NOW' }, emit: [{ kind: 'crash', delayMs: 3_000, data: { code: 1 } }], }, @@ -382,19 +317,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 waitForLogEntry( logPath: string, pred: (row: any) => boolean, @@ -414,9 +336,9 @@ async function waitForLogEntry( return found } -async function waitForStdinFrame( - stdinLog: string, - pred: (frame: any) => boolean, +async function waitForSimRow( + simLedger: string, + pred: (row: any) => boolean, what: string, timeoutMs = 15_000, ): Promise { @@ -424,10 +346,10 @@ async function waitForStdinFrame( await expect .poll( () => { - found = readStdinFrames(stdinLog).find(pred) ?? null + found = readClaudeSimLedger(simLedger).find(pred) ?? null return Boolean(found) }, - { timeout: timeoutMs, message: `timed out waiting for ${what} in ${stdinLog}` }, + { timeout: timeoutMs, message: `timed out waiting for ${what} in ${simLedger}` }, ) .toBe(true) return found @@ -527,16 +449,12 @@ function claudeLaneEnv( flavour: 'freshclaude' | 'kilroy', extraEnv: Record = {}, ): Record { - const sidecar = process.env.FRESHELL_FAKE_NOOP === '1' - ? '/tmp/noop-sidecar.cjs' - : CLAUDE_SIDECAR_FIXTURE return { - FRESHELL_CLAUDE_SIDECAR: sidecar, - FRESHELL_CLAUDE_NODE: process.execPath, - FRESHELL_FAKE_PROVIDER: flavour, - FRESHELL_FAKE_PROGRAM: JSON.stringify(CLAUDE_PROGRAM), - FRESHELL_FAKE_STDIN: path.join(sharedRoot, 'sidecar-stdin.jsonl'), - FRESHELL_FAKE_EVENTS: path.join(sharedRoot, 'sidecar-events.jsonl'), + ...claudeSimulatorServerEnv({ + ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl'), + flavour, + program: CLAUDE_PROGRAM, + }), ...extraEnv, } } @@ -552,8 +470,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-agentctl-${flavour}-`)) @@ -591,8 +508,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) { @@ -602,21 +518,21 @@ async function bootClaudeLane( } /** Raise the scripted Bash permission and wait until its card is up, proving - * ZERO respond frames landed before the user's click. */ + * no decision reached the CLI before the user's click. */ async function raisePermissionAndAssertNoDecisions( page: Page, harness: TestHarness, tabId: string, - stdinLog: string, + simLedger: string, ): Promise { await sendComposerText(page, 'RAISE_PERMISSION') const card = page.getByRole('alert', { name: 'Permission request for Bash' }) await expect(card).toBeVisible({ timeout: 15_000 }) - // AGENT-05 pause-proof: the fake has received zero decisions — the card came + // AGENT-05 pause-proof: the CLI has received zero decisions — the card came // from the snapshot overlay, not from any fabricated user choice. expect( - readStdinFrames(stdinLog).filter((f) => f?.type === 'permission.respond'), - 'no permission.respond may exist before a user click', + readClaudeSimLedger(simLedger).filter((row) => row.kind === 'can_use_tool'), + 'no canUseTool decision may exist before a user click', ).toEqual([]) // And the raise is pending server-side too (the REST snapshot overlay the // card renders from carries exactly one pending approval). @@ -632,26 +548,23 @@ async function raisePermissionAndAssertNoDecisions( test.describe('fresh-agent control surfaces — claude lane (rust)', () => { test.setTimeout(180_000) - test('approval: Allow writes the exact permission.respond, never before the click', async ({ page }) => { + test('approval: Allow reaches the simulated CLI only after the click', async ({ page }) => { const lane = await bootClaudeLane(page, 'freshclaude') try { - await waitForPaneStatus(lane.harness, lane.tabId, 'idle') - await raisePermissionAndAssertNoDecisions(page, lane.harness, lane.tabId, lane.stdinLog) + await waitForPaneStatus(lane.harness, lane.tabId, ['connected', 'idle']) + await raisePermissionAndAssertNoDecisions(page, lane.harness, lane.tabId, lane.simLedger) const card = page.getByRole('alert', { name: 'Permission request for Bash' }) await card.getByRole('button', { name: 'Allow tool use' }).click() - // The decision reaches the sidecar stdin VERBATIM (the transparency the - // pre-fix UNSUPPORTED_MESSAGE path destroyed). - const respond = await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'permission.respond', - 'permission.respond frame', + // The production sidecar resolves its parked canUseTool callback with + // the user's decision; the simulator ledger records that actual result. + const response = await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'can_use_tool' && row.toolName === 'Bash', + 'the CLI permission decision', ) - expect(respond.requestId).toBe('req-perm-1') - expect(respond.decision).toEqual({ behavior: 'allow' }) - const leaf = await paneLeaf(lane.harness, lane.tabId) - expect(respond.sessionId).toBe(leaf?.content?.sessionId) + expect(response.decision).toEqual({ behavior: 'allow' }) // The scripted continuation completes the turn; the card clears. await expect(card).toBeHidden({ timeout: 15_000 }) @@ -662,11 +575,11 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { } }) - test('approval: Deny writes the deny decision, no success completion, rings the unified attention edge, pane stays usable', async ({ page }) => { + test('approval: Deny reaches the CLI, rings the unified attention edge, and leaves the pane usable', async ({ page }) => { const lane = await bootClaudeLane(page, 'freshclaude') try { - await waitForPaneStatus(lane.harness, lane.tabId, 'idle') - await raisePermissionAndAssertNoDecisions(page, lane.harness, lane.tabId, lane.stdinLog) + await waitForPaneStatus(lane.harness, lane.tabId, ['connected', 'idle']) + await raisePermissionAndAssertNoDecisions(page, lane.harness, lane.tabId, lane.simLedger) const card = page.getByRole('alert', { name: 'Permission request for Bash' }) @@ -682,63 +595,19 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { await card.getByRole('button', { name: 'Deny tool use' }).click() - const respond = await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'permission.respond', - 'permission.respond frame', + const response = await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'can_use_tool' && row.toolName === 'Bash', + 'the CLI permission decision', ) - expect(respond.decision).toEqual({ behavior: 'deny', message: 'Denied by user', interrupt: false }) + expect(response.decision).toMatchObject({ behavior: 'deny', message: 'Denied by user' }) - // The denial closes the turn WITHOUT a success completion (the fixture - // keys its scripted continuation on the decision behavior). + // A denied tool result returns control to the simulated model, which + // completes its response just as the real CLI can. await expect(card).toBeHidden({ timeout: 15_000 }) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') - const completions = readJsonl(lane.eventsLog).filter((e) => e.kind === 'completion') - expect( - completions.length, - 'exactly one scripted completion exists for this deny cycle', - ).toBe(1) - expect( - completions.every((e) => e.data?.subtype === 'error'), - 'a deny must never produce a success completion', - ).toBe(true) - - // D1-F2 WIRE TRUTH: the fixture mirrors the real sidecar — the denied - // turn ends with its ERRORED result alone (no per-turn idle), and the - // fixture mints no attention edge: the server rings the result itself - // (C18), the SAME edge a success would. A deny is an error turn end; - // only a user-initiated interrupt stays silent. Race-free: poll the - // outbound ledger until the errored result exists — fenced BEFORE the - // follow-up send below (a LATER, legitimately-completing turn). - const deniedBridgeId = respond.sessionId as string - const denyWires = () => readJsonl(lane.eventsLog).filter((r) => r.kind === 'wire') - const denyResultIndex = (wires: any[]) => wires.findIndex( - (r) => r.frame?.type === 'sdk.result' && r.frame?.sessionId === deniedBridgeId - && r.frame?.result === 'error', - ) - await expect - .poll( - () => denyResultIndex(denyWires()), - { timeout: 15_000, message: 'the errored sdk.result for the denied turn' }, - ) - .toBeGreaterThanOrEqual(0) - const wires = denyWires() - const denyResultIdx = denyResultIndex(wires) - expect( - wires.filter((r) => r.frame?.type === 'sdk.turn.complete' || r.frame?.type === 'sdk.turn.waiting'), - 'the fixture mints no attention edge: the server does', - ).toEqual([]) - expect( - wires - .slice(denyResultIdx) - .filter((r) => r.frame?.type === 'sdk.status' && r.frame?.sessionId === deniedBridgeId && r.frame?.status === 'idle'), - 'no per-turn idle follows the result (the real sidecar sends no idle at all)', - ).toEqual([]) - expect( - wires.some((r) => r.frame?.type === 'sdk.assistant' && r.frame?.sessionId === deniedBridgeId - && r.frame?.content?.[0]?.text?.includes('denied')), - 'the denial assistant frame did arrive on the wire', - ).toBe(true) + const deniedBridgeId = (await paneLeaf(lane.harness, lane.tabId))?.content?.sessionId as string + expect(deniedBridgeId).toBeTruthy() // BROWSER-FACING contract: the server's edge for the denied result // folded in the browser as EXACTLY ONE new turnCompletion event @@ -778,10 +647,10 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { // Prompt-usable again: a plain follow-up send completes normally. await sendComposerText(page, 'follow-up after deny') await waitForPaneStatus(lane.harness, lane.tabId, 'idle') - await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'send' && f?.text === 'follow-up after deny', - 'follow-up send frame', + await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'prompt' && row.text === 'follow-up after deny', + 'the follow-up prompt', ) } finally { await lane.server.stop().catch(() => {}) @@ -792,9 +661,9 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { test('approval: survives page reload mid-pending (exactly one restored card), Allow still works', async ({ page }) => { const lane = await bootClaudeLane(page, 'freshclaude') try { - await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + await waitForPaneStatus(lane.harness, lane.tabId, ['connected', 'idle']) const durableBefore = await captureDurableId(lane.harness, lane.tabId, CANONICAL_UUID_RE) - await raisePermissionAndAssertNoDecisions(page, lane.harness, lane.tabId, lane.stdinLog) + await raisePermissionAndAssertNoDecisions(page, lane.harness, lane.tabId, lane.simLedger) // RELOAD while the permission is pending. The card must come back from // the REST snapshot overlay as EXACTLY ONE entry — no WS replay exists. @@ -814,16 +683,16 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { ).toHaveCount(1) // The reload window itself must never have fabricated a decision. expect( - readStdinFrames(lane.stdinLog).filter((f) => f?.type === 'permission.respond'), + readClaudeSimLedger(lane.simLedger).filter((row) => row.kind === 'can_use_tool'), ).toEqual([]) await card.getByRole('button', { name: 'Allow tool use' }).click() - const respond = await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'permission.respond', - 'permission.respond frame', + const response = await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'can_use_tool' && row.toolName === 'Bash', + 'the CLI permission decision', ) - expect(respond.decision).toEqual({ behavior: 'allow' }) + expect(response.decision).toEqual({ behavior: 'allow' }) await expect(card).toBeHidden({ timeout: 15_000 }) expect(await captureDurableId(harness2, tabId2, CANONICAL_UUID_RE)).toBe(durableBefore) } finally { @@ -835,8 +704,8 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { test('approval cancellation: composer Stop removes the card with zero fabricated decisions', async ({ page }) => { const lane = await bootClaudeLane(page, 'freshclaude') try { - await waitForPaneStatus(lane.harness, lane.tabId, 'idle') - await raisePermissionAndAssertNoDecisions(page, lane.harness, lane.tabId, lane.stdinLog) + await waitForPaneStatus(lane.harness, lane.tabId, ['connected', 'idle']) + await raisePermissionAndAssertNoDecisions(page, lane.harness, lane.tabId, lane.simLedger) const card = page.getByRole('alert', { name: 'Permission request for Bash' }) const paneRoot = page.locator('[data-context="fresh-agent"]').last() @@ -844,16 +713,18 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { await expect(card).toBeHidden({ timeout: 15_000 }) // Give the interrupt's full round trip a deterministic landing point: - // the fixture's cancelled frame drives the snapshot refresh that clears - // the card, and the session returns to idle. + // the sidecar's cancellation event clears the card, and the session + // returns to idle. await waitForPaneStatus(lane.harness, lane.tabId, 'idle') expect( - readStdinFrames(lane.stdinLog).filter((f) => f?.type === 'permission.respond'), - 'a provider cancellation must never invent a user decision', - ).toEqual([]) + readClaudeSimLedger(lane.simLedger) + .filter((row) => row.kind === 'can_use_tool') + .map((row) => ({ toolName: row.toolName, decision: row.decision })), + 'Stop resolves the CLI callback as a provider interruption, never as a user choice', + ).toEqual([{ toolName: 'Bash', decision: { behavior: 'deny', message: 'Interrupted' } }]) expect( - readStdinFrames(lane.stdinLog).some((f) => f?.type === 'interrupt'), - 'the interrupt frame itself must reach the sidecar', + readClaudeSimLedger(lane.simLedger).some((row) => row.kind === 'interrupt'), + 'the CLI observes the actual interrupt call', ).toBe(true) } finally { await lane.server.stop().catch(() => {}) @@ -864,24 +735,23 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { test('questions: single-choice, multi-select, and Other answers keyed by question text', async ({ page }) => { const lane = await bootClaudeLane(page, 'freshclaude') try { - await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + await waitForPaneStatus(lane.harness, lane.tabId, ['connected', 'idle']) const banner = page.getByRole('region', { name: /Question from/ }) // 1. single-choice await sendComposerText(page, 'RAISE_QUESTION') await expect(banner).toBeVisible({ timeout: 15_000 }) expect( - readStdinFrames(lane.stdinLog).filter((f) => f?.type === 'question.respond'), - 'no question.respond may exist before an answer', + readClaudeSimLedger(lane.simLedger).filter((row) => row.kind === 'can_use_tool' && row.toolName === 'AskUserQuestion'), + 'no question answer may reach canUseTool before the user answers', ).toEqual([]) await banner.getByRole('button', { name: 'A' }).click() - let respond = await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'question.respond', - 'question.respond #1', + let answer = await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'can_use_tool' && row.toolName === 'AskUserQuestion', + 'the first CLI question answer', ) - expect(respond.requestId).toBe('req-q-1') - expect(respond.answers).toEqual({ 'Pick one': 'A' }) + expect(answer.decision.updatedInput.answers).toEqual({ 'Pick one': 'A' }) await expect(banner).toBeHidden({ timeout: 15_000 }) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') @@ -893,14 +763,14 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { await banner.getByRole('button', { name: 'Submit' }).click() await expect .poll( - () => readStdinFrames(lane.stdinLog).filter((f) => f?.type === 'question.respond').length, + () => readClaudeSimLedger(lane.simLedger).filter((row) => row.kind === 'can_use_tool' && row.toolName === 'AskUserQuestion').length, { timeout: 15_000 }, ) .toBe(2) - respond = readStdinFrames(lane.stdinLog) - .filter((f) => f?.type === 'question.respond') - .find((f) => f.requestId === 'req-qm-1') - expect(respond.answers).toEqual({ 'Pick any two': 'A, C' }) + answer = readClaudeSimLedger(lane.simLedger) + .filter((row) => row.kind === 'can_use_tool' && row.toolName === 'AskUserQuestion') + .at(-1) + expect(answer.decision.updatedInput.answers).toEqual({ 'Pick any two': 'A, C' }) await expect(banner).toBeHidden({ timeout: 15_000 }) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') @@ -913,14 +783,14 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { await banner.getByRole('button', { name: 'Submit' }).click() await expect .poll( - () => readStdinFrames(lane.stdinLog).filter((f) => f?.type === 'question.respond').length, + () => readClaudeSimLedger(lane.simLedger).filter((row) => row.kind === 'can_use_tool' && row.toolName === 'AskUserQuestion').length, { timeout: 15_000 }, ) .toBe(3) - respond = readStdinFrames(lane.stdinLog) - .filter((f) => f?.type === 'question.respond') - .find((f) => f.requestId === 'req-qo-1') - expect(respond.answers).toEqual({ 'Free text': 'Something custom' }) + answer = readClaudeSimLedger(lane.simLedger) + .filter((row) => row.kind === 'can_use_tool' && row.toolName === 'AskUserQuestion') + .at(-1) + expect(answer.decision.updatedInput.answers).toEqual({ 'Free text': 'Something custom' }) await expect(banner).toBeHidden({ timeout: 15_000 }) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') } finally { @@ -930,9 +800,11 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { }) test('compact: /compact instructions land on the same session; compacting shows; context retained', async ({ page }) => { - const lane = await bootClaudeLane(page, 'freshclaude') + // Give the simulator's real local /compact operation enough wall time for + // the UI's compacting state to be observed. + const lane = await bootClaudeLane(page, 'freshclaude', { FRESHELL_CLAUDE_SIM_TIME_SCALE: '50' }) try { - await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + await waitForPaneStatus(lane.harness, lane.tabId, ['connected', 'idle']) const durableBefore = await captureDurableId(lane.harness, lane.tabId, CANONICAL_UUID_RE) // Warm the session with one ordinary turn. @@ -944,45 +816,42 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { await paneRoot.getByRole('textbox', { name: 'Chat message input' }).fill('/compact focus the diff') await paneRoot.getByRole('textbox', { name: 'Chat message input' }).press('Enter') - // The compacting indicator is visible while the fixture parks the turn - // (the program holds completion for a pollable window). + // The real local command exposes the compacting state while it works. await expect(page.getByTestId('fresh-agent-thinking-bar')).toHaveAttribute( 'data-state', 'active', { timeout: 15_000 }, ) - // The sidecar sees the compact as a SEND with the full instructions on - // the SAME bridge session id (never a fresh session, never dropped args). - const compactSend = await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'send' && typeof f?.text === 'string' && f.text.startsWith('/compact'), - 'compact send frame', + // The CLI ledger records the exact local command text and durable id. + const compactSend = await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'prompt' && typeof row.text === 'string' && row.text.startsWith('/compact'), + 'compact command', ) expect(compactSend.text).toBe('/compact focus the diff') - const leaf = await paneLeaf(lane.harness, lane.tabId) - expect(compactSend.sessionId).toBe(leaf?.content?.sessionId) + expect(compactSend.sessionId).toBe(durableBefore) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') // Follow-up prompt on the same pane → retained session + turn completes. - // Gate on the frame landing in the stdin audit (never the pane status, - // which can report a stale idle before the busy flip). + // Gate on the prompt reaching the simulated CLI, never a stale idle + // status from before the send's busy flip. await sendComposerText(page, 'follow-up after compact') - await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'send' && f?.text === 'follow-up after compact', - 'follow-up send frame', + await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'prompt' && row.text === 'follow-up after compact', + 'follow-up prompt', ) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') - const sends = readStdinFrames(lane.stdinLog).filter((f) => f?.type === 'send') + const prompts = readClaudeSimLedger(lane.simLedger).filter((row) => row.kind === 'prompt') expect( - sends.filter((f) => f.text === '/compact focus the diff').length, - 'exactly one compact send', + prompts.filter((row) => row.text === '/compact focus the diff').length, + 'exactly one compact command', ).toBe(1) expect( - sends.filter((f) => f.text === 'follow-up after compact').length, - 'exactly one follow-up send', + prompts.filter((row) => row.text === 'follow-up after compact').length, + 'exactly one follow-up prompt', ).toBe(1) - expect(new Set(sends.map((f) => f.sessionId)).size, 'all sends ride ONE bridge session').toBe(1) + expect(new Set(prompts.map((row) => row.sessionId)), 'all commands use ONE durable Claude session').toEqual(new Set([durableBefore])) expect(await captureDurableId(lane.harness, lane.tabId, CANONICAL_UUID_RE)).toBe(durableBefore) // The durable transcript accumulates compaction + follow-up entries. @@ -1004,7 +873,7 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { test('capability gate: /fork is offered for claude', async ({ page }) => { const lane = await bootClaudeLane(page, 'freshclaude') try { - await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + await waitForPaneStatus(lane.harness, lane.tabId, ['connected', 'idle']) // One turn so the snapshot sheet is fully hydrated. await sendComposerText(page, 'gate warm-up') await waitForPaneStatus(lane.harness, lane.tabId, 'idle') @@ -1025,12 +894,10 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { } }) - test('per-send settings reach the claude sidecar before the send (freshclaude)', async ({ page }) => { + test('per-send settings reach the CLI before the next prompt (freshclaude)', async ({ page }) => { const lane = await bootClaudeLane(page, 'freshclaude') try { - await waitForPaneStatus(lane.harness, lane.tabId, 'idle') - const paneSessionId = (await paneLeaf(lane.harness, lane.tabId))?.content?.sessionId as string - expect(paneSessionId, 'the pane bridge session id must be known before the send').toBeTruthy() + await waitForPaneStatus(lane.harness, lane.tabId, ['connected', 'idle']) // Change the permission mode BETWEEN sends through the pane's real // settings gear: freshclaude's registry default is 'default', so picking @@ -1043,43 +910,29 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { .toBe('acceptEdits') await sendComposerText(page, 'settings probe') - // Canonical machinery: the settings-bearing send routes through - // configure_for_send, which writes a `configure` frame and awaits the - // sidecar's ack BEFORE the user message frame — the stdin audit proves - // strict ordering (the knobs provably land before the turn starts). - await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'configure' && f?.settings?.permissionMode === 'acceptEdits', - 'configure frame carrying permissionMode:acceptEdits', + // The production sidecar applies controls before it sends the queued + // prompt to the simulated CLI; the ledger records both operations. + await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'control' && row.name === 'setPermissionMode' && row.args?.[0] === 'acceptEdits', + 'the CLI permission-mode control', ) - await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'send' && f?.text === 'settings probe', - 'send frame for "settings probe"', + await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'prompt' && row.text === 'settings probe', + 'settings probe prompt', ) - const frames = readStdinFrames(lane.stdinLog) - const configureIdx = frames.findIndex( - (f) => f?.type === 'configure' && f?.settings?.permissionMode === 'acceptEdits', - ) - const sendIdx = frames.findIndex((f) => f?.type === 'send' && f?.text === 'settings probe') - expect(configureIdx, 'the per-send configure frame must exist in the audit').toBeGreaterThanOrEqual(0) - expect(sendIdx, 'the probe send must exist in the audit').toBeGreaterThanOrEqual(0) - expect(configureIdx, 'configure must land strictly BEFORE the send it applies to').toBeLessThan(sendIdx) - - // The fake sidecar answers with sdk.configured carrying the applied - // settings (the ack configure_for_send awaits): the wire-row proves the - // ack as well as the ordering. - const ack = await waitForLogEntry( - lane.eventsLog, - (e) => e.kind === 'wire' - && e.frame?.type === 'sdk.configured' - && e.frame?.ok === true - && e.frame?.settings?.permissionMode === 'acceptEdits' - && e.frame?.sessionId === paneSessionId, - 'sdk.configured wire row acknowledging permissionMode:acceptEdits', + const records = readClaudeSimLedger(lane.simLedger) + const configureIdx = records.findIndex( + (row) => row.kind === 'control' && row.name === 'setPermissionMode' && row.args?.[0] === 'acceptEdits', ) - expect(ack.frame.ok).toBe(true) + const sendIdx = records.findIndex((row) => row.kind === 'prompt' && row.text === 'settings probe') + expect(configureIdx, 'the CLI must receive the new permission mode').toBeGreaterThanOrEqual(0) + expect(sendIdx, 'the CLI must receive the settings probe').toBeGreaterThanOrEqual(0) + expect(configureIdx, 'the control must land strictly BEFORE the prompt it applies to').toBeLessThan(sendIdx) + expect(records[sendIdx].sessionId).toBe((await paneLeaf(lane.harness, lane.tabId))?.content?.sessionRef?.sessionId) + expect(records[sendIdx].permissionMode).toBe('acceptEdits') await waitForPaneStatus(lane.harness, lane.tabId, 'idle') } finally { await lane.server.stop().catch(() => {}) @@ -1128,12 +981,12 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { 'the raise send carries the non-bypass pane permission mode', ).toBe('default') await card.getByRole('button', { name: 'Allow tool use' }).click() - const respond = await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'permission.respond', - 'permission.respond frame', + const response = await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'can_use_tool' && row.toolName === 'Bash', + 'the CLI permission decision', ) - expect(respond.decision).toEqual({ behavior: 'allow' }) + expect(response.decision).toEqual({ behavior: 'allow' }) await expect(card).toBeHidden({ timeout: 15_000 }) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') @@ -1144,12 +997,12 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { await banner.getByRole('button', { name: 'A' }).click() await expect .poll( - () => readStdinFrames(lane.stdinLog).filter((f) => f?.type === 'question.respond').length, + () => readClaudeSimLedger(lane.simLedger).filter((row) => row.kind === 'can_use_tool' && row.toolName === 'AskUserQuestion').length, { timeout: 15_000 }, ) .toBe(1) - const qRespond = readStdinFrames(lane.stdinLog).find((f) => f?.type === 'question.respond') - expect(qRespond.answers).toEqual({ 'Pick one': 'A' }) + const qAnswer = readClaudeSimLedger(lane.simLedger).find((row) => row.kind === 'can_use_tool' && row.toolName === 'AskUserQuestion') + expect(qAnswer.decision.updatedInput.answers).toEqual({ 'Pick one': 'A' }) await expect(banner).toBeHidden({ timeout: 15_000 }) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') @@ -1168,7 +1021,7 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { await card.getByRole('button', { name: 'Allow tool use' }).click() await expect .poll( - () => readStdinFrames(lane.stdinLog).filter((f) => f?.type === 'permission.respond').length, + () => readClaudeSimLedger(lane.simLedger).filter((row) => row.kind === 'can_use_tool' && row.toolName === 'Bash').length, { timeout: 15_000 }, ) .toBe(2) @@ -1184,9 +1037,15 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { await expect(card).toBeHidden({ timeout: 15_000 }) await waitForPaneStatus(harness2, tabId2, 'idle') expect( - readStdinFrames(lane.stdinLog).filter((f) => f?.type === 'permission.respond'), - 'exactly the two human clicks ever produced responds', - ).toHaveLength(2) + readClaudeSimLedger(lane.simLedger) + .filter((row) => row.kind === 'can_use_tool' && row.toolName === 'Bash') + .map((row) => row.decision), + 'the two clicks allow the tool; Stop resolves the parked callback as Interrupted', + ).toEqual([ + { behavior: 'allow' }, + { behavior: 'allow' }, + { behavior: 'deny', message: 'Interrupted' }, + ]) } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) @@ -1196,7 +1055,7 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { test('kilroy crash mid-turn in the background: one ring, Resume (no reload), a reload and restart recover the SAME durable session', async ({ page }) => { const lane = await bootClaudeLane(page, 'kilroy', { KILROY_ENABLED: '1' }) try { - await waitForPaneStatus(lane.harness, lane.tabId, 'idle') + await waitForPaneStatus(lane.harness, lane.tabId, ['connected', 'idle']) const durableBefore = await captureDurableId(lane.harness, lane.tabId, CANONICAL_UUID_RE) const paneRoot = page.locator('[data-context="fresh-agent"]').last() const tabLocator = page.locator(`[data-context="tab"][data-tab-id="${lane.tabId}"]`) @@ -1206,15 +1065,15 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { // conversation in place. await page.evaluate(() => { (window as any).__freshellNoReloadMarker = 'kilroy-crash-lane' }) - // Crash mid-turn while the user is in another tab: the fixture - // exits(1) with no protocol frames (a Node death), 3s after the send. + // Crash mid-turn while the user is in another tab: the simulator ends + // the CLI process, so the production sidecar reports the stream exit. await sendComposerText(page, 'CRASH_NOW') await page.getByRole('button', { name: 'New shell tab' }).click() await lane.harness.waitForTabCount(2) - await waitForLogEntry( - lane.eventsLog, - (e) => e.kind === 'crash', - 'fixture crash event', + await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'crash', + 'simulated CLI process exit', ) // The server rings the lost turn exactly once (G05: a crash is a turn // end the user did not witness) and the background tab is highlighted. @@ -1224,13 +1083,6 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { .toBe(seqBefore + 1) expect((await turnCompletion()).attentionByTab?.[lane.tabId], 'the crash highlights the tab').toBe(true) await expect(tabLocator, 'the background tab carries the attention highlight').toHaveClass(/bg-emerald-100/) - // Ground truth: the fixture fabricated no completion; the one ring is - // the server's for the dead sidecar. - expect( - readJsonl(lane.eventsLog).filter((e) => e.kind === 'completion'), - 'no completion may be fabricated for the crashed turn', - ).toEqual([]) - // Back on the tab: the session reads as ended, with Resume, and the // composer is disabled (nothing typed there could be lost). await tabLocator.click() @@ -1244,10 +1096,10 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { // Resume reopens the SAME durable session in place, with no reload. await paneRoot.getByRole('button', { name: 'Resume session' }).click() - await waitForLogEntry( - lane.eventsLog, - (e) => e.kind === 'resume' && e.data?.id === durableBefore, - 'the replacement sidecar resumed the crashed durable session', + await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'query' && row.options?.resume === durableBefore, + 'the replacement CLI resumed the crashed durable session', ) expect(await captureDurableId(lane.harness, lane.tabId, CANONICAL_UUID_RE, 30_000)).toBe(durableBefore) await waitForPaneStatus(lane.harness, lane.tabId, ['idle', 'connected']) @@ -1261,10 +1113,10 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { // landing (the sidecar saw the send and completed it), never the // client's optimistic local echo. await sendComposerText(page, 'post-crash recovery turn') - await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'send' && f?.text === 'post-crash recovery turn', - 'post-crash send frame', + await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'prompt' && row.text === 'post-crash recovery turn', + 'post-crash recovery prompt', 30_000, ) await waitForPaneStatus(lane.harness, lane.tabId, 'idle') @@ -1273,9 +1125,9 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { // A second crash, recovered by reloading the page instead: the pane // hydrates as ended, and its attach must still resume the SAME durable // session (the server holds a dead-session record for it until then). - const crashes = () => readJsonl(lane.eventsLog).filter((e) => e.kind === 'crash').length - const resumes = () => readJsonl(lane.eventsLog) - .filter((e) => e.kind === 'resume' && e.data?.id === durableBefore).length + const crashes = () => readClaudeSimLedger(lane.simLedger).filter((row) => row.kind === 'crash').length + const resumes = () => readClaudeSimLedger(lane.simLedger) + .filter((row) => row.kind === 'query' && row.options?.resume === durableBefore).length const resumesBeforeReload = resumes() await sendComposerText(page, 'CRASH_NOW') await expect.poll(crashes, { timeout: 30_000, message: 'the second fixture crash' }).toBe(2) @@ -1296,10 +1148,10 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { await waitForPaneStatus(harness2, tabId2, ['idle', 'connected']) await expect(paneRoot.getByText('This session has ended')).toHaveCount(0) await sendComposerText(page, 'post-reload recovery turn') - await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'send' && f?.text === 'post-reload recovery turn', - 'post-reload send frame', + await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'prompt' && row.text === 'post-reload recovery turn', + 'post-reload recovery prompt', 30_000, ) await waitForPaneStatus(harness2, tabId2, 'idle') @@ -1314,10 +1166,10 @@ test.describe('fresh-agent control surfaces — kilroy lane (rust)', () => { { timeout: 60_000, message: 'the restarted server resumed the same durable session' }) .toBeGreaterThanOrEqual(resumesBeforeReload + 2) await sendComposerText(page, 'post-restart turn') - await waitForStdinFrame( - lane.stdinLog, - (f) => f?.type === 'send' && f?.text === 'post-restart turn', - 'post-restart send frame (lands on the new sidecar process)', + await waitForSimRow( + lane.simLedger, + (row) => row.kind === 'prompt' && row.text === 'post-restart turn', + 'post-restart prompt on the new CLI process', 60_000, ) await waitForPaneStatus(harness2, tabId2, 'idle', 60_000) diff --git a/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts b/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts index 83e7d6126..f1cde3965 100644 --- a/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts @@ -7,8 +7,7 @@ // end the user WAS watching produces the tab-strip-only watched mark, no // sound, cleared on the next away-and-back. A user-initiated interrupt is // silent, full stop, and shows no error banner. The first test drives the -// deterministic fake claude-SDK sidecar -// (`fixtures/providers/fake-claude-sdk-sidecar.mjs`) end to end — composer → +// production Claude sidecar over the CLI simulator end to end — composer → // Rust server → sidecar → unified `freshAgent.turn.complete` → client fold → // marks — through these lanes of that contract: // @@ -23,11 +22,10 @@ // still folds the edge and sets the attention flag — the bell rings for // any tab when the window is unfocused (the sound suppression split is // unit-pinned; e2e asserts the fold partition); -// 5. a mid-turn end of the SDK stream (sdk.stream.ended and the sidecar's -// exit) rings exactly one edge, resolves the pane to exited, reports the -// exit (SIDECAR_EXITED), and the sidecar process is gone: a stream end is -// a session end (G02). The server rings the cut-off turn once; the -// sidecar rings nothing (C18). +// 5. a mid-turn CLI exit ends the production sidecar's SDK stream, rings +// exactly one edge, resolves the pane to exited, and reports +// SIDECAR_EXITED: a stream end is a session end (G02). The server rings +// the cut-off turn once; the sidecar rings nothing (C18). // // The user-Stop lane runs on the PRODUCTION sidecar // (crates/freshell-claude-sidecar/index.mjs) over the CLI simulator @@ -36,8 +34,8 @@ // `[ede_diagnostic]` error, T11) rings nothing, marks nothing and shows no // "Agent error" banner, and the next turn still rings. // -// CLOUD-LEGALITY: everything is driven through the fake's deterministic -// program lanes (`FRESHELL_FAKE_PROGRAM` text-matched rules with bounded +// CLOUD-LEGALITY: everything is driven through the simulator's deterministic +// program lanes (`FRESHELL_CLAUDE_SIM_PROGRAM` text-matched rules with bounded // `delayMs` emissions) — there are NO idle-grace-period or shade-transition // timing dependencies here (the patterns that put truly-idle-alerting.spec.ts // itself in CLOUD_SKIP_SPECS). The delayed emissions hand the spec a bounded @@ -49,37 +47,26 @@ // CLOUD_SKIP_SPECS. import fs from 'node:fs/promises' -import { existsSync, readFileSync } 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 } from '../helpers/rust-server.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) - -const CLAUDE_SIDECAR_FIXTURE = path.resolve(__dirname, '../fixtures/providers/fake-claude-sdk-sidecar.mjs') -// The production sidecar and the CLI simulator it loads through its -// FRESHELL_CLAUDE_SDK_QUERY_MODULE seam (both are 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') +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' /** - * Deterministic fake-sidecar program. The 3s emission delays are the ONLY - * timing in this spec, and they are lane-controlled (the fake consumes the + * Deterministic CLI simulator program. The 3s emission delays are the ONLY + * timing in this spec, and they are lane-controlled (the simulator consumes the * delay BEFORE the emission fires): each send opens a bounded window in * which the spec moves the user's attention, then the scripted turn ends. */ -const FAKE_PROGRAM = { +const SIMULATOR_PROGRAM = { rules: [ { // Errored turn lane: error_max_turns is a turn end like any other. - on: 'msg:send', + on: 'msg:prompt', match: { text: 'ERROR_TURN' }, emit: [ { @@ -90,32 +77,15 @@ const FAKE_PROGRAM = { ], }, { - // Mid-turn end-of-stream lane: sdk.stream.ended and exit code 3, like - // the real sidecar; the server rings the cut-off turn. The separate - // `crash` lane (silent process death) belongs to the death lanes. - on: 'msg:send', + // The simulated CLI exits mid-turn. The production sidecar reports its + // ended SDK stream and exits; the server rings the cut-off turn. + on: 'msg:prompt', match: { text: 'STREAM_ERROR' }, - emit: [{ kind: 'stream-error', delayMs: 3_000 }], + emit: [{ kind: 'crash', delayMs: 3_000 }], }, ], } -/** Read a JSONL fixture log (the fake's event/wire audit). */ -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) -} - function findFreshAgentLeaf(node: any): any { if (!node) return null if (node.type === 'leaf' && node.content?.kind === 'fresh-agent') return node @@ -129,7 +99,7 @@ function findFreshAgentLeaf(node: any): any { } /** - * Boot a server whose claude sidecar is the PRODUCTION sidecar over the CLI + * Boot a server whose Claude sidecar is the PRODUCTION sidecar over the CLI * simulator (with its ledger, and `extraEnv` such as a simulator program), and * open a freshclaude or kilroy pane on its boot tab. */ @@ -152,10 +122,7 @@ async function bootProductionClaudePane( await fs.mkdir(projectDir, { recursive: true }) 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, + ...claudeSimulatorServerEnv({ ledgerPath: simLedger }), // Kilroy is behind its feature flag. ...(flavour === 'kilroy' ? { KILROY_ENABLED: '1' } : {}), ...extraEnv, @@ -214,18 +181,13 @@ test.describe('fresh-agent error-turn attention (unified needs-attention signal) test('errored turns ring the unified signal; watched endings mark the tab strip only', async ({ page }) => { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-error-turn-attention-')) + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') let server: RustServer | undefined try { const projectDir = path.join(sharedRoot, 'project') - await fs.mkdir(projectDir, { recursive: true }) server = new RustServer({ env: { - FRESHELL_CLAUDE_SIDECAR: CLAUDE_SIDECAR_FIXTURE, - FRESHELL_CLAUDE_NODE: process.execPath, - FRESHELL_FAKE_PROVIDER: 'freshclaude', - FRESHELL_FAKE_PROGRAM: JSON.stringify(FAKE_PROGRAM), - FRESHELL_FAKE_STDIN: path.join(sharedRoot, 'sidecar-stdin.jsonl'), - FRESHELL_FAKE_EVENTS: path.join(sharedRoot, 'sidecar-events.jsonl'), + ...claudeSimulatorServerEnv({ ledgerPath: simLedger, program: SIMULATOR_PROGRAM }), }, setupHome: async (homeDir) => { const freshellDir = path.join(homeDir, '.freshell') @@ -309,7 +271,7 @@ test.describe('fresh-agent error-turn attention (unified needs-attention signal) expect((await turnCompletionState()).seq ?? 0).toBe(0) // ── Step 1: an ERRORED turn ending in a BACKGROUND tab ────────────── - // Send, then move to a fresh shell tab inside the fake's 3s emission + // Send, then move to a fresh shell tab inside the simulator's 3s emission // window — the user is in another tab when the max-turns error lands. await composer.fill('ERROR_TURN') await sendButton.click() @@ -420,7 +382,7 @@ test.describe('fresh-agent error-turn attention (unified needs-attention signal) // Restore the REAL focus signal before the away-and-back dismissal // (the click-mode dismiss requires the window to be focused), and - // clear the step-4 attention so the stream-error lane below starts + // clear the step-4 attention so the CLI-exit lane below starts // from a clean slate. await page.evaluate(() => { const original = (window as any).__FRESHELL_ORIGINAL_HAS_FOCUS__ @@ -438,10 +400,10 @@ test.describe('fresh-agent error-turn attention (unified needs-attention signal) .toBeNull() await paneStatus().toBe('idle') - // ── Step 5: the SDK stream ends mid-turn — the sidecar exits ─────── - // The fake's stream-error lane models the real sidecar's end of stream: - // sdk.stream.ended as its last line and exit code 3. The server reports - // the exit and rings the cut-off turn, once (C18). + // ── Step 5: the CLI stream ends mid-turn — the sidecar exits ─────── + // The simulated CLI exits while its turn is open. The production + // sidecar reports the ended stream and exits; the server reports the + // sidecar exit and rings the cut-off turn once (C18). await composer.fill('STREAM_ERROR') await sendButton.click() await page.locator(`[data-context="tab"][data-tab-id="${tabB}"]`).click() @@ -469,33 +431,10 @@ test.describe('fresh-agent error-turn attention (unified needs-attention signal) }, { timeout: 30_000, message: 'the pane learns the sidecar exited (SIDECAR_EXITED)' }) .toBe('SIDECAR_EXITED') - // The sidecar process is GONE: the wire rows carry its pid, the last - // one it wrote is the end report, and signal 0 finds no process. - const streamWires = readJsonl(path.join(sharedRoot, 'sidecar-events.jsonl')) - .filter((r) => r.kind === 'wire' && r.frame?.sessionId === bridgeSessionId) - expect(streamWires.length, 'the session produced wire rows to read the sidecar pid from').toBeGreaterThan(0) - const sidecarPid = streamWires[0].pid as number - expect(streamWires.at(-1)?.frame, 'the sidecar wrote sdk.stream.ended last').toMatchObject({ type: 'sdk.stream.ended', reason: 'stream_error' }) - await expect - .poll(() => { - try { - process.kill(sidecarPid, 0) - return 'alive' - } catch { - return 'gone' - } - }, { timeout: 30_000, message: 'the sidecar process exited after its stream ended' }) - .toBe('gone') - // The sidecar ended the turn with no result and minted no edge: the one - // edge for the lane is the server's. - const lastSendOpen = streamWires - .map((r) => r.frame?.type === 'sdk.status' && r.frame?.status === 'running') - .lastIndexOf(true) - expect(lastSendOpen, 'the ended turn opened with a running status').toBeGreaterThanOrEqual(0) - expect( - streamWires.slice(lastSendOpen).filter((r) => r.frame?.type === 'sdk.result' || r.frame?.type === 'sdk.turn.complete'), - 'the stream ended with no result, and the sidecar minted no edge', - ).toEqual([]) + // The simulator proves the CLI died during this prompt; the client + // observes the resulting sidecar exit, and the server supplies the one + // attention edge because no result arrived for the interrupted turn. + expect(readClaudeSimLedger(simLedger)).toContainEqual(expect.objectContaining({ kind: 'crash' })) // One edge total for the lane — stable across a settle window (the // cut-off turn rings exactly once). @@ -527,7 +466,7 @@ test.describe('fresh-agent user Stop on the production claude sidecar', () => { }, { timeout: 30_000 }) const turnCompletionState = () => harness.getState().then((s: any) => s?.turnCompletion ?? {}) /** Prompts the simulated CLI has started, in order (its ledger's `prompt` rows). */ - const startedPrompts = () => readJsonl(simLedger).filter((row) => row.kind === 'prompt').map((row) => String(row.text ?? '')) + const startedPrompts = () => readClaudeSimLedger(simLedger).filter((row) => row.kind === 'prompt').map((row) => String(row.text ?? '')) // The CLI reports no session until a prompt (T1), so a new pane is // `connected` until its first turn. @@ -693,7 +632,7 @@ test.describe('fresh-agent crash on the production claude sidecar', () => { // The same durable conversation boots again (a resume, not a new session)... await expect - .poll(() => readJsonl(simLedger).filter((row) => row.kind === 'query' && row.options?.resume === durable).length, + .poll(() => readClaudeSimLedger(simLedger).filter((row) => row.kind === 'query' && row.options?.resume === durable).length, { timeout: 30_000, message: 'Resume boots the CLI with resume: ' }) .toBe(1) await paneStatus().toMatch(/^(connected|idle)$/) @@ -709,7 +648,7 @@ test.describe('fresh-agent crash on the production claude sidecar', () => { await composer.fill('after the resume') await sendButton.click() await expect - .poll(() => readJsonl(simLedger).filter((row) => row.kind === 'prompt').map((row) => String(row.text ?? '')), + .poll(() => readClaudeSimLedger(simLedger).filter((row) => row.kind === 'prompt').map((row) => String(row.text ?? '')), { timeout: 30_000, message: 'the resumed CLI ran the next prompt' }) .toContain('after the resume') await expect diff --git a/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts b/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts index b68539ecc..32c7a9ed3 100644 --- a/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts @@ -25,8 +25,8 @@ * path (the overlay recorder) and a broken end-to-end journey. * * Trigger: the hidden pane's session gets a `freshAgent.send` written straight - * to the socket (the pane's composer is hidden), and the fake Claude-SDK - * sidecar's program answers it with one Bash approval: the turn parks on it. + * to the socket (the pane's composer is hidden), and the simulator causes the + * production sidecar's canUseTool callback to raise one Bash approval. * Then the client's own `freshAgent.interrupt` frame for the session — the * exact frame the Stream Deck "stop" sends to a pane in a background tab — * cancels it (the sidecar's interrupt-time pending cancellation, mirroring @@ -39,24 +39,18 @@ * held result and assert that the normal turn end still reaches the client. * * CLOUD-LEGALITY: no timing dependencies — every wait is a poll on observed - * state (sidecar stdin/wire ledgers, received WS frames, DOM). This spec must + * state (simulator ledger, received WS frames, DOM). This spec must * NOT be added to CLOUD_SKIP_SPECS. */ import fs from 'node:fs/promises' -import { existsSync, readFileSync } 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 } from '../helpers/rust-server.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) - -const CLAUDE_SIDECAR_FIXTURE = path.resolve(__dirname, '../fixtures/providers/fake-claude-sdk-sidecar.mjs') +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' const APPROVAL_REQUEST_ID = 'perm-raised-while-hidden' @@ -64,10 +58,10 @@ const APPROVAL_REQUEST_ID = 'perm-raised-while-hidden' const RAISE_TEXT = 'raise an approval while hidden' /** The send raises one Bash approval; the Stop cancels it. */ -const FAKE_PROGRAM = { +const SIMULATOR_PROGRAM = { rules: [ { - on: 'msg:send', + on: 'msg:prompt', match: { text: RAISE_TEXT }, once: true, emit: [ @@ -97,41 +91,6 @@ function findFreshAgentLeaf(node: any): any { return null } -function readJsonl(file: string): any[] { - if (!existsSync(file)) return [] - return readFileSync(file, 'utf8') - .split('\n') - .filter(Boolean) - .map((line) => { - try { - return JSON.parse(line) - } catch { - return null - } - }) - .filter(Boolean) -} - -/** Every outbound sidecar frame of `type` (the fixture's wire audit rows). */ -function wireFrames(eventsLog: string, type: string): any[] { - return readJsonl(eventsLog) - .filter((row) => row.kind === 'wire' && row.frame?.type === type) - .map((row) => row.frame) -} - -/** Every raw stdin frame of `type` the server wrote to the sidecar. */ -function stdinFrames(stdinLog: string, type: string): any[] { - return readJsonl(stdinLog) - .map((row) => { - try { - return JSON.parse(row.line) - } catch { - return null - } - }) - .filter((frame) => frame?.type === type) -} - async function receivedFreshAgentEvents(harness: TestHarness, eventType: string): Promise { const received = (await harness.getReceivedWsMessages()) as any[] return received.filter((msg) => msg?.type === 'freshAgent.event' && msg?.event?.type === eventType) @@ -170,8 +129,7 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without test('an approval raised, then cancelled, while the pane is hidden is current on reveal, with no blocking overlay', async ({ page }) => { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-hidden-approval-')) - const stdinLog = path.join(sharedRoot, 'sidecar-stdin.jsonl') - const eventsLog = path.join(sharedRoot, 'sidecar-events.jsonl') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') let server: RustServer | undefined let pauseAfterCancellation = false let deliveryPaused = false @@ -203,14 +161,7 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) server = new RustServer({ - env: { - FRESHELL_CLAUDE_SIDECAR: CLAUDE_SIDECAR_FIXTURE, - FRESHELL_CLAUDE_NODE: process.execPath, - FRESHELL_FAKE_PROVIDER: 'freshclaude', - FRESHELL_FAKE_PROGRAM: JSON.stringify(FAKE_PROGRAM), - FRESHELL_FAKE_STDIN: stdinLog, - FRESHELL_FAKE_EVENTS: eventsLog, - }, + env: claudeSimulatorServerEnv({ ledgerPath: simLedger, program: SIMULATOR_PROGRAM }), setupHome: async (homeDir) => { const freshellDir = path.join(homeDir, '.freshell') await fs.mkdir(freshellDir, { recursive: true }) @@ -296,9 +247,9 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without // ── 1. An approval RAISED while hidden appears on reveal ───────────── await sendToHiddenPane() await expect - .poll(() => wireFrames(eventsLog, 'sdk.permission.request').length, { + .poll(() => readClaudeSimLedger(simLedger).filter((row) => row.kind === 'approval').length, { timeout: 15_000, - message: 'the sidecar raises the approval', + message: 'the simulated CLI invokes the production canUseTool approval path', }) .toBe(1) await expect @@ -322,17 +273,11 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without pauseAfterCancellation = true await interruptHiddenPane() await expect - .poll(() => stdinFrames(stdinLog, 'interrupt').length, { + .poll(() => readClaudeSimLedger(simLedger).filter((row) => row.kind === 'interrupt').length, { timeout: 15_000, message: 'the server forwards the Stop of the parked turn', }) .toBe(1) - await expect - .poll(() => wireFrames(eventsLog, 'sdk.permission.cancelled').map((frame) => frame.requestId), { - timeout: 15_000, - message: 'the sidecar cancels the pending approval', - }) - .toEqual([APPROVAL_REQUEST_ID]) await expect .poll(async () => (await receivedFreshAgentEvents(harness, 'freshAgent.permission.cancelled')).length, { timeout: 15_000, diff --git a/test/e2e-browser/specs/fresh-agent-mobile-attention.spec.ts b/test/e2e-browser/specs/fresh-agent-mobile-attention.spec.ts index 6f04fc705..819f61f70 100644 --- a/test/e2e-browser/specs/fresh-agent-mobile-attention.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-mobile-attention.spec.ts @@ -15,17 +15,18 @@ * `waiting` edge) produces the same mark. * * The pane is a real Freshclaude pane on the real Rust server, driven by the - * deterministic fake Claude-SDK sidecar. Every mark comes from a REAL turn: - * the composer sends the prompt, the Rust server forwards it to the sidecar, - * the sidecar's program lane ends the turn (or raises the approval), the + * production Claude sidecar over the CLI simulator. Every mark comes from a + * real simulated CLI turn: the composer sends the prompt, the Rust server + * forwards it to the sidecar, the simulator ends the turn (or asks for an + * approval through canUseTool), the * server emits the `freshAgent.turn.complete` / `freshAgent.turn.waiting` * edge, and the client's production fold puts the mark on the mobile strip * and switcher. So this spec covers "server edge → mobile mark" end to end; * nothing here injects a WS frame. Each step also checks the edge on the - * sidecar wire and on the client's received WS frames, so a failure says + * simulator ledger and on the client's received WS frames, so a failure says * which hop broke. * - * CLOUD-LEGALITY: the only timing is the fake's lane-controlled `delayMs` + * CLOUD-LEGALITY: the only timing is the simulator's lane-controlled `delayMs` * before the scripted emission (the same pattern as * fresh-agent-error-turn-attention.spec.ts): it gives the spec a bounded * window to move to the other tab BEFORE the turn ends. If that move were @@ -35,38 +36,30 @@ * CLOUD_SKIP_SPECS. */ import fs from 'node:fs/promises' -import { existsSync, readFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' -import { fileURLToPath } from 'node:url' import { test, expect } from '../helpers/fixtures.js' import { RustServer } from '../helpers/rust-server.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) - -const CLAUDE_SIDECAR_FIXTURE = path.resolve(__dirname, '../fixtures/providers/fake-claude-sdk-sidecar.mjs') +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' const BACKGROUND_TURN = 'BACKGROUND_TURN' const BACKGROUND_APPROVAL = 'BACKGROUND_APPROVAL' -const APPROVAL_REQUEST_ID = 'perm-mobile-background' - /** - * Deterministic fake-sidecar program. Each scripted emission waits 3 s after - * the sidecar receives the send, so the user is in the other tab when the + * Deterministic CLI simulator program. Each scripted emission waits 3 s after + * the CLI receives the prompt, so the user is in the other tab when the * turn ends or the approval is raised. */ -const FAKE_PROGRAM = { +const SIMULATOR_PROGRAM = { rules: [ { - on: 'msg:send', + on: 'msg:prompt', match: { text: BACKGROUND_TURN }, emit: [ { kind: 'completion', - data: { subtype: 'success', text: 'Fixture turn finished while the user was away.' }, + data: { subtype: 'success', text: 'The simulated turn finished while the user was away.' }, delayMs: 3_000, }, ], @@ -74,12 +67,12 @@ const FAKE_PROGRAM = { { // The approval parks the turn (no completion follows) and the server // mints the waiting edge on the 0→1 pending transition. - on: 'msg:send', + on: 'msg:prompt', match: { text: BACKGROUND_APPROVAL }, emit: [ { kind: 'approval', - data: { id: APPROVAL_REQUEST_ID, tool: 'Bash', input: { command: 'echo mobile-background' } }, + data: { id: 'toolu-mobile-background', tool: 'Bash', input: { command: 'echo mobile-background' } }, delayMs: 3_000, }, ], @@ -99,28 +92,6 @@ function findFreshAgentLeaf(node: any): any { return null } -function readJsonl(file: string): any[] { - if (!existsSync(file)) return [] - return readFileSync(file, 'utf8') - .split('\n') - .filter(Boolean) - .map((line) => { - try { - return JSON.parse(line) - } catch { - return null - } - }) - .filter(Boolean) -} - -/** Every outbound sidecar frame of `type` (the fixture's wire audit rows). */ -function wireFrames(eventsLog: string, type: string): any[] { - return readJsonl(eventsLog) - .filter((row) => row.kind === 'wire' && row.frame?.type === type) - .map((row) => row.frame) -} - async function receivedFreshAgentEvents(harness: TestHarness, eventType: string): Promise { const received = (await harness.getReceivedWsMessages()) as any[] return received.filter((msg) => msg?.type === 'freshAgent.event' && msg?.event?.type === eventType) @@ -132,20 +103,13 @@ test.describe('mobile tab surfaces show needs-attention marks (G17)', () => { test('background turn endings and approvals mark the mobile strip and switcher; a tap dismisses', async ({ page }) => { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-mobile-attention-')) - const eventsLog = path.join(sharedRoot, 'sidecar-events.jsonl') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') let server: RustServer | undefined try { const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) server = new RustServer({ - env: { - FRESHELL_CLAUDE_SIDECAR: CLAUDE_SIDECAR_FIXTURE, - FRESHELL_CLAUDE_NODE: process.execPath, - FRESHELL_FAKE_PROVIDER: 'freshclaude', - FRESHELL_FAKE_PROGRAM: JSON.stringify(FAKE_PROGRAM), - FRESHELL_FAKE_STDIN: path.join(sharedRoot, 'sidecar-stdin.jsonl'), - FRESHELL_FAKE_EVENTS: eventsLog, - }, + env: claudeSimulatorServerEnv({ ledgerPath: simLedger, program: SIMULATOR_PROGRAM }), setupHome: async (homeDir) => { const freshellDir = path.join(homeDir, '.freshell') await fs.mkdir(freshellDir, { recursive: true }) @@ -199,7 +163,7 @@ test.describe('mobile tab surfaces show needs-attention marks (G17)', () => { timeout: 30_000, }) .toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) - await paneStatus().toBe('idle') + await paneStatus().toMatch(/^(connected|idle)$/) const turnCompletionState = () => harness.getState().then((s: any) => s?.turnCompletion ?? {}) expect((await turnCompletionState()).seq ?? 0).toBe(0) @@ -211,7 +175,7 @@ test.describe('mobile tab surfaces show needs-attention marks (G17)', () => { const previousTab = page.getByRole('button', { name: 'Previous tab' }) // ── 1. A turn ends in a BACKGROUND tab ───────────────────────────────── - // Send, then (inside the fake's 3 s window) open a new tab with the + // Send, then (inside the simulator's 3 s window) open a new tab with the // strip's right action — tab A is the last tab, so it reads "New tab". await composer.fill(BACKGROUND_TURN) await sendButton.click() @@ -232,11 +196,10 @@ test.describe('mobile tab surfaces show needs-attention marks (G17)', () => { endedState.watchedCompletionByTab?.[tabA], 'the turn ended while the user was in tab B (a watched mark means the tab move was late)', ).toBeUndefined() - // The mark came from the server's edge for a real turn's result, not a - // stand-in: the sidecar ended the turn with its result, and the server - // rang it (C18: the sidecar mints no edge). - expect(wireFrames(eventsLog, 'sdk.result').map((frame) => frame.subtype), 'the sidecar ended the turn with its result').toEqual(['success']) - expect(wireFrames(eventsLog, 'sdk.turn.complete'), 'the sidecar mints no edge').toHaveLength(0) + expect( + readClaudeSimLedger(simLedger).filter((row) => row.kind === 'completion' && row.data?.subtype === 'success'), + 'the simulated CLI completed the first turn successfully', + ).toHaveLength(1) expect( await receivedFreshAgentEvents(harness, 'freshAgent.turn.complete'), 'the server sent exactly one turn-complete edge to the client', @@ -287,16 +250,19 @@ test.describe('mobile tab surfaces show needs-attention marks (G17)', () => { 'the approval was raised while the user was in tab B', ).toBeUndefined() expect( - wireFrames(eventsLog, 'sdk.permission.request').map((frame) => frame.requestId), - 'the sidecar raised the scripted approval', - ).toEqual([APPROVAL_REQUEST_ID]) - expect(wireFrames(eventsLog, 'sdk.turn.waiting'), 'the sidecar mints no waiting edge').toHaveLength(0) + readClaudeSimLedger(simLedger).filter((row) => row.kind === 'approval'), + 'the simulated CLI made one real approval request', + ).toHaveLength(1) + expect( + await receivedFreshAgentEvents(harness, 'freshAgent.permission.request'), + 'the production sidecar forwarded the approval request to the client', + ).toHaveLength(1) expect( await receivedFreshAgentEvents(harness, 'freshAgent.turn.waiting'), 'the server sent exactly one waiting edge to the client', ).toHaveLength(1) - // The approval parks the turn: no second result, no second turn-complete edge. - expect(wireFrames(eventsLog, 'sdk.result')).toHaveLength(1) + // The approval parks the turn: no second result or turn-complete edge. + expect(await receivedFreshAgentEvents(harness, 'freshAgent.result')).toHaveLength(1) expect(await receivedFreshAgentEvents(harness, 'freshAgent.turn.complete')).toHaveLength(1) await expect(otherTabsStatus, 'the waiting tab is counted like a finished one').toBeVisible() diff --git a/test/e2e-browser/specs/freshagent-default-model-rust.spec.ts b/test/e2e-browser/specs/freshagent-default-model-rust.spec.ts index 39f187d4f..73f639efc 100644 --- a/test/e2e-browser/specs/freshagent-default-model-rust.spec.ts +++ b/test/e2e-browser/specs/freshagent-default-model-rust.spec.ts @@ -10,15 +10,15 @@ * (decision D2, matching `claude --resume`). * * The spec drives the real Rust server + real browser UI against the - * deterministic fake Claude-SDK sidecar and asserts the EXACT `create` frame - * the server wrote to the sidecar's stdin (FRESHELL_FAKE_STDIN raw audit). + * production Claude sidecar over the CLI simulator and asserts the query + * options it received for fresh and resumed conversations. * The saved default is written through the real `PATCH /api/settings` route * and observed in the client's settings before any pane is created. * * Pre-fix, both create frames carried the registry default `opus[1m]`. * * CLOUD-LEGALITY: no idle-grace or timing dependencies — every wait is a poll - * on observed state (stdin audit rows, pane layout, sidebar rows). + * on observed state (simulator ledger rows, pane layout, sidebar rows). * * Helpers are copied, not imported, per this suite's per-spec-ownership * convention (donor: fresh-agent-control-rust.spec.ts — boot, stdin audit, @@ -29,18 +29,13 @@ import { existsSync, readFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { randomUUID } from 'node:crypto' -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 } 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) - -const CLAUDE_SIDECAR_FIXTURE = path.resolve(__dirname, '../fixtures/providers/fake-claude-sdk-sidecar.mjs') +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' const CANONICAL_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i @@ -76,22 +71,9 @@ 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, +async function waitForSimQuery( + simLedger: string, + pred: (options: any) => boolean, what: string, timeoutMs = 20_000, ): Promise { @@ -99,10 +81,10 @@ async function waitForStdinFrame( await expect .poll( () => { - found = readStdinFrames(stdinLog).find(pred) ?? null + found = readClaudeSimLedger(simLedger).find((row) => row.kind === 'query' && pred(row.options ?? {})) ?? null return Boolean(found) }, - { timeout: timeoutMs, message: `timed out waiting for ${what} in ${stdinLog}` }, + { timeout: timeoutMs, message: `timed out waiting for ${what} in ${simLedger}` }, ) .toBe(true) return found @@ -171,19 +153,15 @@ async function bootLane(page: Page, flavour: Flavour): Promise<{ harness: TestHarness sharedRoot: string projectDir: string - stdinLog: string + simLedger: string }> { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), `freshell-default-model-${flavour}-`)) const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) - const stdinLog = path.join(sharedRoot, 'sidecar-stdin.jsonl') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const server = new RustServer({ env: { - FRESHELL_CLAUDE_SIDECAR: CLAUDE_SIDECAR_FIXTURE, - FRESHELL_CLAUDE_NODE: process.execPath, - FRESHELL_FAKE_PROVIDER: flavour, - FRESHELL_FAKE_STDIN: stdinLog, - FRESHELL_FAKE_EVENTS: path.join(sharedRoot, 'sidecar-events.jsonl'), + ...claudeSimulatorServerEnv({ ledgerPath: simLedger, flavour }), ...(flavour === 'kilroy' ? { KILROY_ENABLED: '1' } : {}), }, stripEnvPrefixes: flavour === 'kilroy' ? [...GEMINI_STRIP_ENV_PREFIXES] : undefined, @@ -216,7 +194,7 @@ async function bootLane(page: Page, flavour: Flavour): Promise<{ payload: { claude: true }, }) }) - return { server, info, harness, sharedRoot, projectDir, stdinLog } + return { server, info, harness, sharedRoot, projectDir, simLedger } } catch (error) { await server.stop().catch(() => {}) await fs.rm(sharedRoot, { recursive: true, force: true }).catch(() => {}) @@ -226,7 +204,7 @@ async function bootLane(page: Page, flavour: Flavour): Promise<{ /** Append one user/assistant exchange to the session's CLI transcript * (`~/.claude/projects//.jsonl`, parentUuid-chained like - * the CLI and the fake sidecar write it). */ + * the CLI and simulator write it). */ async function appendTranscriptExchange( homeDir: string, cwd: string, @@ -258,7 +236,7 @@ const LABELS: Record = { freshclaude: 'Freshclaude', kilroy: 'K async function runDefaultModelJourney(page: Page, flavour: Flavour): Promise { const lane = await bootLane(page, flavour) try { - const { harness, info, stdinLog } = lane + const { harness, info, simLedger } = lane const label = LABELS[flavour] const tabId = (await harness.getActiveTabId())! expect(tabId).toBeTruthy() @@ -275,12 +253,12 @@ async function runDefaultModelJourney(page: Page, flavour: Flavour): Promise frame?.type === 'create' && frame?.boot?.kind === 'fresh', - `the new ${label} pane's sidecar create`, + const freshCreate = await waitForSimQuery( + simLedger, + (options) => !options.resume && options.model === 'sonnet', + `the new ${label} pane's simulator query`, ) - expect(freshCreate.model, 'a new pane starts on the saved default model').toBe('sonnet') + expect(freshCreate.options.model, 'a new pane starts on the saved default model').toBe('sonnet') let durableId = '' await expect @@ -296,8 +274,8 @@ async function runDefaultModelJourney(page: Page, flavour: Flavour): Promise frame?.type === 'create' && frame?.boot?.kind === 'resume' && frame?.boot?.resume === durableId, - `the resumed ${label} pane's sidecar create`, + const resumeCreate = await waitForSimQuery( + simLedger, + (options) => options.resume === durableId && options.model === 'haiku', + `the resumed ${label} pane's simulator query`, 30_000, ) - expect(resumeCreate.model, 'a resumed pane starts on the current saved default model').toBe('haiku') + expect(resumeCreate.options.model, 'a resumed pane starts on the current saved default model').toBe('haiku') const resumedLeaf = findFreshAgentLeaf(await harness.getPaneLayout((await harness.getActiveTabId())!)) expect(resumedLeaf?.content?.sessionType).toBe(flavour) diff --git a/test/e2e-browser/specs/freshagent-live-model-convergence-rust.spec.ts b/test/e2e-browser/specs/freshagent-live-model-convergence-rust.spec.ts index cb0634de1..1328a3d3c 100644 --- a/test/e2e-browser/specs/freshagent-live-model-convergence-rust.spec.ts +++ b/test/e2e-browser/specs/freshagent-live-model-convergence-rust.spec.ts @@ -8,7 +8,7 @@ // idempotent configure stays silent on the bus. // 2. claude: `freshAgent.configure` applies for real through the sidecar's // configure lane (the fake's sdk.configured receipt) and broadcasts the -// metadata frame; the sidecar request log records the configure. +// metadata frame; the simulator ledger records the SDK settings call. // // The codex lane's wire contract is pinned in-crate // (crates/freshell-freshagent/src/codex.rs configure tests against the real @@ -32,11 +32,11 @@ import WebSocket from 'ws' import { test, expect } from '../helpers/fixtures.js' import { RustServer } from '../helpers/rust-server.js' import { WS_PROTOCOL_VERSION } from '../../../shared/ws-version.js' +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const CLAUDE_FIXTURE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs') const OPENCODE_FIXTURE = path.resolve(__dirname, '../fixtures/fake-opencode.cjs') /** Raw node-side WS client with a real hello handshake and a frame buffer @@ -273,14 +273,11 @@ test.describe('fresh-agent live model convergence (rust)', () => { test('claude: configure applies through the sidecar lane and broadcasts session metadata', async () => { test.setTimeout(180_000) const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'fa-conv-claude-')) - const sidecarLogPath = path.join(sharedRoot, 'sidecar-requests.jsonl') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const projectDir = path.join(sharedRoot, 'proj') await fsp.mkdir(projectDir, { recursive: true }) const server = new RustServer({ - env: { - FRESHELL_CLAUDE_SIDECAR: CLAUDE_FIXTURE, - FAKE_CLAUDE_SIDECAR_LOG: sidecarLogPath, - }, + env: claudeSimulatorServerEnv({ ledgerPath: simLedger }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) let ws: WsCapture | null = null @@ -305,8 +302,8 @@ test.describe('fresh-agent live model convergence (rust)', () => { ) const sessionId = created.sessionId as string - // The live-settings convergence lane: the sidecar applies the configure - // for real (setModel + applyFlagSettings through the fake's receipt)… + // The live-settings convergence lane: the production sidecar applies + // the configure through the simulator's SDK controls… ws.send({ type: 'freshAgent.configure', requestId: 'cfg-conv-claude-1', @@ -327,17 +324,18 @@ test.describe('fresh-agent live model convergence (rust)', () => { expect(metadata.event.model).toBe('sonnet') expect(metadata.event.effort).toBe('low') - // …and the sidecar request log proves the configure reached the runtime. + // …and the simulator ledger proves the SDK controls received the update. await expect .poll( - () => readJsonl(sidecarLogPath).filter((e) => e.msg?.type === 'configure').length, + () => readClaudeSimLedger(simLedger).filter((row) => row.kind === 'control').length, { timeout: 30_000 }, ) .toBeGreaterThan(0) - const configureRequest = readJsonl(sidecarLogPath).find((e) => e.msg?.type === 'configure') - expect(configureRequest.msg.sessionId).toBe(sessionId) - expect(configureRequest.msg.settings.model).toBe('sonnet') - expect(configureRequest.msg.settings.effort).toBe('low') + const controls = readClaudeSimLedger(simLedger).filter((row) => row.kind === 'control') + expect(controls).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'setModel', args: ['sonnet'] }), + expect.objectContaining({ name: 'applyFlagSettings' }), + ])) } finally { ws?.close() await server.stop().catch(() => {}) diff --git a/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts b/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts index 61d3ee3c1..cb2274da6 100644 --- a/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts +++ b/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts @@ -39,6 +39,7 @@ import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' import type { Page } from '@playwright/test' import { WS_PROTOCOL_VERSION } from '../../../shared/ws-version.js' +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -47,7 +48,6 @@ const CODEX_FIXTURE = path.resolve( __dirname, '../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs', ) -const CLAUDE_FIXTURE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs') const OPENCODE_FIXTURE = path.resolve(__dirname, '../fixtures/fake-opencode.cjs') // --------------------------------------------------------------------------- @@ -240,7 +240,7 @@ test.describe('fresh-agent settings survive restart (rust)', () => { test('claude: model, thinking, and permission changes apply to the next message in the same conversation', async ({ page }) => { test.setTimeout(180_000) const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'fa-settings-next-claude-')) - const sidecarLogPath = path.join(sharedRoot, 'sidecar-requests.jsonl') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') // Deterministic catalog only: the conversation and settings writes run // through the real browser, WebSocket handler, and Rust Claude runtime. await page.route('**/api/fresh-agent/model-capabilities/freshclaude**', (route) => route.fulfill({ json: { @@ -249,7 +249,7 @@ test.describe('fresh-agent settings survive restart (rust)', () => { supportedEffortLevels: ['low', 'medium', 'high'], supportsAdaptiveThinking: false }], } })) const { server, harness } = await bootWall(page, { - env: { FRESHELL_CLAUDE_SIDECAR: CLAUDE_FIXTURE, FAKE_CLAUDE_SIDECAR_LOG: sidecarLogPath, FAKE_CLAUDE_SIDECAR_ECHO_SETTINGS: '1' }, + env: claudeSimulatorServerEnv({ ledgerPath: simLedger }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -278,13 +278,10 @@ test.describe('fresh-agent settings survive restart (rust)', () => { await pane.getByRole('button', { name: 'Send', exact: true }).click() await expect(pane.getByText('Fixture claude turn (sonnet, low, plan)', { exact: true })).toBeVisible({ timeout: 30_000 }) - const requests = readJsonl(sidecarLogPath).map((row) => row.msg) - const secondSend = requests.findIndex((msg) => msg.type === 'send' && msg.text === 'Second message uses the selected settings') - const configure = requests.findLastIndex((msg) => msg.type === 'configure') - expect(configure).toBeGreaterThan(-1) - expect(configure).toBeLessThan(secondSend) - expect(requests[configure].settings).toMatchObject({ model: 'sonnet', effort: 'low', permissionMode: 'plan' }) - expect(requests.filter((msg) => msg.type === 'create')).toHaveLength(1) + const ledger = readClaudeSimLedger(simLedger) + const secondPrompt = ledger.find((row) => row.kind === 'prompt' && row.text === 'Second message uses the selected settings') + expect(secondPrompt).toMatchObject({ model: 'sonnet', effort: 'low', permissionMode: 'plan' }) + expect(ledger.filter((row) => row.kind === 'query')).toHaveLength(1) const after = findFreshAgentLeaf(await harness.getPaneLayout(tabId)).content expect(after.sessionId).toBe(before.sessionId) expect(after.sessionRef).toEqual(before.sessionRef) @@ -506,14 +503,11 @@ test.describe('fresh-agent settings survive restart (rust)', () => { test('claude: attach resume request carries model/permissionMode', async () => { test.setTimeout(180_000) const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'fa-settings-claude-')) - const sidecarLogPath = path.join(sharedRoot, 'sidecar-requests.jsonl') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const projectDir = path.join(sharedRoot, 'proj') await fsp.mkdir(projectDir, { recursive: true }) const server = new RustServer({ - env: { - FRESHELL_CLAUDE_SIDECAR: CLAUDE_FIXTURE, - FAKE_CLAUDE_SIDECAR_LOG: sidecarLogPath, - }, + env: claudeSimulatorServerEnv({ ledgerPath: simLedger }), }) let ws: WsCapture | null = null try { @@ -551,7 +545,8 @@ test.describe('fresh-agent settings survive restart (rust)', () => { const durable = init.event.cliSessionId as string expect(durable).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) - // One turn (the fixture ends it with its result; the server rings it). + // The CLI reports its durable id from the first prompt, then ends the + // turn through the production sidecar's result frame. ws.send({ type: 'freshAgent.send', provider: 'claude', @@ -588,21 +583,22 @@ test.describe('fresh-agent settings survive restart (rust)', () => { sessionRef: { provider: 'claude', sessionId: durable }, }) - // The resume-on-attach spawns a fresh sidecar whose create carries - // a resume boot + the LEDGER-recorded settings (Task 10). + // The resume-on-attach starts a new simulated CLI query with the durable + // resume id and LEDGER-recorded settings (Task 10). await expect .poll( - () => - readJsonl(sidecarLogPath).some((m) => m.msg && m.msg.boot?.resume === durable), + () => readClaudeSimLedger(simLedger) + .some((row) => row.kind === 'query' && (row.options as any)?.resume === durable), { timeout: 30_000 }, ) .toBe(true) - const msgs = fs.readFileSync(sidecarLogPath, 'utf8').trim().split('\n').map((l) => JSON.parse(l)) - const resumeCreate = msgs.filter((m) => m.msg && m.msg.boot?.resume).pop() - expect(resumeCreate, 'restart must issue a resume create').toBeTruthy() - expect(resumeCreate.msg.model, 'resume must carry the recorded model, not null').toBe('opus-x') - expect(resumeCreate.msg.permissionMode, 'resume must carry the recorded permissionMode').toBe('plan') + const resumeBoot = readClaudeSimLedger(simLedger) + .filter((row) => row.kind === 'query' && (row.options as any)?.resume === durable) + .at(-1) + expect(resumeBoot, 'restart must issue a resume query').toBeTruthy() + expect((resumeBoot?.options as any)?.model, 'resume must carry the recorded model, not null').toBe('opus-x') + expect((resumeBoot?.options as any)?.permissionMode, 'resume must carry the recorded permissionMode').toBe('plan') } finally { ws?.close() await server.stop().catch(() => {}) diff --git a/test/e2e-browser/specs/freshclaude-first-send-rust.spec.ts b/test/e2e-browser/specs/freshclaude-first-send-rust.spec.ts index deb6999c3..98bd95351 100644 --- a/test/e2e-browser/specs/freshclaude-first-send-rust.spec.ts +++ b/test/e2e-browser/specs/freshclaude-first-send-rust.spec.ts @@ -1,17 +1,6 @@ -// FRESHCLAUDE FIRST SEND -- the real Claude CLI's session-report order. -// -// The real CLI emits system/init (relayed by the sidecar as sdk.session.init, -// which gives the pane its durable session id and the server's ownership -// coordinator its owner record) only once a prompt runs, and again at the -// start of every turn. So a new freshclaude pane's first message is sent -// before anything owns its session, and its later messages are sent after the -// coordinator owns the session under the durable id, while the pane still -// addresses it by the sidecar's placeholder id. Every message must run. -// -// The fake sidecar's FAKE_CLAUDE_SIDECAR_INIT_AT=turn reproduces that order. -// Every other freshclaude spec uses the fake's default, which reports the -// session right at create, so without this spec no browser test sends a -// message before the session is reported. +// FRESHCLAUDE FIRST SEND -- create-time identity reaches the real browser and +// the production sidecar. The first prompt and a follow-up must both run under +// the same durable session identity. // // Helpers are copied, not imported, per this suite's per-spec-ownership // convention (donor: freshclaude-restart-parity-rust.spec.ts, itself copied @@ -27,13 +16,12 @@ import { RustServer } from '../helpers/rust-server.js' import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' // ESM project ("type": "module" in package.json): derive __dirname. const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const FIXTURE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.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 @@ -153,17 +141,11 @@ async function createFreshclaudePane(page: Page, harness: TestHarness, cwd: stri }) } -/** The text of every prompt the fake sidecar received, in order (its request - * log is the ground truth for "the message ran"). */ -function sidecarPrompts(requestLog: string): string[] { - if (!fs.existsSync(requestLog)) return [] - return fs - .readFileSync(requestLog, 'utf-8') - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)) - .filter((entry) => entry.msg?.type === 'send') - .map((entry) => entry.msg.text) +/** The simulated CLI's started prompts, in order. */ +function simulatedPrompts(ledger: string): string[] { + return readClaudeSimLedger(ledger) + .filter((entry) => entry.kind === 'prompt') + .map((entry) => String(entry.text ?? '')) } // --------------------------------------------------------------------------- @@ -171,18 +153,21 @@ function sidecarPrompts(requestLog: string): string[] { // --------------------------------------------------------------------------- test.describe('freshclaude first send (rust)', () => { - test("a new pane's messages run when the CLI reports the session only once a prompt runs", async ({ page }) => { + test("a new pane accepts its first message after create-time identity is available", async ({ page }) => { test.setTimeout(120_000) const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshclaude-first-send-')) - const requestLog = path.join(sharedRoot, 'sidecar-requests.jsonl') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const projectDir = path.join(sharedRoot, 'proj') await fsp.mkdir(projectDir, { recursive: true }) const { server, harness } = await bootWall(page, { - env: { - FRESHELL_CLAUDE_SIDECAR: FIXTURE, - FAKE_CLAUDE_SIDECAR_INIT_AT: 'turn', - FAKE_CLAUDE_SIDECAR_LOG: requestLog, - }, + env: claudeSimulatorServerEnv({ + ledgerPath: simLedger, + // The production sidecar reports the durable session only when the + // simulated CLI starts its first prompt, matching the real CLI order. + program: { + rules: [{ on: 'msg:prompt', emit: [{ kind: 'text', data: { text: 'Fixture claude turn' } }] }], + }, + }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -195,34 +180,31 @@ test.describe('freshclaude first send (rust)', () => { await createFreshclaudePane(page, harness, projectDir) const pane = page.locator('[data-context="fresh-agent"]').last() - // The order under test: the session exists but the CLI has not reported - // it, so the pane has no durable id when the user types. + // Create allocates and returns a durable identity before the user types. await expect - .poll(async () => findFreshAgentLeaf(await harness.getPaneLayout(tabId))?.content?.status, { + .poll(async () => liveDurableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(tabId))), { timeout: 20_000, }) - .toBe('idle') - expect(liveDurableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(tabId)))).toBe('') + .toMatch(CANONICAL_UUID_RE) + const durableBeforeSend = liveDurableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(tabId))) const suffix = Math.random().toString(36).slice(2, 10) const first = `first message ${suffix}` await sendFreshAgentTurn(page, harness, tabId, first) await expect - .poll(() => sidecarPrompts(requestLog), { timeout: 30_000 }) + .poll(() => simulatedPrompts(simLedger), { timeout: 30_000 }) .toEqual([first]) - // Running the first prompt made the CLI report the session: the pane now - // holds its durable id (and the server owns the session under it). await expect .poll(async () => liveDurableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(tabId))), { timeout: 15_000, }) - .toMatch(CANONICAL_UUID_RE) + .toBe(durableBeforeSend) await expect(pane).toContainText('Fixture claude turn', { timeout: 30_000 }) const second = `second message ${suffix}` await sendFreshAgentTurn(page, harness, tabId, second) await expect - .poll(() => sidecarPrompts(requestLog), { timeout: 30_000 }) + .poll(() => simulatedPrompts(simLedger), { timeout: 30_000 }) .toEqual([first, second]) // Each message was sent once and none was refused. diff --git a/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts b/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts index 51338be02..82ec5485f 100644 --- a/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts +++ b/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts @@ -24,11 +24,11 @@ import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' import { MACHINE_ID_STORAGE_KEY } from '../../../src/store/storage-keys.js' +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' // NOTE: `Page` comes from '@playwright/test' (fixtures.ts exports only // `test`/`expect`, as every donor spec does). `openPanePicker` is IMPORTED, // not copied -- the copied `createFreshclaudePane` body calls it (wall :29, -// :448). `fileURLToPath` feeds the FAKE_CLAUDE_SIDECAR_SOURCE constant below -// (wall :34, :39). +// :448). // ESM project ("type": "module" in package.json): __dirname does not exist in // ESM modules, so derive it -- same convention as every fixture-referencing @@ -36,10 +36,8 @@ import { MACHINE_ID_STORAGE_KEY } from '../../../src/store/storage-keys.js' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const FAKE_CLAUDE_SIDECAR_SOURCE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs') - // Canonical claude session id shape (copy of shared/session-contract.ts's -// CLAUDE_SESSION_ID_RE -- per-spec-ownership convention). The fake sidecar +// CLAUDE_SESSION_ID_RE -- per-spec-ownership convention). The simulator // mints a RANDOM canonical UUID per process (council follow-up, PR #562/#563 // close-out: the old static 44444444-... default made every resume-less // create in every sidecar process collide onto one constant id -- @@ -191,6 +189,33 @@ async function persistedFreshAgentIdentity(page: Page, tabId: string): Promise { + const seededIdentity = await page.evaluate(({ id, legacyPlaceholder }) => { + const layoutWindowId = sessionStorage.getItem('freshell.layout-window-id.v1') + if (!layoutWindowId) throw new Error('Missing layout-window-id while seeding the old pane shape') + const key = `freshell.layout.v3.${layoutWindowId}` + const raw = localStorage.getItem(key) + if (!raw) throw new Error('Missing persisted layout while seeding the old pane shape') + const layout = JSON.parse(raw) + const visit = (node: any): any => { + if (!node) return null + if (node.type === 'leaf' && node.content?.kind === 'fresh-agent') return node + for (const child of node.children ?? []) { + const found = visit(child) + if (found) return found + } + return null + } + const leaf = visit(layout?.panes?.layouts?.[id]) + if (!leaf) throw new Error(`Missing persisted fresh-agent pane ${id}`) + leaf.content.sessionId = legacyPlaceholder + localStorage.setItem(key, JSON.stringify(layout)) + return leaf.content.sessionRef?.sessionId ?? null + }, { id: tabId, legacyPlaceholder: placeholder }) + expect(seededIdentity, 'the old placeholder must coexist with a durable Claude sessionRef').toBeTruthy() +} + /** Send one chat turn in the last fresh-agent pane and wait for idle. For a * claude pane the idle wait does not prove the turn ran: the client never marks * a claude pane running on send (only opencode), and the composer hands the @@ -228,13 +253,13 @@ async function sendFreshAgentTurn( .toBe('idle') } -/** The fake sidecar's transcript for `durable` (its ground truth for what - * reached it): the user texts in order and the number of assistant replies. */ +/** The simulated CLI transcript for `durable`: user texts and assistant replies. */ async function readFixtureTranscript( homeDir: string, durable: string, ): Promise<{ userTexts: string[]; assistantTurns: number }> { - const transcriptFile = path.join(homeDir, '.claude', 'projects', '-fixture', `${durable}.jsonl`) + const transcriptFile = await findTranscriptFile(path.join(homeDir, '.claude', 'projects'), `${durable}.jsonl`) + if (!transcriptFile) return { userTexts: [], assistantTurns: 0 } const lines = (await fs.readFile(transcriptFile, 'utf-8')) .trim() .split('\n') @@ -248,6 +273,24 @@ async function readFixtureTranscript( } } +async function findTranscriptFile(root: string, basename: string): Promise { + let entries + try { + entries = await fs.readdir(root, { withFileTypes: true }) + } catch { + return null + } + for (const entry of entries) { + const candidate = path.join(root, entry.name) + if (entry.isFile() && entry.name === basename) return candidate + if (entry.isDirectory()) { + const nested = await findTranscriptFile(candidate, basename) + if (nested) return nested + } + } + return null +} + /** The page's freshAgent.send frames since the last clear, each with when it * left the page relative to `since` (negative: before), and whether the server * accepted it. */ @@ -328,8 +371,7 @@ async function expectFixtureTranscript( } } -// --- freshclaude fresh-agent helper (fixture: fake-claude-sidecar.mjs via the -// production env seam FRESHELL_CLAUDE_SIDECAR) --- +// --- freshclaude fresh-agent helper (production sidecar + CLI simulator) --- async function createFreshclaudePane(page: Page, harness: TestHarness, cwd: string): Promise { // setAvailableClis is client-only AND gets overwritten by the app @@ -377,8 +419,7 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) const { server, info, harness } = await bootWall(page, { - // EXACT leg-G options (wall :1174-1177): - env: { FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE }, + env: claudeSimulatorServerEnv({ ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl') }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -480,6 +521,54 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { } }) + test('old persisted placeholder with a durable sessionRef reloads and accepts another send', async ({ page }) => { + const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-identity-legacy-placeholder-')) + const projectDir = path.join(sharedRoot, 'project') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') + await fs.mkdir(projectDir, { recursive: true }) + const { server, info, harness } = await bootWall(page, { + env: claudeSimulatorServerEnv({ ledgerPath: simLedger }), + setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), + }) + try { + await selectShellIfPickerShowing(page) + const tabId = (await harness.getActiveTabId())! + await expect(page.locator('.xterm').first()).toBeVisible({ timeout: 30_000 }) + await createFreshclaudePane(page, harness, projectDir) + await sendFreshAgentTurn(page, harness, tabId, 'C26 first turn') + await expect.poll(async () => durableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(tabId)))) + .toMatch(CANONICAL_UUID_RE) + const originalDurable = durableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(tabId))) + await expectFixtureTranscript(harness, server, info.homeDir, originalDurable, { + userTexts: ['C26 first turn'], + minAssistantTurns: 1, + }) + + await flushPersistence(page) + await expect.poll(() => persistedFreshAgentIdentity(page, tabId)).toBe(originalDurable) + await seedLegacyFreshAgentPlaceholder(page, tabId, 'fc-e2e-legacy-placeholder') + await harness.clearSentWsMessages() + await reloadAndReconnect(page, harness) + + const reloadedTabId = (await harness.getActiveTabId())! + await expect.poll(async () => durableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(reloadedTabId)))) + .toBe(originalDurable) + await sendFreshAgentTurn(page, harness, reloadedTabId, 'C26 second turn after reload') + await expectFixtureTranscript(harness, server, info.homeDir, originalDurable, { + userTexts: ['C26 first turn', 'C26 second turn after reload'], + minAssistantTurns: 2, + }) + + const resumeBoots = readClaudeSimLedger(simLedger) + .filter((row) => row.kind === 'query') + .filter((row) => (row.options as any)?.resume === originalDurable) + expect(resumeBoots.length, 'reload must resume the durable Claude conversation').toBeGreaterThan(0) + } finally { + await server.stop() + await fs.rm(sharedRoot, { recursive: true, force: true }) + } + }) + test('real-user reload: identity survives with NO manual persist flush (natural persistence path only)', async ({ page }) => { // COUNCIL FOLLOW-UP (PR #562/#563 close-out, user-advocate's held // finding): every other journey in this suite (and the wall's leg G) @@ -503,7 +592,7 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) const { server, info, harness } = await bootWall(page, { - env: { FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE }, + env: claudeSimulatorServerEnv({ ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl') }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -570,7 +659,7 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) const { server, info, harness } = await bootWall(page, { - env: { FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE }, + env: claudeSimulatorServerEnv({ ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl') }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) let coldContext: BrowserContext | null = null @@ -631,9 +720,8 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) const { server, info, harness } = await bootWall(page, { - // Same exact leg-G options as test 1 (env: fake sidecar via - // FAKE_CLAUDE_SIDECAR_SOURCE; setupHome: seedWallConfig claude+freshAgent). - env: { FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE }, + // Use the same production-sidecar simulator lane as the other identity tests. + env: claudeSimulatorServerEnv({ ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl') }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -657,10 +745,9 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { expect(deleted.length, `expected transcript artifacts for ${staleDurable} under ${info.homeDir}`).toBeGreaterThan(0) // SIGKILL, then reload IMMEDIATELY -- the OLD page must never - // reconnect and fire a recovery create-with-resume: the fake sidecar - // re-creates the transcript on ANY create carrying resumeSessionId - // (fake-claude-sidecar.mjs:95, fs.openSync(..., 'a')), after which the - // session is Present again and dead_session can never surface. No + // reconnect and fire a recovery create-with-resume. The simulator + // refuses a resume after its transcript has been deleted, so the + // server can surface the stale session claim. No // waitForWsReady on the old page here, by design. await harness.clearSentWsMessages() await server.restartAbrupt() diff --git a/test/e2e-browser/specs/freshclaude-restart-parity-rust.spec.ts b/test/e2e-browser/specs/freshclaude-restart-parity-rust.spec.ts index 6741f205c..b45490f25 100644 --- a/test/e2e-browser/specs/freshclaude-restart-parity-rust.spec.ts +++ b/test/e2e-browser/specs/freshclaude-restart-parity-rust.spec.ts @@ -1,20 +1,17 @@ // FRESHCLAUDE RESTART PARITY -- restart-resilience plan §2.8 items 2-4 (Lane A2). -// Proves the server-side resume path end to end against the extended fake sidecar: +// Proves the server-side resume path end to end against the production sidecar +// and the committed Claude CLI simulator: // 1. restartAbrupt -> WS auto-reconnect -> client re-attaches -> server resumes // IN PLACE: no INVALID_SESSION_ID lost frame, no client-driven re-create. // 2. History rehydrates via GET /api/fresh-agent/threads/... (transcript adapter). -// 3. The next send continues the SAME conversation (fixture request log shows -// create carried a resume boot of the original durable UUID). +// 3. The next send continues the SAME conversation (the simulator ledger +// shows the resumed SDK query used the original durable UUID). // 4. A pane BUSY at restart un-wedges (idle status snapshot from the attach arm). // exists only on RustServer). NOTE: no page.reload() in test 1/2 -- the reload leg // is the contract wall's freshclaude test; this spec owns the reconnect leg. // // Helpers are copied, not imported, from restore-contract-wall-rust.spec.ts per // that suite's per-spec-ownership convention (donor line refs on each helper). -// The only mechanical adaptation: the donor imports 'node:fs/promises' as `fs`; -// this spec needs sync fs for the fixture request log, so promises live on `fsp` -// and the copied helper bodies use `fsp.` where the donor wrote `fs.`. -import fs from 'node:fs' import fsp from 'node:fs/promises' import os from 'node:os' import path from 'node:path' @@ -25,6 +22,7 @@ import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' import type { Page } from '@playwright/test' +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' // ESM project ("type": "module" in package.json): __dirname does not exist in // ESM modules -- derive it, same shim as the donor spec @@ -32,8 +30,6 @@ import type { Page } from '@playwright/test' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const FIXTURE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs') - // --------------------------------------------------------------------------- // Copied helpers (donor: restore-contract-wall-rust.spec.ts) // --------------------------------------------------------------------------- @@ -171,8 +167,8 @@ async function sendFreshAgentTurn( .toBe('idle') } -// --- freshclaude fresh-agent helper (fixture: fake-claude-sidecar.mjs via the -// production env seam FRESHELL_CLAUDE_SIDECAR) (donor :436-467) --- +// --- freshclaude fresh-agent helper (production sidecar + CLI simulator; +// donor :436-467) --- async function createFreshclaudePane(page: Page, harness: TestHarness, cwd: string): Promise { // setAvailableClis is client-only AND gets overwritten by the app @@ -212,12 +208,9 @@ test.describe('freshclaude restart parity (rust)', () => { test('SIGKILL restart: attach resumes in place, history rehydrates, send continues the same conversation', async ({ page }) => { test.setTimeout(120_000) // restart + resume + snapshot legs are slow const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshclaude-parity-')) - const requestLog = path.join(sharedRoot, 'sidecar-requests.jsonl') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const { server, harness } = await bootWall(page, { - env: { - FRESHELL_CLAUDE_SIDECAR: FIXTURE, - FAKE_CLAUDE_SIDECAR_LOG: requestLog, - }, + env: claudeSimulatorServerEnv({ ledgerPath: simLedger }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -236,9 +229,9 @@ test.describe('freshclaude restart parity (rust)', () => { await sendFreshAgentTurn(page, harness, tabId, prompt) await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('Fixture claude turn') - // Durable identity = the fixture's canonical UUID (via liveDurableIdentity; + // Durable identity = the simulator's canonical UUID (via liveDurableIdentity; // content.sessionId stays the create-time nanoid on a live claude pane). - // The fake sidecar mints a RANDOM canonical UUID per process (council + // The simulator mints a RANDOM canonical UUID per conversation (council // follow-up: the old static default was collision-blind), so gate on // the canonical-UUID SHAPE (which the fc-e2e-* nanoid never matches) // and CAPTURE what this run minted. @@ -300,13 +293,9 @@ test.describe('freshclaude restart parity (rust)', () => { // Server-side resume proof: the post-restart sidecar create carried // options.resume = the original durable UUID (spec item 2 verification). - const resumedCreates = fs - .readFileSync(requestLog, 'utf-8') - .split('\n') - .filter(Boolean) - .map((l) => JSON.parse(l)) - .filter((e) => e.msg?.type === 'create' && e.msg?.boot?.resume === originalDurable) - expect(resumedCreates.length).toBeGreaterThanOrEqual(1) + const resumedBoots = readClaudeSimLedger(simLedger) + .filter((row) => row.kind === 'query' && (row.options as any)?.resume === originalDurable) + expect(resumedBoots.length).toBeGreaterThanOrEqual(1) // History rehydrated (snapshot adapter): the PRE-restart prompt is back in // the pane after the snapshot fetch folds durable turns in. @@ -329,14 +318,14 @@ test.describe('freshclaude restart parity (rust)', () => { test('a pane BUSY at restart un-wedges and the next send works', async ({ page }) => { test.setTimeout(120_000) // restart + resume legs are slow const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshclaude-busy-')) - const requestLog = path.join(sharedRoot, 'sidecar-requests.jsonl') - const holdMarker = path.join(sharedRoot, 'hold-once.marker') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const { server, harness } = await bootWall(page, { - env: { - FRESHELL_CLAUDE_SIDECAR: FIXTURE, - FAKE_CLAUDE_SIDECAR_LOG: requestLog, - FAKE_CLAUDE_SIDECAR_HOLD_TURN_ONCE_MARKER: holdMarker, - }, + env: claudeSimulatorServerEnv({ + ledgerPath: simLedger, + program: { + rules: [{ on: 'msg:prompt', match: { text: 'wedge me' }, emit: [{ kind: 'hang' }] }], + }, + }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -359,7 +348,7 @@ test.describe('freshclaude restart parity (rust)', () => { }) .toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) - // First send wedges (fixture holds the first turn forever): status stuck busy. + // First send wedges (the simulator holds this model request forever): status stuck busy. // Wait for idle BEFORE filling (donor sendFreshAgentTurn's pre-send poll, // :371-391) -- but do NOT wait for idle after: this turn never completes. await expect @@ -391,12 +380,8 @@ test.describe('freshclaude restart parity (rust)', () => { // And the conversation is live again on the SAME durable session. await sendFreshAgentTurn(page, harness, tabId, `post-wedge turn ${Date.now()}`) await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('Fixture claude turn') - const resumed = fs - .readFileSync(requestLog, 'utf-8') - .split('\n') - .filter(Boolean) - .map((l) => JSON.parse(l)) - .some((e) => e.msg?.type === 'create' && e.msg?.boot?.resume === busyDurable) + const resumed = readClaudeSimLedger(simLedger) + .some((row) => row.kind === 'query' && (row.options as any)?.resume === busyDurable) expect(resumed).toBe(true) } finally { await server.stop().catch(() => {}) diff --git a/test/e2e-browser/specs/freshclaude-zero-turn-restart-rust.spec.ts b/test/e2e-browser/specs/freshclaude-zero-turn-restart-rust.spec.ts index 68745a853..26fbea987 100644 --- a/test/e2e-browser/specs/freshclaude-zero-turn-restart-rust.spec.ts +++ b/test/e2e-browser/specs/freshclaude-zero-turn-restart-rust.spec.ts @@ -4,15 +4,11 @@ * restart (RustServer.restartAbrupt(): SIGKILL + revive on the same * home/port/token) and resume in place -- never be adjudicated dead. * - * Why this spec exists: the fake sidecar creates the transcript file AT - * SESSION CREATE, before any turn -- a 0-byte .jsonl. (Validated: the REAL - * CLI materializes the transcript only at first turn; the fixture's shape - * stands in for any on-disk-but-R10b-excluded transcript, e.g. crash-window - * partial writes.) That file fails the session index's R10b cwd gate, so pre-fix the - * reconcile existence probe answered Absent while the attach arm's raw-file - * check would happily resume it => DeadSession{session_not_on_disk} and the - * 'Dead sessions' dialog. Every OTHER freshclaude-restart spec sends a turn - * before restarting (which writes a cwd-bearing line and masks the bug); + * Why this spec exists: the real CLI does not materialize a transcript until + * the first turn. A create-time durable identity with no transcript must + * still survive restart without a false dead-session adjudication. Every + * OTHER freshclaude-restart spec sends a turn before restarting (which writes + * a cwd-bearing line and masks the no-transcript case); * hidden-pane-rebind-rust.spec.ts covers the HIDDEN zero-turn pane; this * spec covers the VISIBLE one. DO NOT add a turn before the restart -- * zero-turn is the entire point. @@ -31,15 +27,7 @@ import type { Page } from '@playwright/test' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { fileURLToPath } from 'node:url' - -// ESM project ("type": "module" in package.json): __dirname does not exist in -// ESM modules, so derive it -- same convention as every fixture-referencing -// donor spec (e.g. restore-contract-wall-rust.spec.ts:39-40). -const __filename = fileURLToPath(import.meta.url) -const __dirname = path.dirname(__filename) - -const FAKE_CLAUDE_SIDECAR_SOURCE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs') +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' // --------------------------------------------------------------------------- // Shared helpers (per-spec copies -- donor: restore-contract-wall-rust.spec.ts) @@ -124,8 +112,7 @@ function findFreshAgentLeaf(node: any): any { return null } -// --- freshclaude fresh-agent helper (fixture: fake-claude-sidecar.mjs via the -// production env seam FRESHELL_CLAUDE_SIDECAR) --- +// --- freshclaude fresh-agent helper (production sidecar + CLI simulator) --- async function createFreshclaudePane(page: Page, harness: TestHarness, cwd: string): Promise { // setAvailableClis is client-only AND gets overwritten by the app @@ -166,10 +153,10 @@ test.describe('freshclaude zero-turn restart (kata 09v1)', () => { test.setTimeout(180_000) test('visible zero-turn freshclaude pane resumes after abrupt restart', async ({ page }) => { - // Sidecar REQUEST log: the post-restart resume proof reads it. - const requestLog = path.join(os.tmpdir(), `freshell-e2e-claude-sidecar-${Date.now()}.jsonl`) + const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-e2e-zero-turn-')) + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const { server, harness } = await bootWall(page, { - env: { FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE, FAKE_CLAUDE_SIDECAR_LOG: requestLog }, + env: claudeSimulatorServerEnv({ ledgerPath: simLedger }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -185,9 +172,9 @@ test.describe('freshclaude zero-turn restart (kata 09v1)', () => { return c?.sessionId && c?.createRequestId ? true : null }, { timeout: 30_000 }) .not.toBeNull() - // Wait for the DURABLE identity (sdk.session.init merge writes the - // canonical UUID to sessionRef.sessionId + resumeSessionId). The fake - // sidecar mints a RANDOM canonical UUID per process, so gate on the + // Wait for the durable identity assigned to this zero-turn pane. The + // production sidecar keeps the create-time identity until the CLI + // reports its session on a prompt, so gate on the // canonical-UUID SHAPE and capture what this run minted. await expect .poll(async () => { @@ -207,9 +194,7 @@ test.describe('freshclaude zero-turn restart (kata 09v1)', () => { await waitForWsReady(page) // Positive proof (same discriminator as the hidden-pane spec): the - // sidecar request log must contain a `create` whose resume boot names - // originalDurable -- only the restart-parity resume arm emits that, and - // the initial create boots fresh, so a match is unambiguous + // simulator ledger must contain a resumed CLI query naming originalDurable. // post-restart evidence. // The parity arm resumes by durable UUID or by the transcript's // .jsonl PATH; both carry the durable UUID -- accept either shape. @@ -218,18 +203,8 @@ test.describe('freshclaude zero-turn restart (kata 09v1)', () => { const c = findFreshAgentLeaf(await harness.getPaneLayout(freshTabId))?.content const status = c?.status ?? '' const usable = c?.sessionId && ['connected', 'idle', 'running'].includes(status) - const log = await fs.readFile(requestLog, 'utf-8').catch(() => '') - const resumed = log - .split('\n') - .filter(Boolean) - .map((l) => JSON.parse(l)) - .some( - (e) => - e.msg?.type === 'create' && - typeof e.msg?.boot?.resume === 'string' && - (e.msg.boot.resume === originalDurable || - e.msg.boot.resume.endsWith(`/${originalDurable}.jsonl`)), - ) + const resumed = readClaudeSimLedger(simLedger) + .some((row) => row.kind === 'query' && (row.options as any)?.resume === originalDurable) return usable && resumed ? `resumed:${status}` : null }, { timeout: 30_000 }) .not.toBeNull() @@ -250,6 +225,7 @@ test.describe('freshclaude zero-turn restart (kata 09v1)', () => { expect(contentAfter.createRequestId).toBe(createRequestIdBefore) } finally { await server.stop() + await fs.rm(sharedRoot, { recursive: true, force: true }).catch(() => {}) } }) }) diff --git a/test/e2e-browser/specs/harness-03-provider-fixtures.spec.ts b/test/e2e-browser/specs/harness-03-provider-fixtures.spec.ts index 7584a21e0..b1fec6f28 100644 --- a/test/e2e-browser/specs/harness-03-provider-fixtures.spec.ts +++ b/test/e2e-browser/specs/harness-03-provider-fixtures.spec.ts @@ -1,7 +1,7 @@ /** * HARNESS-03 — deterministic provider fixtures: fixture-only contract spec. * - * Invokes each of the seven fake provider executables DIRECTLY (no Freshell + * Invokes the six remaining fake provider executables DIRECTLY (no Freshell * server boots — the fixtures are the deliverable; later TERM/AGENT items wire * them into the real pane picker/server later) and asserts, per provider: * @@ -10,9 +10,8 @@ * 2. scripted session/activity/approval/question/completion/crash/resume * events land in the normalized event ledger in scripted order; * 3. the provider's wire surface carries the real protocol shape (stdout - * markers + bare BEL for terminal CLIs; newline-JSON sdk.* frames for - * the kilroy/claude sidecar; WS JSON-RPC notifications for the codex - * app-server; SSE frames for the opencode server); + * markers + bare BEL for terminal CLIs; WS JSON-RPC notifications for + * the codex app-server; SSE frames for the opencode server); * 4. crash exits with the scripted code after recording the crash event; * 5. resume: each provider's real resume argv shape yields a resume event. * @@ -151,161 +150,6 @@ test.describe('terminal CLI fixture: amplifier', () => { }) }) -// ── Kilroy / Claude-SDK sidecar ───────────────────────────────────────────── -// The kilroy/freshclaude providers are ONE protocol family: the Node sidecar -// speaking the newline-JSON bridge of crates/freshell-claude-sidecar/index.mjs -// (created FIRST, sdk.* after). Program rules key on bridge message types -// (`msg:create`, `msg:send`, …). - -const SIDECAR_PROGRAM = { - sessionId: '66666666-6666-4666-8666-666666666666', - rules: [ - { - on: 'msg:send', - match: { text: 'please approve' }, - emit: [ - { kind: 'approval', data: { id: 'perm-1', tool: 'Bash', input: { command: 'rm -rf /tmp/x' } } }, - { kind: 'question', data: { id: 'q-1', text: 'which file should I edit?' } }, - { kind: 'completion', delayMs: 20, data: { subtype: 'success' } }, - ], - }, - { - on: 'msg:send', - match: { text: 'explode' }, - emit: [{ kind: 'crash', data: { code: 5 }, delayMs: 10 }], - }, - ], -} - -async function sendSidecar(fixture: LaunchedFixture, msg: Record) { - fixture.proc.stdin?.write(`${JSON.stringify(msg)}\n`) -} - -async function readSidecarLine(fixture: LaunchedFixture, pred: (obj: any) => boolean, what: string) { - const deadline = Date.now() + 10_000 - for (;;) { - const lines = fixture.stdout - .split('\n') - .map((line) => line.trim()) - .filter((line) => line.startsWith('{')) - .map((line) => { - try { - return JSON.parse(line) - } catch { - return null - } - }) - .filter(Boolean) - const match = lines.find(pred) - if (match) return match - if (Date.now() > deadline) { - throw new Error(`sidecar: timed out waiting for ${what}. stdout: ${fixture.stdout}`) - } - await new Promise((resolve) => setTimeout(resolve, 25)) - } -} - -for (const provider of ['kilroy', 'freshclaude'] as const) { - test.describe(`claude-sdk sidecar fixture (${provider} flavour)`, () => { - let fixture: LaunchedFixture - test.afterEach(async () => { - await fixture?.stop() - }) - - test('create/send protocol with controllable approval, question, completion', async () => { - fixture = await launchProviderFixture({ - fixture: 'fake-claude-sdk-sidecar.mjs', - program: SIDECAR_PROGRAM, - env: { - ...PROBE_ENV, - HARNESS03_PROBE: `probe-${provider}`, - FRESHELL_FAKE_PROVIDER: provider, - }, - }) - await sendSidecar(fixture, { type: 'create', requestId: 'req-1', cwd: fixture.cwd, model: 'fixture-model' }) - const created = await readSidecarLine(fixture, (o) => o.type === 'created', 'created') - expect(created.requestId).toBe('req-1') - const sessionId = created.sessionId as string - expect(sessionId).toBeTruthy() - // Asserted after the first protocol exchange so the child is provably past - // appendLaunchLedger (unlike terminal fixtures, a sidecar prints no prompt - // to wait on). - expectLedgerRow(fixture, provider, []) - - const init = await readSidecarLine(fixture, (o) => o.type === 'sdk.session.init', 'sdk.session.init') - // created must precede every sdk.* frame (claude.rs read_created discards - // earlier lines) — verify wire order. - const raw = fixture.stdout - expect(raw.indexOf('"created"')).toBeLessThan(raw.indexOf('"sdk.session.init"')) - expect(init.cliSessionId).toBe('66666666-6666-4666-8666-666666666666') - const sessionEvent = await fixture.waitEvent('session') - expect(sessionEvent.data.cliSessionId).toBe('66666666-6666-4666-8666-666666666666') - - fixture.proc.stdin?.write( - `${JSON.stringify({ type: 'send', sessionId, text: 'please approve' })}\n`, - ) - const perm = await readSidecarLine(fixture, (o) => o.type === 'sdk.permission.request', 'sdk.permission.request') - expect(perm).toMatchObject({ - sessionId, - requestId: 'perm-1', - subtype: 'can_use_tool', - tool: { name: 'Bash', input: { command: 'rm -rf /tmp/x' } }, - }) - const question = await readSidecarLine(fixture, (o) => o.type === 'sdk.question.request', 'sdk.question.request') - expect(question.requestId).toBe('q-1') - expect(question.questions[0]).toMatchObject({ question: 'which file should I edit?', multiSelect: false }) - // The turn's result carries the facts the server's verdict reads; like - // the real sidecar (C18) the fixture mints no attention edge: the server - // rings the result and the waiting edge itself. - const result = await readSidecarLine(fixture, (o) => o.type === 'sdk.result', 'sdk.result') - expect(result).toMatchObject({ result: 'success', subtype: 'success', isError: false, terminalReason: 'completed' }) - expect(fixture.stdout).not.toContain('"sdk.turn.complete"') - expect(fixture.stdout).not.toContain('"sdk.turn.waiting"') - expect((await readSidecarLine(fixture, (o) => o.type === 'sdk.status' && o.status === 'idle', 'idle')).sessionId).toBe(sessionId) - - // The ledger also carries `kind:'wire'` outbound-frame audit rows - // (D1-F2); the program-emission assertions filter to program kinds. - const kinds = fixture.readEvents().map((event) => event.kind).filter((kind) => kind !== 'wire') - expect(kinds).toEqual(['session', 'activity', 'approval', 'question', 'completion']) - - fixture.proc.stdin?.write(`${JSON.stringify({ type: 'send', sessionId, text: 'explode' })}\n`) - expect(await fixture.exited()).toBe(5) - expect(fixture.readEvents().map((event) => event.kind).at(-1)).toBe('crash') - }) - - test('resume: a resume boot keeps the durable id and snapshots', async () => { - fixture = await launchProviderFixture({ - fixture: 'fake-claude-sdk-sidecar.mjs', - program: { rules: [] }, - env: { - ...PROBE_ENV, - HARNESS03_PROBE: `probe-${provider}`, - FRESHELL_FAKE_PROVIDER: provider, - }, - }) - await sendSidecar(fixture, { - type: 'create', - protocol: 2, - requestId: 'req-resume', - cwd: fixture.cwd, - boot: { kind: 'resume', resume: '77777777-7777-4777-8777-777777777777' }, - }) - await readSidecarLine(fixture, (o) => o.type === 'sdk.session.init', 'init') - const initRaw = fixture.stdout - expect(initRaw).toContain('77777777-7777-4777-8777-777777777777') - await readSidecarLine(fixture, (o) => o.type === 'sdk.session.snapshot', 'snapshot') - const resume = await fixture.waitEvent('resume') - expect(resume.data.id).toBe('77777777-7777-4777-8777-777777777777') - - // interrupt + shutdown are part of the real protocol surface. - const created = await readSidecarLine(fixture, (o) => o.type === 'created', 'created') - fixture.proc.stdin?.write(JSON.stringify({ type: 'interrupt', sessionId: created.sessionId, stopId: 'stop-1' }) + '\n') - fixture.proc.stdin?.write(JSON.stringify({ type: 'shutdown' }) + '\n') - expect(await fixture.exited()).toBe(0) - }) - }) -} - // ── Codex app-server fixture ──────────────────────────────────────────────── // WebSocket JSON-RPC, mirroring test/fixtures/coding-cli/codex-app-server/'s // wire surface: initialize-gated RPCs, thread/start + rollout session_meta, @@ -719,26 +563,6 @@ test.describe('provider fixtures are hermetic', () => { }) } - test('kilroy sidecar: create+turn with scrubbed PATH, no children', async () => { - const fixture = await launchProviderFixture({ - fixture: 'fake-claude-sdk-sidecar.mjs', - program: SIDECAR_PROGRAM, - env: { ...PROBE_ENV, HARNESS03_PROBE: 'probe-kilroy', FRESHELL_FAKE_PROVIDER: 'kilroy' }, - scrub: true, - }) - try { - await sendSidecar(fixture, { type: 'create', requestId: 'scrub-req', cwd: fixture.cwd }) - const created = await readSidecarLine(fixture, (o) => o.type === 'created', 'created') - fixture.proc.stdin?.write( - `${JSON.stringify({ type: 'send', sessionId: created.sessionId, text: 'please approve' })}\n`, - ) - await fixture.waitEvent('completion') - expect(childPidsOf(fixture.pid)).toEqual([]) - } finally { - await fixture.stop() - } - }) - test('codex app-server: RPC contract with scrubbed PATH, no children', async () => { const port = await freePort() const listen = `ws://127.0.0.1:${port}` diff --git a/test/e2e-browser/specs/hidden-pane-rebind-rust.spec.ts b/test/e2e-browser/specs/hidden-pane-rebind-rust.spec.ts index 8abe59953..41598c0b1 100644 --- a/test/e2e-browser/specs/hidden-pane-rebind-rust.spec.ts +++ b/test/e2e-browser/specs/hidden-pane-rebind-rust.spec.ts @@ -10,7 +10,7 @@ * - Test 1 (terminal): the replacement terminal is claimed in the hidden * pane's terminal.interest snapshot without attaching. Reveal then attaches * and proves that the replacement shell accepts input and has real geometry. - * - Test 2 (fresh-agent): the sidecar request log records a resume using the + * - Test 2 (fresh-agent): the simulator ledger records a resume using the * original durable session ID while the pane remains hidden, and the pane * keeps its createRequestId instead of creating a duplicate session. * @@ -24,6 +24,7 @@ import { RustServer } from '../helpers/rust-server.js' import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' import type { Page } from '@playwright/test' import fs from 'node:fs/promises' import os from 'node:os' @@ -36,8 +37,6 @@ import { fileURLToPath } from 'node:url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const FAKE_CLAUDE_SIDECAR_SOURCE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs') - // --------------------------------------------------------------------------- // Shared helpers (per-spec copies -- donor: restore-contract-wall-rust.spec.ts) // --------------------------------------------------------------------------- @@ -141,8 +140,7 @@ async function createTabViaRest(info: E2eServerInfo, body: object): Promise { // setAvailableClis is client-only AND gets overwritten by the app @@ -281,12 +279,11 @@ test.describe('hidden-pane rebind (F8 / P1.11)', () => { }) test('hidden fresh-agent pane recovers after abrupt restart without reveal', async ({ page }) => { - // Sidecar REQUEST log (FAKE_CLAUDE_SIDECAR_LOG, fake-claude-sidecar.mjs - // Task 7 knob): the post-restart resume proof below reads it. NOT the - // terminal-CLI argv log -- the fresh-agent path never spawns the CLI. - const requestLog = path.join(os.tmpdir(), `freshell-e2e-claude-sidecar-${Date.now()}.jsonl`) + // The simulator query ledger proves the durable identity used after restart. + const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-hidden-rebind-')) + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const { server, harness, info } = await bootWall(page, { - env: { FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE, FAKE_CLAUDE_SIDECAR_LOG: requestLog }, + env: claudeSimulatorServerEnv({ ledgerPath: simLedger }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -299,7 +296,7 @@ test.describe('hidden-pane rebind (F8 / P1.11)', () => { // Create a freshclaude pane in the current tab (helper copied from the wall). const freshTabId = (await harness.getActiveTabId())! // Helper signature is (page, harness, cwd) -- the wall spec's call sites - // pass a project dir; any existing directory works for the fake sidecar. + // pass a project dir; any existing directory works for the simulator. await createFreshclaudePane(page, harness, os.tmpdir()) await expect .poll(async () => { @@ -312,7 +309,7 @@ test.describe('hidden-pane rebind (F8 / P1.11)', () => { // FreshAgentView.tsx mergePaneContent) -- the post-restart attach must // deterministically carry it so the server's restart-parity resume arm // (claude.rs handle_attach decision table) can engage. - // The fake sidecar mints a RANDOM canonical UUID per process (council + // The simulator mints a RANDOM canonical UUID per conversation (council // follow-up: the old static default was collision-blind), so gate on // the canonical-UUID SHAPE and capture what this run minted below. await expect @@ -347,8 +344,8 @@ test.describe('hidden-pane rebind (F8 / P1.11)', () => { // session recovers to a usable state. DISCRIMINATING evidence -- stale // pre-restart Redux state also shows `sessionId` + 'idle', so client // state alone proves nothing. The discriminator is SERVER-SIDE: the - // sidecar request log must contain a `create` carrying - // a resume boot of originalDurable, which only the restart-parity + // simulator query ledger must contain a boot resuming originalDurable, + // which only the restart-parity // resume arm emits, and it only runs when THIS hidden pane's // post-restart freshAgent.attach actually went out (the rebind queue // under test driving the attach arm). The initial pane create carries @@ -359,22 +356,12 @@ test.describe('hidden-pane rebind (F8 / P1.11)', () => { const c = findFreshAgentLeaf(await harness.getPaneLayout(freshTabId))?.content const status = c?.status ?? '' const usable = c?.sessionId && ['connected', 'idle', 'running'].includes(status) - const log = await fs.readFile(requestLog, 'utf-8').catch(() => '') // The parity arm resumes by durable UUID when the session's original // cwd survives, or by the transcript's `.jsonl` PATH when it does // not (claude.rs decision table, ledger A15). Both carry the durable // UUID; accept either shape. - const resumed = log - .split('\n') - .filter(Boolean) - .map((l) => JSON.parse(l)) - .some( - (e) => - e.msg?.type === 'create' && - typeof e.msg?.boot?.resume === 'string' && - (e.msg.boot.resume === originalDurable || - e.msg.boot.resume.endsWith(`/${originalDurable}.jsonl`)), - ) + const resumed = readClaudeSimLedger(simLedger) + .some((row) => row.kind === 'query' && (row.options as any)?.resume === originalDurable) return usable && resumed ? `resumed:${status}` : null }, { timeout: 30_000 }) .not.toBeNull() @@ -394,6 +381,7 @@ test.describe('hidden-pane rebind (F8 / P1.11)', () => { .toBe(true) } finally { await server.stop() + await fs.rm(sharedRoot, { recursive: true, force: true }).catch(() => {}) } }) diff --git a/test/e2e-browser/specs/mcp-bridge-rust.spec.ts b/test/e2e-browser/specs/mcp-bridge-rust.spec.ts index afb94cdd3..413a46260 100644 --- a/test/e2e-browser/specs/mcp-bridge-rust.spec.ts +++ b/test/e2e-browser/specs/mcp-bridge-rust.spec.ts @@ -2,12 +2,12 @@ import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { randomUUID } from 'node:crypto' -import { fileURLToPath } from 'node:url' import { test, expect } from '@playwright/test' import { RustServer } from '../helpers/rust-server.js' import { McpStdioClient, ensureMcpServerBuilt, REPO_ROOT } from '../helpers/mcp-stdio-client.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' +import { claudeSimulatorServerEnv } from '../helpers/claude-sim-lane.js' /** * MCP bridge pin -- Slice 2 of the agent-API + MCP parity spec @@ -172,13 +172,6 @@ test.describe('MCP bridge -- Rust QA lever pin (Slice 2)', () => { test.describe('MCP bridge -- b8ke Task 10 respawn/attach ownership recovery', () => { test.setTimeout(180_000) - const __filename = fileURLToPath(import.meta.url) - const __dirname = path.dirname(__filename) - const FAKE_CLAUDE_SDK_SIDECAR = path.resolve( - __dirname, - '../fixtures/providers/fake-claude-sdk-sidecar.mjs', - ) - /** Flatten a pane layout tree into its leaf nodes. */ function collectLeaves(node: any): any[] { if (!node) return [] @@ -419,9 +412,9 @@ process.stdin.resume() test('ownership conflict through MCP returns typed owner info', async ({ page }) => { const { path: mcpBinPath } = ensureMcpServerBuilt(REPO_ROOT) const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-mcp-conflict-')) - // Both halves of the b8ke scenario on one server: the fake `claude` CLI - // (terminal panes) AND the fake SDK sidecar (the fresh-agent lane the - // handoff targets). + // The terminal Claude CLI and fresh-agent Claude sidecar are separate + // launch paths. The terminal fixture remains local to this ownership test; + // fresh-agent recovery uses the production sidecar and CLI simulator. const fake = await installFakeClaudeCli(sharedRoot) const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) @@ -429,9 +422,10 @@ process.stdin.resume() const server = new RustServer({ env: { CLAUDE_CMD: fake.binPath, - FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SDK_SIDECAR, - FRESHELL_CLAUDE_NODE: process.execPath, - FRESHELL_FAKE_PROVIDER: 'freshclaude', + ...claudeSimulatorServerEnv({ + ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl'), + program: { rules: [] }, + }), }, setupHome: seedConfig({ providers: ['claude'], freshAgent: true }), }) diff --git a/test/e2e-browser/specs/reconcile-completion-rust.spec.ts b/test/e2e-browser/specs/reconcile-completion-rust.spec.ts index a57345948..f2a1c372b 100644 --- a/test/e2e-browser/specs/reconcile-completion-rust.spec.ts +++ b/test/e2e-browser/specs/reconcile-completion-rust.spec.ts @@ -7,9 +7,8 @@ * reconcile round-trip names the fresh-agent pane; the pane converges on * the SAME durable identity; the happy path never marks the session lost * and never lands a restoreError); - * 2. two clients resuming one fresh-agent sessionRef spawn exactly ONE - * sidecar (the D8 fresh-agent lease + create dedup, proven via the fake - * sidecar's request log); + * 2. two clients resuming one fresh-agent sessionRef start exactly ONE + * simulated CLI query through the production sidecar; * 3. a page.reload storm never spawns an identity-less resume (the * pre-verdict create hold: every post-watermark spawn carries the resume * identity; live panes across reloads take attach verdicts -- zero @@ -27,6 +26,7 @@ import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' import type { Page } from '@playwright/test' +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' @@ -35,7 +35,6 @@ import { fileURLToPath } from 'node:url' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) -const FAKE_CLAUDE_SIDECAR_SOURCE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs') const FAKE_CLAUDE_CLI_SOURCE = path.resolve(__dirname, '../fixtures/fake-claude-cli.mjs') // --------------------------------------------------------------------------- @@ -64,21 +63,10 @@ function hasFlagPair(argv: string[], flag: string, value: string): boolean { return idx >= 0 && argv[idx + 1] === value } -/** - * Read the fake claude SIDECAR request log (FAKE_CLAUDE_SIDECAR_LOG, - * fake-claude-sidecar.mjs: JSONL rows `{pid, t, msg}` -- one per inbound - * stdio request; `msg.type === 'create'` rows carry the protocol-v2 `boot`). - */ -async function readSidecarLog( - logPath: string, -): Promise> { - const raw = await fs.readFile(logPath, 'utf8').catch(() => '') - if (!raw) return [] - return raw - .trim() - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line) as { pid: number; t: number; msg: { type?: string; boot?: { kind?: string; resume?: string } } }) +type SimQueryRow = { kind: 'query'; options?: { resume?: string } } + +function simQueryRows(ledgerPath: string): SimQueryRow[] { + return readClaudeSimLedger(ledgerPath).filter((row) => row.kind === 'query') as SimQueryRow[] } /** Dismiss the initial pane-type picker by choosing the first visible shell. */ @@ -327,13 +315,10 @@ test.describe('reconcile completion (rust server, real SPA)', () => { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-recompl-verdicts-')) const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) - const sidecarLogPath = path.join(sharedRoot, 'sidecar-requests.jsonl') + const simLedgerPath = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const { server, harness } = await bootSpec(page, { - env: { - FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE, - FAKE_CLAUDE_SIDECAR_LOG: sidecarLogPath, - }, + env: claudeSimulatorServerEnv({ ledgerPath: simLedgerPath }), setupHome: seedSpecConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -395,13 +380,10 @@ test.describe('reconcile completion (rust server, real SPA)', () => { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-recompl-onesidecar-')) const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) - const sidecarLogPath = path.join(sharedRoot, 'sidecar-requests.jsonl') + const simLedgerPath = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const { server, info, harness } = await bootSpec(page, { - env: { - FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE, - FAKE_CLAUDE_SIDECAR_LOG: sidecarLogPath, - }, + env: claudeSimulatorServerEnv({ ledgerPath: simLedgerPath }), setupHome: seedSpecConfig({ providers: ['claude'], freshAgent: true }), }) let pageB: Page | null = null @@ -425,17 +407,17 @@ test.describe('reconcile completion (rust server, real SPA)', () => { await harnessB.waitForHarness() await harnessB.waitForConnection() - const watermark = (await readSidecarLog(sidecarLogPath)).length + const watermark = simQueryRows(simLedgerPath).length await server.restartAbrupt() await waitForWsReady(page) await waitForWsReady(pageB) - // Both recovery rounds settle; exactly ONE resume reaches a sidecar. + // Both recovery rounds settle; exactly ONE resume reaches the simulated CLI. const countResumes = async () => - (await readSidecarLog(sidecarLogPath)) + simQueryRows(simLedgerPath) .slice(watermark) - .filter((r) => r.msg?.type === 'create' && r.msg?.boot?.resume === identity).length + .filter((row) => row.options?.resume === identity).length await expect.poll(countResumes, { timeout: 45_000 }).toBeGreaterThan(0) // STABLE-COUNT settle (donor: restore-contract-wall-rust.spec.ts): // accept only when two samples >=5s apart agree, so a tail-latency @@ -484,7 +466,7 @@ test.describe('reconcile completion (rust server, real SPA)', () => { const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) const argvLogPath = path.join(sharedRoot, 'claude-argv.jsonl') - const sidecarLogPath = path.join(sharedRoot, 'sidecar-requests.jsonl') + const simLedgerPath = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const fakeClaudePath = await installFakeCli( FAKE_CLAUDE_CLI_SOURCE, 'claude', @@ -495,8 +477,7 @@ test.describe('reconcile completion (rust server, real SPA)', () => { env: { CLAUDE_CMD: fakeClaudePath, FAKE_CLAUDE_ARGV_LOG: argvLogPath, - FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE, - FAKE_CLAUDE_SIDECAR_LOG: sidecarLogPath, + ...claudeSimulatorServerEnv({ ledgerPath: simLedgerPath }), }, setupHome: seedSpecConfig({ providers: ['claude'], freshAgent: true }), }) @@ -523,7 +504,7 @@ test.describe('reconcile completion (rust server, real SPA)', () => { await flushPersistence(page) const watermarkArgv = (await readArgvLog(argvLogPath)).length - const watermarkSidecar = (await readSidecarLog(sidecarLogPath)).length + const watermarkSidecar = simQueryRows(simLedgerPath).length for (let i = 0; i < 3; i += 1) { await page.reload({ waitUntil: 'domcontentloaded' }) @@ -562,14 +543,12 @@ test.describe('reconcile completion (rust server, real SPA)', () => { // is the non-negotiable assertion.) expect(spawns.length).toBe(0) - // Fresh-agent: every post-watermark sidecar create carries the resume + // Fresh-agent: every post-watermark simulator query carries the resume // identity (and on the attach path there should be none at all). - const creates = (await readSidecarLog(sidecarLogPath)) - .slice(watermarkSidecar) - .filter((r) => r.msg?.type === 'create') + const creates = simQueryRows(simLedgerPath).slice(watermarkSidecar) expect( - creates.every((r) => r.msg?.boot?.resume === freshclaudeIdentity), - `identity-less fresh-agent create detected: ${JSON.stringify(creates)}`, + creates.every((row) => row.options?.resume === freshclaudeIdentity), + `identity-less fresh-agent query detected: ${JSON.stringify(creates)}`, ).toBe(true) } finally { await server.stop() diff --git a/test/e2e-browser/specs/reconnect-revive-rust.spec.ts b/test/e2e-browser/specs/reconnect-revive-rust.spec.ts index 33f1fe9ee..b50a8ae58 100644 --- a/test/e2e-browser/specs/reconnect-revive-rust.spec.ts +++ b/test/e2e-browser/specs/reconnect-revive-rust.spec.ts @@ -24,24 +24,15 @@ * transition while NO close frame exists (fresh-eyes F3); after thaw, * a normal reconnect + input round trip proves recovery. * 6. fresh-agent pane reattaches and round-trips after a bare socket drop. - * FIXTURE LIMITATION (per Task 8 execution note): the fake claude - * sidecar's real scripted affordances (create/send/interrupt/shutdown + - * HOLD_TURN knobs) offer NO scriptable unsolicited post-creation - * emission, so there is no "server-side-only marker while the client is - * down" hook to use. The discriminator here is therefore the - * post-reconnect composer round trip itself, made non-vacuous two ways: - * (a) server-side the sidecar request log must gain a NEW send after - * the reconnect (the prompt genuinely crossed the new connection), and - * (b) client-side the pane must render a strictly GREATER count of the - * fixture's fixed reply text than before the drop (the response - * genuinely came back over the reattached subscription -- fresh-eyes - * F3-2: render-only assertions can pass on surviving local state with a - * fully broken reattach). + * The production sidecar runs over the CLI simulator. Its ledger counts + * prompts the simulated CLI actually started, and the pane must render a + * new reply after reconnect; together those prove both directions of the + * live round trip. * * (socket-drop/freeze revival; drives RustServer + forceDisconnect + * SIGSTOP). Not in CLOUD_SKIP_SPECS -- no real provider binaries needed - * (every claude interaction uses the committed fakes via CLAUDE_CMD / - * FRESHELL_CLAUDE_SIDECAR env seams). + * (terminal lanes use committed CLI fakes; fresh-agent lanes use the + * production sidecar and the committed Claude CLI simulator). * * Helpers are COPIED from the donor specs (hidden-pane-rebind-rust, * reconcile-client-adoption-rust, restore-contract-wall-rust, @@ -53,6 +44,7 @@ import { RustServer } from '../helpers/rust-server.js' import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' import type { Page } from '@playwright/test' import fs from 'node:fs/promises' import os from 'node:os' @@ -65,7 +57,6 @@ const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const FAKE_CLAUDE_CLI_SOURCE = path.resolve(__dirname, '../fixtures/fake-claude-cli.mjs') -const FAKE_CLAUDE_SIDECAR_SOURCE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs') const noDeadEndText = /still running on the server|\[Restore failed\]/ @@ -330,8 +321,8 @@ async function writeClaudeTranscript(input: { await fs.writeFile(path.join(projectDir, `${sessionId}.jsonl`), `${lines.join('\n')}\n`, 'utf8') } -// --- freshclaude fresh-agent helper (fixture: fake-claude-sidecar.mjs via -// the production env seam FRESHELL_CLAUDE_SIDECAR; donor: hidden-pane-rebind +// --- freshclaude fresh-agent helper (production sidecar + CLI simulator; +// donor: hidden-pane-rebind // :153-182 / freshclaude-identity-persistence :183-219) --- async function createFreshclaudePane(page: Page, cwd: string): Promise { @@ -394,22 +385,9 @@ async function fixtureReplyCount(page: Page): Promise { .count() } -/** Count `send` entries in the fake claude sidecar's request log. */ -async function sidecarSendCount(requestLogPath: string): Promise { - const raw = await fs.readFile(requestLogPath, 'utf8').catch(() => '') - if (!raw) return 0 - return raw - .trim() - .split('\n') - .filter(Boolean) - .map((line) => { - try { - return JSON.parse(line) as { msg?: { type?: string } } - } catch { - return {} - } - }) - .filter((e) => e.msg?.type === 'send').length +/** Count prompts the simulated CLI actually started. */ +function simulatedPromptCount(ledgerPath: string): number { + return readClaudeSimLedger(ledgerPath).filter((row) => row.kind === 'prompt').length } // --------------------------------------------------------------------------- @@ -706,9 +684,9 @@ test.describe('reconnect revive (rust)', () => { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-rr-freshclaude-')) const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) - const requestLogPath = path.join(sharedRoot, 'claude-sidecar-requests.jsonl') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const { server, harness } = await bootWall(page, { - env: { FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE, FAKE_CLAUDE_SIDECAR_LOG: requestLogPath }, + env: claudeSimulatorServerEnv({ ledgerPath: simLedger }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -730,7 +708,7 @@ test.describe('reconnect revive (rust)', () => { .poll(async () => fixtureReplyCount(page), { timeout: 30_000 }) .toBeGreaterThan(0) .then(() => fixtureReplyCount(page)) - const sendsBeforeDrop = await sidecarSendCount(requestLogPath) + const sendsBeforeDrop = simulatedPromptCount(simLedger) expect(sendsBeforeDrop).toBe(1) const contentBeforeDrop = findFreshAgentLeaf( @@ -759,16 +737,16 @@ test.describe('reconnect revive (rust)', () => { contentBeforeDrop.resumeSessionId, ) - // Discriminating round trip (see this file's FIXTURE LIMITATION note): + // Discriminating round trip: // (a) the pane renders strictly MORE replies than survived the drop... await sendFreshAgentTurn(page, harness, freshTabId, 'rr fresh agent turn two') await expect .poll(async () => fixtureReplyCount(page), { timeout: 30_000 }) .toBeGreaterThan(replyCountBeforeDrop) - // ...(b) and the sidecar request log proves the second send genuinely + // ...(b) and the simulator ledger proves the second prompt genuinely // crossed the (new) server connection to the still-live sidecar. await expect - .poll(async () => sidecarSendCount(requestLogPath), { timeout: 15_000 }) + .poll(async () => simulatedPromptCount(simLedger), { timeout: 15_000 }) .toBe(2) // ...and no dead-end text ever surfaces in the pane chrome. await expect(page.getByText(noDeadEndText)).toHaveCount(0) diff --git a/test/e2e-browser/specs/recover-my-panes-rust.spec.ts b/test/e2e-browser/specs/recover-my-panes-rust.spec.ts index 28ec7cae7..6c4682cfe 100644 --- a/test/e2e-browser/specs/recover-my-panes-rust.spec.ts +++ b/test/e2e-browser/specs/recover-my-panes-rust.spec.ts @@ -30,6 +30,7 @@ import { request, type BrowserContext, type Page } from '@playwright/test' import { RustServer, ensureRustServerBuilt } from '../helpers/rust-server.js' import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' +import { claudeSimulatorServerEnv } from '../helpers/claude-sim-lane.js' import { openPanePicker } from '../helpers/pane-picker.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -242,8 +243,8 @@ async function shapeEvidenceExcluding(opts: { } /** - * Donor: hidden-pane-rebind-rust.spec.ts:153 (fixture: fake-claude-sidecar.mjs - * via the production env seam FRESHELL_CLAUDE_SIDECAR). + * Donor: hidden-pane-rebind-rust.spec.ts:153 (production Claude sidecar over + * the committed CLI simulator). */ async function createFreshclaudePane(page: Page, harness: TestHarness, cwd: string): Promise { // setAvailableClis is client-only AND gets overwritten by the app @@ -378,11 +379,10 @@ test.describe('recover-my-panes browser-loss recovery (rust only)', () => { env: { CLAUDE_CMD: fakeClaude, FAKE_CLAUDE_ARGV_LOG: argLog, - // Scenario 5's freshclaude lane: the fake SDK-bridge sidecar via the - // production env seam (read only at freshclaude sidecar spawn — inert - // for scenarios 1-4). restart() re-merges this env on every boot. - FRESHELL_CLAUDE_SIDECAR: path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs'), - FAKE_CLAUDE_SIDECAR_LOG: path.join(sharedRoot, 'claude-sidecar-requests.jsonl'), + // Scenario 5's freshclaude lane: the production sidecar and CLI + // simulator (read only at fresh-agent spawn — inert for scenarios 1-4). + // restart() re-merges this env on every boot. + ...claudeSimulatorServerEnv({ ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl') }), }, setupHome: async (homeDir: string) => { capturedHome = homeDir diff --git a/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts b/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts index 23b912444..e7cbf9f88 100644 --- a/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts +++ b/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts @@ -41,6 +41,7 @@ import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { claudeSimulatorServerEnv } from '../helpers/claude-sim-lane.js' // ESM project ("type": "module" in package.json): __dirname does not exist in // ESM modules, so derive it -- same convention as every fixture-referencing @@ -58,7 +59,6 @@ const FAKE_CODEX_CLI_SOURCE = path.resolve(__dirname, '../fixtures/fake-codex-cl const FAKE_OPENCODE_TERMINAL_SOURCE = path.resolve(__dirname, '../fixtures/fake-opencode-terminal.mjs') const FAKE_OPENCODE_SIDECAR_SOURCE = path.resolve(__dirname, '../fixtures/fake-opencode.cjs') const FAKE_CLAUDE_CLI_SOURCE = path.resolve(__dirname, '../fixtures/fake-claude-cli.mjs') -const FAKE_CLAUDE_SIDECAR_SOURCE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs') const FAKE_CODEX_APP_SERVER_SOURCE = path.resolve( __dirname, '../../fixtures/coding-cli/codex-app-server/fake-app-server.mjs', @@ -479,8 +479,7 @@ async function createFreshopencodePane(page: Page, cwd: string): Promise { }) } -// --- freshclaude fresh-agent helper (fixture: fake-claude-sidecar.mjs via the -// production env seam FRESHELL_CLAUDE_SIDECAR) --- +// --- freshclaude fresh-agent helper (production sidecar + CLI simulator) --- async function createFreshclaudePane(page: Page, harness: TestHarness, cwd: string): Promise { // setAvailableClis is client-only AND gets overwritten by the app @@ -1326,7 +1325,7 @@ test.describe('Restore Contract Wall (P0.1)', () => { await fs.mkdir(projectDir, { recursive: true }) const { server, harness, info, context, page } = await bootWall(browser, { - env: { FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE }, + env: claudeSimulatorServerEnv({ ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl') }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -1621,6 +1620,7 @@ test.describe('Restore Contract Wall (P0.1)', () => { const codexArgLog = path.join(sharedRoot, 'codex-argv.jsonl') const opencodeArgLog = path.join(sharedRoot, 'opencode-argv.jsonl') const opencodeAuditLog = path.join(sharedRoot, 'opencode-audit.jsonl') + const claudeSimLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const binDir = path.join(sharedRoot, 'bin') const fakeClaudePath = await installFakeCli(FAKE_CLAUDE_CLI_SOURCE, 'claude', binDir) const fakeCodexPath = await installDualRoleCodex(binDir, codexArgLog) @@ -1633,7 +1633,7 @@ test.describe('Restore Contract Wall (P0.1)', () => { CODEX_CMD: fakeCodexPath, FAKE_CODEX_ARGV_LOG: codexArgLog, OPENCODE_CMD: fakeOpencodePath, - FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE, + ...claudeSimulatorServerEnv({ ledgerPath: claudeSimLedger }), }, setupHome: async (homeDir) => { await seedWallConfig({ providers: ['claude', 'codex', 'opencode'], freshAgent: true })( @@ -2368,8 +2368,12 @@ test.describe('Restore Contract Wall (P0.1)', () => { await fs.mkdir(projectDir, { recursive: true }) const { server, harness, context, page } = await bootWall(browser, { env: { - FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE, - FAKE_CLAUDE_SIDECAR_HOLD_TURN: '1', + ...claudeSimulatorServerEnv({ + ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl'), + program: { + rules: [{ on: 'msg:prompt', match: { text: 'busy turn' }, emit: [{ kind: 'hang' }] }], + }, + }), }, setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) @@ -2381,7 +2385,7 @@ test.describe('Restore Contract Wall (P0.1)', () => { await expect(page.locator('.xterm').first()).toBeVisible({ timeout: 30_000 }) await createFreshclaudePane(page, harness, projectDir) - // Send a turn that NEVER completes (HOLD_TURN) -> status running. + // Send a turn that NEVER completes (the simulator hangs this prompt) -> status running. const paneRoot = page.locator('[data-context="fresh-agent"]').last() await expect .poll(async () => findFreshAgentLeaf(await harness.getPaneLayout(tabId))?.content?.status, { diff --git a/test/e2e-browser/specs/wavea-interactions-rust.spec.ts b/test/e2e-browser/specs/wavea-interactions-rust.spec.ts index 9b1909e05..32a9144ba 100644 --- a/test/e2e-browser/specs/wavea-interactions-rust.spec.ts +++ b/test/e2e-browser/specs/wavea-interactions-rust.spec.ts @@ -34,11 +34,10 @@ import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' import type { Page } from '@playwright/test' +import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) -const FAKE_CLAUDE_SIDECAR_SOURCE = path.resolve(__dirname, '../fixtures/fake-claude-sidecar.mjs') - // --- helpers (per-spec copies; donors named inline) ------------------------ /** Donor: pane-ledger-restart-rust.spec.ts:29 */ @@ -313,7 +312,7 @@ test.describe('wave-A cross-lane interactions', () => { test('A2xA3: one restart restores BOTH claude identity stores with no cross-talk', async ({ page }) => { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'wavea-a2a3-')) - // The fake sidecar mints a RANDOM canonical UUID per process (council + // The simulator mints a RANDOM canonical UUID per conversation (council // follow-up: the old static 44444444-... default was collision-blind), // so the fresh-agent durable id is CAPTURED from the run below. let FRESH_DURABLE = '' @@ -321,14 +320,13 @@ test.describe('wave-A cross-lane interactions', () => { try { const fakeClaude = await installFakeCli(path.join(sharedRoot, 'bin'), 'claude', 'fake-claude-cli.mjs') const argvLog = path.join(sharedRoot, 'claude-argv.jsonl') - const sidecarLog = path.join(sharedRoot, 'sidecar-requests.jsonl') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const seed = seedConfig({ providers: ['claude'], freshAgent: true }) const server = new RustServer({ env: { CLAUDE_CMD: fakeClaude, FAKE_CLAUDE_ARGV_LOG: argvLog, - FRESHELL_CLAUDE_SIDECAR: FAKE_CLAUDE_SIDECAR_SOURCE, - FAKE_CLAUDE_SIDECAR_LOG: sidecarLog, + ...claudeSimulatorServerEnv({ ledgerPath: simLedger }), }, setupHome: async (homeDir: string) => { capturedHome = homeDir @@ -417,22 +415,12 @@ test.describe('wave-A cross-lane interactions', () => { expect(rowsAfter.some((r) => r.sessionId === terminalSession)).toBe(true) // A2 path: the fresh-agent pane resumed in place — server-side proof - // is the sidecar create carrying a resume boot (UUID or transcript + // is the simulator query carrying a resume boot (UUID or transcript // path shape, claude.rs decision table). await expect .poll(async () => { - const log = await fs.readFile(sidecarLog, 'utf-8').catch(() => '') - return log - .split('\n') - .filter(Boolean) - .map((l) => JSON.parse(l)) - .some( - (e) => - e.msg?.type === 'create' && - typeof e.msg?.boot?.resume === 'string' && - (e.msg.boot.resume === FRESH_DURABLE || - e.msg.boot.resume.endsWith(`/${FRESH_DURABLE}.jsonl`)), - ) + return readClaudeSimLedger(simLedger) + .some((row) => row.kind === 'query' && (row.options as any)?.resume === FRESH_DURABLE) }, { timeout: 30_000 }) .toBe(true) await expect From 2c7b8b711fd6716bbac88b8fc9ab63982da5e3ff Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:47:23 -0700 Subject: [PATCH 033/100] fix(freshclaude): fence abandoned create cleanup by runtime owner --- crates/freshell-freshagent/src/claude.rs | 125 ++++++++++++++---- .../claude/tests/abandoned_create_tests.rs | 74 +++++++++++ 2 files changed, 175 insertions(+), 24 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude/tests/abandoned_create_tests.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index ea4df30cb..7c7794212 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -2577,6 +2577,32 @@ impl FreshClaudeState { /// `ws-handler.ts:3673`), then broadcast `freshAgent.killed`. Idempotent for an /// unknown session id, matching the codex/opencode `success:true` pattern. pub async fn handle_kill(&self, msg: FreshAgentKill) { + self.handle_kill_inner(msg, None).await; + } + + /// Release a runtime allocated by a create whose destination disappeared. + /// The receipt is authority only for the incarnation that create allocated. + pub async fn abandon_created_runtime( + &self, + session_id: &str, + session_type: SessionType, + ownership_id: &str, + ) { + self.handle_kill_inner( + FreshAgentKill { + provider: freshell_protocol::AgentProvider::Claude, + session_id: session_id.to_string(), + session_type, + observed_epoch: None, + observed_generation: None, + cwd: None, + }, + Some(ownership_id), + ) + .await; + } + + async fn handle_kill_inner(&self, msg: FreshAgentKill, expected_owner: Option<&str>) { let session_id = msg.session_id.clone(); let session_type = session_type_str(msg.session_type); @@ -2672,6 +2698,7 @@ impl FreshClaudeState { // envelope writes next), and a Clean failure simply releases the // gate with the session untouched. let mut retire_ids = vec![session_id.clone()]; + let mut expected_stop_stamp = None; { let index = self.cli_index.lock().await; let sessions = self.sessions.lock().await; @@ -2692,6 +2719,36 @@ impl FreshClaudeState { if let Some(current_map_key) = current_map_key { map_key = current_map_key; } + if let Some(expected_owner) = expected_owner { + let Some(session) = sessions + .get(&map_key) + .filter(|session| session.ownership_id == expected_owner) + else { + tracing::info!(target: "freshell_freshagent::claude", + event = "freshagent.claude.abandoned_create_already_released", + session_id, expected_owner, + "the create receipt no longer owns this runtime; cleanup is complete"); + return; + }; + if self.ownership.is_some() { + let durable = session.cli_session_id.as_deref().unwrap_or(&map_key); + let Some(stamp) = + crate::ownership_lane::peek_retained_stamp(&self.ownership_stamps, durable) + .filter(|stamp| { + stamp.owner.ownership_id.as_deref() == Some(expected_owner) + }) + else { + tracing::warn!(target: "freshell_freshagent::claude", + event = "freshagent.claude.abandoned_create_stamp_missing", + session_id, expected_owner, + "the create receipt has no matching retained ownership stamp"); + return; + }; + // Capture authority while inspecting that exact runtime. A + // later stop must never borrow a replacement's stamp. + expected_stop_stamp = Some((durable.to_string(), stamp)); + } + } if sessions.contains_key(&map_key) { let mut gates = self.close_pending.lock().expect("close-pending lock"); *gates.entry(map_key.clone()).or_insert(0) += 1; @@ -2742,30 +2799,34 @@ impl FreshClaudeState { // progressing stop is not stale; never fenced on age alone). let mut _stop_settlement: Option = None; if let Some(registry) = self.ownership.as_ref() { - let stop_stamp = match crate::ownership_lane::peek_retained_stamp( - &self.ownership_stamps, - &session_id, - ) { - Some(stamp) => Some((session_id.clone(), stamp)), - None => { - let map_key = self.resolve_session_key(&session_id).await; - match map_key { - Some(map_key) => { - let durable = self - .sessions - .lock() - .await - .get(&map_key) - .and_then(|s| s.cli_session_id.clone()); - durable.and_then(|d| { - crate::ownership_lane::peek_retained_stamp( - &self.ownership_stamps, - &d, - ) - .map(|st| (d, st)) - }) + let stop_stamp = if expected_owner.is_some() { + expected_stop_stamp + } else { + match crate::ownership_lane::peek_retained_stamp( + &self.ownership_stamps, + &session_id, + ) { + Some(stamp) => Some((session_id.clone(), stamp)), + None => { + let map_key = self.resolve_session_key(&session_id).await; + match map_key { + Some(map_key) => { + let durable = self + .sessions + .lock() + .await + .get(&map_key) + .and_then(|s| s.cli_session_id.clone()); + durable.and_then(|d| { + crate::ownership_lane::peek_retained_stamp( + &self.ownership_stamps, + &d, + ) + .map(|st| (d, st)) + }) + } + None => None, } - None => None, } } }; @@ -3310,7 +3371,22 @@ impl FreshClaudeState { // synchronous (the minter channel closes). // C09: a closed session is not an ended one waiting to be resumed. self.dead_sessions.forget(&retire_ids, "killed"); - let removed = self.sessions.lock().await.remove(&map_key); + let removed = { + let mut sessions = self.sessions.lock().await; + if expected_owner.is_some_and(|owner| { + sessions + .get(&map_key) + .is_some_and(|session| session.ownership_id != owner) + }) { + tracing::error!(target: "freshell_freshagent::claude", + event = "freshagent.claude.abandoned_create_replacement_preserved", + session_id, + "a replacement appeared during the close; its runtime is preserved"); + None + } else { + sessions.remove(&map_key) + } + }; // The gate entry dies with the session (the map rule was the gate's // whole meaning; an aborted session adopts nothing more). self.close_pending @@ -8099,6 +8175,7 @@ pub(crate) mod tests { use super::*; use crate::naming::SessionNaming as _; + mod abandoned_create_tests; mod control_tests; mod create_binding_tests; mod deadman_tests; diff --git a/crates/freshell-freshagent/src/claude/tests/abandoned_create_tests.rs b/crates/freshell-freshagent/src/claude/tests/abandoned_create_tests.rs new file mode 100644 index 000000000..c20a87517 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/tests/abandoned_create_tests.rs @@ -0,0 +1,74 @@ +use super::*; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn abandoned_create_cannot_close_a_replacement_with_the_same_session_id() { + let (mut state, _rx) = state_with_bus(); + let sink = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(sink.clone()); + let id = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&state, &id).await; + let owner = state.sessions.lock().await[&id].ownership_id.clone(); + let pause = Arc::new(AdoptionTestPause { + reached: std::sync::atomic::AtomicBool::new(false), + notify: tokio::sync::Notify::new(), + }); + state.kill_after_resolve_pause = Some(pause.clone()); + let task = { + let state = state.clone(); + let id = id.clone(); + tokio::spawn(async move { + state + .abandon_created_runtime(&id, SessionType::Freshclaude, &owner) + .await; + }) + }; + tokio::time::timeout(Duration::from_secs(5), async { + while !pause.reached.load(std::sync::atomic::Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let old = state.sessions.lock().await.remove(&id).unwrap(); + insert_fake_claude_session(&state, &id).await; + state + .sessions + .lock() + .await + .get_mut(&id) + .unwrap() + .ownership_id = "replacement".into(); + pause.notify.notify_one(); + task.await.unwrap(); + let replacement_alive = state + .sessions + .lock() + .await + .get_mut(&id) + .is_some_and(|session| { + session.ownership_id == "replacement" && session.child.try_wait().unwrap().is_none() + }); + let retired = sink.retires.lock().unwrap().clone(); + state.teardown_removed_session_logged(old).await; + state.shutdown().await; + assert!( + replacement_alive, + "a stale create receipt must leave the replacement alive" + ); + assert!( + retired.is_empty(), + "a stale receipt must not close durable rows: {retired:?}" + ); +} + +#[tokio::test] +async fn abandoned_create_closes_only_its_allocated_runtime() { + let (state, _rx) = state_with_bus(); + let id = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&state, &id).await; + let owner = state.sessions.lock().await[&id].ownership_id.clone(); + state + .abandon_created_runtime(&id, SessionType::Freshclaude, &owner) + .await; + assert!(!state.sessions.lock().await.contains_key(&id)); +} From edb054c7161811ddb93a107897e0bc226539bbf0 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:47:25 -0700 Subject: [PATCH 034/100] docs: describe Rust Claude attention and process recovery --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 320ef4a9b..53c7e4975 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -281,7 +281,7 @@ stderr note). Provisioning, rotation, and revocation live in **Rename scope contract:** Claude, Codex, and OpenCode coding-agent sessions (terminal CLI panes and fresh* panes) have ONE durable saved name per session, shared by the pane header, the tab (when it takes its name from the session), the sidebar/history rows, the terminal presentation, and every API projection; renaming from any surface (UI inline editors and menus, the session/terminal/pane/tab routes, CLI verbs, MCP actions) writes that same record everywhere, automation defaults to a non-permanent automatic suggestion, and only Rename is exposed (no generate/reset/custom-label control). Everything else keeps the legacy four-scope model (layout-local pane/tab labels, terminal-scoped titles, provider-specific session overrides). The unified contract, native sync status semantics (finite unsynced recovery; no native human-intent provenance; serial generation queuing), and the migration recovery procedure (immutable `name-migration-v1` evidence + a deliberate Rename; never reactivate aliases) live in [docs/development/rename-scope-contract.md](docs/development/rename-scope-contract.md). -**Agent Status Indicators:** Blue/busy status is derived from provider activity slices through `resolvePaneActivity`; the unified needs-attention signal (green highlight + one bell) flows through `recordTurnComplete`/`recordTerminalIdle` and `useTurnCompletionNotifications`. The signal is server-authoritative everywhere and outcome-uniform: any turn end the user did not witness — finished, errored, max-turns, crashed (sidecar death with a turn in flight), mid-turn stream exception, wedged/stuck (codex deadman), or waiting on an approval/question — produces the identical attention treatment (tab highlight, sidebar row highlight, pane-header mark, and a single bell), with no distinction by outcome; only a USER-initiated interrupt is silent. Terminal CLI panes keep today's behavior unchanged (a per-event source discriminator partitions the hook — `recordTerminalIdle` stamps `source:'terminal'`, `recordTurnComplete` stamps `source:'freshAgent'`): terminals mark tab+pane attention on the truly-idle `terminal.idle` edge, suppress only the sound when watched, and coalesce a batch into one chime, while fresh-agent panes (freshclaude/kilroy/freshcodex/freshopencode) ring a discrete `freshAgent.turn.complete` edge emitted for every turn end except a user-initiated interrupt — freshclaude/kilroy for ANY SDK `result` subtype except the stopped turn's own result: the sidecar's interrupt gate silences a result only when it holds a pending accepted user-interrupt mark AND the result is aborted (`terminal_reason` aborted_streaming/aborted_tools), so a Stop that missed its turn (sent while the CLI was still starting) never silences it (the mark is armed only while a result is pending, set before the interrupt RPC, disarmed on RPC failure, and consumed by the next result either way — ordering-scoped, deliberately no reset-at-send; a stopped turn shows no error banner), plus the consumeStream-finally mint when a pending turn ends with no result frame (stream exception or natural EOF; a pending mark never silences it) and a Rust-synthesized edge on an unrequested sidecar death mid-turn (requested teardown and idle deaths stay silent); freshcodex for `completed`/`failed`/absent statuses and non-user `interrupted` (the interrupt lane arms a per-session marker before the RPC and disarms it on RPC failure; the marker is consumed by the matching `interrupted` completion and cleared at the next `turn/started` — the opencode discipline, where `turn_aborted` clears at every turn dispatch — so only user-caused interrupts are silent); freshopencode for every settle except `turn_aborted`. Every fresh-agent edge carries a finite numeric strictly-monotonic per-session `at` (the client silently drops a malformed `at`) and folds in via `applyFreshAgentCompletion` under the `at`-monotonic dedupe regime (wall-clock `at`, no per-session counter, so a resumed durable session can't swallow completions across a server restart — a real restart also clears the per-terminal baselines). Attention flags are no longer persisted across reloads: a reload witnesses nothing (idle/busy state rehydrates; no highlight or bell ever replays a pre-load turn end). A watched fresh-agent ending (window focused + tab active) sets a tab-strip-only watched mark (`watchedCompletionByTab` — no sidebar row, no pane-header mark, no sound) that clears when the user navigates away and back. The fresh-agent bell rings audibly once per event through a serialized unbounded ring queue (a fresh chime per ring; the next ring starts when the previous chime ends or is paused, or the built-in fallback tone completes; an unplayable or aborted chime file substitutes a quiet built-in tone; a 5 s per-ring watchdog (`RING_WATCHDOG_MS` in `src/hooks/useNotificationSound.ts`, logged as the `notification_ring_watchdog_fired` warn event) releases a ring whose completion signal never arrives, so one dead ring cannot silence later bells; with sound disabled the highlights still appear). The waiting-for-approval edge is ALSO server-authoritative: freshclaude/kilroy emit a discrete `freshAgent.turn.waiting` edge on the 0→≥1 pending permission/question transition, and codex's approval/question controls emit the same edge on their own 0→≥1 pending transition (Claude/kilroy and codex both raise approvals/questions); the client folds it in via `applyFreshAgentWaiting` under a distinct `${provider}:${sessionId}#waiting` dedupe namespace so it can never poison (or be poisoned by) the turn-complete bucket. The fragile client-side busy→idle derivation AND the client-side waiting-edge hook (`useAgentSessionTurnCompletion`) were both removed — all attention edges are server-emitted. freshcodex additionally self-heals a crashed/disconnected codex sidecar by consuming the runtime `onExit` hook in `subscribe()`, broadcasting the terminal `exited` status to clear BLUE while the exit watcher's crash arm rings the same unified edge when a turn was in flight (an idle crash stays silent). freshcodex also runs a wedged-sidecar deadman: after a bounded quiet window (default 10 min, env `FRESHELL_FRESHCODEX_QUIET_WINDOW_MS`) with a turn in flight and no sidecar events, the server stops asserting busy, marks the pane `stuck`, and fires the same unified edge; the client shows an amber "Agent appears stuck" card (`role="alert"`) with "Restart sidecar" (kill + resume re-mint) and "Start new conversation" actions as pane-level inline content. `freshopencode` still runs on a shared long-lived `opencode serve` sidecar and uses server-pushed `session.idle`/`session.status` events to drive busy. A freshAgent.send arriving while a native /compact drive is in flight — or while older queued sends are still pending — is queued server-side (FIFO, immediate send.accepted) and auto-driven when the compact settles; kill drops the queue, and the drain re-validates a captured observed fence through the lane op guard (stale pairs refuse typed, request-correlated), releasing the guard at the prompt's dispatch boundary. The runtime self-heals a shared-daemon death (the 2026-09-20 incident class): daemon loss fans a typed `freshAgent.error{code:"OPENCODE_DAEMON_LOST"}` edge out to exactly one frame per materialized session (the client's generic `sessionError` banner + busy-clear; the typed edge itself carries no chime — a daemon death mid-turn is a crashed turn end and rings the unified edge through the turn's own non-clean settle), the manager respawns the daemon on a backoff ladder (fresh-incident reset, crash-loop escalation), every successful cold start drives a level-triggered revival pass that restarts dead session bridges and pushes `freshAgent.session.snapshot{status:"idle"}` (the client's transcript-refetch trigger) while respecting the ownership coordinator (retired, transitioned, or terminal-owned sessions are never revived), and a generation-fenced `freshAgent.attach` is itself a recovery verb that respawns the daemon before re-bridging. Client-side, a snapshot GET that still answers the typed 409 `RESTORE_UNAVAILABLE` for the pane's own stale fresh-agent claim (e.g. the pane loaded while the server was restarting) does not dead-end on the dismiss-only banner: the pane drives the documented recovery ONCE per pane identity — it refreshes the observed owner fence from the refusal's own `ownerGeneration` (preserving the record's epoch), sends one generation-fenced `freshAgent.attach`, and refetches through the reveal path when reveal-dirty (so the "Refreshing conversation" overlay can clear) or via `manual` otherwise; a suppressed attach restores the once-guard, and repeated 409s fall through to the honest error banner — terminal-owned refusals stay out of this path (their recovery door is the session-directory handoff). Gemini and Kimi terminal modes are status-in... [truncated] Separately, the sidebar shows cross-device remote status rings around a session row's icon: a green ring means the session is open on another device, a blue ring means it is busy on another device (blue wins over green), and rings are suppressed entirely when the session is open on this device (derived from `tabs.sync` registry snapshots — producing clients stamp pane payloads with `sessionKeys`/`busySessionKeys`, consumers re-query remote snapshots on a 30s interval, and the server partitions same-device records into `sameDeviceOpen`, which never produces rings). +**Agent Status Indicators:** Blue/busy status is derived from provider activity slices through `resolvePaneActivity`; the unified needs-attention signal (green highlight + one bell) flows through `recordTurnComplete`/`recordTerminalIdle` and `useTurnCompletionNotifications`. The signal is server-authoritative everywhere and outcome-uniform: any turn end the user did not witness — finished, errored, max-turns, crashed (sidecar death with a turn in flight), mid-turn stream exception, wedged/stuck (Claude/Kilroy and Codex deadmen), or waiting on an approval/question — produces the identical attention treatment (tab highlight, sidebar row highlight, pane-header mark, and a single bell), with no distinction by outcome; only a USER-initiated interrupt is silent. Terminal CLI panes keep today's behavior unchanged (a per-event source discriminator partitions the hook — `recordTerminalIdle` stamps `source:'terminal'`, `recordTurnComplete` stamps `source:'freshAgent'`): terminals mark tab+pane attention on the truly-idle `terminal.idle` edge, suppress only the sound when watched, and coalesce a batch into one chime, while fresh-agent panes (freshclaude/kilroy/freshcodex/freshopencode) ring a discrete `freshAgent.turn.complete` edge emitted for every turn end except a user-initiated interrupt — freshclaude/kilroy use the Rust turn ledger to correlate accepted user-message UUIDs, CLI lifecycle events and results. Rust mints all completion and waiting edges; the sidecar forwards SDK facts. Stop latches one target (the started turn, otherwise the oldest accepted turn, otherwise the active CLI turn), and only that target's user-caused abort is silent. A Stop that misses its target leaves other results audible; an idle Stop is a no-op. A stream error or EOF ends the runtime, publishes `exited`, and rings once if a turn was still owed; requested teardown and idle death are silent. Send and compact transparently recover an ended runtime using its saved conversation and settings, and the exited pane also exposes Resume; freshcodex for `completed`/`failed`/absent statuses and non-user `interrupted` (the interrupt lane arms a per-session marker before the RPC and disarms it on RPC failure; the marker is consumed by the matching `interrupted` completion and cleared at the next `turn/started` — the opencode discipline, where `turn_aborted` clears at every turn dispatch — so only user-caused interrupts are silent); freshopencode for every settle except `turn_aborted`. Every fresh-agent edge carries a finite numeric strictly-monotonic per-session `at` (the client silently drops a malformed `at`) and folds in via `applyFreshAgentCompletion` under the `at`-monotonic dedupe regime (wall-clock `at`, no per-session counter, so a resumed durable session can't swallow completions across a server restart — a real restart also clears the per-terminal baselines). Attention flags are no longer persisted across reloads: a reload witnesses nothing (idle/busy state rehydrates; no highlight or bell ever replays a pre-load turn end). A watched fresh-agent ending (window focused + tab active) sets a tab-strip-only watched mark (`watchedCompletionByTab` — no sidebar row, no pane-header mark, no sound) that clears when the user navigates away and back. The fresh-agent bell rings audibly once per event through a serialized unbounded ring queue (a fresh chime per ring; the next ring starts when the previous chime ends or is paused, or the built-in fallback tone completes; an unplayable or aborted chime file substitutes a quiet built-in tone; a 5 s per-ring watchdog (`RING_WATCHDOG_MS` in `src/hooks/useNotificationSound.ts`, logged as the `notification_ring_watchdog_fired` warn event) releases a ring whose completion signal never arrives, so one dead ring cannot silence later bells; with sound disabled the highlights still appear). The waiting-for-approval edge is ALSO server-authoritative: freshclaude/kilroy emit a discrete `freshAgent.turn.waiting` edge on the 0→≥1 pending permission/question transition, and codex's approval/question controls emit the same edge on their own 0→≥1 pending transition (Claude/kilroy and codex both raise approvals/questions); the client folds it in via `applyFreshAgentWaiting` under a distinct `${provider}:${sessionId}#waiting` dedupe namespace so it can never poison (or be poisoned by) the turn-complete bucket. The fragile client-side busy→idle derivation AND the client-side waiting-edge hook (`useAgentSessionTurnCompletion`) were both removed — all attention edges are server-emitted. Freshclaude/Kilroy pause their quiet timer while an approval or question is pending. With a turn owed, the timer defaults to 10 min (`FRESHELL_FRESHCLAUDE_QUIET_WINDOW_MS`); tool heartbeats stop feeding it after `FRESHELL_FRESHCLAUDE_TOOL_HEARTBEAT_CAP_MS`, defaulting to `BASH_MAX_TIMEOUT_MS + 120000` (12 min when unset). A fenced deadline marks the runtime `stuck` and rings once. Restart sidecar uses `freshAgent.recovery.stop` to stop the process while retaining the conversation, genesis, settings and attention clock; it does not close the saved session. freshcodex additionally self-heals a crashed/disconnected codex sidecar by consuming the runtime `onExit` hook in `subscribe()`, broadcasting the terminal `exited` status to clear BLUE while the exit watcher's crash arm rings the same unified edge when a turn was in flight (an idle crash stays silent). freshcodex also runs a wedged-sidecar deadman: after a bounded quiet window (default 10 min, env `FRESHELL_FRESHCODEX_QUIET_WINDOW_MS`) with a turn in flight and no sidecar events, the server stops asserting busy, marks the pane `stuck`, and fires the same unified edge; the client shows an amber "Agent appears stuck" card (`role="alert"`) with "Restart sidecar" (kill + resume re-mint) and "Start new conversation" actions as pane-level inline content. `freshopencode` still runs on a shared long-lived `opencode serve` sidecar and uses server-pushed `session.idle`/`session.status` events to drive busy. A freshAgent.send arriving while a native /compact drive is in flight — or while older queued sends are still pending — is queued server-side (FIFO, immediate send.accepted) and auto-driven when the compact settles; kill drops the queue, and the drain re-validates a captured observed fence through the lane op guard (stale pairs refuse typed, request-correlated), releasing the guard at the prompt's dispatch boundary. The runtime self-heals a shared-daemon death (the 2026-09-20 incident class): daemon loss fans a typed `freshAgent.error{code:"OPENCODE_DAEMON_LOST"}` edge out to exactly one frame per materialized session (the client's generic `sessionError` banner + busy-clear; the typed edge itself carries no chime — a daemon death mid-turn is a crashed turn end and rings the unified edge through the turn's own non-clean settle), the manager respawns the daemon on a backoff ladder (fresh-incident reset, crash-loop escalation), every successful cold start drives a level-triggered revival pass that restarts dead session bridges and pushes `freshAgent.session.snapshot{status:"idle"}` (the client's transcript-refetch trigger) while respecting the ownership coordinator (retired, transitioned, or terminal-owned sessions are never revived), and a generation-fenced `freshAgent.attach` is itself a recovery verb that respawns the daemon before re-bridging. Client-side, a snapshot GET that still answers the typed 409 `RESTORE_UNAVAILABLE` for the pane's own stale fresh-agent claim (e.g. the pane loaded while the server was restarting) does not dead-end on the dismiss-only banner: the pane drives the documented recovery ONCE per pane identity — it refreshes the observed owner fence from the refusal's own `ownerGeneration` (preserving the record's epoch), sends one generation-fenced `freshAgent.attach`, and refetches through the reveal path when reveal-dirty (so the "Refreshing conversation" overlay can clear) or via `manual` otherwise; a suppressed attach restores the once-guard, and repeated 409s fall through to the honest error banner — terminal-owned refusals stay out of this path (their recovery door is the session-directory handoff). Gemini and Kimi terminal modes are status-in... [truncated] Separately, the sidebar shows cross-device remote status rings around a session row's icon: a green ring means the session is open on another device, a blue ring means it is busy on another device (blue wins over green), and rings are suppressed entirely when the session is open on this device (derived from `tabs.sync` registry snapshots — producing clients stamp pane payloads with `sessionKeys`/`busySessionKeys`, consumers re-query remote snapshots on a 30s interval, and the server partitions same-device records into `sameDeviceOpen`, which never produces rings). **Fresh-Agent Orchestration:** The Rust REST agent API (`/api/tabs`, `/api/panes/:id/split`, `/api/panes/:id/send-keys`, `/api/panes/:id/capture`, `/api/panes/:id/wait-for`) and standalone CLI/MCP client accept `agent`/`model`/`effort` where supported. In the default server, `new-tab` supports `agent: "claude" | "kilroy" | "opencode"`; fresh-agent `split-pane` supports Claude and Kilroy. Claude operations use the SAME `FreshClaudeState` and `createRequestId` deduplication as browser panes, including headless creation. Create defaults are resolved once from live settings and copied into the provider request and pane content. On MCP `new-tab`, `resume`/`resumeSessionId` map Claude and Kilroy to a Claude `sessionRef`, and OpenCode to an OpenCode ref; terminal-mode resume uses a provider-matched `sessionRef` (raw Codex IDs remain insufficient restore identity). REST Claude send strips exactly one trailing CR or LF; line-terminator-only input returns `noop`. A completed send returns its actual `submittedTurnId`; a deadline returns `approx`, and an unavailable or ended runtime returns a retryable 409. Capture reads durable history, including after restart. The opt-in hosted gateway takes precedence over local lanes; installing a local REST lane does not change the snapshot HTTP authority. Unsupported legacy actions return a deterministic unavailable result. From 8d96881038db14c27f47177c8560a5fc0ac4ad5b Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:50:24 -0700 Subject: [PATCH 035/100] refactor(agent-api): finalize deferred creates before publication --- .../src/claude_rest_lane.rs | 1 + .../freshell-freshagent/src/layout_store.rs | 90 ++++++----- .../src/layout_store/publication.rs | 54 ++++++- crates/freshell-freshagent/src/lib.rs | 14 +- crates/freshell-freshagent/src/pane_ops.rs | 24 ++- crates/freshell-freshagent/src/rest_lane.rs | 18 +++ .../src/rest_lane_create.rs | 26 +++ .../src/rest_lane_publication_tests.rs | 149 +++++++++++++++++- 8 files changed, 333 insertions(+), 43 deletions(-) diff --git a/crates/freshell-freshagent/src/claude_rest_lane.rs b/crates/freshell-freshagent/src/claude_rest_lane.rs index 166c8905f..83efe3de4 100644 --- a/crates/freshell-freshagent/src/claude_rest_lane.rs +++ b/crates/freshell-freshagent/src/claude_rest_lane.rs @@ -67,6 +67,7 @@ impl ClaudeRestLane { } => Ok(RestLaneCreated { session_id, session_ref, + cleanup_token: None, }), ClaudeCreateOutcome::Failed { code, diff --git a/crates/freshell-freshagent/src/layout_store.rs b/crates/freshell-freshagent/src/layout_store.rs index 88fbd7fe0..981b44be7 100644 --- a/crates/freshell-freshagent/src/layout_store.rs +++ b/crates/freshell-freshagent/src/layout_store.rs @@ -726,14 +726,22 @@ impl LayoutStore { /// `ensureSnapshot` + append an ordered tab with a single terminal leaf and /// a seeded title (`createTab`, `layout-store.ts:431-460`). pub fn create_tab(&self, title: Option<&str>) -> (String, String) { - self.create_tab_with_publication(title, false) + self.create_tab_with_publication(title, None) } - pub(crate) fn create_pending_tab(&self, title: Option<&str>) -> (String, String) { - self.create_tab_with_publication(title, true) + pub(crate) fn create_pending_tab( + &self, + title: Option<&str>, + create_request_id: &str, + ) -> (String, String) { + self.create_tab_with_publication(title, Some(create_request_id)) } - fn create_tab_with_publication(&self, title: Option<&str>, pending: bool) -> (String, String) { + fn create_tab_with_publication( + &self, + title: Option<&str>, + pending: Option<&str>, + ) -> (String, String) { let tab_id = Uuid::new_v4().to_string(); let pane_id = Uuid::new_v4().to_string(); // `buildContent({})` (`layout-store.ts:317-325`): a detached terminal pane. @@ -771,7 +779,7 @@ impl LayoutStore { if let Some(tab) = snapshot.tabs.iter_mut().find(|t| t.id == tab_id) { tab.name_source = name_source; } - if pending { + if let Some(create_request_id) = pending { inner .pending_publications .push(publication::PendingPublication { @@ -779,7 +787,7 @@ impl LayoutStore { pane_id: pane_id.clone(), placement: publication::Placement::Tab, published: false, - create_request_id: None, + create_request_id: Some(create_request_id.to_string()), }); } persist_locked(&inner); @@ -849,32 +857,7 @@ impl LayoutStore { } let mut found = false; for snapshot in inner.snapshots_mut() { - let Some(removed_index) = snapshot.tabs.iter().position(|t| t.id == tab_id) else { - continue; - }; - // Cursor parity with the client's removeTab reducer: a BACKGROUND - // close never moves the selection (a failed agent create rolls the - // layout back to exactly the user's tab); only closing the cursor's - // own tab selects a survivor (previous neighbor, else the new first). - let was_active = snapshot.active_tab_id.as_deref() == Some(tab_id); - snapshot.tabs.remove(removed_index); - snapshot.layouts.remove(tab_id); - snapshot.active_pane.remove(tab_id); - snapshot.pane_titles.remove(tab_id); - snapshot.pane_title_set_by_user.remove(tab_id); - if was_active { - let next_index = if removed_index > 0 { - removed_index - 1 - } else { - 0 - }; - snapshot.active_tab_id = snapshot - .tabs - .get(next_index) - .or_else(|| snapshot.tabs.first()) - .map(|t| t.id.clone()); - } - found = true; + found |= close_tab_in(snapshot, tab_id); } if found { persist_locked(&inner); @@ -1178,6 +1161,13 @@ impl LayoutStore { .retain(|entry| entry.pane_id != pane_id); let mut removed = false; for snapshot in inner.snapshots_mut() { + let sole_tab = snapshot.layouts.iter().find_map(|(tab_id, root)| { + matches!(root, PaneNode::Leaf { id, .. } if id == pane_id).then(|| tab_id.clone()) + }); + if let Some(tab_id) = sole_tab { + removed |= close_tab_in(snapshot, &tab_id); + continue; + } let tab_id = snapshot.layouts.iter_mut().find_map(|(tab_id, root)| { collapse_split_leaf(root, pane_id).then(|| tab_id.clone()) }); @@ -1260,7 +1250,15 @@ impl LayoutStore { pane_id: &str, content: Value, ) -> RenameOutcome { - let mut inner = self.lock(); + Self::attach_pane_content_locked(&mut self.lock(), tab_id, pane_id, content) + } + + fn attach_pane_content_locked( + inner: &mut LayoutInner, + tab_id: &str, + pane_id: &str, + content: Value, + ) -> RenameOutcome { if inner.clients.is_empty() { return RenameOutcome::failed("no layout snapshot"); } @@ -1328,7 +1326,6 @@ impl LayoutStore { .iter_mut() .find(|entry| entry.pane_id == pane_id) { - pending.published = true; pending.create_request_id = normalized .get("createRequestId") .and_then(Value::as_str) @@ -1345,7 +1342,7 @@ impl LayoutStore { }, ); } - persist_locked(&inner); + persist_locked(inner); RenameOutcome::tab_pane(tab_id, pane_id) } else { RenameOutcome::failed("tab not found") @@ -1390,7 +1387,7 @@ impl LayoutStore { inner.recovery_stamps.remove(pane_id); inner .pending_publications - .retain(|entry| entry.pane_id != pane_id); + .retain(|entry| entry.pane_id != pane_id && !(!entry.published && matches!(&entry.placement, publication::Placement::Split { source_pane_id, .. } if source_pane_id == pane_id))); persist_locked(&inner); } first.unwrap_or(Err("pane not found")) @@ -1775,6 +1772,27 @@ fn string_field(content: &Value, key: &str) -> Option { /// Every pane id in every tab layout of one snapshot — the supersede /// (subset) check's unit of coverage. +fn close_tab_in(snapshot: &mut UiSnapshot, tab_id: &str) -> bool { + let Some(removed_index) = snapshot.tabs.iter().position(|tab| tab.id == tab_id) else { + return false; + }; + let was_active = snapshot.active_tab_id.as_deref() == Some(tab_id); + snapshot.tabs.remove(removed_index); + snapshot.layouts.remove(tab_id); + snapshot.active_pane.remove(tab_id); + snapshot.pane_titles.remove(tab_id); + snapshot.pane_title_set_by_user.remove(tab_id); + if was_active { + let next_index = removed_index.saturating_sub(1); + snapshot.active_tab_id = snapshot + .tabs + .get(next_index) + .or_else(|| snapshot.tabs.first()) + .map(|tab| tab.id.clone()); + } + true +} + fn pane_ids_of(snapshot: &UiSnapshot) -> std::collections::HashSet { snapshot .layouts diff --git a/crates/freshell-freshagent/src/layout_store/publication.rs b/crates/freshell-freshagent/src/layout_store/publication.rs index 19ac4c555..39d592c40 100644 --- a/crates/freshell-freshagent/src/layout_store/publication.rs +++ b/crates/freshell-freshagent/src/layout_store/publication.rs @@ -97,11 +97,61 @@ pub(super) fn preserve( } impl LayoutStore { + pub(crate) fn set_pending_create_request(&self, pane_id: &str, request_id: &str) -> bool { + let mut inner = self.lock(); + let Some(entry) = inner + .pending_publications + .iter_mut() + .find(|entry| entry.pane_id == pane_id) + else { + return false; + }; + entry.create_request_id = Some(request_id.to_string()); + true + } + + pub(crate) fn finish_pending_pane(&self, tab_id: &str, pane_id: &str, content: Value) -> bool { + let mut inner = self.lock(); + if !inner + .pending_publications + .iter() + .any(|entry| entry.pane_id == pane_id) + || !inner.snapshots().any(|snapshot| { + snapshot + .layouts + .get(tab_id) + .and_then(|root| root.find_leaf(pane_id)) + .is_some() + }) + { + inner + .pending_publications + .retain(|entry| entry.pane_id != pane_id); + return false; + } + Self::attach_pane_content_locked(&mut inner, tab_id, pane_id, content); + if let Some(entry) = inner + .pending_publications + .iter_mut() + .find(|entry| entry.pane_id == pane_id) + { + entry.published = true; + } + true + } + /// `pane.closed` is acknowledged before the browser removes its leaf. /// Release publication authority first so its next layout sync can close it. pub fn release_pane_publication(&self, create_request_id: &str) { - self.lock() + let mut inner = self.lock(); + let released: std::collections::HashSet<_> = inner.pending_publications.iter().filter(|entry| { + entry.create_request_id.as_deref() == Some(create_request_id) + || (!entry.published && matches!(&entry.placement, Placement::Split { source_pane_id, .. } + if inner.snapshots().find_map(|snapshot| pane_content_in_snapshot(snapshot, source_pane_id)) + .is_some_and(|content| content.get("createRequestId").and_then(Value::as_str) == Some(create_request_id)))) + }).map(|entry| entry.pane_id.clone()).collect(); + inner .pending_publications - .retain(|entry| entry.create_request_id.as_deref() != Some(create_request_id)); + .retain(|entry| !released.contains(&entry.pane_id)); } } diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index 8030fc2b9..5b07819ca 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -5077,10 +5077,12 @@ async fn create_lane_agent_tab( mut request: rest_lane::RestLaneCreate, name: Option, ) -> Response { - let (tab_id, pane_id) = state.layout.create_pending_tab(name.as_deref()); + let (tab_id, pane_id) = state + .layout + .create_pending_tab(name.as_deref(), &request.request_id); request.tab_id = Some(tab_id.clone()); request.pane_id = Some(pane_id.clone()); - let created = match gateway.create_agent(request.clone()).await { + let created = match Arc::clone(&gateway).create_agent(request.clone()).await { Ok(created) => created, Err(error) => { state.layout.close_tab(&tab_id); @@ -5088,6 +5090,14 @@ async fn create_lane_agent_tab( } }; let pane_content = rest_lane_create::pane_content(&request, &created); + if !state + .layout + .finish_pending_pane(&tab_id, &pane_id, pane_content.clone()) + { + return state + .abandon_rest_create(&gateway, &request, &created) + .await; + } let rest_lane::RestLaneCreate { provider, session_type, diff --git a/crates/freshell-freshagent/src/pane_ops.rs b/crates/freshell-freshagent/src/pane_ops.rs index 4227e4021..2a9e8c0e7 100644 --- a/crates/freshell-freshagent/src/pane_ops.rs +++ b/crates/freshell-freshagent/src/pane_ops.rs @@ -175,7 +175,12 @@ pub(crate) async fn split_pane( .unwrap_or("horizontal") .to_string(); - let (tab_id, new_pane_id) = match state.layout.split_pending_pane(&pane_id, &direction) { + let split = if body.get("agent").is_some() { + state.layout.split_pending_pane(&pane_id, &direction) + } else { + state.layout.split_pane(&pane_id, &direction) + }; + let (tab_id, new_pane_id) = match split { Ok(split) => split, // Node: `if (!result?.tabId || !result?.newPaneId) res.json(approx( // result, 'pane split requested; not applied'))` (router.ts:1312-1314). @@ -209,7 +214,14 @@ pub(crate) async fn split_pane( }; request.tab_id = Some(tab_id.clone()); request.pane_id = Some(new_pane_id.clone()); - let created = match gateway.create_agent(request.clone()).await { + if !state + .layout + .set_pending_create_request(&new_pane_id, &request.request_id) + { + state.layout.rollback_split(&new_pane_id); + return crate::rest_lane_create::cancelled_create_response(); + } + let created = match gateway.clone().create_agent(request.clone()).await { Ok(created) => created, Err(error) => { state.layout.rollback_split(&new_pane_id); @@ -217,6 +229,14 @@ pub(crate) async fn split_pane( } }; let content = crate::rest_lane_create::pane_content(&request, &created); + if !state + .layout + .finish_pending_pane(&tab_id, &new_pane_id, content.clone()) + { + return state + .abandon_rest_create(&gateway, &request, &created) + .await; + } state.panes.lock().expect("panes mutex").insert( new_pane_id.clone(), PaneEntry { diff --git a/crates/freshell-freshagent/src/rest_lane.rs b/crates/freshell-freshagent/src/rest_lane.rs index c28ed4e81..73df0e26f 100644 --- a/crates/freshell-freshagent/src/rest_lane.rs +++ b/crates/freshell-freshagent/src/rest_lane.rs @@ -145,6 +145,9 @@ pub struct RestLaneCreated { /// table. It is not substituted for the provider's native identity. pub session_id: String, pub session_ref: Option, + /// Provider-owned identity of a runtime newly started by this create. + /// An adopted existing writer has no cleanup token. + pub cleanup_token: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -218,6 +221,21 @@ pub trait FreshAgentRestLane: Send + Sync { request: RestLaneCreate, ) -> Result; + async fn abandon_create( + &self, + _request: &RestLaneCreate, + created: &RestLaneCreated, + ) -> Result<(), RestLaneError> { + if created.cleanup_token.is_none() { + return Ok(()); + } + Err(RestLaneError::new( + RestLaneErrorKind::Unavailable, + "CREATE_CLEANUP_UNAVAILABLE", + "the provider could not clean up the abandoned create", + )) + } + async fn send_agent(&self, request: RestLaneSend) -> Result; async fn capture( diff --git a/crates/freshell-freshagent/src/rest_lane_create.rs b/crates/freshell-freshagent/src/rest_lane_create.rs index 8c9db9000..3b15548e0 100644 --- a/crates/freshell-freshagent/src/rest_lane_create.rs +++ b/crates/freshell-freshagent/src/rest_lane_create.rs @@ -7,6 +7,22 @@ use freshell_protocol::{SessionLocator, SettingsFreshAgent}; use serde_json::{json, Value}; impl FreshAgentState { + pub(crate) async fn abandon_rest_create( + &self, + lane: &rest_lane::SharedFreshAgentRestLane, + request: &RestLaneCreate, + created: &RestLaneCreated, + ) -> axum::response::Response { + if let Some(pane_id) = request.pane_id.as_deref() { + self.layout.rollback_split(pane_id); + } + if let Err(error) = lane.abandon_create(request, created).await { + tracing::error!(event = "agent_api.abandoned_create_cleanup_failed", request_id = %request.request_id, session_id = %created.session_id, code = %error.code, message = %error.message); + return crate::rest_lane_failure(error); + } + cancelled_create_response() + } + pub fn set_rest_create_settings_reader( &self, reader: rest_lane::RestCreateSettingsReader, @@ -159,6 +175,16 @@ impl FreshAgentState { } } +pub(crate) fn cancelled_create_response() -> axum::response::Response { + let mut error = RestLaneError::new( + RestLaneErrorKind::Conflict, + "PANE_CREATE_CANCELLED", + "the pane was closed while its runtime was starting", + ); + error.retryable = Some(false); + crate::rest_lane_failure(error) +} + pub(crate) fn pane_content(request: &RestLaneCreate, created: &RestLaneCreated) -> Value { let mut content = json!({ "kind": "fresh-agent", "sessionType": request.session_type, "provider": request.provider, diff --git a/crates/freshell-freshagent/src/rest_lane_publication_tests.rs b/crates/freshell-freshagent/src/rest_lane_publication_tests.rs index 05682491e..0208dc568 100644 --- a/crates/freshell-freshagent/src/rest_lane_publication_tests.rs +++ b/crates/freshell-freshagent/src/rest_lane_publication_tests.rs @@ -1,9 +1,156 @@ -use super::tests::{app, get, post, state_with_registry}; +use super::tests::{app, delete, get, post, state_with_registry}; use crate::rest_lane::*; use axum::http::StatusCode; use serde_json::json; use std::sync::Arc; +#[derive(Default)] +struct PausedCreateLane { + started: tokio::sync::Notify, + finish: tokio::sync::Notify, + request: std::sync::Mutex>, + running: std::sync::atomic::AtomicBool, +} + +#[async_trait::async_trait] +impl FreshAgentRestLane for PausedCreateLane { + async fn create_agent( + self: Arc, + request: RestLaneCreate, + ) -> Result { + *self.request.lock().unwrap() = Some(request); + self.started.notify_one(); + self.finish.notified().await; + self.running + .store(true, std::sync::atomic::Ordering::SeqCst); + Ok(RestLaneCreated { + session_id: "late-runtime".into(), + cleanup_token: Some("late-runtime-owner".into()), + ..Default::default() + }) + } + async fn send_agent(&self, _: RestLaneSend) -> Result { + unreachable!("the closed pane must never accept a send") + } + async fn abandon_create( + &self, + request: &RestLaneCreate, + created: &RestLaneCreated, + ) -> Result<(), RestLaneError> { + assert_eq!( + request.request_id, + self.request.lock().unwrap().as_ref().unwrap().request_id + ); + assert_eq!(created.cleanup_token.as_deref(), Some("late-runtime-owner")); + self.running + .store(false, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } +} + +async fn close_during_create(split: bool) { + let state = state_with_registry(); + let before: freshell_protocol::UiLayoutSync = serde_json::from_value(json!({ + "tabs":[{"id":"current-tab"}], "activeTabId":"current-tab", "timestamp":1, + "layouts":{"current-tab":{"type":"split", "id":"ancestor", "direction":"horizontal", "sizes":[50,50], "children":[ + {"type":"leaf", "id":"source-pane", "content":{"kind":"terminal", "mode":"shell", "createRequestId":"source-create"}}, + {"type":"leaf", "id":"sibling-pane", "content":{"kind":"browser", "url":"https://survivor.example"}}, + ]}}, "activePane":{"current-tab":"source-pane"}, + })).unwrap(); + state.layout.update_from_ui(&before, "creating-browser"); + let lane = Arc::new(PausedCreateLane::default()); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state.clone()); + let create_router = router.clone(); + let mut events = state.broadcast_tx.subscribe(); + let creating = tokio::spawn(async move { + post( + create_router, + if split { + "/api/panes/source-pane/split" + } else { + "/api/tabs" + }, + json!({"agent":"claude"}), + true, + ) + .await + }); + tokio::time::timeout(std::time::Duration::from_secs(5), lane.started.notified()) + .await + .unwrap(); + let request = lane.request.lock().unwrap().clone().unwrap(); + if split { + let (status, closed) = post( + router.clone(), + "/api/panes/source-pane/close", + json!({}), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{closed}"); + // The closing browser's next sync excludes the not-yet-announced + // split too: it never knew that tentative pane existed. + let closed: freshell_protocol::UiLayoutSync = serde_json::from_value(json!({ + "tabs":[{"id":"current-tab"}], "activeTabId":"current-tab", "timestamp":2, + "layouts":{"current-tab":{"type":"leaf", "id":"sibling-pane", "content":{"kind":"browser", "url":"https://survivor.example"}}}, + "activePane":{"current-tab":"sibling-pane"}, + })).unwrap(); + state.layout.update_from_ui(&closed, "creating-browser"); + } else { + let (status, closed) = delete( + router.clone(), + &format!("/api/tabs/{}", request.tab_id.as_deref().unwrap()), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{closed}"); + } + lane.finish.notify_one(); + let (status, result) = creating.await.unwrap(); + assert_eq!( + status, + StatusCode::CONFLICT, + "a closed create target must not publish success: {result}" + ); + assert_eq!(result["code"], "PANE_CREATE_CANCELLED"); + assert_eq!(result["retryable"], false); + assert!( + !lane.running.load(std::sync::atomic::Ordering::SeqCst), + "the late owned runtime must be cleaned up" + ); + assert!(state + .layout + .get_pane_snapshot(request.pane_id.as_deref().unwrap()) + .is_none()); + while let Ok(frame) = events.try_recv() { + let frame: serde_json::Value = serde_json::from_str(&frame).unwrap(); + assert!( + !matches!(frame["command"].as_str(), Some("pane.split" | "tab.create")), + "an abandoned create must not be announced: {frame}" + ); + } + assert_eq!( + state + .layout + .get_pane_snapshot("sibling-pane") + .unwrap() + .pane_content + .unwrap()["url"], + "https://survivor.example" + ); +} + +#[tokio::test] +async fn closing_the_target_tab_while_runtime_create_awaits_cleans_the_late_runtime() { + close_during_create(false).await; +} + +#[tokio::test] +async fn closing_the_source_pane_while_runtime_create_awaits_cleans_the_late_runtime() { + close_during_create(true).await; +} + struct SyncDuringCreateLane { layout: crate::layout_store::LayoutStore, browser_sync: freshell_protocol::UiLayoutSync, From 15d952287b262f060e57bfb74d3beed52a8865cd Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:52:28 -0700 Subject: [PATCH 036/100] refactor(freshclaude): bind names without placeholder state --- crates/freshell-freshagent/src/claude.rs | 483 ++---------------- .../src/claude/create_binding.rs | 11 +- .../src/claude/establish.rs | 17 +- .../src/claude/rollback.rs | 7 - .../src/claude/tests/create_binding_tests.rs | 1 - 5 files changed, 44 insertions(+), 475 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 7a1299acc..ac9f8e537 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -290,12 +290,6 @@ pub struct FreshClaudeState { /// set-once/shared model as [`Self::identity_sink`]. Wired by /// `freshell-server::main`; unwired = scoped naming fails unavailable. naming: crate::naming::NamingSink, - /// Unified agent names: placeholder session id → the pre-durable naming - /// handle admitted at create. Popped by [`Self::adopt_session_init`]'s - /// naming hook, so the handle binds to the durable UUID exactly once - /// (idempotent in the store) even when blank settings bypass - /// `record_binding`. - naming_handles: Arc>>, /// Sessions whose sidecar ended unrequested (C09, G05): the end-of-output /// path records each one here as it evicts it from [`Self::sessions`], so the /// pane's ids still resolve to an ended session — its snapshot overlay says @@ -781,7 +775,6 @@ impl FreshClaudeState { 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(), - naming_handles: Arc::new(TokioMutex::new(HashMap::new())), dead_sessions: Default::default(), test_holds: Arc::default(), handoff_kill_pause: None, @@ -1825,8 +1818,6 @@ impl FreshClaudeState { /// A rename committed inside that window must resolve the same record /// the server accepted on the wire (the pre-fix shape answered 404 /// NAME_NOT_FOUND and silently lost the manual pre-durable name). - /// Idempotent: the post-`created` re-ensure in [`Self::admit_create_handle`] - /// answers the same record and adds the placeholder-keyed stash. async fn admit_create_handle_early(&self, msg: &FreshAgentCreate, session_type: &str) { if session_type == "kilroy" { return; @@ -1849,41 +1840,7 @@ impl FreshClaudeState { .await; } - async fn admit_create_handle( - &self, - msg: &FreshAgentCreate, - session_type: &str, - placeholder: &str, - ) -> Option<( - freshell_protocol::session_names::SessionNameRef, - freshell_protocol::session_names::SessionNameRecord, - )> { - if session_type == "kilroy" { - return None; - } - let handle = msg.naming_handle.as_deref()?.trim().to_string(); - if handle.is_empty() { - return None; - } - let sink = self.naming(); - let projection = crate::naming::admit_pending_projection( - &sink, - &handle, - freshell_protocol::session_names::NamedProvider::Claude, - msg.cwd.as_deref(), - ) - .await?; - self.naming_handles - .lock() - .await - .insert(placeholder.to_string(), handle); - Some(projection) - } - - /// Unified agent names: the current frame projection for an ALREADY - /// created session (the dedup-replay arm) — the stashed handle's - /// record, resolved through the wired authority (a pending ref and its - /// redirected durable record answer the same record via `get`). + /// Resolve the native name record for an already-created conversation. async fn created_projection_for( &self, session_id: &str, @@ -1891,27 +1848,12 @@ impl FreshClaudeState { freshell_protocol::session_names::SessionNameRef, freshell_protocol::session_names::SessionNameRecord, )> { - if let Some(projection) = crate::naming::session_projection( + crate::naming::session_projection( &self.naming(), freshell_protocol::session_names::NamedProvider::Claude, session_id, ) .await - { - return Some(projection); - } - let handle = self.naming_handles.lock().await.get(session_id).cloned()?; - let sink = self.naming(); - let target = freshell_protocol::session_names::SessionNameRef::Pending { id: handle }; - let updates = sink? - .get(vec![target.clone()]) - .await - .map_err(|error| crate::naming::log_name_error("get", &target, &error)) - .ok()?; - updates - .into_iter() - .next() - .map(|update| (update.record.name_ref.clone(), update.record)) } /// Broadcast a `freshAgent.error` alarm/degradation frame (P1.13; Task 10 @@ -2048,7 +1990,7 @@ impl FreshClaudeState { // and the client's rename capture carry the handle immediately, // while the sidecar spawn + `created` handshake below takes seconds. // Idempotent with the post-`created` re-ensure - // (`admit_create_handle`, which also stashes the placeholder key). + // The pending name binds to the native identity during establishment. self.admit_create_handle_early(&msg, session_type).await; // Task 12 (D8 for fresh agents): a create-with-resume claims the per-sessionRef @@ -4534,75 +4476,26 @@ impl FreshClaudeState { } drop(ownership_guard); - // Unified agent names (Task 4): the shared accepted-input callback — - // the send reached the sidecar, so the user input is ACCEPTED. The - // naming target is the stashed pre-durable handle when one exists - // (it redirects to the bound durable record once adopted), else - // the adopted durable claude session, else — review N1 — the - // pending ref for the ADDRESSED id itself: a send racing the init - // adoption (the stash entry just consumed by the bound cleanup, - // the addressed id still the placeholder) must never silently - // drop the feed; the store's redirect resolution decides, and - // the next send addresses the adopted id anyway. Kilroy is out - // of scope; a failed feed never blocks the turn. - // - // Task 8 acceptance: once the init ADOPTION consumed the stash and - // recorded the session's durable `cli_session_id`, a placeholder- - // addressed send targets the ADOPTED durable record — the old - // pending- fallback can never resolve in the real - // store (no record is keyed by a placeholder id), and the - // first-message fallback upgrade was silently lost for exactly - // these sends. - let naming_target = { - // Sequential lock reads (never nested): the pre-durable stash, - // then the session row's adopted durable id. - let stashed_handle = self.naming_handles.lock().await.get(&session_id).cloned(); - let adopted_cli_id = self - .sessions - .lock() - .await - .get(&map_key) - .and_then(|s| s.cli_session_id.clone()); - stashed_handle - .map( - |handle| freshell_protocol::session_names::SessionNameRef::Pending { - id: handle, - }, - ) - .or_else(|| { - adopted_cli_id.map(|cli_id| { - freshell_protocol::session_names::SessionNameRef::Session { - provider: freshell_protocol::session_names::NamedProvider::Claude, - session_id: cli_id, - } - }) - }) - .or_else(|| { - is_canonical_claude_uuid(&session_id).then(|| { - freshell_protocol::session_names::SessionNameRef::Session { - provider: freshell_protocol::session_names::NamedProvider::Claude, - session_id: session_id.clone(), - } - }) - }) - .or_else(|| { - Some(freshell_protocol::session_names::SessionNameRef::Pending { - id: session_id.clone(), - }) - }) - }; + // Accepted input belongs to the conversation created before this send. + // Resolve a concurrent reset through the same native lineage as routing. if session_type != "kilroy" { - if let Some(target) = naming_target { - crate::naming::report_accepted_input( - &self.naming(), - &target, - session_type, - request_id.as_deref().unwrap_or(""), - &msg.text, - msg.cwd.as_deref(), - ) - .await; - } + let current_id = self + .resolve_session_key(&map_key) + .await + .unwrap_or(map_key.clone()); + let target = freshell_protocol::session_names::SessionNameRef::Session { + provider: freshell_protocol::session_names::NamedProvider::Claude, + session_id: current_id, + }; + crate::naming::report_accepted_input( + &self.naming(), + &target, + session_type, + request_id.as_deref().unwrap_or(""), + &msg.text, + msg.cwd.as_deref(), + ) + .await; } self.broadcast(&ServerMessage::FreshAgentSendAccepted( @@ -6187,23 +6080,6 @@ impl FreshClaudeState { // load_settings keying (Task 3) keeps a legitimately-default // session from ever arming a false SETTINGS_RESET on a later // resume (the codex REST lane's unconditional-lineage discipline). - // Unified agent names (Task 2): the init hook runs EVEN for an - // all-blank settings row — the naming bind is driven by verified - // transcript durability, not by the settings row (their r27-F4 - // write below is unconditional for exactly the same reason). The - // stashed pre-durable handle (keyed by this placeholder) binds to - // the durable UUID exactly once (idempotent in the store); a - // 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). 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) - .await; if let Some(sink) = &identity_sink { if let Err(e) = sink .record_binding(crate::identity_sink::FreshAgentBindingUpsert { @@ -6219,7 +6095,7 @@ impl FreshClaudeState { // naming fold above consumed (InitialMaterialization), so // the identity event carries the naming fact alongside the // ledger write. - name_transition: declared_transition, + name_transition: None, provenance: provenance.cloned().into(), observed_epoch: binding_epoch, observed_generation: binding_generation, @@ -6335,106 +6211,6 @@ impl FreshClaudeState { SessionInitAdoptionOutcome::Published } - /// Unified agent names (Task 2): the `sdk.session.init` naming hook — - /// see [`Self::adopt_session_init`]. 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). - async fn bind_naming_handle_at_init( - &self, - cli_id: &str, - placeholder: &str, - session_type: &str, - ) -> Option { - if session_type == "kilroy" { - return None; - } - let handle = self.naming_handles.lock().await.get(placeholder).cloned()?; - // Existing provider durability evidence: the claude CLI writes the - // session transcript at startup, so a located transcript file - // verifies the durable identity (`claude_snapshot::locate_transcript_selected` - // walks the same ordered candidate roots the snapshot lane reads and - // answers the selected root + original cwd the store retains). - let selected = crate::claude_snapshot::locate_transcript_selected(cli_id); - let (persistence, evidence) = if selected.is_some() { - ( - freshell_protocol::native_location::NativePersistence::Verified, - freshell_protocol::native_location::NativeEvidenceKind::SelectedTranscript, - ) - } else { - ( - freshell_protocol::native_location::NativePersistence::Prospective, - freshell_protocol::native_location::NativeEvidenceKind::InitializedRuntime, - ) - }; - let acquisition = freshell_protocol::native_location::NativeAcquisition { - location: freshell_protocol::native_location::NativeLocation::Claude { - config_root: selected - .as_ref() - .map(|s| s.config_root.display().to_string()) - .or_else(|| { - crate::claude_snapshot::claude_home_candidates() - .first() - .map(|root| root.display().to_string()) - }) - .unwrap_or_default(), - transcript_path: selected - .as_ref() - .map(|s| s.transcript_path.display().to_string()), - project_directory_key: None, - transcript_cwd: selected.as_ref().and_then(|s| s.transcript_cwd.clone()), - effective_project_key_override: None, - }, - evidence, - persistence, - }; - let target = freshell_protocol::session_names::SessionNameRef::Session { - provider: freshell_protocol::session_names::NamedProvider::Claude, - session_id: cli_id.to_string(), - }; - // 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 = crate::naming::NameTransitionReason::InitialMaterialization; - let transition = crate::naming::NameTransition { - reason, - acquisition, - pending: Some(handle), - }; - let pending_ref = freshell_protocol::session_names::SessionNameRef::Pending { - id: transition.pending.clone().unwrap_or_default(), - }; - match crate::naming::fold_identity_transition(&self.naming(), &transition, target.clone()) - .await - { - Some(update) => { - tracing::debug!(target: "freshell_freshagent::claude", - session_id = %cli_id, - reason = ?reason, - "freshagent.claude.naming_transition_committed" - ); - // Bound: the stash entry is consumed once the handle is - // redirected to the durable record. - if update.record.name_ref == target { - self.naming_handles.lock().await.remove(placeholder); - } - } - None => { - // Retained (prospective zero-turn), unwired, or a failed - // bind: the stash survives for the tick's visible retry. - tracing::debug!(target: "freshell_freshagent::claude", - session_id = %cli_id, - reason = ?reason, - "freshagent.claude.naming_transition_retained" - ); - let _ = pending_ref; - } - } - Some(transition) - } - // ── stdout consumer (the completion edge normalization) ────────────────────────── /// Consume the sidecar's stdout event stream (one `sdk.*` JSON per line), normalize @@ -6753,13 +6529,7 @@ impl FreshClaudeState { .unwrap_or_else(|| (settings.clone(), false)) }; if known_identity && create_binding_expected { - // Create/establish already bound this exact durable - // identity. Init verifies it; no second adoption or - // lineage rewrite is needed. A retained naming bind - // may still retry once native persistence appears. - state - .bind_naming_handle_at_init(cli_id, &session_id, &session_type) - .await; + // Establishment already bound this native identity. } else if create_binding_expected { tracing::error!(target: "freshell_freshagent::claude", event = "freshagent.claude.unannounced_rekey", provider = PROVIDER, @@ -10163,13 +9933,14 @@ rl.on('line', (line) => { /// Unified agent names (Task 4): the shared accepted-input callback — an /// accepted freshclaude send feeds the naming authority ONE activity /// carrying the prompt text (the first-message fallback upgrade plus - /// generation arming happen store-side); the pre-durable handle is the - /// activity target until the init adoption binds it. + /// generation arming happen store-side), addressed to the native identity + /// already bound at creation. #[tokio::test(flavor = "multi_thread")] async fn send_feeds_the_naming_authority_once_with_the_accepted_text() { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); let (state, mut rx) = state_with_bus(); + state.set_identity_sink(Arc::new(crate::identity_sink::FakeIdentitySink::default())); let sink = crate::naming::test_support::RecordingSink::new(); state.set_session_naming(sink.clone()); let mut create = dedup_create_msg("naming-send"); @@ -10187,8 +9958,9 @@ rl.on('line', (line) => { assert_eq!(activities.len(), 1, "{activities:?}"); assert_eq!( activities[0].target, - freshell_protocol::session_names::SessionNameRef::Pending { - id: "handle-naming-send".to_string() + freshell_protocol::session_names::SessionNameRef::Session { + provider: freshell_protocol::session_names::NamedProvider::Claude, + session_id: session_id.clone(), } ); assert_eq!(activities[0].mode, "freshclaude"); @@ -10210,8 +9982,8 @@ rl.on('line', (line) => { /// pane-header rename committed inside that window targeted a record /// the server had accepted on the wire but not yet admitted (404 /// NAME_NOT_FOUND), silently losing the manual pre-durable name to the - /// first-message fallback. Admission is idempotent, so the - /// post-`created` re-ensure answers the same record unchanged. + /// first-message fallback. Establishment then binds that pending name to + /// the new conversation's native identity. #[tokio::test(flavor = "multi_thread")] async fn create_admits_the_pending_record_before_the_sidecar_answers_created() { let _guard = CLAUDE_ENV_LOCK.lock().await; @@ -10258,197 +10030,6 @@ rl.on('line', (line) => { let _ = joined.await; } - /// Unified agent names (Task 4, review N1): a send RACING the init - /// adoption — the stash entry already consumed by the adoption's - /// bound cleanup, the addressed id still the placeholder (the - /// client has not yet learned the adopted id) — must still feed the - /// authority instead of silently dropping the activity. The feed - /// falls back to the pending ref for the addressed id itself (the - /// store's redirect resolution decides); the next send addresses the - /// adopted id anyway. - #[tokio::test(flavor = "multi_thread")] - async fn a_send_racing_the_init_adoption_still_feeds_the_naming_authority() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (state, mut rx) = state_with_bus(); - let sink = crate::naming::test_support::RecordingSink::new(); - state.set_session_naming(sink.clone()); - let mut create = dedup_create_msg("naming-race"); - create.naming_handle = Some("handle-naming-race".into()); - state.handle_create(create, None).await; - let created = await_claude_created(&mut rx, "naming-race").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - - // The adoption's bound cleanup consumed the stash entry - // (`bind_naming_handle_at_init` removes it once the handle is - // redirected to the durable record) — this send is still - // addressed to the placeholder and is not a canonical UUID. - // Model the TRUE racing moment: BEFORE the adoption recorded the - // durable id on the session row, neither the stash nor the adopted - // id can answer, and the feed falls back to the pending ref for - // the addressed id itself (the store's redirect resolution - // decides); the next send addresses the adopted id anyway. - // - // DEFLAKE (parallel-starvation family): the fake's ASYNC init - // adoption re-writes `cli_session_id` whenever it lands, so - // hand-clearing it immediately after `created` races the adoption - // — a scheduling delay lets the adoption land between the - // hand-clear and the send, and the feed then targets the fake's - // adopted id instead of the pending fallback (observed in a - // full-crate run). Wait for the adoption to COMPLETE first (the - // fake emits init ONCE), then hand-model the mid-adoption window: - // stash consumed + durable id not yet recorded. - { - let adoption_deadline = - tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - { - let adopted_yet = state - .sessions - .lock() - .await - .get(&session_id) - .and_then(|s| s.cli_session_id.clone()); - if adopted_yet.is_some() { - break; - } - assert!( - tokio::time::Instant::now() < adoption_deadline, - "the fake sidecar's init adoption never landed" - ); - } - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - } - state.naming_handles.lock().await.remove(&session_id); - state - .sessions - .lock() - .await - .get_mut(&session_id) - .expect("the created session row") - .cli_session_id = None; - - let legacy_id = "legacy-name-placeholder"; - state - .cli_index - .lock() - .await - .insert(legacy_id.into(), session_id.clone()); - state - .handle_send(send_msg(legacy_id, "Race the adoption")) - .await; - let _ = env.respond_log_frames(1).await; - - let activities = await_activities(&sink, 1).await; - assert_eq!( - activities.len(), - 1, - "the racing send must not silently drop the activity feed: {activities:?}" - ); - assert_eq!( - activities[0].target, - freshell_protocol::session_names::SessionNameRef::Pending { - id: legacy_id.into() - } - ); - assert_eq!(activities[0].mode, "freshclaude"); - assert_eq!( - activities[0].first_user_message.as_deref(), - Some("Race the adoption") - ); - } - - /// Unified agent names (Task 8 acceptance): once the init ADOPTION has - /// consumed the pre-durable stash and recorded the session's durable - /// `cli_session_id`, a send still addressed to the PLACEHOLDER (the - /// client has not yet learned the adopted id) must feed the activity to - /// the ADOPTED durable record — the pending- fallback of - /// the racing-send shape can never resolve in the real store (no naming - /// record is keyed by a placeholder id), and the first-message fallback - /// upgrade was silently lost for exactly these sends. - #[tokio::test(flavor = "multi_thread")] - async fn a_post_adoption_send_targets_the_adopted_durable_record() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (state, mut rx) = state_with_bus(); - let sink = crate::naming::test_support::RecordingSink::new(); - state.set_session_naming(sink.clone()); - let mut create = dedup_create_msg("naming-adopted"); - create.naming_handle = Some("handle-naming-adopted".into()); - state.handle_create(create, None).await; - let created = await_claude_created(&mut rx, "naming-adopted").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - - // The real post-adoption shape, set directly (the unit lane's fake - // sidecar emits init asynchronously and the racing-send precedent - // models the state the same way): the stash is consumed (the init - // bind's cleanup) and the session row carries the adopted durable - // id — the client is still addressing the placeholder. - // - // DEFLAKE (parallel-starvation family): the fake's ASYNC init - // adoption overwrites `cli_session_id` (with the fake's own id) - // whenever it runs, so hand-setting the adopted id immediately - // after `created` races it — a scheduling delay lets the adoption - // land between the hand-set and the send, and the activity then - // targets the fake's id (observed once in a full-crate run). Wait - // for the adoption to COMPLETE first, then hand-set the modeled - // adopted id — the scenario is the POST-adoption state either way. - let adopted_cli_id = "11111111-2222-4333-8444-555555555555".to_string(); - { - let adoption_deadline = - tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - { - let adopted_yet = state - .sessions - .lock() - .await - .get(&session_id) - .and_then(|s| s.cli_session_id.clone()); - if adopted_yet.is_some() { - break; - } - assert!( - tokio::time::Instant::now() < adoption_deadline, - "the fake sidecar's init adoption never landed" - ); - } - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - let mut sessions = state.sessions.lock().await; - sessions - .get_mut(&session_id) - .expect("the created session row") - .cli_session_id = Some(adopted_cli_id.clone()); - } - state.naming_handles.lock().await.remove(&session_id); - - state - .handle_send(send_msg(&session_id, "Feed the adopted record")) - .await; - let _ = env.respond_log_frames(1).await; - - let activities = await_activities(&sink, 1).await; - assert_eq!( - activities.len(), - 1, - "the post-adoption send must not silently drop the activity feed: {activities:?}" - ); - assert_eq!( - activities[0].target, - freshell_protocol::session_names::SessionNameRef::Session { - provider: freshell_protocol::session_names::NamedProvider::Claude, - session_id: adopted_cli_id, - }, - "the activity targets the ADOPTED durable record, not the unresolvable placeholder" - ); - assert_eq!( - activities[0].first_user_message.as_deref(), - Some("Feed the adopted record") - ); - } - #[tokio::test(flavor = "multi_thread")] async fn send_applies_changed_settings_before_text_and_persists_them() { let _guard = CLAUDE_ENV_LOCK.lock().await; diff --git a/crates/freshell-freshagent/src/claude/create_binding.rs b/crates/freshell-freshagent/src/claude/create_binding.rs index 3835023e7..49a82c4de 100644 --- a/crates/freshell-freshagent/src/claude/create_binding.rs +++ b/crates/freshell-freshagent/src/claude/create_binding.rs @@ -56,6 +56,7 @@ impl FreshClaudeState { &self, session_id: &str, session_type: &str, + naming_handle: Option<&str>, ) -> Option<( freshell_protocol::session_names::SessionNameRef, freshell_protocol::session_names::SessionNameRecord, @@ -68,7 +69,10 @@ impl FreshClaudeState { { return None; } - let handle = self.naming_handles.lock().await.get(session_id).cloned()?; + let handle = naming_handle?.trim(); + if handle.is_empty() { + return None; + } let target = freshell_protocol::session_names::SessionNameRef::Session { provider: freshell_protocol::session_names::NamedProvider::Claude, session_id: session_id.into(), @@ -86,14 +90,11 @@ impl FreshClaudeState { evidence: freshell_protocol::native_location::NativeEvidenceKind::PersistedMetadata, persistence: freshell_protocol::native_location::NativePersistence::Verified, }, - handle, + handle.to_string(), ); let update = crate::naming::fold_identity_transition(&self.naming(), &transition, target.clone()) .await?; - if update.record.name_ref == target { - self.naming_handles.lock().await.remove(session_id); - } Some((update.record.name_ref.clone(), update.record)) } } diff --git a/crates/freshell-freshagent/src/claude/establish.rs b/crates/freshell-freshagent/src/claude/establish.rs index 54e0e0a03..5571f8714 100644 --- a/crates/freshell-freshagent/src/claude/establish.rs +++ b/crates/freshell-freshagent/src/claude/establish.rs @@ -656,16 +656,7 @@ impl FreshClaudeState { 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 mut naming_projection = match naming { - Some(msg) => { - self.admit_create_handle(msg, &spec.session_type, &map_key) - .await - } - None => None, - }; + let mut naming_projection = 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` @@ -941,7 +932,11 @@ impl FreshClaudeState { } } naming_projection = self - .bind_create_name(&map_key, &spec.session_type) + .bind_create_name( + &map_key, + &spec.session_type, + naming.and_then(|msg| msg.naming_handle.as_deref()), + ) .await .or(naming_projection); } diff --git a/crates/freshell-freshagent/src/claude/rollback.rs b/crates/freshell-freshagent/src/claude/rollback.rs index 3a1982f14..19c59d06a 100644 --- a/crates/freshell-freshagent/src/claude/rollback.rs +++ b/crates/freshell-freshagent/src/claude/rollback.rs @@ -1120,13 +1120,6 @@ impl FreshClaudeState { 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 diff --git a/crates/freshell-freshagent/src/claude/tests/create_binding_tests.rs b/crates/freshell-freshagent/src/claude/tests/create_binding_tests.rs index 3e1006f0c..5f8891ef1 100644 --- a/crates/freshell-freshagent/src/claude/tests/create_binding_tests.rs +++ b/crates/freshell-freshagent/src/claude/tests/create_binding_tests.rs @@ -60,7 +60,6 @@ async fn create_binding_and_name_are_durable_before_live_and_created() { session_id: id.clone() } ); - assert!(state.naming_handles.lock().await.get(&id).is_none()); let mut replay = dedup_create_msg("create-bound-before-live"); replay.naming_handle = Some("pre-create-name".into()); state.handle_create(replay, None).await; From d214c80b6d399e38188475781580397904b9c6f5 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:54:01 -0700 Subject: [PATCH 037/100] fix(freshclaude): quiesce turns before restart notice --- crates/freshell-freshagent/src/claude.rs | 14 ++++- crates/freshell-ws/tests/common/claude_rig.rs | 11 +++- .../freshell-ws/tests/common/ledger_sink.rs | 56 ++++++++++++++++++- .../tests/freshagent_claude_lifecycle.rs | 46 +++++++++++++-- test/fixtures/claude-cli-sim/query.mjs | 11 +++- 5 files changed, 127 insertions(+), 11 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index dff18e017..3a2bf6ce7 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -1936,10 +1936,22 @@ impl FreshClaudeState { /// rather than one session grace per session. Called on server shutdown. pub async fn shutdown(&self) { use tracing::instrument::WithSubscriber; - let drained: Vec = { + let mut drained: Vec = { let mut guard = self.sessions.lock().await; guard.drain().map(|(_, s)| s).collect() }; + // Stop the stdout consumers before checking which turns remain owed. + // Otherwise a result can be folded while the awaited marker writes are + // in progress, publishing completion after shutdown has already decided + // to persist an interruption notice for that same turn. + for session in &drained { + session.consumer.abort(); + } + for session in &mut drained { + while !session.consumer.is_finished() { + tokio::task::yield_now().await; + } + } let interrupted_ids: Vec = drained .iter() .filter(|session| session.ledger.lock().expect("turn ledger lock").busy()) diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index f6701dfd5..fd2caac9a 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -72,6 +72,9 @@ pub struct RigOptions { /// 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, + /// Hold the next shutdown interruption-marker write until the lifecycle + /// test releases it, leaving the real sidecar alive during that window. + pub turn_interrupted_marker_gate: Option>, /// 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 @@ -1033,10 +1036,14 @@ async fn spawn_rig_server( .with_hosted_mcp(options.hosted_claude); fresh_claude.set_ownership(Arc::clone(&ownership)); if let Some(ledger) = pane_ledger.as_ref() { - fresh_claude.set_identity_sink(Arc::new(super::ledger_sink::LedgerSink::with_ownership( + let mut sink = super::ledger_sink::LedgerSink::with_ownership( Arc::clone(ledger), Some(Arc::clone(&ownership)), - ))); + ); + if let Some(gate) = options.turn_interrupted_marker_gate.clone() { + sink = sink.with_turn_interrupted_marker_gate(gate); + } + fresh_claude.set_identity_sink(Arc::new(sink)); } let lane = fresh_claude.clone(); let fresh_agent_state = freshell_freshagent::FreshAgentState::new( diff --git a/crates/freshell-ws/tests/common/ledger_sink.rs b/crates/freshell-ws/tests/common/ledger_sink.rs index 68cb826ac..a1aaa540b 100644 --- a/crates/freshell-ws/tests/common/ledger_sink.rs +++ b/crates/freshell-ws/tests/common/ledger_sink.rs @@ -12,6 +12,42 @@ use std::sync::Arc; +/// Deterministic hold point for the shutdown-marker write in the lifecycle +/// integration test. The sidecar keeps running while this awaited write is +/// held, so the test can prove how a late CLI result races server shutdown. +#[derive(Debug)] +pub struct TurnInterruptedMarkerGate { + entered: tokio::sync::Notify, + release: tokio::sync::Semaphore, +} + +impl TurnInterruptedMarkerGate { + pub fn new() -> Self { + Self { + entered: tokio::sync::Notify::new(), + release: tokio::sync::Semaphore::new(0), + } + } + + pub async fn wait_until_entered(&self) { + self.entered.notified().await; + } + + pub fn release(&self) { + self.release.add_permits(1); + } + + async fn hold(&self) -> std::io::Result<()> { + self.entered.notify_one(); + self.release + .acquire() + .await + .map_err(std::io::Error::other)? + .forget(); + Ok(()) + } +} + use freshell_freshagent::identity_sink::SinkCloseError; use freshell_freshagent::{ BindProvenance, ClaimCommit, FreshAgentBindingUpsert, FreshAgentSettings, PaneIdentitySink, @@ -28,6 +64,7 @@ pub struct LedgerSink { /// The ownership coordinator an authoritative write is re-validated /// against (the one the lanes writing through this sink are wired to). ownership: Option>, + turn_interrupted_marker_gate: Option>, } impl LedgerSink { @@ -36,6 +73,7 @@ impl LedgerSink { Self { ledger, ownership: None, + turn_interrupted_marker_gate: None, } } @@ -45,7 +83,19 @@ impl LedgerSink { ledger: Arc, ownership: Option>, ) -> Self { - Self { ledger, ownership } + Self { + ledger, + ownership, + turn_interrupted_marker_gate: None, + } + } + + pub fn with_turn_interrupted_marker_gate( + mut self, + gate: Arc, + ) -> Self { + self.turn_interrupted_marker_gate = Some(gate); + self } } @@ -209,8 +259,12 @@ impl PaneIdentitySink for LedgerSink { at_ms: i64, ) -> SinkMarkTurnInterruptedWrite { let ledger = self.ledger.clone(); + let gate = self.turn_interrupted_marker_gate.clone(); let (provider, session_id) = (provider.to_string(), session_id.to_string()); Box::pin(async move { + if let Some(gate) = gate { + gate.hold().await?; + } tokio::task::spawn_blocking(move || { ledger.mark_fresh_agent_turn_interrupted(&provider, &session_id, at_ms) }) diff --git a/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs b/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs index 203aed15c..b364dec26 100644 --- a/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs +++ b/crates/freshell-ws/tests/freshagent_claude_lifecycle.rs @@ -23,9 +23,11 @@ mod common; +use std::sync::Arc; use std::time::Duration; use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions, RIG_FRAME_BUDGET}; +use common::ledger_sink::TurnInterruptedMarkerGate; use serde_json::{json, Value}; /// How long a test keeps reading after the exit report before it counts edges: the @@ -542,13 +544,17 @@ async fn a_resume_create_answers_created_with_its_session_ref_once_the_cli_boote /// attention edge. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn an_owed_turn_is_reported_once_after_attach_across_a_server_restart() { - let program = - json!({ "rules": [{ "on": "msg:prompt", "emit": [{ "kind": "hang" }] }] }).to_string(); + let program = json!({ "rules": [{ "on": "msg:prompt", "emit": [ + { "kind": "completion" }, + ] }] }) + .to_string(); + let marker_gate = Arc::new(TurnInterruptedMarkerGate::new()); let mut rig = ClaudeRig::start_with_options( RigKind::Sim, RigOptions { durable_ledger: true, env: vec![("FRESHELL_CLAUDE_SIM_PROGRAM".into(), program)], + turn_interrupted_marker_gate: Some(marker_gate.clone()), ..RigOptions::default() }, ) @@ -564,20 +570,50 @@ async fn an_owed_turn_is_reported_once_after_attach_across_a_server_restart() { .await; first.wait_for_event("freshAgent.session.init").await; - let row_before_restart = rig + let row_before_shutdown = rig .pane_ledger .as_ref() .expect("durable ledger enabled") .load_binding("claude", &session_id) - .expect("fresh-agent binding exists before restart"); + .expect("fresh-agent binding exists before shutdown"); assert!( - serde_json::to_value(&row_before_restart) + serde_json::to_value(&row_before_shutdown) .expect("binding serializes") .get("turnInterruptedAt") .is_none(), "a live turn is not marked interrupted before shutdown" ); + // Park shutdown inside its real persisted-marker write, then let the + // simulator finish the turn. The consumer must already be quiesced, so a + // result arriving during disk I/O cannot publish a completion edge while + // shutdown still persists the restart marker. + let shutdown_state = rig.fresh_claude.clone(); + let shutdown = tokio::spawn(async move { shutdown_state.shutdown().await }); + tokio::time::timeout(Duration::from_secs(5), marker_gate.wait_until_entered()) + .await + .expect("shutdown reaches the held marker write"); + let result_deadline = tokio::time::Instant::now() + Duration::from_secs(5); + while !rig + .ledger_rows("result") + .iter() + .any(|row| row["sessionId"] == session_id) + { + assert!( + tokio::time::Instant::now() < result_deadline, + "the simulated turn did not reach its result while the marker write was held" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + first.read_for(Duration::from_millis(100)).await; + let completions_while_marker_write_held = first.events_seen("freshAgent.turn.complete"); + marker_gate.release(); + shutdown.await.expect("Claude shutdown task joins"); + assert_eq!( + completions_while_marker_write_held, 0, + "a CLI result arriving during the marker write cannot publish a completion edge" + ); + rig.restart().await; let row_after_reload = rig diff --git a/test/fixtures/claude-cli-sim/query.mjs b/test/fixtures/claude-cli-sim/query.mjs index 2a59ee5db..6f57b1b35 100644 --- a/test/fixtures/claude-cli-sim/query.mjs +++ b/test/fixtures/claude-cli-sim/query.mjs @@ -72,8 +72,9 @@ // the CLI took: the receipt's stillQueued and // whether a turn was running), `control` (each settings call // the CLI handles: setModel, setPermissionMode, -// applyFlagSettings, with its arguments) and -// `emission` (program steps) +// applyFlagSettings, with its arguments), +// `emission` (program steps), and `result` +// (a turn's result reached the SDK stream) import fs from 'node:fs' import path from 'node:path' @@ -1007,6 +1008,12 @@ class SimulatedCli { ...(turn.cmd?.uuid && subtype === 'success' ? { user_message_uuid: turn.cmd.uuid } : {}), } if (isError || subtype !== 'success') result.usage = usage(0, 0) + this.ledger('result', { + sessionId: this.sessionId, + uuid: turn.cmd?.uuid ?? null, + subtype, + isError, + }) this.emit(result) await this.endCommand(turn, lifecycle ?? (success ? 'completed' : 'cancelled')) } From 635fb3c7f89864fd2ad5dcd5a5fd175ff465de0f Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:59:40 -0700 Subject: [PATCH 038/100] fix(freshclaude): report cleanup failures and match the owned process --- crates/freshell-freshagent/src/claude.rs | 77 +++++++++++++++---- .../claude/tests/abandoned_create_tests.rs | 58 +++++++++++++- .../src/claude_rest_lane.rs | 1 + crates/freshell-freshagent/src/lib.rs | 2 +- 4 files changed, 119 insertions(+), 19 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index dddad23e5..4fc20f935 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -80,6 +80,29 @@ use turn_status::LedgerStep; /// The runtime provider (`AGENT_SESSION_TYPES.claude.provider`). pub(crate) const PROVIDER: &str = "claude"; +#[derive(Debug)] +pub struct ClaudeCleanupError { + pub code: &'static str, + pub message: String, +} + +impl std::fmt::Display for ClaudeCleanupError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for ClaudeCleanupError {} + +impl ClaudeCleanupError { + fn unconfirmed() -> Self { + Self { + code: "CLAUDE_CLEANUP_UNCONFIRMED", + message: "Claude could not confirm cleanup of this cancelled pane. Try again.".into(), + } + } +} + /// Build the sidecar-only correlation record for one checkpointed Claude /// send. The send's UUID is supplied on SDKUserMessage and the attempt is /// later accepted only when the SDK replays the persisted user row carrying @@ -2540,7 +2563,7 @@ impl FreshClaudeState { /// `ws-handler.ts:3673`), then broadcast `freshAgent.killed`. Idempotent for an /// unknown session id, matching the codex/opencode `success:true` pattern. pub async fn handle_kill(&self, msg: FreshAgentKill) { - self.handle_kill_inner(msg, None).await; + let _ = self.handle_kill_inner(msg, None).await; } /// Release a runtime allocated by a create whose destination disappeared. @@ -2550,7 +2573,7 @@ impl FreshClaudeState { session_id: &str, session_type: SessionType, ownership_id: &str, - ) { + ) -> Result<(), ClaudeCleanupError> { self.handle_kill_inner( FreshAgentKill { provider: freshell_protocol::AgentProvider::Claude, @@ -2562,10 +2585,14 @@ impl FreshClaudeState { }, Some(ownership_id), ) - .await; + .await } - async fn handle_kill_inner(&self, msg: FreshAgentKill, expected_owner: Option<&str>) { + async fn handle_kill_inner( + &self, + msg: FreshAgentKill, + expected_owner: Option<&str>, + ) -> Result<(), ClaudeCleanupError> { let session_id = msg.session_id.clone(); let session_type = session_type_str(msg.session_type); @@ -2588,7 +2615,7 @@ impl FreshClaudeState { code: Some(err.code().to_string()), message: Some(err.message().to_string()), })); - return; + return Err(ClaudeCleanupError::unconfirmed()); } }; @@ -2691,21 +2718,28 @@ impl FreshClaudeState { event = "freshagent.claude.abandoned_create_already_released", session_id, expected_owner, "the create receipt no longer owns this runtime; cleanup is complete"); - return; + return Ok(()); }; if self.ownership.is_some() { let durable = session.cli_session_id.as_deref().unwrap_or(&map_key); let Some(stamp) = crate::ownership_lane::peek_retained_stamp(&self.ownership_stamps, durable) .filter(|stamp| { - stamp.owner.ownership_id.as_deref() == Some(expected_owner) + // The receipt names the process-tree tag; the + // coordinator names a lifecycle operation. The + // exact runtime record ties them together. + stamp.owner.kind + == freshell_ownership::RuntimeOwnerKind::FreshAgent + && stamp.owner.live_session_key.as_deref() + == Some(map_key.as_str()) + && stamp.owner.pid == session.child.id() }) else { tracing::warn!(target: "freshell_freshagent::claude", event = "freshagent.claude.abandoned_create_stamp_missing", session_id, expected_owner, "the create receipt has no matching retained ownership stamp"); - return; + return Err(ClaudeCleanupError::unconfirmed()); }; // Capture authority while inspecting that exact runtime. A // later stop must never borrow a replacement's stamp. @@ -2907,7 +2941,7 @@ impl FreshClaudeState { code: Some(refusal_code.to_string()), message: Some(refusal_message), })); - return; + return Err(ClaudeCleanupError::unconfirmed()); } } else { // b8ke delta round-3 F1: NO retained stamp — the kill STILL @@ -2969,7 +3003,7 @@ impl FreshClaudeState { .to_string(), ), })); - return; + return Err(ClaudeCleanupError::unconfirmed()); } // e3r1 F1: honor the REQUEST'S fence — a delayed // kill carrying a stale observed pair is refused by @@ -3102,7 +3136,7 @@ impl FreshClaudeState { message: Some(refusal_message), }, )); - return; + return Err(ClaudeCleanupError::unconfirmed()); } } } @@ -3143,7 +3177,7 @@ impl FreshClaudeState { .to_string(), ), })); - return; + return Err(ClaudeCleanupError::unconfirmed()); } freshell_ownership::OwnershipState::Starting { .. } | freshell_ownership::OwnershipState::Stopping { .. } => { @@ -3174,7 +3208,7 @@ impl FreshClaudeState { .to_string(), ), })); - return; + return Err(ClaudeCleanupError::unconfirmed()); } freshell_ownership::OwnershipState::Fenced { .. } => { tracing::warn!(target: "freshell_freshagent::claude", @@ -3205,7 +3239,7 @@ impl FreshClaudeState { .to_string(), ), })); - return; + return Err(ClaudeCleanupError::unconfirmed()); } _ => { // Genuinely Vacant (or this-lane rekey residue): @@ -3324,7 +3358,7 @@ impl FreshClaudeState { "the pane-ledger close failed; nothing was killed — retry".to_string(), ), })); - return; + return Err(ClaudeCleanupError::unconfirmed()); } let main_close_reported_failure = close_answer == crate::identity_sink::CloseAnswer::Persisted; @@ -3437,6 +3471,8 @@ impl FreshClaudeState { // confirmation (non-Linux) fences the key typed — never a Vacant // over an unverifiable descendant tree. NEVER before the reap // (round-1 review), never unconfirmed (round-2 review). + let cleanup_confirmed = + expected_owner.is_none() || matches!(&teardown, LoggedTeardown::Confirmed); let mut kill_failure: Option<(&'static str, String)> = None; if let (Some(registry), Some(generation), Some(op_id), Some(key)) = ( self.ownership.as_ref(), @@ -3558,15 +3594,24 @@ impl FreshClaudeState { } } + let success = !main_close_reported_failure + && !invariant_broken + && kill_failure.is_none() + && cleanup_confirmed; self.edges.forgotten(&session_id); self.broadcast(&ServerMessage::FreshAgentKilled(FreshAgentKilled { provider: PROVIDER.to_string(), session_id, session_type: session_type.to_string(), - success: !main_close_reported_failure && !invariant_broken && kill_failure.is_none(), + success, code: kill_failure.as_ref().map(|(code, _)| code.to_string()), message: kill_failure.as_ref().map(|(_, message)| message.clone()), })); + if success { + Ok(()) + } else { + Err(ClaudeCleanupError::unconfirmed()) + } } /// Round 5 (focused-ep5-r4 Finding 1): for every id this kill retired diff --git a/crates/freshell-freshagent/src/claude/tests/abandoned_create_tests.rs b/crates/freshell-freshagent/src/claude/tests/abandoned_create_tests.rs index c20a87517..5e79fc400 100644 --- a/crates/freshell-freshagent/src/claude/tests/abandoned_create_tests.rs +++ b/crates/freshell-freshagent/src/claude/tests/abandoned_create_tests.rs @@ -19,7 +19,8 @@ async fn abandoned_create_cannot_close_a_replacement_with_the_same_session_id() tokio::spawn(async move { state .abandon_created_runtime(&id, SessionType::Freshclaude, &owner) - .await; + .await + .unwrap(); }) }; tokio::time::timeout(Duration::from_secs(5), async { @@ -69,6 +70,59 @@ async fn abandoned_create_closes_only_its_allocated_runtime() { let owner = state.sessions.lock().await[&id].ownership_id.clone(); state .abandon_created_runtime(&id, SessionType::Freshclaude, &owner) - .await; + .await + .unwrap(); assert!(!state.sessions.lock().await.contains_key(&id)); } + +#[tokio::test] +async fn abandoned_create_reports_a_failed_close_without_claiming_cleanup() { + let (state, _rx) = state_with_bus(); + let sink = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(sink.clone()); + let id = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&state, &id).await; + let owner = state.sessions.lock().await[&id].ownership_id.clone(); + sink.set_fail_writes(true); + let outcome = state + .abandon_created_runtime(&id, SessionType::Freshclaude, &owner) + .await; + let still_live = state.sessions.lock().await.contains_key(&id); + state.shutdown().await; + assert!( + still_live, + "a failed durable close leaves its runtime intact" + ); + assert!(outcome.is_err(), "cleanup must report the retained runtime"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn abandoned_create_uses_the_allocated_process_tag_with_its_coordinator_stamp() { + let _env_lock = CLAUDE_ENV_LOCK.lock().await; + let _env = FakeClaudeSidecarEnv::install(); + let (mut state, _rx) = state_with_bus(); + state.set_identity_sink(Arc::new(crate::identity_sink::FakeIdentitySink::default())); + state.set_ownership(Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new())); + let ClaudeCreateOutcome::Created { + session_id, + cleanup_ownership_id: Some(ownership_id), + .. + } = state + .handle_create(dedup_create_msg("owned-create-cleanup"), None) + .await + else { + panic!("create must return its allocated runtime's cleanup receipt"); + }; + let outcome = state + .abandon_created_runtime(&session_id, SessionType::Freshclaude, &ownership_id) + .await; + let remains = state.sessions.lock().await.contains_key(&session_id); + let owner_state = state.ownership_snapshot(PROVIDER, &session_id).state; + state.shutdown().await; + assert!(outcome.is_ok(), "{outcome:?}"); + assert!(!remains, "the allocated process was not released"); + assert!(matches!( + owner_state, + freshell_ownership::OwnershipState::Vacant + )); +} diff --git a/crates/freshell-freshagent/src/claude_rest_lane.rs b/crates/freshell-freshagent/src/claude_rest_lane.rs index 7122f0c99..523796124 100644 --- a/crates/freshell-freshagent/src/claude_rest_lane.rs +++ b/crates/freshell-freshagent/src/claude_rest_lane.rs @@ -64,6 +64,7 @@ impl ClaudeRestLane { ClaudeCreateOutcome::Created { session_id, session_ref, + .. } => Ok(RestLaneCreated { session_id, session_ref, diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index a68ba1f09..7e164d227 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -93,7 +93,7 @@ pub mod terminal_tabs; #[cfg(test)] mod test_environment; -pub use claude::{ClaudeCreateOutcome, ClaudeSendOutcome, FreshClaudeState}; +pub use claude::{ClaudeCleanupError, ClaudeCreateOutcome, ClaudeSendOutcome, FreshClaudeState}; pub use claude_edges::{AttentionObserver, ClaudeTurnEvent}; pub use claude_turns::TurnOutcome; // Kata 09v1 + SYNC-06 + G28: the claude_snapshot items visible outside this From fb6edd662dd1c27e6ccca223fd93cf2aad5ded9d Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:00:04 -0700 Subject: [PATCH 039/100] style(freshclaude): format cleanup owner predicate --- crates/freshell-freshagent/src/claude.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 2948c449c..934ad7bbd 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -2763,8 +2763,7 @@ impl FreshClaudeState { // The receipt names the process-tree tag; the // coordinator names a lifecycle operation. The // exact runtime record ties them together. - stamp.owner.kind - == freshell_ownership::RuntimeOwnerKind::FreshAgent + stamp.owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent && stamp.owner.live_session_key.as_deref() == Some(map_key.as_str()) && stamp.owner.pid == session.child.id() From 6d5eca35e3de1c51e0ba676fdaf678e930a69795 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:00:28 -0700 Subject: [PATCH 040/100] feat(freshclaude): carry create ownership into REST cancellation --- .../src/claude_rest_lane.rs | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/freshell-freshagent/src/claude_rest_lane.rs b/crates/freshell-freshagent/src/claude_rest_lane.rs index 83efe3de4..dd2ad5a8d 100644 --- a/crates/freshell-freshagent/src/claude_rest_lane.rs +++ b/crates/freshell-freshagent/src/claude_rest_lane.rs @@ -64,10 +64,11 @@ impl ClaudeRestLane { ClaudeCreateOutcome::Created { session_id, session_ref, + cleanup_ownership_id, } => Ok(RestLaneCreated { session_id, session_ref, - cleanup_token: None, + cleanup_token: cleanup_ownership_id, }), ClaudeCreateOutcome::Failed { code, @@ -161,6 +162,24 @@ impl ClaudeRestLane { #[async_trait::async_trait] impl FreshAgentRestLane for ClaudeRestLane { + async fn abandon_create( + &self, + request: &RestLaneCreate, + created: &RestLaneCreated, + ) -> Result<(), RestLaneError> { + if let Some(ownership_id) = created.cleanup_token.as_deref() { + self.state + .abandon_created_runtime( + &created.session_id, + claude_session_type(&request.provider, &request.session_type)?, + ownership_id, + ) + .await; + tracing::info!(event = "freshagent.claude.rest_lane.create_abandoned", request_id = %request.request_id, session_id = %created.session_id, ownership_id, "abandoned the closed destination's owned create"); + } + Ok(()) + } + async fn create_agent( self: Arc, request: RestLaneCreate, From 323b0cf5e4b7fcd1d5bb0c23a65fb62a31c18b0c Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:06:46 -0700 Subject: [PATCH 041/100] fix(freshclaude): verify native identity after runtime recovery --- crates/freshell-freshagent/src/claude.rs | 44 +++------------ .../src/claude/establish.rs | 3 - crates/freshell-ws/tests/common/claude_rig.rs | 24 ++++++-- .../tests/freshagent_claude_reset.rs | 56 +++++++++++++++++++ 4 files changed, 83 insertions(+), 44 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 934ad7bbd..0d6579f90 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -6348,7 +6348,6 @@ impl FreshClaudeState { ownership_id: String, session_type: String, sidecar_session_id: String, - settings: Option, broadcast_id: Arc>, pending: Arc>, ledger: crate::claude_turns::SharedLedger, @@ -6358,12 +6357,6 @@ impl FreshClaudeState { // below can mint AFTER the eviction has already torn the session record // out of the map. clock: crate::attention::AttentionClock, - // 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, - create_binding_expected: bool, ) -> tokio::task::JoinHandle<()> { let broadcast_tx = self.broadcast_tx.clone(); let sessions = self.sessions.clone(); @@ -6608,29 +6601,23 @@ impl FreshClaudeState { state.publish_claude_status(&ledger, &quiet, &stamp, &session_type, false); } } - // Restart-parity (plan §2.8 item 2): record the durable Claude UUID - // through the shared adoption block (cli_index insert is - // load-bearing; the session-field copy is best-effort — the map - // entry may not exist yet during create). + // Establishment already bound the native identity. Every + // consumer verifies init, including a resumed conversation. if value.get("type").and_then(Value::as_str) == Some("sdk.session.init") { if let Some(cli_id) = value.get("cliSessionId").and_then(Value::as_str) { - let (current_settings, known_identity) = { + let known_identity = { let mut guard = sessions.lock().await; guard .get_mut(&session_id) + .filter(|session| Arc::ptr_eq(&session.pending, &pending)) .map(|session| { session.configuration.runtime_cwd = value["cwd"].as_str().map(str::to_string); - ( - Some(session.configuration.settings.clone()), - session.cli_session_id.as_deref() == Some(cli_id), - ) + session.cli_session_id.as_deref() == Some(cli_id) }) - .unwrap_or_else(|| (settings.clone(), false)) + .unwrap_or(false) }; - if known_identity && create_binding_expected { - // Establishment already bound this native identity. - } else if create_binding_expected { + if !known_identity { tracing::error!(target: "freshell_freshagent::claude", event = "freshagent.claude.unannounced_rekey", provider = PROVIDER, session_id = %session_id, requested_session_id = cli_id, @@ -6653,17 +6640,6 @@ impl FreshClaudeState { "the unannounced conversation identity could not be followed") } } - } else { - state - .adopt_session_init( - cli_id, - &session_id, - &session_type, - current_settings.as_ref(), - identity_sink.clone(), - provenance.as_ref(), - ) - .await; } } } @@ -17519,14 +17495,11 @@ rl.on('line', (line) => { "test-fold-session".to_string(), "freshclaude".to_string(), "fold-session".to_string(), - None, Arc::new(std::sync::Mutex::new("fold-session".to_string())), Arc::clone(&pending), crate::claude_turns::SharedLedger::default(), quiet, crate::attention::AttentionClock::default(), - None, // provenance: test lane is conn-less (D8) - false, ); // The replace-resend is the LAST scripted line: observing its input proves the @@ -19057,14 +19030,11 @@ rl.on('line', (line) => { format!("test-{map_key}"), "freshclaude".to_string(), map_key.to_string(), - None, Arc::clone(&broadcast_id), Arc::clone(&pending), Arc::clone(&ledger), quiet.state.clone(), clock.clone(), - None, // provenance: test lane is conn-less (D8) - false, ); st.sessions.lock().await.insert( map_key.to_string(), diff --git a/crates/freshell-freshagent/src/claude/establish.rs b/crates/freshell-freshagent/src/claude/establish.rs index 8a3a825c5..d5ff0a4c2 100644 --- a/crates/freshell-freshagent/src/claude/establish.rs +++ b/crates/freshell-freshagent/src/claude/establish.rs @@ -776,14 +776,11 @@ impl FreshClaudeState { ownership_id.clone(), spec.session_type.clone(), minted.clone(), - recorded_settings, Arc::clone(&broadcast_id), Arc::clone(&pending), Arc::clone(&ledger), quiet.state.clone(), clock.clone(), - provenance.clone(), - naming.is_some(), ); self.test_holds.registration().await; sessions.insert( diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index fd2caac9a..6ddbd6a53 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -677,7 +677,18 @@ impl RigConnection { /// The first frame (already read or still to come) matching `predicate`. pub async fn wait_for(&mut self, what: &str, predicate: impl Fn(&Value) -> bool) -> Value { - if let Some(hit) = self.frames.iter().find(|frame| predicate(frame)) { + self.wait_for_since(0, what, predicate).await + } + + /// A request acknowledgement must be newer than that request, even when + /// this connection previously observed the same session and operation. + pub async fn wait_for_since( + &mut self, + start: usize, + what: &str, + predicate: impl Fn(&Value) -> bool, + ) -> Value { + if let Some(hit) = self.frames[start..].iter().find(|frame| predicate(frame)) { return hit.clone(); } let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; @@ -706,6 +717,7 @@ impl RigConnection { /// `extra` fields merged in (a resume, say); returns the answer: /// `freshAgent.created`, `freshAgent.create.failed` or a request `error`. pub async fn request_create(&mut self, request_id: &str, extra: Value) -> Value { + let start = self.frames.len(); let cwd = path_string(&self.workspace); let mut frame = json!({ "type": "freshAgent.create", @@ -718,7 +730,7 @@ impl RigConnection { frame.extend(extra); } self.send(frame).await; - self.wait_for(&format!("the answer to create {request_id}"), |v| { + self.wait_for_since(start, &format!("the answer to create {request_id}"), |v| { v["requestId"] == request_id && matches!( v["type"].as_str(), @@ -851,6 +863,7 @@ impl RigConnection { /// 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) { + let start = self.frames.len(); self.send(json!({ "type": "freshAgent.attach", "provider": "claude", @@ -859,7 +872,7 @@ impl RigConnection { "sessionRef": { "provider": "claude", "sessionId": session_id }, })) .await; - self.wait_for("the attach acknowledgement", |v| { + self.wait_for_since(start, "the attach acknowledgement", |v| { v["type"] == "freshAgent.event" && v["event"]["type"] == "freshAgent.session.snapshot" && v["event"]["sessionId"] == session_id @@ -885,6 +898,7 @@ impl RigConnection { /// `freshAgent.kill`, then wait for `freshAgent.killed{success:true}`. pub async fn kill(&mut self, session_id: &str) { + let start = self.frames.len(); self.send(json!({ "type": "freshAgent.kill", "provider": "claude", @@ -893,7 +907,9 @@ impl RigConnection { })) .await; let killed = self - .wait_for("freshAgent.killed", |v| v["type"] == "freshAgent.killed") + .wait_for_since(start, "freshAgent.killed", |v| { + v["type"] == "freshAgent.killed" && v["sessionId"] == session_id + }) .await; assert_eq!(killed["success"], true, "{killed}"); } diff --git a/crates/freshell-ws/tests/freshagent_claude_reset.rs b/crates/freshell-ws/tests/freshagent_claude_reset.rs index 904332d6e..6099ba764 100644 --- a/crates/freshell-ws/tests/freshagent_claude_reset.rs +++ b/crates/freshell-ws/tests/freshagent_claude_reset.rs @@ -159,3 +159,59 @@ async fn pinned_cli_clear_then_send_keeps_the_new_conversation_working() { async fn an_unannounced_init_identity_change_keeps_following_the_healthy_runtime() { conversation_reset(RigKind::Sim, true).await; } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_resumed_runtime_also_follows_an_unannounced_conversation_reset() { + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + env: vec![( + "FRESHELL_CLAUDE_SIM_DROP_CONVERSATION_RESET".into(), + "1".into(), + )], + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let old = conn.create("create-before-resumed-reset").await; + rig.converse(&mut conn, &old, &["before resumed reset"]) + .await; + conn.kill(&old).await; + conn.attach(&old).await; + let start = conn.frames.len(); + conn.send_text(&old, "resumed-clear", "/clear").await; + let init = conn + .wait_for("resumed runtime reset init", |frame| { + frame["event"]["type"] == "freshAgent.session.init" + && frame["event"]["cliSessionId"] + .as_str() + .is_some_and(|id| id != old) + }) + .await; + let new = init["event"]["cliSessionId"].as_str().unwrap().to_string(); + assert_eq!( + init["sessionId"], new, + "a resumed consumer follows the native UUID" + ); + assert!( + conn.frames[start..].iter().any(|frame| { + frame["type"] == "freshAgent.session.materialized" + && frame["previousSessionId"] == old + && frame["sessionId"] == new + }), + "the identity correction precedes the new init" + ); + conn.wait_for("clear retires on the resumed identity", |frame| { + frame["sessionId"] == new + && frame["event"]["type"] == "freshAgent.status" + && frame["event"]["status"] == "idle" + }) + .await; + rig.converse(&mut conn, &new, &["after resumed reset"]) + .await; + conn.kill(&old).await; + assert!(!rig.fresh_claude.has_live_session(&new).await); + rig.fresh_claude.shutdown().await; +} From 6425d3761a77435298b71a27f0d227268d5254ad Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:07:00 -0700 Subject: [PATCH 042/100] test(e2e): log fresh Claude cloud failure context --- .../specs/fresh-agent-control-rust.spec.ts | 24 ++++++++++++- ...fresh-agent-hidden-approval-reveal.spec.ts | 35 +++++++++++++++---- .../freshagent-settings-resume-rust.spec.ts | 7 +++- ...shclaude-identity-persistence-rust.spec.ts | 6 +++- 4 files changed, 63 insertions(+), 9 deletions(-) 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 ee6aa39f8..6a13680c4 100644 --- a/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts @@ -280,6 +280,7 @@ async function bootWall( env: options.env, stripEnvPrefixes: options.stripEnvPrefixes, setupHome: options.setupHome, + verbose: process.env.FRESHELL_E2E_SERVER_VERBOSE === '1', }) const info = await server.start() await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) @@ -527,7 +528,28 @@ async function raisePermissionAndAssertNoDecisions( ): Promise { await sendComposerText(page, 'RAISE_PERMISSION') const card = page.getByRole('alert', { name: 'Permission request for Bash' }) - await expect(card).toBeVisible({ timeout: 15_000 }) + try { + await expect(card).toBeVisible({ timeout: 15_000 }) + } catch (error) { + const rows = readClaudeSimLedger(simLedger) + const received = (await harness.getReceivedWsMessages()) as any[] + console.error(JSON.stringify({ + severity: 'error', + event: 'e2e.claude_approval_card_diagnostic', + queryPermissionModes: rows.filter((row) => row.kind === 'query') + .map((row) => row.options?.permissionMode ?? null), + promptPermissionModes: rows.filter((row) => row.kind === 'prompt') + .map((row) => row.permissionMode ?? null), + approvalEmissionCount: rows.filter((row) => row.kind === 'approval').length, + canUseToolOutcomes: rows.filter((row) => row.kind === 'can_use_tool') + .map((row) => ({ toolName: row.toolName, behavior: row.decision?.behavior, rejected: Boolean(row.error) })), + receivedFreshAgentEvents: received + .filter((message) => message?.type === 'freshAgent.event') + .map((message) => ({ type: message.event?.type, code: message.event?.code })) + .slice(-30), + })) + throw error + } // AGENT-05 pause-proof: the CLI has received zero decisions — the card came // from the snapshot overlay, not from any fabricated user choice. expect( diff --git a/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts b/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts index 32c7a9ed3..bf241cb9a 100644 --- a/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts @@ -161,6 +161,7 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without const projectDir = path.join(sharedRoot, 'project') await fs.mkdir(projectDir, { recursive: true }) server = new RustServer({ + verbose: process.env.FRESHELL_E2E_SERVER_VERBOSE === '1', env: claudeSimulatorServerEnv({ ledgerPath: simLedger, program: SIMULATOR_PROGRAM }), setupHome: async (homeDir) => { const freshellDir = path.join(homeDir, '.freshell') @@ -252,12 +253,34 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without message: 'the simulated CLI invokes the production canUseTool approval path', }) .toBe(1) - await expect - .poll(async () => (await receivedFreshAgentEvents(harness, 'freshAgent.permission.request')).length, { - timeout: 15_000, - message: 'the client receives the approval while tab A is hidden', - }) - .toBe(1) + try { + await expect + .poll(async () => (await receivedFreshAgentEvents(harness, 'freshAgent.permission.request')).length, { + timeout: 15_000, + message: 'the client receives the approval while tab A is hidden', + }) + .toBe(1) + } catch (error) { + const rows = readClaudeSimLedger(simLedger) + const received = (await harness.getReceivedWsMessages()) as any[] + console.error(JSON.stringify({ + severity: 'error', + event: 'e2e.claude_hidden_approval_diagnostic', + queryPermissionModes: rows.filter((row) => row.kind === 'query') + .map((row) => row.options?.permissionMode ?? null), + promptPermissionModes: rows.filter((row) => row.kind === 'prompt') + .map((row) => row.permissionMode ?? null), + approvalEmissionCount: rows.filter((row) => row.kind === 'approval').length, + canUseToolOutcomes: rows.filter((row) => row.kind === 'can_use_tool') + .map((row) => ({ toolName: row.toolName, behavior: row.decision?.behavior, rejected: Boolean(row.error) })), + receivedFreshAgentEvents: received + .filter((message) => message?.type === 'freshAgent.event') + .map((message) => ({ type: message.event?.type, code: message.event?.code })) + .slice(-30), + serverOutput: server?.capturedOutput(), + })) + throw error + } expect(await harness.getActiveTabId(), 'the approval arrived while tab A was hidden').toBe(tabB) await armRevealOverlayRecorder(page) diff --git a/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts b/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts index cb2274da6..594d4bb95 100644 --- a/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts +++ b/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts @@ -185,7 +185,11 @@ async function bootWall( setupHome?: (homeDir: string) => Promise } = {}, ): Promise<{ server: RustServer; info: Awaited>; harness: TestHarness }> { - const server = new RustServer({ env: options.env, setupHome: options.setupHome }) + const server = new RustServer({ + env: options.env, + setupHome: options.setupHome, + verbose: process.env.FRESHELL_E2E_SERVER_VERBOSE === '1', + }) const info = await server.start() await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) const harness = new TestHarness(page) @@ -507,6 +511,7 @@ test.describe('fresh-agent settings survive restart (rust)', () => { const projectDir = path.join(sharedRoot, 'proj') await fsp.mkdir(projectDir, { recursive: true }) const server = new RustServer({ + verbose: process.env.FRESHELL_E2E_SERVER_VERBOSE === '1', env: claudeSimulatorServerEnv({ ledgerPath: simLedger }), }) let ws: WsCapture | null = null diff --git a/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts b/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts index 82ec5485f..e966b9981 100644 --- a/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts +++ b/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts @@ -132,7 +132,11 @@ async function bootWall( setupHome?: (homeDir: string) => Promise } = {}, ): Promise<{ server: RustServer; info: E2eServerInfo; harness: TestHarness }> { - const server = new RustServer({ env: options.env, setupHome: options.setupHome }) + const server = new RustServer({ + env: options.env, + setupHome: options.setupHome, + verbose: process.env.FRESHELL_E2E_SERVER_VERBOSE === '1', + }) const info = await server.start() await page.goto(`${info.baseUrl}/?token=${info.token}&e2e=1`) const harness = new TestHarness(page) From f286d101fc7632a6e6bf7c9ac0c69e15edbfe74e Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:17:12 -0700 Subject: [PATCH 043/100] test(e2e): align Claude scenarios with SDK behavior --- .../specs/fresh-agent-control-rust.spec.ts | 18 ++++++++ ...fresh-agent-hidden-approval-reveal.spec.ts | 14 ++++++ .../freshagent-settings-resume-rust.spec.ts | 46 +++++++++++++------ ...shclaude-identity-persistence-rust.spec.ts | 19 ++++++-- 4 files changed, 80 insertions(+), 17 deletions(-) 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 6a13680c4..66e4a5344 100644 --- a/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts @@ -526,7 +526,25 @@ async function raisePermissionAndAssertNoDecisions( tabId: string, simLedger: string, ): Promise { + // Claude's REST create default is bypassPermissions unless the user saved a + // provider default. This scenario exercises canUseTool, so choose the + // product's asking mode explicitly instead of relying on that saved default. + await page.getByRole('button', { name: 'Agent settings', exact: true }).click() + await page.getByRole('combobox', { name: 'Permission mode' }).selectOption('default') + await page.keyboard.press('Escape') + await expect.poll(async () => (await paneLeaf(harness, tabId))?.content?.permissionMode ?? null) + .toBe('default') + await waitForSimRow( + simLedger, + (row) => row.kind === 'control' && row.name === 'setPermissionMode' && row.args?.[0] === 'default', + 'the CLI asking permission mode', + ) + await sendComposerText(page, 'RAISE_PERMISSION') + const raiseSend = (await harness.getSentWsMessages() as any[]).find( + (message) => message?.type === 'freshAgent.send' && message?.text === 'RAISE_PERMISSION', + ) + expect(raiseSend?.settings?.permissionMode, 'the approval send carries the asking mode').toBe('default') const card = page.getByRole('alert', { name: 'Permission request for Bash' }) try { await expect(card).toBeVisible({ timeout: 15_000 }) diff --git a/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts b/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts index bf241cb9a..7019d9bb1 100644 --- a/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts @@ -207,6 +207,20 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without const bridgeSessionId: string = (await leafA()).content.sessionId expect(bridgeSessionId, 'the pane is bound to its sidecar session').toBeTruthy() + // The provider's saved default is bypassPermissions in a fresh test + // HOME. This scenario needs a real canUseTool approval, so opt into the + // asking mode through the same settings control a user would use. + await page.getByRole('button', { name: 'Agent settings', exact: true }).click() + await page.getByRole('combobox', { name: 'Permission mode' }).selectOption('default') + await page.keyboard.press('Escape') + await expect.poll(async () => { + const content = findFreshAgentLeaf(await harness.getPaneLayout(tabA))?.content + return content?.permissionMode ?? null + }).toBe('default') + await expect.poll(() => readClaudeSimLedger(simLedger).some( + (row) => row.kind === 'control' && row.name === 'setPermissionMode' && row.args?.[0] === 'default', + )).toBe(true) + const paneA = page.locator('[data-context="fresh-agent"]').first() await expect(paneA).toBeVisible({ timeout: 15_000 }) // The composer is up: the pane's first snapshot has rendered. diff --git a/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts b/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts index 594d4bb95..ed6d96cbf 100644 --- a/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts +++ b/test/e2e-browser/specs/freshagent-settings-resume-rust.spec.ts @@ -253,7 +253,25 @@ test.describe('fresh-agent settings survive restart (rust)', () => { supportedEffortLevels: ['low', 'medium', 'high'], supportsAdaptiveThinking: false }], } })) const { server, harness } = await bootWall(page, { - env: claudeSimulatorServerEnv({ ledgerPath: simLedger }), + env: claudeSimulatorServerEnv({ + ledgerPath: simLedger, + program: { + rules: [ + { + on: 'msg:prompt', + match: { text: 'First message before settings change' }, + once: true, + emit: [{ kind: 'text', data: { text: 'settings test first response' } }], + }, + { + on: 'msg:prompt', + match: { text: 'Second message uses the selected settings' }, + once: true, + emit: [{ kind: 'text', data: { text: 'settings test updated response' } }], + }, + ], + }, + }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -266,7 +284,7 @@ test.describe('fresh-agent settings survive restart (rust)', () => { await expect(composer).toBeEnabled({ timeout: 30_000 }) await composer.fill('First message before settings change') await pane.getByRole('button', { name: 'Send', exact: true }).click() - await expect(pane.getByText(/^Fixture claude turn/)).toBeVisible({ timeout: 30_000 }) + await expect(pane.getByText('settings test first response', { exact: true })).toBeVisible({ timeout: 30_000 }) await expect(pane.getByRole('button', { name: 'Stop', exact: true })).toBeHidden() const before = findFreshAgentLeaf(await harness.getPaneLayout(tabId)).content @@ -280,7 +298,7 @@ test.describe('fresh-agent settings survive restart (rust)', () => { await page.getByRole('button', { name: 'Agent settings', exact: true }).click() await composer.fill('Second message uses the selected settings') await pane.getByRole('button', { name: 'Send', exact: true }).click() - await expect(pane.getByText('Fixture claude turn (sonnet, low, plan)', { exact: true })).toBeVisible({ timeout: 30_000 }) + await expect(pane.getByText('settings test updated response', { exact: true })).toBeVisible({ timeout: 30_000 }) const ledger = readClaudeSimLedger(simLedger) const secondPrompt = ledger.find((row) => row.kind === 'prompt' && row.text === 'Second message uses the selected settings') @@ -538,7 +556,16 @@ test.describe('fresh-agent settings survive restart (rust)', () => { 'freshAgent.created (claude create)', ) const bridgeSessionId = created.sessionId as string - // sdk.session.init -> freshAgent.session.init carries the durable UUID. + + // The SDK reports its durable identity with the first model turn's + // system init. Drive that turn before waiting for the identity event. + ws.send({ + type: 'freshAgent.send', + provider: 'claude', + sessionId: bridgeSessionId, + sessionType: 'freshclaude', + text: 'settings-resume first claude turn', + }) const init = await ws.waitFor( (f) => f.type === 'freshAgent.event' && @@ -550,15 +577,8 @@ test.describe('fresh-agent settings survive restart (rust)', () => { const durable = init.event.cliSessionId as string expect(durable).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) - // The CLI reports its durable id from the first prompt, then ends the - // turn through the production sidecar's result frame. - ws.send({ - type: 'freshAgent.send', - provider: 'claude', - sessionId: bridgeSessionId, - sessionType: 'freshclaude', - text: 'settings-resume first claude turn', - }) + // The production sidecar reports the turn's durable id before forwarding + // its completion frame. await ws.waitFor( (f) => f.type === 'freshAgent.event' && diff --git a/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts b/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts index e966b9981..b6e8f55b7 100644 --- a/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts +++ b/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts @@ -547,6 +547,8 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { userTexts: ['C26 first turn'], minAssistantTurns: 1, }) + const bootsBeforeReload = readClaudeSimLedger(simLedger).filter((row) => row.kind === 'boot').length + const queriesBeforeReload = readClaudeSimLedger(simLedger).filter((row) => row.kind === 'query').length await flushPersistence(page) await expect.poll(() => persistedFreshAgentIdentity(page, tabId)).toBe(originalDurable) @@ -557,16 +559,25 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { const reloadedTabId = (await harness.getActiveTabId())! await expect.poll(async () => durableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(reloadedTabId)))) .toBe(originalDurable) + await expect.poll(async () => (await harness.getSentWsMessages() as any[]).some( + (message) => message?.type === 'freshAgent.attach' + && message?.sessionRef?.provider === 'claude' + && message?.sessionRef?.sessionId === originalDurable, + ), { message: 'reload attaches using the pane’s durable Claude reference' }).toBe(true) + const durableAttach = (await harness.getSentWsMessages() as any[]).find( + (message) => message?.type === 'freshAgent.attach' + && message?.sessionRef?.sessionId === originalDurable, + ) + expect(durableAttach?.sessionRef).toEqual({ provider: 'claude', sessionId: originalDurable }) await sendFreshAgentTurn(page, harness, reloadedTabId, 'C26 second turn after reload') await expectFixtureTranscript(harness, server, info.homeDir, originalDurable, { userTexts: ['C26 first turn', 'C26 second turn after reload'], minAssistantTurns: 2, }) - const resumeBoots = readClaudeSimLedger(simLedger) - .filter((row) => row.kind === 'query') - .filter((row) => (row.options as any)?.resume === originalDurable) - expect(resumeBoots.length, 'reload must resume the durable Claude conversation').toBeGreaterThan(0) + const ledgerAfterReload = readClaudeSimLedger(simLedger) + expect(ledgerAfterReload.filter((row) => row.kind === 'boot')).toHaveLength(bootsBeforeReload) + expect(ledgerAfterReload.filter((row) => row.kind === 'query')).toHaveLength(queriesBeforeReload) } finally { await server.stop() await fs.rm(sharedRoot, { recursive: true, force: true }) From 7db5bc2108abba19e837e5689cc86edd476d473f Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:17:18 -0700 Subject: [PATCH 044/100] fix(testing): isolate sandbox Rust builds by host worktree --- scripts/testing/run-rust-tests.ts | 13 ++++++--- test/unit/tooling/run-rust-tests-fixture.ts | 22 +++++++++++++-- test/unit/tooling/run-rust-tests.test.ts | 31 ++++++++++++++++++++- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/scripts/testing/run-rust-tests.ts b/scripts/testing/run-rust-tests.ts index 3064fd983..5676725c9 100644 --- a/scripts/testing/run-rust-tests.ts +++ b/scripts/testing/run-rust-tests.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { createHash } from 'node:crypto' import { spawn, type ChildProcess } from 'node:child_process' import path from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -10,6 +11,8 @@ const PROJECT_ROOT = path.resolve(SCRIPT_DIR, '../..') const SANDBOX_CARGO_BUILD_JOBS = 3 const SANDBOX_RUST_TEST_THREADS = 4 const SANDBOX_TOKIO_WORKER_THREADS = 2 +// All worktrees mount at /workspace; choose the namespace on the host first. +const SANDBOX_TARGET_DIRECTORY = `/freshell-test-artifacts/targets/${createHash('sha256').update(PROJECT_ROOT).digest('hex').slice(0, 12)}` function log(severity: 'info' | 'error', event: string, fields: Record = {}): void { const stream = severity === 'error' ? process.stderr : process.stdout @@ -116,6 +119,7 @@ owned_runtime_tmp="$(mktemp -d /freshell-test-artifacts/XXX)" printf '{"severity":"info","event":"rust_test_temp_created","directory":"%s","runtimeDirectory":"%s"}\\n' "$owned_tmp" "$owned_runtime_tmp" # Preserve every test while bounding independent libtest/Tokio runtimes within # the sandbox's existing PID budget. Runtime construction precedes fixture locks. +export CARGO_TARGET_DIR=${shellQuote(SANDBOX_TARGET_DIRECTORY)} export TMPDIR="$owned_runtime_tmp" FRESHELL_TEST_EXECUTABLE_TMP_DIR="$owned_tmp" FRESHELL_RUST_TEST_SANDBOX=0 CARGO_BUILD_JOBS=${SANDBOX_CARGO_BUILD_JOBS} RUST_TEST_THREADS=${SANDBOX_RUST_TEST_THREADS} TOKIO_WORKER_THREADS=${SANDBOX_TOKIO_WORKER_THREADS} node --import tsx /workspace/scripts/testing/run-rust-tests.ts ${argv.map(shellQuote).join(' ')} & runner_pid=$! @@ -125,16 +129,17 @@ wait "$runner_pid"` export async function main(argv: string[] = process.argv.slice(2)): Promise { if (process.env.FRESHELL_RUST_TEST_SANDBOX === '1') { log('info', 'rust_test_sandbox_route_selected', { - mode: 'runtime-suite', cargoBuildJobs: SANDBOX_CARGO_BUILD_JOBS, + mode: 'runtime-suite', cargoTargetDirectory: SANDBOX_TARGET_DIRECTORY, cargoBuildJobs: SANDBOX_CARGO_BUILD_JOBS, rustTestThreads: SANDBOX_RUST_TEST_THREADS, tokioWorkerThreads: SANDBOX_TOKIO_WORKER_THREADS, forwardedArgumentCount: argv.length, }) - // Do not inherit a host binary selection. The unchanged inner runner - // selects its own freshly built /workspace/target/debug server. + // The inner runner selects the server it builds in the sandbox's + // worktree-specific Cargo target directory. const { FRESHELL_SERVER_BIN: _hostBinary, ...env } = process.env return runChild(path.join(PROJECT_ROOT, 'scripts/sandbox-test.sh'), ['--runtime-suite', sandboxCommand(argv)], { ...env, FRESHELL_SANDBOX_OWNER_GUARD: '1' }, true) } - const binary = path.join(PROJECT_ROOT, 'target', 'debug', `freshell-server${process.platform === 'win32' ? '.exe' : ''}`) + const targetDirectory = path.resolve(PROJECT_ROOT, process.env.CARGO_TARGET_DIR || 'target') + const binary = path.join(targetDirectory, 'debug', `freshell-server${process.platform === 'win32' ? '.exe' : ''}`) const cargo = process.platform === 'win32' ? 'cargo.exe' : 'cargo' const buildExitCode = await runChild(cargo, ['build', '-p', 'freshell-server', '--locked'], process.env) if (buildExitCode !== 0) return buildExitCode diff --git a/test/unit/tooling/run-rust-tests-fixture.ts b/test/unit/tooling/run-rust-tests-fixture.ts index 4d4f50150..f0695c040 100644 --- a/test/unit/tooling/run-rust-tests-fixture.ts +++ b/test/unit/tooling/run-rust-tests-fixture.ts @@ -43,7 +43,22 @@ export function fixture() { const result = cp.spawnSync(process.execPath, [file], {env:process.env, stdio:'inherit'}); process.exit(result.status ?? 1); } - fs.appendFileSync(process.env.RECEIPT, JSON.stringify({kind:'cargo', args, + const target = (await import('node:path')).default.resolve(process.cwd(), process.env.CARGO_TARGET_DIR || 'target'); + const binary = target + '/debug/freshell-server'; + if (args[0] === 'build' && !Number(process.env.BUILD_EXIT || 0)) { + fs.mkdirSync(target + '/debug', { recursive: true }); + fs.writeFileSync(binary, '#!/usr/bin/env node\\nprocess.stdout.write(' + JSON.stringify(process.cwd()) + ')\\n'); + fs.chmodSync(binary, 0o755); + } + if (args[0] === 'test') { + const cp = await import('node:child_process'); + const actual = cp.spawnSync(process.env.FRESHELL_SERVER_BIN, [], { encoding: 'utf8' }); + if (actual.status !== 0 || actual.stdout !== process.cwd()) { + console.error('the selected server was not built for this checkout', process.env.FRESHELL_SERVER_BIN, actual.error); + process.exit(91); + } + } + fs.appendFileSync(process.env.RECEIPT, JSON.stringify({kind:'cargo', args, target, testThreads:process.env.RUST_TEST_THREADS, tokioThreads:process.env.TOKIO_WORKER_THREADS, binary:process.env.FRESHELL_SERVER_BIN, route:process.env.FRESHELL_RUST_TEST_SANDBOX, jobs:process.env.CARGO_BUILD_JOBS, tmp:process.env.TMPDIR, executableTmp:process.env.FRESHELL_TEST_EXECUTABLE_TMP_DIR})+'\\n'); if (args[0] === 'test' && process.env.HOLD_TEST) { @@ -59,9 +74,10 @@ export function fixture() { import fs from 'node:fs'; import cp from 'node:child_process'; const args = process.argv.slice(2); - fs.appendFileSync(process.env.RECEIPT, JSON.stringify({kind:'sandbox', args})+'\\n'); + fs.appendFileSync(process.env.RECEIPT, JSON.stringify({kind:'sandbox', args, ownerGuard:process.env.FRESHELL_SANDBOX_OWNER_GUARD})+'\\n'); if (process.env.SANDBOX_EXIT) process.exit(Number(process.env.SANDBOX_EXIT)); const command = args[1] + .replaceAll('/freshell-test-artifacts/targets/', ${JSON.stringify(artifacts)} + '/targets/') .replace('mktemp -d /freshell-test-artifacts/.freshell-rust-tests-', 'mktemp -d ' + ${JSON.stringify(artifacts)} + '/.freshell-rust-tests-') .replace('mktemp -d /freshell-test-artifacts/XXX', 'mktemp -d ' + ${JSON.stringify(runtimeTemplate)}) .replace('mktemp -d /workspace/target/', 'mktemp -d ' + ${JSON.stringify(root)} + '/target/') @@ -74,7 +90,7 @@ export function fixture() { run(env: NodeJS.ProcessEnv = {}, args: string[] = []) { const result = spawnSync(process.execPath, ['--import', tsx, path.join(root, 'scripts/testing/run-rust-tests.ts'), ...args], { env: { ...process.env, PATH: `${root}/bin:${process.env.PATH}`, RECEIPT: receipt, - FRESHELL_RUST_TEST_SANDBOX: '0', FRESHELL_SERVER_BIN: '/host/stale-server', ...env }, + FRESHELL_RUST_TEST_SANDBOX: '0', CARGO_TARGET_DIR: '', FRESHELL_SERVER_BIN: '/host/stale-server', ...env }, encoding: 'utf8', timeout: 15_000, }) const events = existsSync(receipt) ? readFileSync(receipt, 'utf8').trim().split('\n').map(line => JSON.parse(line)) : [] diff --git a/test/unit/tooling/run-rust-tests.test.ts b/test/unit/tooling/run-rust-tests.test.ts index 3e97580f0..779591e94 100644 --- a/test/unit/tooling/run-rust-tests.test.ts +++ b/test/unit/tooling/run-rust-tests.test.ts @@ -27,6 +27,35 @@ describe('Rust test launcher children', () => { } finally { f.cleanup() } }) + it.each(['relative target with spaces', '/absolute'])('executes the server built in the effective native Cargo target directory (%s)', target => { + const f = fixture() + try { + const targetDirectory = target === '/absolute' ? path.join(f.root, 'absolute target') : target + const result = f.run({ CARGO_TARGET_DIR: targetDirectory }) + expect(result.status, result.stderr).toBe(0) + const effectiveTarget = path.resolve(f.root, targetDirectory) + expect(result.events.map(e => e.target)).toEqual([effectiveTarget, effectiveTarget]) + expect(result.events[1].binary).toBe(path.join(effectiveTarget, 'debug/freshell-server')) + } finally { f.cleanup() } + }) + + it('keeps a stable sandbox Cargo namespace per host worktree and replaces a host target override', () => { + const first = fixture() + const second = fixture() + try { + const one = first.run({ FRESHELL_RUST_TEST_SANDBOX: '1', CARGO_TARGET_DIR: '/host/foreign-target' }) + const repeated = first.run({ FRESHELL_RUST_TEST_SANDBOX: '1' }) + const other = second.run({ FRESHELL_RUST_TEST_SANDBOX: '1' }) + for (const result of [one, repeated, other]) expect(result.status, result.stderr).toBe(0) + const target = one.events[1].target + expect(path.dirname(target)).toBe(path.join(first.artifacts, 'targets')) + expect(repeated.events[4].target).toBe(target) + expect(path.basename(other.events[1].target)).not.toBe(path.basename(target)) + expect(one.events[0].ownerGuard).toBe('1') + expect(one.events[1]).toMatchObject({ jobs: '3', testThreads: '4', tokioThreads: '2' }) + } finally { first.cleanup(); second.cleanup() } + }) + it('preserves build failure and does not launch tests', () => { const f = fixture() try { @@ -56,7 +85,7 @@ describe('Rust test launcher children', () => { expect(result.events[1].route).toBe('0') expect(result.events[1].jobs).toBe('3') expect(result.events[2].args).toEqual(['test', '--workspace', '--locked', ...args.slice(1)]) - expect(result.events[2].binary).toBe(path.join(f.root, 'target/debug/freshell-server')) + expect(result.events[2].binary).toBe(path.join(result.events[1].target, 'debug/freshell-server')) expect(existsSync(result.events[2].tmp)).toBe(false) expect(existsSync(result.events[2].executableTmp)).toBe(false) } finally { f.cleanup() } From 2780cd020d40af9e339565624c45776e5e7223a6 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:17:32 -0700 Subject: [PATCH 045/100] fix(agent-api): clean abandoned hosted creates by captured intent --- .../freshell-server/src/fresh_agent_proxy.rs | 7 +- .../src/fresh_agent_proxy_rest.rs | 51 +++++++++- .../src/fresh_agent_proxy_tests.rs | 92 +++++++++++++++++++ 3 files changed, 146 insertions(+), 4 deletions(-) diff --git a/crates/freshell-server/src/fresh_agent_proxy.rs b/crates/freshell-server/src/fresh_agent_proxy.rs index 0d871045e..5c8e25ede 100644 --- a/crates/freshell-server/src/fresh_agent_proxy.rs +++ b/crates/freshell-server/src/fresh_agent_proxy.rs @@ -491,7 +491,7 @@ impl HostedFreshAgentProxy { async fn create( self: &Arc, message: freshell_protocol::FreshAgentCreate, - ) -> Result<(), ()> { + ) -> Result, ()> { let provider = fresh_provider(&message.provider, message.session_type).ok_or(())?; let public_provider = fresh_provider_wire(&provider); if let Some(session_ref) = message.session_ref.as_ref() { @@ -554,7 +554,7 @@ impl HostedFreshAgentProxy { ) .await; } - return Ok(()); + return Ok(None); } } let cwd = canonical_cwd(message.cwd.as_deref()).map_err(|_| ())?; @@ -822,6 +822,7 @@ impl HostedFreshAgentProxy { "Retain provider inputs until dispatch outcome is confirmed"); })? }; + let cleanup_receipt = (soul.clone(), launched.view.intent_revision); let naming_projection = match message.naming_handle.as_deref() { Some(handle) if !handle.trim().is_empty() => { admit_pending_projection( @@ -863,7 +864,7 @@ impl HostedFreshAgentProxy { session_type, ) .await; - Ok(()) + Ok(Some(cleanup_receipt)) } async fn resolve_decision( diff --git a/crates/freshell-server/src/fresh_agent_proxy_rest.rs b/crates/freshell-server/src/fresh_agent_proxy_rest.rs index b111953bb..31f86d8b9 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_rest.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_rest.rs @@ -159,13 +159,62 @@ impl FreshAgentRestLane for HostedFreshAgentProxy { .as_ref() .map(|value| value.session_id.as_str()), ); - self.create(message).await?; + let cleanup_token = self + .create(message) + .await? + .map(|receipt| serde_json::to_string(&receipt).map_err(|_| ())) + .transpose()?; Ok(RestLaneCreated { session_id, + cleanup_token, ..Default::default() }) } + async fn abandon_create( + &self, + _: &RestLaneCreate, + created: &RestLaneCreated, + ) -> Result<(), freshell_freshagent::rest_lane::RestLaneError> { + use freshell_freshagent::rest_lane::{RestLaneError, RestLaneErrorKind}; + let Some(token) = created.cleanup_token.as_deref() else { + return Ok(()); + }; + let (soul, revision): (SoulId, u64) = serde_json::from_str(token).map_err(|_| { + RestLaneError::new( + RestLaneErrorKind::Internal, + "INVALID_CREATE_CLEANUP_TOKEN", + "invalid hosted create cleanup receipt", + ) + })?; + let stopped = self + .client + .stop_expected(soul.clone(), Some(revision)) + .await; + match stopped { + Ok((StopOutcome::VerifiedEmpty, _)) => Ok(()), + Err(error) + if matches!( + error.runtime_code(), + Some(RuntimeErrorCode::StaleIntentRevision | RuntimeErrorCode::UnknownSoul) + ) => + { + Ok(()) + } + result => { + tracing::warn!(event = "fresh_agent.abandoned_create_cleanup_failed", soul_id = %soul, expected_intent_revision = revision, result = ?result); + let mut error = RestLaneError::new( + RestLaneErrorKind::Unavailable, + "CREATE_CLEANUP_UNCONFIRMED", + "the abandoned hosted runtime could not be confirmed stopped", + ); + error.retryable = Some(true); + error.retry_after_ms = Some(250); + Err(error) + } + } + } + async fn send_agent( &self, request: RestLaneSend, diff --git a/crates/freshell-server/src/fresh_agent_proxy_tests.rs b/crates/freshell-server/src/fresh_agent_proxy_tests.rs index bc8dd97e9..98c172b6e 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_tests.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_tests.rs @@ -176,6 +176,98 @@ fn configure_command(session_id: &str) -> HostedFreshAgentCommand { ) } +async fn abandon_hosted_create(stale_intent: bool) { + let dir = tempfile::tempdir().unwrap(); + let socket = dir.path().join("supervisor.sock"); + let listener = UnixListener::bind(&socket).unwrap(); + let owned = SoulId::parse("newly-created-hosted-soul").unwrap(); + let expected = owned.clone(); + let server = tokio::spawn(async move { + for _ in 0..2 { + let (mut stream, _) = listener.accept().await.unwrap(); + let request: Envelope = read_frame(&mut stream).await.unwrap(); + let result = match request.body { + AdminCommand::Health => Ok(AdminResult::Health { + control_epoch: 77, + installation_id: InstallationId::new(), + }), + AdminCommand::Stop(stop) => { + assert_eq!(stop.soul_id, expected); + assert_eq!(stop.expected_intent_revision, Some(9)); + if stale_intent { + Err(freshell_runtime_protocol::RuntimeError::new( + RuntimeErrorCode::StaleIntentRevision, + "another intent now owns this soul", + )) + } else { + Ok(AdminResult::Stop { outcome: StopOutcome::VerifiedEmpty, view: serde_json::from_value(serde_json::json!({ + "soulId":expected, "incarnationId":"created-incarnation", "launchState":"stopped", "cleanupState":"none", + "intentRevision":9, "executionGeneration":1, + })).unwrap() }) + } + } + other => { + panic!("abandon must use its captured receipt without re-resolving: {other:?}") + } + }; + write_frame( + &mut stream, + &AdminReply { + request_id: request.request_id, + result, + }, + ) + .await + .unwrap(); + } + }); + let (proxy, _) = configure_proxy( + &socket, + HashMap::from([( + ("claude".into(), "public-created".into()), + SoulId::parse("replacement-alias-target").unwrap(), + )]), + ); + proxy + .abandon_create( + &RestLaneCreate::default(), + &RestLaneCreated { + session_id: "public-created".into(), + cleanup_token: Some(serde_json::to_string(&(owned, 9_u64)).unwrap()), + ..Default::default() + }, + ) + .await + .unwrap(); + settled(server).await; +} + +#[tokio::test] +async fn hosted_rest_abandon_stops_only_the_captured_created_soul_and_intent() { + abandon_hosted_create(false).await; +} + +#[tokio::test] +async fn hosted_rest_abandon_does_not_stop_a_replacement_intent() { + abandon_hosted_create(true).await; +} + +#[tokio::test] +async fn hosted_rest_abandon_preserves_an_adopted_existing_writer_without_a_host_call() { + let dir = tempfile::tempdir().unwrap(); + let (proxy, _) = configure_proxy(&dir.path().join("no-supervisor.sock"), HashMap::new()); + proxy + .abandon_create( + &RestLaneCreate::default(), + &RestLaneCreated { + session_id: "adopted-writer".into(), + ..Default::default() + }, + ) + .await + .unwrap(); +} + /// Answers Health and then one configure the way `answer` decides, and /// returns the admin methods it saw. fn configure_supervisor( From 551d89411fbb4b83997ffb41ded2cd723de4c656 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:17:43 -0700 Subject: [PATCH 046/100] test(freshclaude): match buffered clear history to the pinned CLI --- .../tests/rest_freshclaude_lane.rs | 26 +++++++++++++++---- .../claude-cli-sim/local-commands.mjs | 5 ++-- .../providers/claude-cli-contract.test.ts | 14 +++++++--- .../providers/claude-cli-scenarios.mjs | 20 ++++++++++---- 4 files changed, 50 insertions(+), 15 deletions(-) diff --git a/crates/freshell-ws/tests/rest_freshclaude_lane.rs b/crates/freshell-ws/tests/rest_freshclaude_lane.rs index c46fe7082..70168ba70 100644 --- a/crates/freshell-ws/tests/rest_freshclaude_lane.rs +++ b/crates/freshell-ws/tests/rest_freshclaude_lane.rs @@ -563,11 +563,27 @@ async fn clear_rekeys_the_headless_rest_pane(kind: RigKind) { cleared["data"]["sessionRef"], json!({"provider":"claude", "sessionId":new_id}) ); - let (status, fresh_capture) = get(&rig, &format!("/api/panes/{pane}/capture")).await; - assert_eq!(status, 200, "{fresh_capture}"); - assert!( - fresh_capture.is_empty(), - "the same headless pane now captures the new empty conversation: {fresh_capture}" + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + let fresh_capture = loop { + let (status, capture) = get(&rig, &format!("/api/panes/{pane}/capture")).await; + assert_eq!(status, 200, "{capture}"); + assert!( + !capture.contains("before clear conversation"), + "the new conversation must never expose its parent's dialogue: {capture}" + ); + if capture.trim() == "user: /clear" { + break capture; + } + assert!( + tokio::time::Instant::now() < deadline, + "the CLI's buffered local-command rows never reached capture: {capture}" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + }; + assert_eq!( + fresh_capture.trim(), + "user: /clear", + "the new saved history holds only its own local command" ); let next = send(&rig, pane, "after clear conversation").await; assert_eq!(next["status"], "idle"); diff --git a/test/fixtures/claude-cli-sim/local-commands.mjs b/test/fixtures/claude-cli-sim/local-commands.mjs index d5f66cb9f..ebf732c3d 100644 --- a/test/fixtures/claude-cli-sim/local-commands.mjs +++ b/test/fixtures/claude-cli-sim/local-commands.mjs @@ -71,10 +71,11 @@ export async function runLocalCommand(cli, turn, { name, args }) { await cli.sleep(T.localCommand) cli.emit(cli.systemInit()) replay(commandText, cmd.uuid) - // T16: /clear creates B immediately with a mode record. Its replayed - // command is SDK output only and is not saved dialogue in B. + // T16: mode metadata exists by the result; the local-command records + // reach B in the normal buffered write after that boundary. cli.transcript.appendVerbatim({ type: 'mode', mode: 'normal', sessionId: cli.sessionId }) cli.transcript.flush() + recordCommand('') cli.modelTurnsSinceCompact = 0 return localResult('') } diff --git a/test/integration/providers/claude-cli-contract.test.ts b/test/integration/providers/claude-cli-contract.test.ts index 1e1749513..7304724da 100644 --- a/test/integration/providers/claude-cli-contract.test.ts +++ b/test/integration/providers/claude-cli-contract.test.ts @@ -388,7 +388,7 @@ describe(`pinned Claude CLI ${PINNED_CLI_VERSION} contract (real CLI, loopback p expect(messagesOf(r, 'S').find((m) => m.subtype === 'compact_boundary')?.compact_metadata?.trigger).toBe('manual') }, SCENARIO_TIMEOUT) - it('T16: /clear sends conversation_reset stamped with the old id, then the new session id arrives in the next init and the result; its transcript exists at once without saved dialogue and resumes', async () => { + it('T16: /clear sends the old-id reset then new-id init/result; B has mode metadata at once, flushes only its local command, and resumes', async () => { const r = await real('clear') const events = eventsOf(r, 'S') const reset = messagesOf(r, 'S').find((m) => m.type === 'conversation_reset') @@ -401,8 +401,16 @@ describe(`pinned Claude CLI ${PINNED_CLI_VERSION} contract (real CLI, loopback p expect(results(r, 'S').map((m) => m.session_id)).toEqual(['A', 'B', 'B']) expect(lifecycleStates(messagesOf(r, 'S'), 'u2')).toEqual(['queued', 'started', 'completed']) expect(r.observations.afterClear.files.map((f: string) => path.basename(f)).sort()).toEqual(['A.jsonl', 'B.jsonl']) - expect(r.observations.afterClear.newSessionLineTypes).toEqual(['mode']) - expect(r.observations.afterClear.newSessionMessages).toEqual([]) + expect(r.observations.afterClear.hasModeMetadata).toBe(true) + expect(r.observations.afterClearFlush.lineTypes).toEqual(['mode', 'user', 'user', 'system']) + expect(r.observations.afterClearFlush.messages).toEqual([ + { type: 'user', isMeta: true, content: expect.stringContaining('') }, + { type: 'user', isMeta: false, content: expect.stringContaining('/clear') }, + ]) + expect(JSON.stringify(r.observations.afterClearFlush.messages)).not.toContain('hello') + expect(r.observations.afterClearFlush.localCommands).toEqual([ + { subtype: 'local_command', content: '', isMeta: false }, + ]) expect(eventsOf(r, 'resumeB').some((e) => e.k === 'init-resolved')).toBe(true) }, SCENARIO_TIMEOUT) diff --git a/test/integration/providers/claude-cli-scenarios.mjs b/test/integration/providers/claude-cli-scenarios.mjs index 8addda20a..e1f178631 100644 --- a/test/integration/providers/claude-cli-scenarios.mjs +++ b/test/integration/providers/claude-cli-scenarios.mjs @@ -673,9 +673,19 @@ const SCENARIOS = { const cleared = ctx.need(await ctx.fixture.readTranscript(B), 'post-reset transcript exists') ctx.observe('afterClear', { files: await ctx.files(), - newSessionLineTypes: cleared.map((row) => row.type), - newSessionMessages: cleared.filter((row) => row.type === 'user' || row.type === 'assistant') + hasModeMetadata: cleared.some((row) => row.type === 'mode' && row.mode === 'normal'), + }) + const flushed = ctx.need(await ctx.pollTranscript(B, 5000, 1, (rows) => + rows.some((row) => row.type === 'user' && typeof row.message?.content === 'string' + && row.message.content.includes('/clear')) + && rows.some((row) => row.type === 'system' && row.subtype === 'local_command')), + 'post-reset local-command rows flushed') + ctx.observe('afterClearFlush', { + lineTypes: flushed.map((row) => row.type), + messages: flushed.filter((row) => row.type === 'user' || row.type === 'assistant') .map((row) => ({ type: row.type, isMeta: row.isMeta === true, content: row.message?.content })), + localCommands: flushed.filter((row) => row.type === 'system' && row.subtype === 'local_command') + .map((row) => ({ subtype: row.subtype, content: row.content, isMeta: row.isMeta === true })), }) s.push('after clear', 'u3') await s.waitForResults(3) @@ -864,12 +874,12 @@ export async function runScenario(target, name, { repoRoot = DEFAULT_REPO_ROOT } return value }, files: () => fixture.transcripts(), - /** Poll until `.jsonl` holds at least `minLines` lines; returns the lines or null. */ - async pollTranscript(id, ms, minLines = 1) { + /** Poll until the saved lines satisfy the requested count and optional predicate. */ + async pollTranscript(id, ms, minLines = 1, predicate) { const started = Date.now() while (Date.now() - started <= ms) { const lines = await fixture.readTranscript(id) - if (lines && lines.length >= minLines) { + if (lines && lines.length >= minLines && (!predicate || predicate(lines))) { trace.add({ k: 'transcript-seen', id, lines: lines.length }) return lines } From 8e05e67a362e8df1041bb0bc8cc122d0da99ff85 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:17:52 -0700 Subject: [PATCH 047/100] test(freshclaude): verify abandoned create cleanup with real runtimes --- Cargo.lock | 1 + crates/freshell-freshagent/src/claude.rs | 3 +- .../src/rest_lane_publication_tests.rs | 32 ++- crates/freshell-ws/Cargo.toml | 1 + crates/freshell-ws/tests/common/claude_rig.rs | 14 +- .../tests/rest_freshclaude_lane.rs | 182 ++++++++++++++++++ 6 files changed, 223 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f510e36e4..727bd8b4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1599,6 +1599,7 @@ dependencies = [ name = "freshell-ws" version = "0.1.0" dependencies = [ + "async-trait", "axum", "chrono", "freshell-activity", diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 4fc20f935..7859c460a 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -2728,8 +2728,7 @@ impl FreshClaudeState { // The receipt names the process-tree tag; the // coordinator names a lifecycle operation. The // exact runtime record ties them together. - stamp.owner.kind - == freshell_ownership::RuntimeOwnerKind::FreshAgent + stamp.owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent && stamp.owner.live_session_key.as_deref() == Some(map_key.as_str()) && stamp.owner.pid == session.child.id() diff --git a/crates/freshell-freshagent/src/rest_lane_publication_tests.rs b/crates/freshell-freshagent/src/rest_lane_publication_tests.rs index 0208dc568..549be2298 100644 --- a/crates/freshell-freshagent/src/rest_lane_publication_tests.rs +++ b/crates/freshell-freshagent/src/rest_lane_publication_tests.rs @@ -48,7 +48,15 @@ impl FreshAgentRestLane for PausedCreateLane { } } -async fn close_during_create(split: bool) { +#[derive(Clone, Copy)] +enum CloseDuringCreate { + Tab, + SourcePane { sync_layout: bool }, + PendingPane, +} + +async fn close_during_create(target: CloseDuringCreate) { + let split = matches!(target, CloseDuringCreate::SourcePane { .. }); let state = state_with_registry(); let before: freshell_protocol::UiLayoutSync = serde_json::from_value(json!({ "tabs":[{"id":"current-tab"}], "activeTabId":"current-tab", "timestamp":1, @@ -80,7 +88,7 @@ async fn close_during_create(split: bool) { .await .unwrap(); let request = lane.request.lock().unwrap().clone().unwrap(); - if split { + if let CloseDuringCreate::SourcePane { sync_layout } = target { let (status, closed) = post( router.clone(), "/api/panes/source-pane/close", @@ -96,7 +104,11 @@ async fn close_during_create(split: bool) { "layouts":{"current-tab":{"type":"leaf", "id":"sibling-pane", "content":{"kind":"browser", "url":"https://survivor.example"}}}, "activePane":{"current-tab":"sibling-pane"}, })).unwrap(); - state.layout.update_from_ui(&closed, "creating-browser"); + if sync_layout { + state.layout.update_from_ui(&closed, "creating-browser"); + } + } else if matches!(target, CloseDuringCreate::PendingPane) { + state.layout.release_pane_publication(&request.request_id); } else { let (status, closed) = delete( router.clone(), @@ -143,12 +155,22 @@ async fn close_during_create(split: bool) { #[tokio::test] async fn closing_the_target_tab_while_runtime_create_awaits_cleans_the_late_runtime() { - close_during_create(false).await; + close_during_create(CloseDuringCreate::Tab).await; } #[tokio::test] async fn closing_the_source_pane_while_runtime_create_awaits_cleans_the_late_runtime() { - close_during_create(true).await; + close_during_create(CloseDuringCreate::SourcePane { sync_layout: true }).await; +} + +#[tokio::test] +async fn closing_the_source_before_its_layout_debounce_cancels_the_pending_create() { + close_during_create(CloseDuringCreate::SourcePane { sync_layout: false }).await; +} + +#[tokio::test] +async fn a_pane_closed_ack_before_runtime_create_returns_cancels_its_publication() { + close_during_create(CloseDuringCreate::PendingPane).await; } struct SyncDuringCreateLane { diff --git a/crates/freshell-ws/Cargo.toml b/crates/freshell-ws/Cargo.toml index 2f8503ad1..d76159b58 100644 --- a/crates/freshell-ws/Cargo.toml +++ b/crates/freshell-ws/Cargo.toml @@ -107,6 +107,7 @@ sha2 = "0.10" libc = "0.2" [dev-dependencies] +async-trait = "0.1" # The claude rigs (tests/common/claude_rig.rs) give every sidecar their own # isolated environment through FreshClaudeState::with_sidecar_environment, a seam # that only exists with this feature (dev builds only; production never has it). diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index f6701dfd5..059ef81cc 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -318,14 +318,22 @@ impl ClaudeRig { /// `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) { + Self::request_json_to("POST", &self.http_url, path, body).await + } + + pub async fn request_json_to( + method: &str, + base_url: &str, + path: &str, + body: &Value, + ) -> (u16, Value) { use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let host = self - .http_url + let host = base_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\ + "{method} {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(), diff --git a/crates/freshell-ws/tests/rest_freshclaude_lane.rs b/crates/freshell-ws/tests/rest_freshclaude_lane.rs index 70168ba70..18c262dd5 100644 --- a/crates/freshell-ws/tests/rest_freshclaude_lane.rs +++ b/crates/freshell-ws/tests/rest_freshclaude_lane.rs @@ -4,7 +4,12 @@ mod common; use common::claude_rig::{ClaudeRig, RigKind, RigOptions, RIG_FRAME_BUDGET}; +use freshell_freshagent::rest_lane::{ + FreshAgentRestLane, RestLaneCreate, RestLaneCreated, RestLaneError, RestLaneSend, + RestLaneSendResult, +}; use serde_json::{json, Value}; +use std::sync::Arc; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -16,6 +21,183 @@ fn options() -> RigOptions { } } +struct PausedRealCreate { + inner: Arc, + started: tokio::sync::Notify, + finish: tokio::sync::Notify, + receipt: std::sync::Mutex>, +} + +#[async_trait::async_trait] +impl FreshAgentRestLane for PausedRealCreate { + async fn create_agent( + self: Arc, + request: RestLaneCreate, + ) -> Result { + let created = self.inner.clone().create_agent(request.clone()).await?; + *self.receipt.lock().unwrap() = Some((request, created.clone())); + self.started.notify_one(); + self.finish.notified().await; + Ok(created) + } + async fn send_agent(&self, request: RestLaneSend) -> Result { + self.inner.send_agent(request).await + } + async fn abandon_create( + &self, + request: &RestLaneCreate, + created: &RestLaneCreated, + ) -> Result<(), RestLaneError> { + self.inner.abandon_create(request, created).await + } +} + +#[derive(Clone, Copy)] +enum CancelRealCreate { + Owned, + Adopted, + CloseWriteFailure, +} + +async fn close_after_real_create(case: CancelRealCreate) { + let adopt_existing = matches!(case, CancelRealCreate::Adopted); + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..Default::default() + }, + ) + .await; + let mut browser = rig.connect().await; + let existing = if adopt_existing { + Some(browser.create("existing-visible-runtime").await) + } else { + None + }; + let lane = Arc::new(PausedRealCreate { + inner: Arc::new(freshell_freshagent::claude_rest_lane::ClaudeRestLane::new( + rig.fresh_claude.clone(), + Arc::new(|| true), + )), + started: Default::default(), + finish: Default::default(), + receipt: Default::default(), + }); + let state = freshell_freshagent::FreshAgentState::new( + Arc::new(common::AUTH_TOKEN.into()), + Arc::new(tokio::sync::broadcast::channel(64).0), + ); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = freshell_freshagent::router(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let (shutdown, stopped) = tokio::sync::oneshot::channel(); + let server = tokio::spawn(async move { + axum::serve(listener, router) + .with_graceful_shutdown(async { + let _ = stopped.await; + }) + .await + .unwrap() + }); + let create_base = base.clone(); + let mut body = json!({"agent":"claude", "cwd":rig.workspace.to_str().unwrap()}); + if let Some(id) = existing.as_deref() { + body["sessionRef"] = json!({"provider":"claude", "sessionId":id}); + } + let creating = tokio::spawn(async move { + ClaudeRig::request_json_to("POST", &create_base, "/api/tabs", &body).await + }); + tokio::time::timeout(RIG_FRAME_BUDGET, lane.started.notified()) + .await + .unwrap(); + let (request, receipt) = lane.receipt.lock().unwrap().clone().unwrap(); + assert_eq!( + receipt.cleanup_token.is_none(), + adopt_existing, + "only a newly started runtime belongs to cancellation" + ); + let processes = rig.runtime_processes(&receipt.session_id); + // This is the isolated rig's actual close journal. A regular file at the + // directory path causes a real, root-independent atomic-write failure. + let journal = rig + .workspace + .parent() + .unwrap() + .join("pane-ledger/close-envelopes"); + let saved_journal = journal.with_extension("saved"); + let had_journal = journal.exists(); + if matches!(case, CancelRealCreate::CloseWriteFailure) { + if had_journal { + std::fs::rename(&journal, &saved_journal).unwrap(); + } + std::fs::write(&journal, b"injected close-journal failure").unwrap(); + } + let (status, closed) = ClaudeRig::request_json_to( + "DELETE", + &base, + &format!("/api/tabs/{}", request.tab_id.as_deref().unwrap()), + &json!({}), + ) + .await; + assert_eq!(status, 200, "{closed}"); + lane.finish.notify_one(); + let (status, cancelled) = creating.await.unwrap(); + if matches!(case, CancelRealCreate::CloseWriteFailure) { + assert_eq!(status, 503, "{cancelled}"); + assert_eq!(cancelled["code"], "CLAUDE_CLEANUP_UNCONFIRMED"); + assert_eq!(cancelled["retryable"], true); + assert!( + rig.fresh_claude.has_live_session(&receipt.session_id).await, + "an uncommitted close must not claim the writer was stopped" + ); + std::fs::remove_file(&journal).unwrap(); + if had_journal { + std::fs::rename(saved_journal, &journal).unwrap(); + } + lane.inner.abandon_create(&request, &receipt).await.unwrap(); + } else { + assert_eq!(status, 409, "{cancelled}"); + assert_eq!(cancelled["code"], "PANE_CREATE_CANCELLED"); + } + if adopt_existing { + assert!(rig.fresh_claude.has_live_session(&receipt.session_id).await); + browser + .send_text( + &receipt.session_id, + "still-owned-by-existing-pane", + "the existing writer survives the cancelled adoption", + ) + .await; + browser.wait_for_event("freshAgent.result").await; + assert_eq!(rig.query_options().len(), 1); + } else { + processes + .wait_until_exited("the cancelled REST create's owned runtime") + .await; + assert!(!rig.fresh_claude.has_live_session(&receipt.session_id).await); + } + rig.fresh_claude.shutdown().await; + let _ = shutdown.send(()); + server.await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn closing_a_late_rest_create_reaps_its_real_sidecar_and_cli() { + close_after_real_create(CancelRealCreate::Owned).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn closing_a_late_rest_adoption_keeps_the_existing_real_writer_alive() { + close_after_real_create(CancelRealCreate::Adopted).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn closing_a_late_rest_create_reports_failed_cleanup_and_can_retry_after_repair() { + close_after_real_create(CancelRealCreate::CloseWriteFailure).await; +} + async fn get(rig: &ClaudeRig, path: &str) -> (u16, String) { let host = rig.http_url.strip_prefix("http://").unwrap(); let mut stream = tokio::net::TcpStream::connect(host).await.unwrap(); From e8e4eea57e9d2acb6a7e24302775a0f41d551f69 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:20:24 -0700 Subject: [PATCH 048/100] test(e2e): surface Claude approval diagnostics in failures --- test/e2e-browser/specs/fresh-agent-control-rust.spec.ts | 6 +++--- .../specs/fresh-agent-hidden-approval-reveal.spec.ts | 7 +++---- 2 files changed, 6 insertions(+), 7 deletions(-) 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 66e4a5344..6646de4f9 100644 --- a/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts @@ -551,7 +551,7 @@ async function raisePermissionAndAssertNoDecisions( } catch (error) { const rows = readClaudeSimLedger(simLedger) const received = (await harness.getReceivedWsMessages()) as any[] - console.error(JSON.stringify({ + const diagnostic = { severity: 'error', event: 'e2e.claude_approval_card_diagnostic', queryPermissionModes: rows.filter((row) => row.kind === 'query') @@ -565,8 +565,8 @@ async function raisePermissionAndAssertNoDecisions( .filter((message) => message?.type === 'freshAgent.event') .map((message) => ({ type: message.event?.type, code: message.event?.code })) .slice(-30), - })) - throw error + } + throw new Error(`${error instanceof Error ? error.message : String(error)}\n${JSON.stringify(diagnostic)}`) } // AGENT-05 pause-proof: the CLI has received zero decisions — the card came // from the snapshot overlay, not from any fabricated user choice. diff --git a/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts b/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts index 7019d9bb1..24466dd66 100644 --- a/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts @@ -277,7 +277,7 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without } catch (error) { const rows = readClaudeSimLedger(simLedger) const received = (await harness.getReceivedWsMessages()) as any[] - console.error(JSON.stringify({ + const diagnostic = { severity: 'error', event: 'e2e.claude_hidden_approval_diagnostic', queryPermissionModes: rows.filter((row) => row.kind === 'query') @@ -291,9 +291,8 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without .filter((message) => message?.type === 'freshAgent.event') .map((message) => ({ type: message.event?.type, code: message.event?.code })) .slice(-30), - serverOutput: server?.capturedOutput(), - })) - throw error + } + throw new Error(`${error instanceof Error ? error.message : String(error)}\n${JSON.stringify(diagnostic)}`) } expect(await harness.getActiveTabId(), 'the approval arrived while tab A was hidden').toBe(tabB) From 20758113fe5d864a80d6ffed32ef80783f2bfc06 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:24:11 -0700 Subject: [PATCH 049/100] refactor(agent-api): simplify pending publication close retention --- crates/freshell-freshagent/src/layout_store.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/freshell-freshagent/src/layout_store.rs b/crates/freshell-freshagent/src/layout_store.rs index 981b44be7..8008cf29f 100644 --- a/crates/freshell-freshagent/src/layout_store.rs +++ b/crates/freshell-freshagent/src/layout_store.rs @@ -1385,9 +1385,13 @@ impl LayoutStore { // b8ke ext r25 F2: a closed pane's recovery stamp is dead // weight — the pane no longer exists to protect. inner.recovery_stamps.remove(pane_id); - inner - .pending_publications - .retain(|entry| entry.pane_id != pane_id && !(!entry.published && matches!(&entry.placement, publication::Placement::Split { source_pane_id, .. } if source_pane_id == pane_id))); + inner.pending_publications.retain(|entry| { + entry.pane_id != pane_id + && (entry.published + || !matches!(&entry.placement, + publication::Placement::Split { source_pane_id, .. } + if source_pane_id == pane_id)) + }); persist_locked(&inner); } first.unwrap_or(Err("pane not found")) From 9749ceef513080a353d8bac5433359561da67813 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:35:33 -0700 Subject: [PATCH 050/100] fix(agent-api): announce finalized creates before concurrent closes --- .../src/layout_store/publication.rs | 11 +- crates/freshell-freshagent/src/lib.rs | 73 +++---- crates/freshell-freshagent/src/pane_ops.rs | 129 ++++++++----- .../src/rest_lane_publication_tests.rs | 178 ++++++++++++++++++ 4 files changed, 308 insertions(+), 83 deletions(-) diff --git a/crates/freshell-freshagent/src/layout_store/publication.rs b/crates/freshell-freshagent/src/layout_store/publication.rs index 39d592c40..33daae715 100644 --- a/crates/freshell-freshagent/src/layout_store/publication.rs +++ b/crates/freshell-freshagent/src/layout_store/publication.rs @@ -110,7 +110,15 @@ impl LayoutStore { true } - pub(crate) fn finish_pending_pane(&self, tab_id: &str, pane_id: &str, content: Value) -> bool { + /// Register caches and queue the announcement before a close can acquire + /// the layout lock. The callback must stay synchronous and not reenter us. + pub(crate) fn finish_pending_pane( + &self, + tab_id: &str, + pane_id: &str, + content: Value, + publish: impl FnOnce(), + ) -> bool { let mut inner = self.lock(); if !inner .pending_publications @@ -137,6 +145,7 @@ impl LayoutStore { { entry.published = true; } + publish(); true } diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index 0457ef808..c6b924988 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -5091,45 +5091,38 @@ async fn create_lane_agent_tab( } }; let pane_content = rest_lane_create::pane_content(&request, &created); - if !state - .layout - .finish_pending_pane(&tab_id, &pane_id, pane_content.clone()) - { + let published = + state + .layout + .finish_pending_pane(&tab_id, &pane_id, pane_content.clone(), || { + register_fresh_agent_tab_cache( + state, + &tab_id, + &pane_id, + name.as_deref(), + PaneEntry { + placeholder_id: created.session_id.clone(), + provider: request.provider.clone(), + session_type: request.session_type.clone(), + cwd: request.cwd.clone(), + model: request.model.clone(), + effort: request.effort.clone(), + durable_id: Some( + created + .session_ref + .as_ref() + .map(|reference| reference.session_id.clone()) + .unwrap_or_else(|| created.session_id.clone()), + ), + }, + ); + broadcast_tab_create(state, &tab_id, &pane_id, name.as_deref(), &pane_content); + }); + if !published { return state .abandon_rest_create(&gateway, &request, &created) .await; } - let rest_lane::RestLaneCreate { - provider, - session_type, - cwd, - model, - effort, - .. - } = request; - register_fresh_agent_tab( - state, - &tab_id, - &pane_id, - name.as_deref(), - &pane_content, - PaneEntry { - placeholder_id: created.session_id.clone(), - provider, - session_type, - cwd, - model, - effort, - durable_id: Some( - created - .session_ref - .as_ref() - .map(|reference| reference.session_id.clone()) - .unwrap_or_else(|| created.session_id.clone()), - ), - }, - ); - broadcast_tab_create(state, &tab_id, &pane_id, name.as_deref(), &pane_content); let mut data = json!({"tabId":tab_id,"paneId":pane_id,"sessionId":created.session_id}); if let Some(reference) = created.session_ref { data["sessionRef"] = json!(reference); @@ -5153,6 +5146,16 @@ fn register_fresh_agent_tab( state .layout .attach_pane_content(tab_id, pane_id, pane_content.clone()); + register_fresh_agent_tab_cache(state, tab_id, pane_id, name, pane_entry); +} + +fn register_fresh_agent_tab_cache( + state: &FreshAgentState, + tab_id: &str, + pane_id: &str, + name: Option<&str>, + pane_entry: PaneEntry, +) { state.tabs.lock().expect("tabs mutex").insert( tab_id.to_string(), TabRecord { diff --git a/crates/freshell-freshagent/src/pane_ops.rs b/crates/freshell-freshagent/src/pane_ops.rs index 2a9e8c0e7..6a3b324a1 100644 --- a/crates/freshell-freshagent/src/pane_ops.rs +++ b/crates/freshell-freshagent/src/pane_ops.rs @@ -229,32 +229,51 @@ pub(crate) async fn split_pane( } }; let content = crate::rest_lane_create::pane_content(&request, &created); - if !state - .layout - .finish_pending_pane(&tab_id, &new_pane_id, content.clone()) - { + let published = + state + .layout + .finish_pending_pane(&tab_id, &new_pane_id, content.clone(), || { + state.panes.lock().expect("panes mutex").insert( + new_pane_id.clone(), + PaneEntry { + placeholder_id: created.session_id.clone(), + provider: provider.into(), + session_type: session_type.into(), + cwd: request.cwd.clone(), + model: request.model.clone(), + effort: request.effort.clone(), + durable_id: Some( + created + .session_ref + .as_ref() + .map(|reference| reference.session_id.clone()) + .unwrap_or_else(|| created.session_id.clone()), + ), + }, + ); + state + .pane_tabs + .lock() + .expect("pane_tabs mutex") + .insert(new_pane_id.clone(), tab_id.clone()); + broadcast_pane_split( + &state, + &tab_id, + &pane_id, + &direction, + &new_pane_id, + &content, + ); + }); + if !published { return state .abandon_rest_create(&gateway, &request, &created) .await; } - state.panes.lock().expect("panes mutex").insert( - new_pane_id.clone(), - PaneEntry { - placeholder_id: created.session_id.clone(), - provider: provider.into(), - session_type: session_type.into(), - cwd: request.cwd, - model: request.model, - effort: request.effort, - durable_id: Some( - created - .session_ref - .map(|reference| reference.session_id) - .unwrap_or(created.session_id), - ), - }, + return ok_json( + json!({ "paneId": new_pane_id, "terminalId": Value::Null }), + "pane split (non-terminal)", ); - content } else if body .get("hostStats") .and_then(Value::as_bool) @@ -321,8 +340,35 @@ pub(crate) async fn split_pane( let terminal_id = new_content.get("terminalId").cloned(); - // `ui.command{pane.split}` payload (`router.ts:1373-1382`): tabId, paneId - // (the SOURCE pane), direction, newPaneId, newContent. + broadcast_pane_split( + &state, + &tab_id, + &pane_id, + &direction, + &new_pane_id, + &new_content, + ); + + let message = if terminal_id.is_some() { + "pane split" + } else { + "pane split (non-terminal)" + }; + ok_json( + json!({ "paneId": new_pane_id, "terminalId": terminal_id }), + message, + ) +} + +fn broadcast_pane_split( + state: &FreshAgentState, + tab_id: &str, + pane_id: &str, + direction: &str, + new_pane_id: &str, + new_content: &Value, +) { + // The source pane and newly allocated pane remain distinct on the wire. state.broadcast(&ServerMessage::UiCommand(UiCommand { command: "pane.split".to_string(), payload: Some(json!({ @@ -333,16 +379,18 @@ pub(crate) async fn split_pane( "newContent": new_content, })), })); +} - let message = if terminal_id.is_some() { - "pane split" - } else { - "pane split (non-terminal)" - }; - ok_json( - json!({ "paneId": new_pane_id, "terminalId": terminal_id }), - message, - ) +fn pane_tab_from_cache_or_layout(state: &FreshAgentState, pane_id: &str) -> Option { + // Publication takes layout before these caches, so release this guard + // before the fallback acquires layout. + let cached = state + .pane_tabs + .lock() + .expect("pane_tabs mutex") + .get(pane_id) + .cloned(); + cached.or_else(|| state.layout.find_pane_tab(pane_id)) } // ── POST /api/panes/:id/close ────────────────────────────────────────────── @@ -902,13 +950,7 @@ pub(crate) async fn respawn_pane( ); } - let tab_id = state - .pane_tabs - .lock() - .expect("pane_tabs mutex") - .get(&pane_id) - .cloned() - .or_else(|| state.layout.find_pane_tab(&pane_id)); + let tab_id = pane_tab_from_cache_or_layout(&state, &pane_id); // Round-1 review: an authoritative registry misses only when no connected // browser has synced the pane -- ask the owner client to re-sync before // giving up (bounded window, above the mirror's debounce). @@ -1046,14 +1088,7 @@ pub(crate) async fn attach_pane( }; // The pane resolves through the same authoritative registry // respawn uses (`pane_tabs` first, then the durable LayoutStore). - let Some(tab_id) = state - .pane_tabs - .lock() - .expect("pane_tabs mutex") - .get(&pane_id) - .cloned() - .or_else(|| state.layout.find_pane_tab(&pane_id)) - else { + let Some(tab_id) = pane_tab_from_cache_or_layout(&state, &pane_id) else { return crate::fail_json_code( StatusCode::NOT_FOUND, "PANE_NOT_FOUND", diff --git a/crates/freshell-freshagent/src/rest_lane_publication_tests.rs b/crates/freshell-freshagent/src/rest_lane_publication_tests.rs index 549be2298..6cc1cf8e5 100644 --- a/crates/freshell-freshagent/src/rest_lane_publication_tests.rs +++ b/crates/freshell-freshagent/src/rest_lane_publication_tests.rs @@ -173,6 +173,184 @@ async fn a_pane_closed_ack_before_runtime_create_returns_cancels_its_publication close_during_create(CloseDuringCreate::PendingPane).await; } +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_close_after_layout_finalization_cannot_announce_before_the_create() { + let state = state_with_registry(); + let lane = Arc::new(PausedCreateLane::default()); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state.clone()); + let mut events = state.broadcast_tx.subscribe(); + let create_router = router.clone(); + let creating = tokio::spawn(async move { + post(create_router, "/api/tabs", json!({"agent":"claude"}), true).await + }); + lane.started.notified().await; + let request = lane.request.lock().unwrap().clone().unwrap(); + let tab_id = request.tab_id.clone().unwrap(); + + // Block the existing cache insertion after the layout has been finalized. + // DELETE does not need this cache lock to close and announce the tab. + let held_state = state.clone(); + let (locked, ready) = tokio::sync::oneshot::channel(); + let (release, released) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + let _cache = held_state.panes.lock().unwrap(); + locked.send(()).unwrap(); + released.recv().unwrap(); + }); + ready.await.unwrap(); + lane.finish.notify_one(); + tokio::time::timeout(std::time::Duration::from_secs(5), async { + while !state.tabs.lock().unwrap().contains_key(&tab_id) { + tokio::task::yield_now().await; + } + }) + .await + .expect("the create reached cache registration"); + let close_tab = tab_id.clone(); + let mut closing = + tokio::spawn(async move { delete(router, &format!("/api/tabs/{close_tab}"), true).await }); + // While registration is blocked, a properly serialized close must wait. + let early_close = + tokio::time::timeout(std::time::Duration::from_millis(100), &mut closing).await; + release.send(()).unwrap(); + holder.join().unwrap(); + let (created_status, created) = creating.await.unwrap(); + assert_eq!(created_status, StatusCode::OK, "{created}"); + let (closed_status, closed) = match early_close { + Ok(closed) => closed.unwrap(), + Err(_) => closing.await.unwrap(), + }; + assert_eq!(closed_status, StatusCode::OK, "{closed}"); + let mut commands = Vec::new(); + while let Ok(frame) = events.try_recv() { + let frame: serde_json::Value = serde_json::from_str(&frame).unwrap(); + if let Some(command) = frame["command"].as_str() { + commands.push(command.to_string()); + } + } + assert_eq!(commands, ["tab.create", "tab.close"]); + assert!(!state.tabs.lock().unwrap().contains_key(&tab_id)); + assert!(state + .layout + .get_pane_snapshot(request.pane_id.as_deref().unwrap()) + .is_none()); +} + +async fn split_finalization_and_close_are_ordered(close_source: bool) { + let state = state_with_registry(); + let before = serde_json::from_value(json!({ + "tabs":[{"id":"current-tab"}], "activeTabId":"current-tab", "timestamp":1, + "layouts":{"current-tab":{"type":"leaf", "id":"source-pane", "content":{"kind":"browser", "url":"https://source.example"}}}, + "activePane":{"current-tab":"source-pane"}, + })).unwrap(); + state.layout.update_from_ui(&before, "creating-browser"); + let lane = Arc::new(PausedCreateLane::default()); + state.set_claude_rest_lane(lane.clone()).unwrap(); + let router = app(state.clone()); + let mut events = state.broadcast_tx.subscribe(); + let create_router = router.clone(); + let creating = tokio::spawn(async move { + post( + create_router, + "/api/panes/source-pane/split", + json!({"agent":"claude"}), + true, + ) + .await + }); + lane.started.notified().await; + let request = lane.request.lock().unwrap().clone().unwrap(); + let new_pane = request.pane_id.clone().unwrap(); + let held_state = state.clone(); + let (locked, ready) = tokio::sync::oneshot::channel(); + let (release, released) = std::sync::mpsc::channel(); + let holder = std::thread::spawn(move || { + let _cache = held_state.panes.lock().unwrap(); + locked.send(()).unwrap(); + released.recv().unwrap(); + }); + ready.await.unwrap(); + lane.finish.notify_one(); + + // On the broken path, the finalized content is observable while cache + // insertion is blocked. Atomic publication instead keeps this reader and + // the close behind the layout lock until the announcement is queued. + let observed_state = state.clone(); + let observed_pane = new_pane.clone(); + let stopping = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let observer_stopping = stopping.clone(); + let (finalized, observed) = tokio::sync::oneshot::channel(); + let observer = std::thread::spawn(move || { + while !observer_stopping.load(std::sync::atomic::Ordering::SeqCst) { + if observed_state + .layout + .get_pane_snapshot(&observed_pane) + .and_then(|pane| pane.pane_content) + .is_some_and(|content| content["sessionId"] == "late-runtime") + { + let _ = finalized.send(()); + return; + } + std::thread::yield_now(); + } + }); + let _ = tokio::time::timeout(std::time::Duration::from_millis(100), observed).await; + let close_pane = if close_source { + "source-pane".to_string() + } else { + new_pane.clone() + }; + let mut closing = tokio::spawn(async move { + post( + router, + &format!("/api/panes/{close_pane}/close"), + json!({}), + true, + ) + .await + }); + let early_close = + tokio::time::timeout(std::time::Duration::from_millis(100), &mut closing).await; + stopping.store(true, std::sync::atomic::Ordering::SeqCst); + release.send(()).unwrap(); + holder.join().unwrap(); + observer.join().unwrap(); + let (created_status, created) = creating.await.unwrap(); + assert_eq!(created_status, StatusCode::OK, "{created}"); + let (closed_status, closed) = match early_close { + Ok(closed) => closed.unwrap(), + Err(_) => closing.await.unwrap(), + }; + assert_eq!(closed_status, StatusCode::OK, "{closed}"); + let mut commands = Vec::new(); + while let Ok(frame) = events.try_recv() { + let frame: serde_json::Value = serde_json::from_str(&frame).unwrap(); + if let Some(command) = frame["command"].as_str() { + commands.push(command.to_string()); + } + } + assert_eq!(commands, ["pane.split", "pane.close"]); + assert_eq!( + state.layout.get_pane_snapshot(&new_pane).is_some(), + close_source + ); + assert_eq!( + state.layout.get_pane_snapshot("source-pane").is_some(), + !close_source + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn closing_a_finalized_split_cannot_announce_before_the_split() { + split_finalization_and_close_are_ordered(false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn closing_a_finalized_split_source_cannot_announce_before_the_split() { + split_finalization_and_close_are_ordered(true).await; +} + struct SyncDuringCreateLane { layout: crate::layout_store::LayoutStore, browser_sync: freshell_protocol::UiLayoutSync, From 70adfa432d7d4d6df83c076b9a01b61ae1056e2c Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:35:44 -0700 Subject: [PATCH 051/100] refactor(freshclaude): persist settings without repeating adoption --- crates/freshell-freshagent/src/claude.rs | 113 ++++------ .../freshell-freshagent/src/claude/binding.rs | 204 ++++++++++++++++++ .../src/claude/create_binding.rs | 100 --------- .../src/claude/establish.rs | 15 +- .../src/claude/tests/recovery_tests.rs | 1 - .../tests/settings_persistence_tests.rs | 151 +++++++++++++ 6 files changed, 411 insertions(+), 173 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude/binding.rs delete mode 100644 crates/freshell-freshagent/src/claude/create_binding.rs create mode 100644 crates/freshell-freshagent/src/claude/tests/settings_persistence_tests.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 0d6579f90..b9e32d656 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -638,6 +638,7 @@ struct ClaudeSession { /// still-vacant canonical key, a foreign start) fails CLOSED — the /// freshly spawned runtime is torn down, never left running unowned /// while the other operation believes there was no prior owner. + #[cfg(test)] owning_operation: Option, /// b8ke ext r27 F2: the GENERATION of the lifecycle op that owns this /// runtime's coordinator transition (the handoff runner's under-ticket @@ -769,6 +770,7 @@ pub struct AdoptionTestPause { /// unreachable live runtime. `Deferred` — the session is not yet /// registered; the publication lands post-registration and the completion /// hook carries the final answer. +#[cfg(test)] enum SessionInitAdoptionOutcome { Published, Abandoned, @@ -4456,10 +4458,7 @@ impl FreshClaudeState { // device's model surfaces — the same frame `handle_configure` // emits (a device that missed the configure still lands here). let settings_before = self.stated_settings(&map_key).await; - if let Err(err) = self - .configure_for_send(&map_key, settings, session_type) - .await - { + if let Err(err) = self.configure_for_send(&map_key, settings).await { return self.refuse_send(&request_id, "CLAUDE_SETTINGS_FAILED", &err); } let settings_after = self.stated_settings(&map_key).await; @@ -4621,7 +4620,6 @@ impl FreshClaudeState { &self, map_key: &str, requested: &freshell_protocol::FreshAgentSendSettings, - session_type: &str, ) -> Result<(), String> { let request_id = uuid::Uuid::new_v4().to_string(); let (response, next, writer, frame, ownership_id) = { @@ -4682,44 +4680,25 @@ impl FreshClaudeState { .into(), ), }; - let (durable, effective) = { + { let mut sessions = self.sessions.lock().await; let session = sessions - .get_mut(map_key) - .ok_or("Claude session is no longer available.")?; - if session.ownership_id != ownership_id { - return Err( - "Claude changed while applying settings. Your message was not sent; try again." - .into(), - ); - } + .values_mut() + .find(|session| session.ownership_id == ownership_id) + .ok_or( + "Claude changed while applying settings. Your message was not sent; try again.", + )?; session.configuration.pending = None; session.configuration.needs_configure = applied.is_err(); if applied.is_ok() { session.configuration.settings = next; } - // A setter can succeed before a later setter rejects. The receipt - // reconciles that partial success; persist the actual runtime state - // even though the user's prompt must remain unsent. - ( - session.cli_session_id.clone(), - session.configuration.settings.clone(), - ) - }; - if let Some(durable) = durable { - self.adopt_session_init( - &durable, - map_key, - session_type, - Some(&effective), - 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 - // stamps (ep4 writer rules). - None, - ) - .await; } + // A partial setter success is actual runtime state even when a later + // setter rejects. Save it without repeating identity establishment. + self.persist_runtime_settings(&ownership_id) + .await + .map_err(|message| format!("{message} Your message was not sent."))?; applied } @@ -4791,10 +4770,7 @@ impl FreshClaudeState { let map_key = live_key; let before = self.stated_settings(&map_key).await; - if let Err(err) = self - .configure_for_send(&map_key, &msg.settings, session_type) - .await - { + if let Err(err) = self.configure_for_send(&map_key, &msg.settings).await { // The refusal/failure is user-facing session state (busy turn, // sidecar death): the pane banner is the surface, on every device. self.emit_control_error( @@ -5650,6 +5626,7 @@ impl FreshClaudeState { /// 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. + #[cfg(test)] async fn adopt_session_init( &self, cli_id: &str, @@ -5742,6 +5719,7 @@ impl FreshClaudeState { /// (its own failure path killed the child): nothing adopted, nothing /// committed. #[allow(clippy::too_many_arguments)] // the adoption field set + the completion channel + #[cfg(test)] fn defer_session_init_adoption( &self, cli_id: &str, @@ -5807,6 +5785,7 @@ impl FreshClaudeState { /// remove the registered session, run the standard typed teardown, /// evict its placeholder aliases, and engage the client's /// lost-session recovery. + #[cfg(test)] async fn teardown_unadopted_session(&self, session_id: &str, session_type: &str) { tracing::error!(target: "invariant", placeholder = %session_id, @@ -5857,6 +5836,7 @@ impl FreshClaudeState { /// captured pid of a KILLED runtime (snapshots then reported the dead /// runtime as the authoritative owner). Missing/incarnation-changed → /// `false`: no alias, no commit — the kill owns the session. + #[cfg(test)] async fn publish_session_init_alias( &self, cli_id: &str, @@ -5911,6 +5891,7 @@ impl FreshClaudeState { } #[allow(clippy::too_many_arguments)] // the adoption field set (the deferral adds the re-entry flag) + #[cfg(test)] async fn adopt_session_init_inner( &self, cli_id: &str, @@ -6321,13 +6302,8 @@ impl FreshClaudeState { /// eviction so a stale consumer can never evict a newer session re-registered under /// the same map key (attach-resume, Task 6). /// - /// `settings` (P1.13): `Some` = record a fresh-agent binding row at - /// `sdk.session.init` (create path / resume-with-record); `None` = do NOT record - /// (resume of a never-recorded session — writing would launder a blank row under - /// the new cliSessionId, V7/A10). The row's `mode` is the `session_type` param - /// (the `"freshclaude"` vs `"kilroy"` flavour; provider is always [`PROVIDER`]). - /// `broadcast_id` (Task 10b): the shared envelope-stamp handle read PER EVENT -- - /// starts as the map key; an attach-by-durable rebind flips it to the durable id. + /// The shared current UUID follows native conversation resets. Attaching + /// another observer never changes it or repeats identity establishment. /// `pending` (Task 2): the shared pending approval/question handle the consumer /// folds `sdk.permission.*`/`sdk.question.*` lines into BEFORE the normalize/ /// broadcast step (so a respond racing the event never sees stale membership). @@ -6340,7 +6316,7 @@ impl FreshClaudeState { /// ledger's verdict on each result says whether it rings, and every edge /// (a result, a waiting edge, a death while a turn was owed) leaves through /// the edge hub on the session's `clock`. - #[allow(clippy::too_many_arguments)] // Session-scoped wiring handed to the detached consumer; four call sites. + #[allow(clippy::too_many_arguments)] // Session-scoped wiring handed to the detached consumer. fn spawn_consumer( &self, mut reader: tokio::io::Lines>, @@ -6360,7 +6336,6 @@ impl FreshClaudeState { ) -> tokio::task::JoinHandle<()> { let broadcast_tx = self.broadcast_tx.clone(); let sessions = self.sessions.clone(); - let identity_sink = self.identity_sink(); let state = self.clone(); tokio::spawn(async move { let mut session_id = session_id; @@ -6497,28 +6472,29 @@ impl FreshClaudeState { // boots the conversation in the mode the CLI is in. Some("sdk.session.metadata") => { if let Some(mode) = value.get("permissionMode").and_then(Value::as_str) { - let adopted = - sessions.lock().await.get_mut(&session_id).map(|session| { + let changed = sessions + .lock() + .await + .get_mut(&session_id) + .filter(|session| session.ownership_id == ownership_id) + .map(|session| { session.configuration.settings.permission_mode = Some(mode.to_string()); - ( - session.cli_session_id.clone(), - session.configuration.settings.clone(), - ) - }); - if let Some((Some(durable), settings)) = adopted { - state - .adopt_session_init( - &durable, - &session_id, + }) + .is_some(); + if changed { + if let Err(message) = + state.persist_runtime_settings(&ownership_id).await + { + let stamp = + broadcast_id.lock().expect("broadcast id lock").clone(); + state.emit_fresh_agent_error( + &stamp, &session_type, - Some(&settings), - identity_sink.clone(), - // Conn-less, like a settings apply: the - // ledger merge keeps the row's stamps. - None, - ) - .await; + "SETTINGS_PERSIST_FAILED", + &message, + ); + } } } } @@ -7955,7 +7931,7 @@ fn log_claude_teardown( } } -mod create_binding; +mod binding; mod create_outcome; mod deadman; mod recovery; @@ -8031,6 +8007,7 @@ pub(crate) mod tests { mod recovery_stop_tests; mod recovery_tests; mod refusal_tests; + mod settings_persistence_tests; fn state() -> FreshClaudeState { let (tx, _rx) = tokio::sync::broadcast::channel::(64); diff --git a/crates/freshell-freshagent/src/claude/binding.rs b/crates/freshell-freshagent/src/claude/binding.rs new file mode 100644 index 000000000..e27e8de84 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/binding.rs @@ -0,0 +1,204 @@ +//! Establishment binds recoverable identity before its acknowledgement. +//! Later settings updates preserve that identity without claiming or adopting +//! another conversation. Fresh genesis covers the zero-turn transcript window. + +use super::*; + +impl FreshClaudeState { + pub(super) async fn record_established_binding( + &self, + session_id: &str, + session_type: &str, + create_request_id: Option<&str>, + provenance: Option<&crate::BindProvenance>, + ticket: &Option, + ) -> std::io::Result<()> { + let Some(sink) = self.identity_sink() else { + return Ok(()); + }; + let (settings, genesis, owning_generation) = { + let sessions = self.sessions.lock().await; + let session = sessions.get(session_id).ok_or_else(|| { + std::io::Error::other("Claude stopped while recording its create identity") + })?; + ( + session.configuration.settings.clone(), + session.launch_genesis.clone(), + session.owning_generation, + ) + }; + let generation = ticket + .as_ref() + .map(|ticket| ticket.generation()) + .or(owning_generation); + sink.record_binding(crate::identity_sink::FreshAgentBindingUpsert { + provider: PROVIDER.into(), + session_id: session_id.into(), + mode: session_type.into(), + create_request_id: create_request_id.map(str::to_string), + resolves_pending: None, + supersedes: None, + name_transition: None, + genesis: genesis.map_or(crate::identity_sink::GenesisUpdate::Keep, |recorded| { + crate::identity_sink::GenesisUpdate::Set(recorded.genesis) + }), + provenance: provenance.cloned().into(), + observed_epoch: generation.and( + self.ownership + .as_ref() + .map(|registry| registry.boot_epoch()), + ), + observed_generation: generation, + authoritative: ticket.is_none() && owning_generation.is_some(), + settings, + }) + .await + } + + /// The persisted binding/genesis verifies durable routing independently of + /// native transcript flush. Naming can bind to this identity at create. + pub(super) async fn bind_create_name( + &self, + session_id: &str, + session_type: &str, + naming_handle: Option<&str>, + ) -> Option<( + freshell_protocol::session_names::SessionNameRef, + freshell_protocol::session_names::SessionNameRecord, + )> { + if session_type == "kilroy" + || !self.identity_sink().is_some_and(|sink| { + sink.load_genesis(PROVIDER, session_id).is_some() + || sink.was_recorded(PROVIDER, session_id) + }) + { + return None; + } + let handle = naming_handle?.trim(); + if handle.is_empty() { + return None; + } + let target = freshell_protocol::session_names::SessionNameRef::Session { + provider: freshell_protocol::session_names::NamedProvider::Claude, + session_id: session_id.into(), + }; + let transition = crate::naming::NameTransition::binding( + crate::naming::NameTransitionReason::InitialMaterialization, + freshell_protocol::native_location::NativeAcquisition { + location: freshell_protocol::native_location::NativeLocation::Claude { + config_root: self.transcript_roots().first()?.display().to_string(), + transcript_path: None, + project_directory_key: None, + transcript_cwd: None, + effective_project_key_override: None, + }, + evidence: freshell_protocol::native_location::NativeEvidenceKind::PersistedMetadata, + persistence: freshell_protocol::native_location::NativePersistence::Verified, + }, + handle.to_string(), + ); + let update = + crate::naming::fold_identity_transition(&self.naming(), &transition, target.clone()) + .await?; + Some((update.record.name_ref.clone(), update.record)) + } + + pub(super) async fn persist_runtime_settings(&self, ownership_id: &str) -> Result<(), String> { + if let Err(error) = self.write_runtime_settings(ownership_id).await { + // The SDK may already have applied some setters. Retain that + // actual state and retry persistence before a later prompt. + if let Some(session) = self + .sessions + .lock() + .await + .values_mut() + .find(|session| session.ownership_id == ownership_id) + { + session.configuration.needs_configure = true; + } + tracing::error!(target: "freshell_freshagent::claude", + event = "freshagent.claude.settings_persist_failed", + provider = PROVIDER, ownership_id, cause = %error, + "the runtime retained its settings; persistence must be retried"); + return Err("Claude could not save the session settings. Try again.".into()); + } + Ok(()) + } + + async fn write_runtime_settings(&self, ownership_id: &str) -> std::io::Result<()> { + let Some(sink) = self.identity_sink() else { + return Ok(()); + }; + let (session_id, session_type, settings, stamp, _guard) = { + let sessions = self.sessions.lock().await; + // The receipt's process tag survives a native reset. Resolve the + // current UUID from that exact runtime after the control await. + let (id, session) = sessions + .iter() + .find(|(_, session)| session.ownership_id == ownership_id) + .ok_or_else(|| std::io::Error::other("the configured runtime ended"))?; + let stamp = match &self.ownership { + Some(_) => Some( + crate::ownership_lane::peek_retained_stamp(&self.ownership_stamps, id) + .filter(|stamp| { + stamp.owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent + && stamp.owner.live_session_key.as_deref() == Some(id.as_str()) + && stamp.owner.pid == session.child.id() + }) + .ok_or_else(|| { + std::io::Error::other("the configured runtime's ownership changed") + })?, + ), + None => None, + }; + let observed = stamp + .as_ref() + .map(|stamp| freshell_ownership::ObservedFence { + epoch: stamp.epoch, + generation: stamp.generation, + }); + let guard = match crate::ownership_lane::arm_reclaimless_op_guard( + &self.ownership, + PROVIDER, + id, + &format!("claude-settings-{}", uuid::Uuid::new_v4()), + observed, + "claude/settings-persist", + ) { + crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), + crate::ownership_lane::LaneOpGuard::Unwired => None, + crate::ownership_lane::LaneOpGuard::Refused { message, .. } => { + return Err(std::io::Error::other(message)); + } + }; + ( + id.clone(), + session.session_type.clone(), + session.configuration.settings.clone(), + stamp, + guard, + ) + }; + sink.record_binding(crate::identity_sink::FreshAgentBindingUpsert { + provider: PROVIDER.into(), + session_id: session_id.clone(), + mode: session_type.clone(), + create_request_id: None, + resolves_pending: None, + supersedes: None, + name_transition: None, + genesis: crate::identity_sink::GenesisUpdate::Keep, + provenance: crate::identity_sink::ProvenanceUpdate::Inherit, + observed_epoch: stamp.as_ref().map(|stamp| stamp.epoch), + observed_generation: stamp.as_ref().map(|stamp| stamp.generation), + authoritative: false, + settings, + }) + .await?; + tracing::debug!(target: "freshell_freshagent::claude", + event = "freshagent.claude.settings_persisted", provider = PROVIDER, + session_id, session_type, ownership_id, + "the established conversation's actual settings were saved"); + Ok(()) + } +} diff --git a/crates/freshell-freshagent/src/claude/create_binding.rs b/crates/freshell-freshagent/src/claude/create_binding.rs deleted file mode 100644 index 49a82c4de..000000000 --- a/crates/freshell-freshagent/src/claude/create_binding.rs +++ /dev/null @@ -1,100 +0,0 @@ -//! A create establishes recoverable identity before its acknowledgement. The -//! native transcript may not exist until the first prompt; Fresh genesis and -//! the create-request lineage make that zero-turn identity durable meanwhile. - -use super::*; - -impl FreshClaudeState { - pub(super) async fn record_create_binding( - &self, - session_id: &str, - msg: &FreshAgentCreate, - provenance: Option<&crate::BindProvenance>, - ticket: &Option, - ) -> std::io::Result<()> { - let Some(sink) = self.identity_sink() else { - return Ok(()); - }; - let (settings, genesis) = { - let sessions = self.sessions.lock().await; - let session = sessions.get(session_id).ok_or_else(|| { - std::io::Error::other("Claude stopped while recording its create identity") - })?; - ( - session.configuration.settings.clone(), - session.launch_genesis.clone(), - ) - }; - let generation = ticket.as_ref().map(|ticket| ticket.generation()); - sink.record_binding(crate::identity_sink::FreshAgentBindingUpsert { - provider: PROVIDER.into(), - session_id: session_id.into(), - mode: session_type_str(msg.session_type).into(), - create_request_id: Some(msg.request_id.clone()), - resolves_pending: None, - supersedes: None, - name_transition: None, - genesis: genesis.map_or(crate::identity_sink::GenesisUpdate::Keep, |recorded| { - crate::identity_sink::GenesisUpdate::Set(recorded.genesis) - }), - provenance: provenance.cloned().into(), - observed_epoch: generation.and( - self.ownership - .as_ref() - .map(|registry| registry.boot_epoch()), - ), - observed_generation: generation, - authoritative: false, - settings, - }) - .await - } - - /// The persisted binding/genesis verifies durable routing independently of - /// native transcript flush. Naming can bind to this identity at create. - pub(super) async fn bind_create_name( - &self, - session_id: &str, - session_type: &str, - naming_handle: Option<&str>, - ) -> Option<( - freshell_protocol::session_names::SessionNameRef, - freshell_protocol::session_names::SessionNameRecord, - )> { - if session_type == "kilroy" - || !self.identity_sink().is_some_and(|sink| { - sink.load_genesis(PROVIDER, session_id).is_some() - || sink.was_recorded(PROVIDER, session_id) - }) - { - return None; - } - let handle = naming_handle?.trim(); - if handle.is_empty() { - return None; - } - let target = freshell_protocol::session_names::SessionNameRef::Session { - provider: freshell_protocol::session_names::NamedProvider::Claude, - session_id: session_id.into(), - }; - let transition = crate::naming::NameTransition::binding( - crate::naming::NameTransitionReason::InitialMaterialization, - freshell_protocol::native_location::NativeAcquisition { - location: freshell_protocol::native_location::NativeLocation::Claude { - config_root: self.transcript_roots().first()?.display().to_string(), - transcript_path: None, - project_directory_key: None, - transcript_cwd: None, - effective_project_key_override: None, - }, - evidence: freshell_protocol::native_location::NativeEvidenceKind::PersistedMetadata, - persistence: freshell_protocol::native_location::NativePersistence::Verified, - }, - handle.to_string(), - ); - let update = - crate::naming::fold_identity_transition(&self.naming(), &transition, target.clone()) - .await?; - Some((update.record.name_ref.clone(), update.record)) - } -} diff --git a/crates/freshell-freshagent/src/claude/establish.rs b/crates/freshell-freshagent/src/claude/establish.rs index d5ff0a4c2..8a7459c9d 100644 --- a/crates/freshell-freshagent/src/claude/establish.rs +++ b/crates/freshell-freshagent/src/claude/establish.rs @@ -597,7 +597,7 @@ impl FreshClaudeState { claim, rekey_provisional_claim, recorded_settings, - owning_operation, + owning_operation: _owning_operation, owning_generation, provenance, naming, @@ -803,7 +803,8 @@ impl FreshClaudeState { // 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, + #[cfg(test)] + owning_operation: _owning_operation, // b8ke ext r27 F2: the handoff's generation (the under-ticket // target resume) stamps the adoption's binding write. owning_generation, @@ -899,9 +900,15 @@ impl FreshClaudeState { // A created session is already a durable identity, including before // its first prompt. Write lineage/genesis while the ownership claim // still fences the registration, before Live or the acknowledgement. - if let Some(create) = naming { + if naming.is_some() || lane == EstablishLane::AttachResume { if let Err(error) = self - .record_create_binding(&map_key, create, provenance.as_ref(), own_ticket) + .record_established_binding( + &map_key, + &spec.session_type, + naming.map(|create| create.request_id.as_str()), + provenance.as_ref(), + own_ticket, + ) .await { if let Some(session) = { self.sessions.lock().await.remove(&map_key) } { diff --git a/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs b/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs index 01f48640e..6ea169fa4 100644 --- a/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs +++ b/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs @@ -72,7 +72,6 @@ async fn a_configure_receipt_from_a_replaced_runtime_cannot_change_its_replaceme permission_mode: None, sandbox: None, }, - "freshclaude", ) .await }) diff --git a/crates/freshell-freshagent/src/claude/tests/settings_persistence_tests.rs b/crates/freshell-freshagent/src/claude/tests/settings_persistence_tests.rs new file mode 100644 index 000000000..a34f34563 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/tests/settings_persistence_tests.rs @@ -0,0 +1,151 @@ +use super::*; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_configure_receipt_after_reset_saves_the_same_runtime_under_its_current_uuid() { + let (state, _rx) = state_with_bus(); + let identity = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(identity.clone()); + let old = uuid::Uuid::new_v4().to_string(); + let new = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&state, &old).await; + let pending = state.sessions.lock().await[&old].pending.clone(); + let turn_lock = state.sessions.lock().await[&old].turn_lock.clone(); + let task = { + let state = state.clone(); + let old = old.clone(); + tokio::spawn(async move { + let _turn = turn_lock.lock().await; + state + .configure_for_send( + &old, + &freshell_protocol::FreshAgentSendSettings { + model: Some("sonnet".into()), + cwd: None, + effort: None, + permission_mode: None, + sandbox: None, + }, + ) + .await + }) + }; + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + if state.sessions.lock().await[&old] + .configuration + .pending + .is_some() + { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "configure did not reach its control await" + ); + tokio::task::yield_now().await; + } + state + .rekey_conversation(&old, &new, "freshclaude", &pending, "test-control-await") + .await + .unwrap(); + let (_, reply) = state + .sessions + .lock() + .await + .get_mut(&new) + .unwrap() + .configuration + .pending + .take() + .unwrap(); + reply.send(Ok(())).unwrap(); + let result = task.await.unwrap(); + let saved = identity + .settings + .lock() + .unwrap() + .get(&(PROVIDER.into(), new.clone())) + .cloned(); + state.shutdown().await; + assert!(result.is_ok(), "{result:?}"); + assert_eq!(saved.unwrap().model.as_deref(), Some("sonnet")); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn settings_persistence_failure_preserves_the_runtime_and_unsent_prompt_for_retry() { + let _env_lock = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + let (mut state, _rx) = state_with_bus(); + let identity = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + state.set_identity_sink(identity.clone()); + state.set_ownership(Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new())); + let mut create = dedup_create_msg("settings-persist-retry-create"); + create.model = Some("opus".into()); + let ClaudeCreateOutcome::Created { session_id, .. } = state.handle_create(create, None).await + else { + panic!("a settings persistence fixture must create"); + }; + let owner = state.sessions.lock().await[&session_id] + .ownership_id + .clone(); + let observed = state.ownership_snapshot(PROVIDER, &session_id); + let mut send = send_msg(&session_id, "keep this prompt until settings are saved"); + send.settings = Some(freshell_protocol::FreshAgentSendSettings { + model: Some("sonnet".into()), + cwd: None, + effort: None, + permission_mode: None, + sandbox: None, + }); + send.checkpoint_intent = Some(freshell_protocol::FreshAgentCheckpointIntent { + logical_attempt_id: "settings-persist-checkpoint-attempt".into(), + workspace: None, + }); + send.observed_epoch = Some(observed.epoch); + send.observed_generation = Some(observed.generation); + identity.set_fail_writes(true); + let refused = state.handle_send(send.clone()).await; + let same_runtime = state + .sessions + .lock() + .await + .get(&session_id) + .is_some_and(|session| { + session.ownership_id == owner + && session.configuration.settings.model.as_deref() == Some("sonnet") + }); + identity.set_fail_writes(false); + if !same_runtime || !matches!(refused, ClaudeSendOutcome::Refused { .. }) { + state.shutdown().await; + panic!("failed settings persistence must preserve the runtime and refuse its prompt: same_runtime={same_runtime}, outcome={refused:?}"); + } + let initial = env.respond_log_frames(1).await; + assert_eq!( + initial.len(), + 1, + "the first prompt was never sent: {initial:?}" + ); + assert_eq!(initial[0]["type"], "configure"); + send.request_id = Some("settings-persist-retry-send".into()); + let accepted = state.handle_send(send).await; + let recorded = env.respond_log_frames(3).await; + let saved = identity + .settings + .lock() + .unwrap() + .get(&(PROVIDER.into(), session_id.clone())) + .cloned(); + state.shutdown().await; + assert!( + matches!(accepted, ClaudeSendOutcome::Accepted { .. }), + "{accepted:?}" + ); + assert_eq!(saved.unwrap().model.as_deref(), Some("sonnet")); + assert_eq!( + recorded + .iter() + .filter(|frame| frame["type"] == "send") + .count(), + 1 + ); +} From 55e4490102438748e00d1d4a866ede3ebb459e18 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:44:52 -0700 Subject: [PATCH 052/100] test: exercise Kilroy unified-name exclusion --- .../unified-agent-names-cross-mode.spec.ts | 67 +++++++++++++------ 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/test/e2e-browser/specs/unified-agent-names-cross-mode.spec.ts b/test/e2e-browser/specs/unified-agent-names-cross-mode.spec.ts index bd44cb606..7930a7676 100644 --- a/test/e2e-browser/specs/unified-agent-names-cross-mode.spec.ts +++ b/test/e2e-browser/specs/unified-agent-names-cross-mode.spec.ts @@ -13,7 +13,6 @@ import { } from '../helpers/unified-agent-names.js' import { bootJourney, - collectFreshAgentSessionIds, connect, findPaneIdOfSession, postJsonRetry429, @@ -245,7 +244,6 @@ test.describe('cross-mode unified agent names', () => { // to the unified store or the generator. const journey = await bootJourney('freshclaude', { env: { - FRESHELL_FAKE_PROVIDER: 'kilroy', KILROY_ENABLED: '1', GEMINI_CMD: path.join(REPO_ROOT, 'test', 'e2e-browser', 'fixtures', 'providers', 'fake-gemini.mjs'), }, @@ -356,24 +354,55 @@ test.describe('cross-mode unified agent names', () => { 'an excluded provider pane never carries a unified nameRef', ).toBeUndefined() - // (4) The excluded kilroy runtime: kilroy panes keep their existing - // naming behavior and never enter the unified store (no session-names - // record is created for a kilroy session, and no generation arms). - const kilroy = await createNamedAgent(harness, 'freshclaude', { entry: 'ui', page, server: journey.server }) + // (4) Create a real Kilroy pane through the supported REST agent path. + // FRESHELL_FAKE_PROVIDER only selected the retired protocol fake's + // flavor; the production sidecar uses the pane's actual sessionType. + const kilroyResponse = await postJsonRetry429( + `${journey.server.info.baseUrl}/api/tabs`, + { agent: 'kilroy', cwd: journey.server.projectDir }, + journey.server.info.token, + ) + const kilroyText = await kilroyResponse.text() + expect(kilroyResponse.ok, kilroyText).toBe(true) + const kilroyData = (JSON.parse(kilroyText) as { data: { tabId: string; paneId: string } }).data + await harness.waitForTabCount(5, 15_000) + await expect.poll(async () => ( + await paneContentFromLayoutSnapshot(journey.server, kilroyData.tabId, kilroyData.paneId) + )?.sessionType ?? null, { timeout: 15_000 }).toBe('kilroy') + const kilroyContent = await paneContentFromLayoutSnapshot(journey.server, kilroyData.tabId, kilroyData.paneId) + expect(kilroyContent?.namingHandle, 'Kilroy creates no unified pending-name handle').toBeUndefined() + expect(kilroyContent?.nameRef, 'Kilroy creates no unified session-name reference').toBeUndefined() + + const kilroy = { tabId: kilroyData.tabId, paneId: kilroyData.paneId, nameRef: null } + await page.locator(`[data-context="tab"][data-tab-id="${kilroy.tabId}"]`).click() + const kilroyPane = page.locator( + `[data-pane-id="${kilroy.paneId}"] [data-context="fresh-agent"], [data-pane-id="${kilroy.paneId}"][data-context="fresh-agent"]`, + ).first() + await expect(kilroyPane).toBeVisible({ timeout: 15_000 }) await sendFirstMessage(page, harness, kilroy, 'freshclaude', 'Kilroy keeps its naming') - await page.waitForTimeout(4_000) - const state = await harness.getState() - const kilroySessionIds = collectFreshAgentSessionIds(state) - expect(kilroySessionIds.length).toBeGreaterThan(0) - for (const sessionId of kilroySessionIds) { - const read = await fetch(`${journey.server.info.baseUrl}/api/session-names/read`, { - method: 'POST', - headers: { 'x-auth-token': journey.server.info.token, 'content-type': 'application/json' }, - body: JSON.stringify({ refs: [{ kind: 'session', provider: 'claude', sessionId } as SessionNameRef] }), - }) - const payload = await read.json() as { names: unknown[] } - expect(payload.names, `kilroy session ${sessionId} never enters the unified store`).toEqual([]) - } + await expect(kilroyPane).toContainText('YouKilroy keeps its naming', { timeout: 30_000 }) + + let kilroySessionId = '' + await expect.poll(async () => { + const content = await paneContentFromLayoutSnapshot(journey.server, kilroy.tabId, kilroy.paneId) + const sessionRef = content?.sessionRef as { provider?: unknown; sessionId?: unknown } | undefined + if (content?.sessionType !== 'kilroy' || sessionRef?.provider !== 'claude' + || typeof sessionRef.sessionId !== 'string') return '' + kilroySessionId = sessionRef.sessionId + return kilroySessionId + }, { + timeout: 15_000, + message: 'the real Kilroy sidecar materialized a Claude sessionRef after the first turn', + }).not.toBe('') + const read = await fetch(`${journey.server.info.baseUrl}/api/session-names/read`, { + method: 'POST', + headers: { 'x-auth-token': journey.server.info.token, 'content-type': 'application/json' }, + body: JSON.stringify({ refs: [{ kind: 'session', provider: 'claude', sessionId: kilroySessionId } as SessionNameRef] }), + }) + const readText = await read.text() + expect(read.ok, readText).toBe(true) + const payload = JSON.parse(readText) as { names: unknown[] } + expect(payload.names, `real Kilroy session ${kilroySessionId} never enters the unified store`).toEqual([]) await context.close() } finally { await journey.cleanup() From e53a7fd45cd12689093c029efa878e8b7b497de8 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:52:21 -0700 Subject: [PATCH 053/100] test(freshclaude): run Rust fixtures through the production sidecar --- crates/freshell-freshagent/src/claude.rs | 316 ++---------------- .../src/claude/tests/real_fixture_tests.rs | 64 ++++ .../src/claude/tests/test_sidecar_env.rs | 176 ++++++++++ .../providers/claude-sdk-for-rust-runtime.mjs | 93 ++++++ 4 files changed, 356 insertions(+), 293 deletions(-) create mode 100644 crates/freshell-freshagent/src/claude/tests/real_fixture_tests.rs create mode 100644 crates/freshell-freshagent/src/claude/tests/test_sidecar_env.rs create mode 100644 test/integration/providers/claude-sdk-for-rust-runtime.mjs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index b9e32d656..ce89e9e2b 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -8004,6 +8004,7 @@ pub(crate) mod tests { mod create_binding_tests; mod create_cleanup_tests; mod deadman_tests; + mod real_fixture_tests; mod recovery_stop_tests; mod recovery_tests; mod refusal_tests; @@ -9265,291 +9266,8 @@ pub(crate) mod tests { // per-file locks would NOT serialize against each other. pub(crate) static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - /// A minimal scripted fake claude sidecar (no real `@anthropic-ai/claude-agent-sdk`, - /// no network, no cost): on `{"type":"create",...}` it appends a marker line to - /// `FRESHELL_TEST_CLAUDE_SPAWN_LOG` (so tests can count spawns without a global - /// tracing subscriber) and replies with a fresh `{"type":"created","sessionId":...}`; - /// on `{"type":"interrupt",sessionId}` it appends `sessionId` to - /// `FRESHELL_TEST_CLAUDE_INTERRUPT_LOG` (the observable proxy for "the sidecar's - /// `query.interrupt()` was actually invoked", mirroring the real sidecar's - /// `handleInterrupt`); on `{"type":"shutdown"}` it exits. Task 2 arms: a magic send - /// of `__raise_permission__` emits a canned `sdk.permission.request` (the canUseTool - /// stand-in — the fake parks nothing), a magic send of `__set_status__:` - /// emits a real `sdk.status` frame (the attach-ack status-arrangement hook), - /// `__emit_assistant__` emits a real `sdk.assistant` frame (a turn's RUNNING edge) - /// and `__emit_result__` a real `sdk.result` frame (a completed turn's SETTLE edge - /// — the real sidecar emits it on every SDK result whatever the subtype), and - /// `permission.respond`/`question.respond`/non-magic `send` frames append the full - /// received line to `FRESHELL_TEST_CLAUDE_RESPOND_LOG` (the assertion surface for - /// the respond/compact handlers' exact stdin frame shapes). - const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" -import fs from 'node:fs' -import readline from 'node:readline' - -const spawnLog = process.env.FRESHELL_TEST_CLAUDE_SPAWN_LOG -const respondLog = process.env.FRESHELL_TEST_CLAUDE_RESPOND_LOG -// Task 4 review (C1) determinism knob: DEFER every create ANSWER by N ms. The -// create request is still logged IMMEDIATELY (the spawn-log gate observes the -// rollback parked inside its respawn), so a test can fire a send while the -// rollback deterministically waits on `created` — the mid-rollback send -// interleaving is guaranteed, never luck. -const deferCreateMs = parseInt(process.env.FRESHELL_TEST_CLAUDE_DEFER_CREATE_MS || '0', 10) -// Task 4 review (M3) provable-rejection knob: die on create like a spawn-time -// provider rejection (EOF before `created`). -const failCreate = process.env.FRESHELL_TEST_CLAUDE_FAIL_CREATE === '1' - -let counter = 0 -// The sessions this sidecar hosts; a send to another is answered with the -// real sidecar's signed session-not-found error. -const liveSessions = new Set() -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() - if (!trimmed) return - let msg - try { - msg = JSON.parse(trimmed) - } catch { - return - } - if (msg.type === 'create') { - // Log the WHOLE create request (one line per create) so tests can both count - // spawns AND assert what the sidecar received (e.g. the resume boot). - if (spawnLog) { - fs.appendFileSync(spawnLog, `${JSON.stringify(msg)}\n`) - } - const answer = () => { - if (failCreate) process.exit(1) - counter += 1 - const sessionId = msg.sessionId ?? `fake-claude-session-${process.pid}-${counter}` - liveSessions.add(sessionId) - configuredSettings.set(sessionId, { model: msg.model, effort: msg.effort, permissionMode: msg.permissionMode, cwd: msg.cwd }) - process.stdout.write(JSON.stringify({ type: 'created', sessionId, protocol: 2 }) + '\n') - 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. - const cliSessionId = msg.boot?.kind === 'fork' - ? `fork-${process.pid}-${counter}-0000-4000-8000-000000000000` - : (msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa') - console.log(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] })) - console.log(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' })) - } - if (deferCreateMs > 0) setTimeout(answer, deferCreateMs) - else answer() - } else if (msg.type === 'configure') { - if (respondLog) fs.appendFileSync(respondLog, `${JSON.stringify(msg)}\n`) - console.log(JSON.stringify({ type: 'sdk.session.changed', sessionId: msg.sessionId, reason: 'session-commands', commands: [{ name: 'review', description: 'Review changes' }] })) - const settings = configuredSettings.get(msg.sessionId) ?? {} - const ok = msg.settings?.model !== 'unavailable' && msg.settings?.effort !== 'invalid' - if (msg.settings?.model !== 'unavailable') settings.model = msg.settings.model - if (ok) Object.assign(settings, msg.settings) - console.log(JSON.stringify({ type: 'sdk.configured', sessionId: msg.sessionId, requestId: msg.requestId, ok, settings, message: 'Model or effort is unavailable' })) - } else if (msg.type === 'send') { - // 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: [] })) - } - // Magic texts: each emits ONE canned sdk.* line through the real stdout - // consumer (the fake reports no command lifecycle, so the turn ledger - // settles a result by its old-CLI head fallback). - if (msg.text === '__emit_result_error__') { - console.log(JSON.stringify({ type: 'sdk.result', sessionId: msg.sessionId, result: 'error_during_execution' })) - } else if (!liveSessions.has(msg.sessionId)) { - // A send to a session this sidecar does not host is refused before any - // CLI sees it, as the real sidecar refuses it (sdk.send.rejected): the - // server takes the send back. - console.log(JSON.stringify({ type: 'sdk.send.rejected', sessionId: msg.sessionId, uuid: msg.uuid ?? null, code: 'SESSION_NOT_FOUND', message: 'Claude session is no longer available.' })) - } else if (msg.text === '__emit_assistant__') { - console.log(JSON.stringify({ type: 'sdk.assistant', sessionId: msg.sessionId, content: [{ type: 'text', text: 'noise' }] })) - // Task 2 test hook: raise a canned pending permission the approve/deny flow can - // respond to. The fake parks nothing — Rust's pending fold is the state under test. - } else if (msg.text === '__raise_permission__') { - process.stdout.write(JSON.stringify({ - type: 'sdk.permission.request', - sessionId: msg.sessionId, - requestId: 'req-1', - subtype: 'can_use_tool', - tool: { name: 'Bash', input: { command: 'ls' } }, - toolUseID: 'toolu_fake_1', - blockedPath: null, - decisionReason: null, - }) + '\n') - } else if (msg.text.startsWith('__set_status__:')) { - // Reconnect-revive hook: emit a real sdk.status frame so a test can arrange a - // non-default tracked status through the REAL stdout consumer fold (the attach - // ack under test speaks it back). - process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId: msg.sessionId, status: msg.text.slice('__set_status__:'.length) }) + '\n') - } else if (msg.text === '__emit_assistant__') { - // Reconnect-revive hook: emit a real sdk.assistant frame (the real wire shape — - // index.mjs:165) so a test can drive the turn ledger's turn evidence ("running"). - process.stdout.write(JSON.stringify({ type: 'sdk.assistant', sessionId: msg.sessionId, content: [{ type: 'text', text: 'part' }], model: 'fake-model' }) + '\n') - } else if (msg.text === '__emit_result__') { - // Reconnect-revive hook: emit a real sdk.result frame (the real wire shape — - // index.mjs:177) so a test can drive the turn ledger's result settle. - process.stdout.write(JSON.stringify({ type: 'sdk.result', sessionId: msg.sessionId, result: 'success', durationMs: 1, costUsd: 0, usage: {} }) + '\n') - } else if (respondLog) { - // Non-magic sends (e.g. `/compact …`) land in the respond log verbatim so - // tests can assert the exact stdin frame shape the compact handler writes. - fs.appendFileSync(respondLog, `${JSON.stringify(msg)}\n`) - } - } else if (msg.type === 'permission.respond' || msg.type === 'question.respond') { - if (respondLog) fs.appendFileSync(respondLog, `${JSON.stringify(msg)}\n`) - } else if (msg.type === 'interrupt') { - const interruptLog = process.env.FRESHELL_TEST_CLAUDE_INTERRUPT_LOG - if (interruptLog) fs.appendFileSync(interruptLog, `${msg.sessionId}\n`) - // The control receipt (echoing the Stop's id) is written BEFORE the - // interrupted turn's own result (sdk.d.ts:3760): the settle frame first, - // then the stopped turn's result in the pinned CLI's aborted shape (T11; no - // trailing idle: the real sidecar sends none). - console.log(JSON.stringify({ type: 'sdk.interrupt_settled', sessionId: msg.sessionId, stopId: msg.stopId, ok: true })) - console.log(JSON.stringify({ type: 'sdk.result', sessionId: msg.sessionId, result: 'error_during_execution', subtype: 'error_during_execution', isError: true, terminalReason: 'aborted_streaming', numTurns: 1 })) - } else if (msg.type === 'shutdown') { - process.exit(0) - } -}) -"#; - - /// A fresh temp dir holding the fake sidecar script + this test's spawn-count log, - /// with `FRESHELL_CLAUDE_SIDECAR`/`FRESHELL_CLAUDE_NODE`/ - /// `FRESHELL_TEST_CLAUDE_SPAWN_LOG` pointed at it. Caller must hold - /// [`CLAUDE_ENV_LOCK`] for the lifetime of the returned guard. - struct FakeClaudeSidecarEnv { - dir: PathBuf, - spawn_log: PathBuf, - interrupt_log: PathBuf, - respond_log: PathBuf, - } - impl FakeClaudeSidecarEnv { - fn install() -> Self { - Self::install_with_knobs(None, false) - } - - /// `install` + the fake sidecar's scripted knobs (see the source header): - /// `defer_create_ms` delays every create ANSWER (the request is logged - /// immediately — a test can park the rollback inside its respawn); - /// `fail_create` makes every create a spawn-time provider rejection. - fn install_with_knobs(defer_create_ms: Option, fail_create: bool) -> Self { - let dir = std::env::temp_dir().join(format!( - "freshell-fake-claude-sidecar-{}", - uuid::Uuid::new_v4() - )); - std::fs::create_dir_all(&dir).expect("create fake sidecar temp dir"); - let script = dir.join("fake-claude-sidecar.mjs"); - std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); - let spawn_log = dir.join("spawn.log"); - std::fs::write(&spawn_log, "").expect("init spawn log"); - let interrupt_log = dir.join("interrupt.log"); - std::fs::write(&interrupt_log, "").expect("init interrupt log"); - let respond_log = dir.join("respond.log"); - std::fs::write(&respond_log, "").expect("init respond log"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("FRESHELL_TEST_CLAUDE_SPAWN_LOG", &spawn_log); - std::env::set_var("FRESHELL_TEST_CLAUDE_INTERRUPT_LOG", &interrupt_log); - std::env::set_var("FRESHELL_TEST_CLAUDE_RESPOND_LOG", &respond_log); - match defer_create_ms { - Some(ms) => { - std::env::set_var("FRESHELL_TEST_CLAUDE_DEFER_CREATE_MS", ms.to_string()) - } - None => std::env::remove_var("FRESHELL_TEST_CLAUDE_DEFER_CREATE_MS"), - } - if fail_create { - std::env::set_var("FRESHELL_TEST_CLAUDE_FAIL_CREATE", "1"); - } else { - std::env::remove_var("FRESHELL_TEST_CLAUDE_FAIL_CREATE"); - } - Self { - dir, - spawn_log, - interrupt_log, - respond_log, - } - } - - /// Path of the spawn log (one full JSON create-request line per spawn) so tests - /// can assert what the sidecar received (e.g. the resume boot). - fn spawn_log_path(&self) -> &std::path::Path { - &self.spawn_log - } - - async fn create_request(&self) -> Value { - let deadline = tokio::time::Instant::now() + Duration::from_secs(10); - loop { - let text = std::fs::read_to_string(self.spawn_log_path()).unwrap_or_default(); - if let Some(line) = text.lines().next() { - return serde_json::from_str(line).expect("logged create JSON"); - } - assert!( - tokio::time::Instant::now() < deadline, - "create never reached sidecar" - ); - tokio::time::sleep(Duration::from_millis(10)).await; - } - } - - /// Number of times the fake sidecar has been spawned so far (one marker line per - /// process start). - fn spawn_count(&self) -> usize { - std::fs::read_to_string(&self.spawn_log) - .map(|s| s.lines().filter(|l| !l.is_empty()).count()) - .unwrap_or(0) - } - - /// Contents of the interrupt log (one `sessionId` per line the fake sidecar - /// received a `{"type":"interrupt",...}` request for). - fn interrupt_log_contents(&self) -> String { - std::fs::read_to_string(&self.interrupt_log).unwrap_or_default() - } - - /// The parsed respond-log lines (one full JSON frame per line the fake sidecar - /// received a `permission.respond` / `question.respond` / non-magic `send` - /// request for). Bounded-waits for at least `min` lines so the assertion reads - /// AFTER the sidecar's append, never racing it. - async fn respond_log_frames(&self, min: usize) -> Vec { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - let frames: Vec = std::fs::read_to_string(&self.respond_log) - .unwrap_or_default() - .lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| serde_json::from_str(l).expect("respond log line is JSON")) - .collect(); - if frames.len() >= min { - return frames; - } - assert!( - tokio::time::Instant::now() < deadline, - "respond log never reached {min} frame(s)" - ); - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - } - } - impl Drop for FakeClaudeSidecarEnv { - 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_SPAWN_LOG"); - std::env::remove_var("FRESHELL_TEST_CLAUDE_INTERRUPT_LOG"); - std::env::remove_var("FRESHELL_TEST_CLAUDE_RESPOND_LOG"); - std::env::remove_var("FRESHELL_TEST_CLAUDE_DEFER_CREATE_MS"); - std::env::remove_var("FRESHELL_TEST_CLAUDE_FAIL_CREATE"); - let _ = std::fs::remove_dir_all(&self.dir); - } - } + mod test_sidecar_env; + use test_sidecar_env::ClaudeTestRuntime as FakeClaudeSidecarEnv; fn send_msg(session_id: &str, text: &str) -> FreshAgentSend { FreshAgentSend { @@ -10127,10 +9845,19 @@ rl.on('line', (line) => { snapshot["settings"], json!({ "model": "sonnet", "effort": "max", "permissionMode": "plan" }) ); - assert_eq!( - snapshot["commands"], - json!([{ "name": "review", "description": "Review changes" }]) - ); + let catalog: Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../test/fixtures/claude-cli-sim/catalog.json" + ))) + .unwrap(); + let commands: Vec = catalog["initializationResult"]["commands"] + .as_array() + .unwrap() + .iter() + .filter(|command| !matches!(command["name"].as_str(), Some("doctor" | "color"))) + .cloned() + .collect(); + assert_eq!(snapshot["commands"], json!(commands)); assert_eq!( snapshot["status"], "running", "accepted input is busy before the first provider output" @@ -10150,6 +9877,7 @@ rl.on('line', (line) => { async fn failed_effort_records_the_model_that_already_took_effect() { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + std::env::set_var("FRESHELL_TEST_CLAUDE_REJECT_EFFORT", "invalid"); let (state, mut rx) = state_with_bus(); let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); state.set_identity_sink(fake.clone()); @@ -10198,6 +9926,7 @@ rl.on('line', (line) => { async fn send_rejects_failed_settings_without_losing_text_or_changing_saved_settings() { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + std::env::set_var("FRESHELL_TEST_CLAUDE_REJECT_MODEL", "unavailable"); let (state, mut rx) = state_with_bus(); let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); state.set_identity_sink(fake.clone()); @@ -19978,11 +19707,11 @@ rl.on('line', (line) => { out } - /// A configure applies the settings to the LIVE sidecar session (the fake - /// mirrors the real configure lane: setModel + the sdk.configured receipt) + /// A configure applies the settings through the production sidecar's SDK + /// controls (setModel and the sdk.configured receipt) /// and broadcasts `freshAgent.session.metadata` so every device's model /// surfaces converge. The ledger row's settings re-snapshot through the - /// existing adopt_session_init leg configure_for_send performs. + /// existing configure_for_send persistence path. #[tokio::test] async fn configure_applies_settings_and_broadcasts_session_metadata() { let _env_guard = CLAUDE_ENV_LOCK.lock().await; @@ -20036,13 +19765,14 @@ rl.on('line', (line) => { assert_eq!(metadata["event"]["effort"], json!("high")); } - /// The fake sidecar's scripted refusal (model 'unavailable') surfaces on + /// An SDK setModel refusal injected at the SDK method boundary surfaces on /// the pane-banner error channel with the session's flavour, and NO /// metadata converges a value the live session never took. #[tokio::test] async fn a_refused_configure_surfaces_the_banner_error_and_converges_nothing() { let _env_guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + std::env::set_var("FRESHELL_TEST_CLAUDE_REJECT_MODEL", "unavailable"); let (state, rx) = state_with_bus(); let mut rx = rx; state diff --git a/crates/freshell-freshagent/src/claude/tests/real_fixture_tests.rs b/crates/freshell-freshagent/src/claude/tests/real_fixture_tests.rs new file mode 100644 index 000000000..cf93c22c8 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/tests/real_fixture_tests.rs @@ -0,0 +1,64 @@ +use super::*; + +#[tokio::test] +async fn the_rust_runtime_fixture_completes_a_real_sidecar_turn() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install(); + std::env::set_var( + "FRESHELL_CLAUDE_SIM_PROGRAM", + json!({"rules":[{"on":"msg:prompt", "match":{"text":"prove the real sidecar path"}, + "emit":[{"kind":"text", "data":{"text":"the simulator answered through index.mjs"}}]}]}) + .to_string(), + ); + let (state, mut rx) = state_with_bus(); + state + .handle_create(dedup_create_msg("real-fixture-smoke"), None) + .await; + let created = await_claude_created(&mut rx, "real-fixture-smoke").await; + let session_id = created["sessionId"].as_str().unwrap(); + state + .handle_send(send_msg(session_id, "prove the real sidecar path")) + .await; + let assistant = await_frame_of_inner_type(&mut rx, "freshAgent.assistant").await; + assert_eq!( + assistant["event"]["content"][0]["text"], + "the simulator answered through index.mjs" + ); + let result = await_frame_of_inner_type(&mut rx, "freshAgent.result").await; + assert_eq!(result["sessionId"], session_id); + let rows: Vec = std::fs::read_to_string(env.dir.join("sim-ledger.jsonl")) + .unwrap() + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + let prompt = rows.iter().find(|row| row["kind"] == "prompt").unwrap(); + assert_eq!(prompt["sessionId"], session_id); + assert!(uuid::Uuid::parse_str(prompt["uuid"].as_str().unwrap()).is_ok()); + assert_eq!(rows.iter().filter(|row| row["kind"] == "query").count(), 1); + assert_eq!(rows.iter().filter(|row| row["kind"] == "result").count(), 1); + #[cfg(unix)] + let processes = { + let boot = rows.iter().find(|row| row["kind"] == "boot").unwrap(); + [ + boot["pid"].as_u64().unwrap(), + boot["cliPid"].as_u64().unwrap(), + ] + .map(|pid| { + ( + pid, + crate::process_probe::ProcessProbe::open(u32::try_from(pid).unwrap()), + ) + }) + }; + // Keep the Rust state alive: ending the unique fixture guard must itself + // close the SDK query and stop both the production sidecar and its CLI. + drop(env); + #[cfg(unix)] + for (pid, process) in processes { + assert!( + process.exits_within(Duration::from_secs(15)).await, + "dropping the fixture guard left process {pid} running" + ); + } + state.shutdown().await; +} diff --git a/crates/freshell-freshagent/src/claude/tests/test_sidecar_env.rs b/crates/freshell-freshagent/src/claude/tests/test_sidecar_env.rs new file mode 100644 index 000000000..6f9f5fd55 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/tests/test_sidecar_env.rs @@ -0,0 +1,176 @@ +//! The production sidecar over the contract-tested CLI simulator. + +use serde_json::{json, Value}; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +const KEYS: &[&str] = &[ + "FRESHELL_CLAUDE_SIDECAR", + "FRESHELL_CLAUDE_NODE", + "FRESHELL_CLAUDE_SDK_QUERY_MODULE", + "FRESHELL_TEST_CLAUDE_SPAWN_LOG", + "FRESHELL_TEST_CLAUDE_INTERRUPT_LOG", + "FRESHELL_TEST_CLAUDE_RESPOND_LOG", + "FRESHELL_TEST_CLAUDE_DEFER_CREATE_MS", + "FRESHELL_TEST_CLAUDE_FAIL_CREATE", + "FRESHELL_TEST_CLAUDE_RUNTIME_GUARD", + "FRESHELL_TEST_CLAUDE_REJECT_MODEL", + "FRESHELL_TEST_CLAUDE_REJECT_EFFORT", + "FRESHELL_CLAUDE_SIM_PROGRAM", + "FRESHELL_CLAUDE_SIM_TIME_SCALE", + "FRESHELL_CLAUDE_SIM_LEDGER", + "CLAUDE_CONFIG_DIR", +]; + +pub(super) struct ClaudeTestRuntime { + pub(super) dir: PathBuf, + spawn_log: PathBuf, + interrupt_log: PathBuf, + respond_log: PathBuf, + previous: Vec<(&'static str, Option)>, +} + +impl ClaudeTestRuntime { + pub(super) fn install() -> Self { + Self::install_with_knobs(None, false) + } + + pub(super) fn install_with_knobs(defer_create_ms: Option, fail_create: bool) -> Self { + let previous = KEYS + .iter() + .map(|&key| (key, std::env::var_os(key))) + .collect(); + let dir = std::env::temp_dir().join(format!( + "freshell-claude-test-runtime-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&dir).expect("create runtime fixture directory"); + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap(); + let spawn_log = dir.join("query-options.jsonl"); + let interrupt_log = dir.join("interrupt.log"); + let respond_log = dir.join("requests.jsonl"); + for file in [&spawn_log, &interrupt_log, &respond_log] { + std::fs::write(file, "").unwrap(); + } + let guard = dir.join("active"); + std::fs::write(&guard, "").unwrap(); + std::env::set_var( + "FRESHELL_CLAUDE_SIDECAR", + root.join("crates/freshell-claude-sidecar/index.mjs"), + ); + std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); + std::env::set_var( + "FRESHELL_CLAUDE_SDK_QUERY_MODULE", + root.join("test/integration/providers/claude-sdk-for-rust-runtime.mjs"), + ); + std::env::set_var("FRESHELL_TEST_CLAUDE_SPAWN_LOG", &spawn_log); + std::env::set_var("FRESHELL_TEST_CLAUDE_INTERRUPT_LOG", &interrupt_log); + std::env::set_var("FRESHELL_TEST_CLAUDE_RESPOND_LOG", &respond_log); + std::env::set_var("FRESHELL_TEST_CLAUDE_RUNTIME_GUARD", &guard); + std::env::set_var("FRESHELL_CLAUDE_SIM_LEDGER", dir.join("sim-ledger.jsonl")); + std::env::set_var("FRESHELL_CLAUDE_SIM_TIME_SCALE", "0.01"); + std::env::set_var("CLAUDE_CONFIG_DIR", dir.join("claude")); + // Most runtime unit tests inspect an accepted, still-running turn. + std::env::set_var( + "FRESHELL_CLAUDE_SIM_PROGRAM", + json!({"rules":[{"on":"msg:prompt", "emit":[{"kind":"hang"}]}]}).to_string(), + ); + match defer_create_ms { + Some(ms) => std::env::set_var("FRESHELL_TEST_CLAUDE_DEFER_CREATE_MS", ms.to_string()), + None => std::env::remove_var("FRESHELL_TEST_CLAUDE_DEFER_CREATE_MS"), + } + if fail_create { + std::env::set_var("FRESHELL_TEST_CLAUDE_FAIL_CREATE", "1"); + } else { + std::env::remove_var("FRESHELL_TEST_CLAUDE_FAIL_CREATE"); + } + for key in [ + "FRESHELL_TEST_CLAUDE_REJECT_MODEL", + "FRESHELL_TEST_CLAUDE_REJECT_EFFORT", + ] { + std::env::remove_var(key); + } + Self { + dir, + spawn_log, + interrupt_log, + respond_log, + previous, + } + } + + pub(super) fn spawn_log_path(&self) -> &Path { + &self.spawn_log + } + + pub(super) async fn create_request(&self) -> Value { + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + loop { + if let Some(row) = self.query_rows().first() { + return row["options"].clone(); + } + assert!( + tokio::time::Instant::now() < deadline, + "query never reached the SDK" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + + fn query_rows(&self) -> Vec { + rows(&self.spawn_log) + } + + pub(super) fn spawn_count(&self) -> usize { + self.query_rows().len() + } + + pub(super) fn interrupt_log_contents(&self) -> String { + std::fs::read_to_string(&self.interrupt_log).unwrap_or_default() + } + + pub(super) async fn respond_log_frames(&self, min: usize) -> Vec { + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + loop { + let frames = rows(&self.respond_log); + if frames.len() >= min { + return frames; + } + assert!( + tokio::time::Instant::now() < deadline, + "sidecar stdin never received {min} frame(s): {frames:?}" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } +} + +fn rows(file: &Path) -> Vec { + std::fs::read_to_string(file) + .unwrap_or_default() + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).expect("fixture JSONL row")) + .collect() +} + +impl Drop for ClaudeTestRuntime { + fn drop(&mut self) { + let _ = std::fs::write(self.dir.join("active.stop"), ""); + let _ = std::fs::remove_file(self.dir.join("active")); + // The SDK watcher also treats this unique directory disappearing as + // shutdown, so deleting it cannot lose the sentinel notification. + let _ = std::fs::remove_dir_all(&self.dir); + for (key, value) in &self.previous { + match value { + Some(value) => std::env::set_var(key, value), + None => std::env::remove_var(key), + } + } + } +} diff --git a/test/integration/providers/claude-sdk-for-rust-runtime.mjs b/test/integration/providers/claude-sdk-for-rust-runtime.mjs new file mode 100644 index 000000000..992138d53 --- /dev/null +++ b/test/integration/providers/claude-sdk-for-rust-runtime.mjs @@ -0,0 +1,93 @@ +// Rust runtime tests use the production index.mjs over the contract simulator. +// This adapter records actual SDK options and subsequent sidecar input; it +// never emits a sidecar protocol frame. Faults stay at SDK method boundaries. +import fs from 'node:fs' +import path from 'node:path' +import { query as simulateQuery } from '../../fixtures/claude-cli-sim/query.mjs' + +function append(file, row) { + if (file) fs.appendFileSync(file, `${JSON.stringify(row)}\n`) +} + +let recording = false +function recordInput() { + if (recording) return + recording = true + let partial = '' + // Installed during query(), after production readline consumed create. + // Observe the same stream without replacing its consumer or its replies. + process.stdin.on('data', (chunk) => { + partial += String(chunk) + for (;;) { + const end = partial.indexOf('\n') + if (end < 0) break + const line = partial.slice(0, end) + partial = partial.slice(end + 1) + let request + try { request = JSON.parse(line) } catch { continue } + if (['configure', 'send', 'permission.respond', 'question.respond'].includes(request.type)) { + append(process.env.FRESHELL_TEST_CLAUDE_RESPOND_LOG, request) + } + if (request.type === 'interrupt' && process.env.FRESHELL_TEST_CLAUDE_INTERRUPT_LOG) { + fs.appendFileSync(process.env.FRESHELL_TEST_CLAUDE_INTERRUPT_LOG, `${request.sessionId}\n`) + } + } + }) +} + +function closeWhenGuardEnds(runtime) { + const guard = process.env.FRESHELL_TEST_CLAUDE_RUNTIME_GUARD + if (!guard) return + let watcher + let closed = false + const close = () => { + if (closed) return + closed = true + watcher?.close() + runtime.close() + } + const ended = () => !fs.existsSync(guard) || fs.existsSync(`${guard}.stop`) + try { + watcher = fs.watch(path.dirname(guard), { persistent: false }, () => { + if (ended()) close() + }) + watcher.unref() + watcher.on('error', close) + if (ended()) close() + } catch { + close() + } +} + +export function query(params) { + // Keep credentials and callbacks out of fixture diagnostics. + const { env, ...options } = params.options + append(process.env.FRESHELL_TEST_CLAUDE_SPAWN_LOG, { + pid: process.pid, + options: { ...options, envKeys: Object.keys(env ?? {}).sort() }, + }) + recordInput() + const runtime = simulateQuery(params) + closeWhenGuardEnds(runtime) + const initialize = runtime.initializationResult.bind(runtime) + runtime.initializationResult = async () => { + const delay = Number(process.env.FRESHELL_TEST_CLAUDE_DEFER_CREATE_MS ?? 0) + if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay)) + if (process.env.FRESHELL_TEST_CLAUDE_FAIL_CREATE === '1') throw new Error('Injected SDK initialization failure') + return initialize() + } + for (const [method, key] of [ + ['setModel', 'FRESHELL_TEST_CLAUDE_REJECT_MODEL'], + ['applyFlagSettings', 'FRESHELL_TEST_CLAUDE_REJECT_EFFORT'], + ]) { + const original = runtime[method].bind(runtime) + runtime[method] = async (value) => { + const requested = method === 'setModel' ? value : value?.effortLevel + if (process.env[key] !== undefined && requested === process.env[key]) { + throw new Error(`Injected SDK ${method} rejection: ${JSON.stringify(requested)}`) + } + return original(value) + } + } + return runtime +} From 72d9f9b4e0e0b4a6bb2e7cc7b8bd1d323e0f4ea9 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:57:57 -0700 Subject: [PATCH 054/100] test(freshclaude): drive retained controls through real SDK requests --- crates/freshell-freshagent/src/claude.rs | 194 +++++++++++------- .../src/claude/tests/test_sidecar_env.rs | 40 +++- 2 files changed, 160 insertions(+), 74 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index ce89e9e2b..1fc713be7 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -16461,19 +16461,28 @@ pub(crate) mod tests { ) } - /// Bounded-wait until the consumer has folded a pending `requestId` permission for + fn permission_runtime() -> FakeClaudeSidecarEnv { + let env = FakeClaudeSidecarEnv::install(); + env.program(json!([ + {"kind":"approval", "data":{"tool":"Bash", "input":{"command":"ls"}}}, + {"kind":"hang"}, + ])); + env + } + + /// Bounded-wait until the consumer has folded the SDK's pending permission for /// the session (the respond handlers refuse ids outside the pending set, so the /// fold MUST be observed before responding). - async fn await_pending_permission(st: &FreshClaudeState, session_id: &str, request_id: &str) { + async fn await_pending_permission(st: &FreshClaudeState, session_id: &str) -> String { let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let (permissions, _) = pending_request_ids(st, session_id).await; - if permissions.iter().any(|id| id == request_id) { - return; + if let Some(request_id) = permissions.into_iter().next() { + return request_id; } assert!( tokio::time::Instant::now() < deadline, - "pending permission {request_id} never folded into the session" + "the pending permission never folded into the session" ); tokio::time::sleep(std::time::Duration::from_millis(25)).await; } @@ -16557,29 +16566,29 @@ pub(crate) mod tests { #[tokio::test] async fn approval_respond_writes_the_frame_and_removes_the_pending_entry() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); + let env = permission_runtime(); let (st, mut rx) = state_with_bus(); st.handle_create(dedup_create_msg("req-approval-respond"), None) .await; let created = await_claude_created(&mut rx, "req-approval-respond").await; let session_id = created["sessionId"].as_str().unwrap().to_string(); - // Raise the pending permission through the fake's canUseTool stand-in; the + // Raise the pending permission through the real sidecar's canUseTool callback; the // consumer folds it into the session's pending set. - st.handle_send(send_msg(&session_id, "__raise_permission__")) + st.handle_send(send_msg(&session_id, "ask before listing files")) .await; - await_pending_permission(&st, &session_id, "req-1").await; + let request_id = await_pending_permission(&st, &session_id).await; let decision = json!({ "behavior": "allow", "updatedInput": { "command": "ls -la" } }); st.handle_approval_respond(approval_respond_msg( &session_id, SessionType::Freshclaude, - "req-1", + &request_id, decision.clone(), )) .await; - let frames = env.respond_log_frames(1).await; + let frames = env.request_frames_of_type("permission.respond", 1).await; let respond = frames .iter() .find(|f| f["type"] == "permission.respond") @@ -16589,14 +16598,14 @@ pub(crate) mod tests { json!(session_id), "sidecar-keyed sessionId" ); - assert_eq!(respond["requestId"], "req-1"); + assert_eq!(respond["requestId"], json!(request_id)); assert_eq!( respond["decision"], decision, "the decision payload is a VERBATIM passthrough" ); let (permissions, _) = pending_request_ids(&st, &session_id).await; assert!( - !permissions.iter().any(|id| id == "req-1"), + !permissions.iter().any(|id| id == &request_id), "the resolved entry leaves the pending set" ); drop(env); @@ -16652,23 +16661,23 @@ pub(crate) mod tests { #[tokio::test] async fn approval_respond_with_a_non_object_decision_is_refused_and_keeps_the_pending_entry() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); + let env = permission_runtime(); let (st, mut rx) = state_with_bus(); st.handle_create(dedup_create_msg("req-approval-null-decision"), None) .await; let created = await_claude_created(&mut rx, "req-approval-null-decision").await; let session_id = created["sessionId"].as_str().unwrap().to_string(); - // Raise the pending permission through the fake's canUseTool stand-in; the + // Raise the pending permission through the real sidecar's canUseTool callback; the // consumer folds it into the session's pending set. - st.handle_send(send_msg(&session_id, "__raise_permission__")) + st.handle_send(send_msg(&session_id, "ask before listing files")) .await; - await_pending_permission(&st, &session_id, "req-1").await; + let request_id = await_pending_permission(&st, &session_id).await; st.handle_approval_respond(approval_respond_msg( &session_id, SessionType::Freshclaude, - "req-1", + &request_id, json!(null), )) .await; @@ -16679,11 +16688,13 @@ pub(crate) mod tests { let (permissions, _) = pending_request_ids(&st, &session_id).await; assert_eq!( permissions, - vec!["req-1".to_string()], + vec![request_id.clone()], "a refused non-object decision leaves the entry pending (never hide-then-wedge)" ); assert!( - env.respond_log_frames(0).await.is_empty(), + env.request_frames_of_type("permission.respond", 0) + .await + .is_empty(), "a refused non-object decision is never forwarded to the sidecar" ); drop(env); @@ -16696,20 +16707,20 @@ pub(crate) mod tests { #[tokio::test] async fn approval_respond_refuses_malformed_updated_permissions_until_a_valid_retry() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); + let env = permission_runtime(); let (st, mut rx) = state_with_bus(); st.handle_create(dedup_create_msg("req-approval-bad-updates"), None) .await; let created = await_claude_created(&mut rx, "req-approval-bad-updates").await; let session_id = created["sessionId"].as_str().unwrap().to_string(); - st.handle_send(send_msg(&session_id, "__raise_permission__")) + st.handle_send(send_msg(&session_id, "ask before listing files")) .await; - await_pending_permission(&st, &session_id, "req-1").await; + let request_id = await_pending_permission(&st, &session_id).await; st.handle_approval_respond(approval_respond_msg( &session_id, SessionType::Freshclaude, - "req-1", + &request_id, json!({ "behavior": "allow", "updatedPermissions": [ { "type": "grantEverything", "destination": "session" }, ] }), @@ -16722,11 +16733,13 @@ pub(crate) mod tests { let (permissions, _) = pending_request_ids(&st, &session_id).await; assert_eq!( permissions, - vec!["req-1".to_string()], + vec![request_id.clone()], "a refused decision leaves the request pending" ); assert!( - env.respond_log_frames(0).await.is_empty(), + env.request_frames_of_type("permission.respond", 0) + .await + .is_empty(), "a refused decision never reaches the sidecar" ); @@ -16738,11 +16751,11 @@ pub(crate) mod tests { st.handle_approval_respond(approval_respond_msg( &session_id, SessionType::Freshclaude, - "req-1", + &request_id, decision.clone(), )) .await; - let frames = env.respond_log_frames(1).await; + let frames = env.request_frames_of_type("permission.respond", 1).await; let respond = frames .iter() .find(|f| f["type"] == "permission.respond") @@ -16769,21 +16782,21 @@ pub(crate) mod tests { // cache once this thread's capture is installed. let (events, _capture_guard) = info_capture_for_test::capture(); tracing::callsite::rebuild_interest_cache(); - let env = FakeClaudeSidecarEnv::install(); + let env = permission_runtime(); let (st, mut rx) = state_with_bus(); st.handle_create(dedup_create_msg("req-approval-refusal-log"), None) .await; let created = await_claude_created(&mut rx, "req-approval-refusal-log").await; let session_id = created["sessionId"].as_str().unwrap().to_string(); - st.handle_send(send_msg(&session_id, "__raise_permission__")) + st.handle_send(send_msg(&session_id, "ask before listing files")) .await; - await_pending_permission(&st, &session_id, "req-1").await; + let request_id = await_pending_permission(&st, &session_id).await; const SECRET: &str = "deploy --token s3cr3t-value"; st.handle_approval_respond(approval_respond_msg( &session_id, SessionType::Freshclaude, - "req-1", + &request_id, json!({ "behavior": "allow", "updatedPermissions": [ { "type": "setMode", "mode": "acceptEdits", "destination": "session" }, { "type": "addRules", "rules": [{ "toolName": "Bash", "ruleContent": SECRET }], @@ -16867,43 +16880,40 @@ pub(crate) mod tests { async fn question_respond_writes_the_frame_with_the_answers_object() { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + env.program(json!([ + {"kind":"question", "data":{"questions":[ + {"question":"choice", "header":"Choice", "options":[ + {"label":"yes", "description":"Continue"}, {"label":"no", "description":"Stop"}]}, + {"question":"note", "header":"Note", "options":[ + {"label":"ship it", "description":"Proceed"}, {"label":"wait", "description":"Wait"}]}, + ]}}, + {"kind":"hang"}, + ])); let (st, mut rx) = state_with_bus(); st.handle_create(dedup_create_msg("req-question-respond"), None) .await; let created = await_claude_created(&mut rx, "req-question-respond").await; let session_id = created["sessionId"].as_str().unwrap().to_string(); - // Seed the pending question directly (the consumer fold itself is pinned by - // consumer_folds_permission_and_question_frames_into_the_pending_set below). - { - let guard = st.sessions.lock().await; - guard - .get(&session_id) - .unwrap() - .pending - .lock() - .unwrap() - .questions - .push(PendingQuestionEntry { - request_id: "q-1".to_string(), - questions: json!([{ "question": "Continue?" }]), - }); - } + st.handle_send(send_msg(&session_id, "ask the release questions")) + .await; + let question = await_frame_of_inner_type(&mut rx, "freshAgent.question.request").await; + let request_id = question["event"]["requestId"].as_str().unwrap().to_string(); st.handle_question_respond(question_respond_msg( &session_id, - "q-1", + &request_id, &[("choice", "yes"), ("note", "ship it")], )) .await; - let frames = env.respond_log_frames(1).await; + let frames = env.request_frames_of_type("question.respond", 1).await; let respond = frames .iter() .find(|f| f["type"] == "question.respond") .expect("question.respond frame written to the sidecar"); assert_eq!(respond["sessionId"], json!(session_id)); - assert_eq!(respond["requestId"], "q-1"); + assert_eq!(respond["requestId"], json!(request_id)); assert_eq!( respond["answers"], json!({ "choice": "yes", "note": "ship it" }), @@ -16914,6 +16924,13 @@ pub(crate) mod tests { questions.is_empty(), "the answered question leaves the pending set" ); + let answer = env.simulator_row("can_use_tool").await; + assert_eq!(answer["toolName"], "AskUserQuestion"); + assert_eq!( + answer["decision"]["updatedInput"]["answers"], + json!({ "choice": "yes", "note": "ship it" }), + "the production sidecar resolves the SDK's parked question" + ); drop(env); } @@ -17013,9 +17030,26 @@ pub(crate) mod tests { let created = await_claude_created(&mut rx, "req-compact").await; let session_id = created["sessionId"].as_str().unwrap().to_string(); - st.handle_compact(compact_msg(&session_id, None)).await; - st.handle_compact(compact_msg(&session_id, Some(" focus the diff "))) - .await; + for instructions in [None, Some(" focus the diff ")] { + st.handle_compact(compact_msg(&session_id, instructions)) + .await; + // Each real command owns its turn until the CLI's result. Observe + // all frames through that boundary so an erroneous send ack fails. + loop { + let raw = tokio::time::timeout(Duration::from_secs(15), rx.recv()) + .await + .expect("compaction result in time") + .expect("bus open"); + let frame: Value = serde_json::from_str(&raw).unwrap(); + assert_ne!( + frame["type"], "freshAgent.send.accepted", + "compact emits no send ack: {frame}" + ); + if frame["event"]["type"] == "freshAgent.result" { + break; + } + } + } let frames = env.respond_log_frames(2).await; let sends: Vec<&Value> = frames.iter().filter(|f| f["type"] == "send").collect(); @@ -17028,7 +17062,6 @@ pub(crate) mod tests { ); // No ack: compact must not reuse handle_send's send.accepted broadcast. - tokio::time::sleep(std::time::Duration::from_millis(100)).await; while let Ok(frame) = rx.try_recv() { let frame: Value = serde_json::from_str(&frame).unwrap(); assert_ne!( @@ -17096,7 +17129,7 @@ pub(crate) mod tests { #[tokio::test] async fn kilroy_approval_respond_lands_and_removes_the_pending_entry() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); + let env = permission_runtime(); let (st, mut rx) = state_with_bus(); let mut create = dedup_create_msg("req-kilroy-approval-lands"); create.session_type = SessionType::Kilroy; @@ -17108,23 +17141,23 @@ pub(crate) mod tests { ); let session_id = created["sessionId"].as_str().unwrap().to_string(); - // Raise the pending permission through the fake's canUseTool stand-in; the + // Raise the pending permission through the real sidecar's canUseTool callback; the // consumer folds it into the kilroy session's pending set. - st.handle_send(send_msg(&session_id, "__raise_permission__")) + st.handle_send(send_msg(&session_id, "ask before listing files")) .await; - await_pending_permission(&st, &session_id, "req-1").await; + let request_id = await_pending_permission(&st, &session_id).await; let decision = json!({ "behavior": "deny", "message": "Denied by user", "interrupt": false }); st.handle_approval_respond(approval_respond_msg( &session_id, SessionType::Kilroy, - "req-1", + &request_id, decision.clone(), )) .await; - let frames = env.respond_log_frames(1).await; + let frames = env.request_frames_of_type("permission.respond", 1).await; let respond = frames .iter() .find(|f| f["type"] == "permission.respond") @@ -17134,14 +17167,14 @@ pub(crate) mod tests { json!(session_id), "sidecar-keyed sessionId" ); - assert_eq!(respond["requestId"], "req-1"); + assert_eq!(respond["requestId"], json!(request_id)); assert_eq!( respond["decision"], decision, "the decision payload is a VERBATIM passthrough" ); let (permissions, _) = pending_request_ids(&st, &session_id).await; assert!( - !permissions.iter().any(|id| id == "req-1"), + !permissions.iter().any(|id| id == &request_id), "the resolved entry leaves the pending set" ); drop(env); @@ -17432,11 +17465,21 @@ pub(crate) mod tests { .collect(); assert_eq!(creates.len(), 2); assert_eq!( - creates[0]["plugins"], - json!(["/workspace/.claude/plugins/local"]) + creates[0]["options"]["plugins"], + json!([{"type":"local", "path":"/workspace/.claude/plugins/local"}]) + ); + assert_eq!(creates[1]["options"]["plugins"], Value::Null); + assert!(creates[1]["options"].get("resume").is_none()); + assert!(creates[1]["options"].get("forkSession").is_none()); + for create in &creates { + assert!( + uuid::Uuid::parse_str(create["options"]["sessionId"].as_str().unwrap()).is_ok() + ); + } + assert_ne!( + creates[0]["options"]["sessionId"], + creates[1]["options"]["sessionId"] ); - assert_eq!(creates[1]["plugins"], Value::Null); - assert_eq!(creates[1]["boot"], json!({ "kind": "fresh" })); } #[tokio::test(flavor = "multi_thread")] @@ -18170,7 +18213,7 @@ pub(crate) mod tests { /// A known session's interrupt is forwarded to the sidecar (the Rust-side half of /// the parity fix -- mirrors `adapters/claude/adapter.ts:163-168`'s /// `sdkBridge.interrupt(sessionId)` -> `sp.query.interrupt()`), observed via the - /// fake sidecar's interrupt log since a successful interrupt broadcasts NOTHING + /// production sidecar's captured stdin since a successful interrupt broadcasts NOTHING /// (fire-and-forget, matching legacy exactly -- there is no confirmation frame to /// assert on instead). #[tokio::test] @@ -18184,11 +18227,12 @@ pub(crate) mod tests { .await; let created = await_claude_created(&mut rx, "req-claude-interrupt").await; let session_id = created["sessionId"].as_str().unwrap().to_string(); - // C18: a Stop reaches the sidecar only while something runs. The fake - // answers no plain send, so the turn stays owed until the Stop. + // C18: a Stop reaches the sidecar only while something runs. The + // simulated model request stays pending until the SDK interrupt. st.handle_send(send_msg(&session_id, "a turn to stop")) .await; await_ledger_busy(&st, &session_id, true).await; + let _ = env.simulator_row("prompt").await; st.handle_interrupt(FreshAgentInterrupt { request_id: None, @@ -18199,7 +18243,7 @@ pub(crate) mod tests { }) .await; - // Bounded poll for the fake sidecar's interrupt log (it's an OS-level pipe + // Bounded poll for the production sidecar's captured input (an OS-level pipe // write, not synchronous with `handle_interrupt`'s `write_line` await). let deadline = std::time::Instant::now() + Duration::from_secs(10); loop { @@ -18247,6 +18291,7 @@ pub(crate) mod tests { async fn handle_interrupt_of_an_idle_session_sends_nothing() { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + env.program(json!([{"kind":"text", "data":{"text":"the next turn completes"}}])); let (log_events, _log_guard) = crate::claude_sidecar_io::test_capture::capture(); let (tx, mut rx) = tokio::sync::broadcast::channel::(64); let st = FreshClaudeState::new(Arc::new(tx)); @@ -18264,7 +18309,6 @@ pub(crate) mod tests { cwd: None, }) .await; - tokio::time::sleep(Duration::from_millis(200)).await; assert_eq!( env.interrupt_log_contents(), "", @@ -18281,9 +18325,13 @@ pub(crate) mod tests { while rx.try_recv().is_ok() {} st.handle_send(send_msg(&session_id, "a turn")).await; await_ledger_busy(&st, &session_id, true).await; - inject_raw_send(&st, &session_id, "__emit_result__").await; let edge = await_frame_of_inner_type(&mut rx, "freshAgent.turn.complete").await; assert!(edge["event"]["at"].as_i64().is_some(), "{edge}"); + assert_eq!( + env.interrupt_log_contents(), + "", + "the idle Stop never reaches the sidecar, even after the next real turn" + ); } /// Adapter-asymmetry fix (bug-hunt pbh-20260807): an UNREQUESTED sidecar death diff --git a/crates/freshell-freshagent/src/claude/tests/test_sidecar_env.rs b/crates/freshell-freshagent/src/claude/tests/test_sidecar_env.rs index 6f9f5fd55..7b1cd11ec 100644 --- a/crates/freshell-freshagent/src/claude/tests/test_sidecar_env.rs +++ b/crates/freshell-freshagent/src/claude/tests/test_sidecar_env.rs @@ -108,6 +108,33 @@ impl ClaudeTestRuntime { &self.spawn_log } + pub(super) fn program(&self, steps: Value) { + std::env::set_var( + "FRESHELL_CLAUDE_SIM_PROGRAM", + json!({ + "rules":[{"on":"msg:prompt", "emit":steps}] + }) + .to_string(), + ); + } + + pub(super) async fn simulator_row(&self, kind: &str) -> Value { + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + loop { + if let Some(row) = rows(&self.dir.join("sim-ledger.jsonl")) + .into_iter() + .find(|row| row["kind"] == kind) + { + return row; + } + assert!( + tokio::time::Instant::now() < deadline, + "simulator never recorded {kind}" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + pub(super) async fn create_request(&self) -> Value { let deadline = tokio::time::Instant::now() + Duration::from_secs(15); loop { @@ -135,9 +162,20 @@ impl ClaudeTestRuntime { } pub(super) async fn respond_log_frames(&self, min: usize) -> Vec { + self.wait_request_frames(None, min).await + } + + pub(super) async fn request_frames_of_type(&self, kind: &str, min: usize) -> Vec { + self.wait_request_frames(Some(kind), min).await + } + + async fn wait_request_frames(&self, kind: Option<&str>, min: usize) -> Vec { let deadline = tokio::time::Instant::now() + Duration::from_secs(15); loop { - let frames = rows(&self.respond_log); + let frames: Vec<_> = rows(&self.respond_log) + .into_iter() + .filter(|frame| kind.is_none_or(|kind| frame["type"] == kind)) + .collect(); if frames.len() >= min { return frames; } From 342acebfe44d636cc6e013c980978ea9ad122f18 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:58:47 -0700 Subject: [PATCH 055/100] test(freshclaude): expose native lineage loss after unwired recovery --- crates/freshell-ws/tests/common/claude_rig.rs | 9 ++- .../tests/freshagent_claude_native_lineage.rs | 73 +++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 crates/freshell-ws/tests/freshagent_claude_native_lineage.rs diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index b67d87347..62f754160 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -63,6 +63,9 @@ pub enum RigKind { /// local claude lane, no handoff route). #[derive(Debug, Clone, Default)] pub struct RigOptions { + /// Exercise the hosted kernel's in-memory lifecycle without a coordinator + /// or identity sink; native files and dead-runtime memory are authoritative. + pub unwired_runtime: bool, /// REST create/split/send/capture over the shared Claude runtime and layout. pub rest_api: bool, /// Extra sidecar environment (for example simulator knobs such as @@ -1058,8 +1061,10 @@ async fn spawn_rig_server( let mut fresh_claude = freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)) .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() { + if !options.unwired_runtime { + fresh_claude.set_ownership(Arc::clone(&ownership)); + } + if let Some(ledger) = pane_ledger.as_ref().filter(|_| !options.unwired_runtime) { let mut sink = super::ledger_sink::LedgerSink::with_ownership( Arc::clone(ledger), Some(Arc::clone(&ownership)), diff --git a/crates/freshell-ws/tests/freshagent_claude_native_lineage.rs b/crates/freshell-ws/tests/freshagent_claude_native_lineage.rs new file mode 100644 index 000000000..1f61aaf63 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_native_lineage.rs @@ -0,0 +1,73 @@ +//! A hosted kernel follows native conversation resets across runtime recovery. + +mod common; + +use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions}; +use serde_json::Value; + +async fn reset(conn: &mut RigConnection, old: &str, request: &str) -> String { + let start = conn.frames.len(); + conn.send_text(old, request, "/clear").await; + let materialized = conn + .wait_for_since(start, "the reset identity", |frame| { + frame["type"] == "freshAgent.session.materialized" && frame["previousSessionId"] == old + }) + .await; + let new = materialized["sessionId"].as_str().unwrap().to_string(); + conn.wait_for_since(start, "the reset command settling", |frame| { + frame["sessionId"] == new + && frame["event"]["type"] == "freshAgent.status" + && frame["event"]["status"] == "idle" + }) + .await; + new +} + +async fn send_and_result(conn: &mut RigConnection, id: &str, request: &str) -> Value { + let start = conn.frames.len(); + conn.send_text(id, request, request).await; + conn.wait_for_since(start, "the submitted turn result", |frame| { + frame["event"]["type"] == "freshAgent.result" + }) + .await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn unwired_native_lineage_survives_two_resets_and_runtime_recovery() { + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + unwired_runtime: true, + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let a = conn.create("native-lineage-create").await; + rig.converse(&mut conn, &a, &["original dialogue"]).await; + let b = reset(&mut conn, &a, "first-native-reset").await; + rig.converse(&mut conn, &b, &["middle dialogue"]).await; + let c = reset(&mut conn, &b, "second-native-reset").await; + rig.converse(&mut conn, &c, &["current dialogue"]).await; + assert_eq!(rig.ledger_rows("boot").len(), 1); + rig.kill_first_sidecar(&mut conn).await; + rig.converse(&mut conn, &c, &["recover current conversation"]) + .await; + assert_eq!(rig.ledger_rows("boot").len(), 2); + + let late_a = send_and_result(&mut conn, &a, "late-first-identity-send").await; + assert_eq!( + late_a["sessionId"], c, + "the old UUID must address the recovered writer" + ); + let late_b = send_and_result(&mut conn, &b, "late-second-identity-send").await; + assert_eq!(late_b["sessionId"], c); + assert_eq!( + rig.ledger_rows("boot").len(), + 2, + "late requests never reopen historical writers" + ); + conn.kill(&a).await; + assert!(!rig.fresh_claude.has_live_session(&c).await); + rig.fresh_claude.shutdown().await; +} From 0441eff203ed971b18490ea69a073eab5aadf078 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:06:31 -0700 Subject: [PATCH 056/100] refactor(freshclaude): model native runtime identity and close guards --- .../src/claude/runtime_identity.rs | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 crates/freshell-freshagent/src/claude/runtime_identity.rs diff --git a/crates/freshell-freshagent/src/claude/runtime_identity.rs b/crates/freshell-freshagent/src/claude/runtime_identity.rs new file mode 100644 index 000000000..b3729eeb7 --- /dev/null +++ b/crates/freshell-freshagent/src/claude/runtime_identity.rs @@ -0,0 +1,198 @@ +//! One process incarnation's native conversation identity and close guard. +//! Callers take the sessions-map lock before mutating this short mutex. A +//! rekey shares this handle; a replacement process copies only its lineage. + +use std::sync::{Arc, Mutex}; + +#[derive(Clone, Debug)] +pub(super) struct RuntimeIdentity(Arc>); + +#[derive(Debug)] +struct IdentityState { + current: String, + prior: Vec, + close_count: usize, +} + +impl IdentityState { + fn remember_prior(&mut self, id: String) { + if id != self.current && !self.prior.contains(&id) { + self.prior.push(id); + } + } +} + +/// The native conversation cannot move while an operation is closing its +/// runtime. The guard, rather than the session id, owns that operation's count. +#[must_use = "keep this guard until the close operation has settled"] +#[derive(Debug)] +pub(super) struct RuntimeCloseGuard(Arc>); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) struct RuntimeClosing; + +impl std::fmt::Display for RuntimeClosing { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("the runtime is closing") + } +} + +impl std::error::Error for RuntimeClosing {} + +impl RuntimeIdentity { + pub(super) fn new( + current: impl Into, + prior_ids: impl IntoIterator, + ) -> Self { + let mut state = IdentityState { + current: current.into(), + prior: Vec::new(), + close_count: 0, + }; + for id in prior_ids { + state.remember_prior(id); + } + Self(Arc::new(Mutex::new(state))) + } + + pub(super) fn current(&self) -> String { + self.0 + .lock() + .expect("runtime identity lock") + .current + .clone() + } + + pub(super) fn contains(&self, id: &str) -> bool { + let state = self.0.lock().expect("runtime identity lock"); + state.current == id || state.prior.iter().any(|known| known == id) + } + + /// Prior ids in discovery order, followed by the current id once. + pub(super) fn ids(&self) -> Vec { + let state = self.0.lock().expect("runtime identity lock"); + let mut ids = state.prior.clone(); + ids.push(state.current.clone()); + ids + } + + pub(super) fn inherit(&self, ids: impl IntoIterator) { + let mut state = self.0.lock().expect("runtime identity lock"); + for id in ids { + state.remember_prior(id); + } + } + + pub(super) fn begin_close(&self) -> RuntimeCloseGuard { + self.0.lock().expect("runtime identity lock").close_count += 1; + RuntimeCloseGuard(Arc::clone(&self.0)) + } + + pub(super) fn is_closing(&self) -> bool { + self.0.lock().expect("runtime identity lock").close_count > 0 + } + + pub(super) fn rekey(&self, next: &str) -> Result<(), RuntimeClosing> { + let mut state = self.0.lock().expect("runtime identity lock"); + if state.close_count > 0 { + return Err(RuntimeClosing); + } + if state.current != next { + state.prior.retain(|id| id != next); + let previous = std::mem::replace(&mut state.current, next.to_string()); + state.remember_prior(previous); + } + Ok(()) + } +} + +impl Drop for RuntimeCloseGuard { + fn drop(&mut self) { + self.0.lock().expect("runtime identity lock").close_count -= 1; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::{ + future::Future, + task::{Context, Poll, Waker}, + }; + + #[test] + fn rekeys_keep_every_native_id_once_and_inherit_prior_lineage() { + let identity = RuntimeIdentity::new("A", Vec::new()); + identity.rekey("B").unwrap(); + identity.rekey("C").unwrap(); + assert_eq!(identity.current(), "C"); + assert_eq!(identity.ids(), ["A", "B", "C"]); + for id in ["A", "B", "C"] { + assert!(identity.contains(id)); + } + assert!(!identity.contains("unknown")); + identity.inherit(["A", "B", "C", "D", "D"].map(str::to_string)); + let mut ids = identity.ids(); + ids.sort(); + assert_eq!(ids, ["A", "B", "C", "D"]); + identity.rekey("A").unwrap(); + let mut ids = identity.ids(); + ids.sort(); + assert_eq!(identity.current(), "A"); + assert_eq!(ids, ["A", "B", "C", "D"]); + } + + #[test] + fn recovery_copies_lineage_without_sharing_the_old_close_counter() { + let old = RuntimeIdentity::new("A", Vec::new()); + old.rekey("B").unwrap(); + let close = old.begin_close(); + let recovered = RuntimeIdentity::new(old.current(), old.ids()); + assert_eq!(recovered.ids(), ["A", "B"]); + assert!(old.is_closing()); + assert!(!recovered.is_closing()); + recovered.rekey("C").unwrap(); + assert_eq!(recovered.ids(), ["A", "B", "C"]); + assert_eq!(old.current(), "B"); + drop(close); + assert!(!old.is_closing()); + } + + #[test] + fn overlapping_close_guards_refuse_rekey_until_the_last_guard_drops() { + let identity = RuntimeIdentity::new("A", ["A", "older", "older"].map(str::to_string)); + assert_eq!(identity.ids(), ["older", "A"]); + let first = identity.begin_close(); + let second = identity.clone().begin_close(); + let error = identity.rekey("B").unwrap_err(); + assert!(!error.to_string().is_empty()); + drop(first); + assert!(identity.is_closing()); + assert!(identity.rekey("B").is_err()); + assert_eq!(identity.current(), "A"); + drop(second); + assert!(!identity.is_closing()); + identity.rekey("B").unwrap(); + assert_eq!(identity.current(), "B"); + } + + #[test] + fn canceling_an_actually_polled_future_releases_its_close_guard() { + let identity = RuntimeIdentity::new("A", Vec::new()); + let mut operation = Box::pin(async { + let _closing = identity.begin_close(); + std::future::pending::<()>().await; + }); + assert!(!identity.is_closing()); + let mut context = Context::from_waker(Waker::noop()); + assert!(matches!( + operation.as_mut().poll(&mut context), + Poll::Pending + )); + assert!(identity.is_closing()); + assert!(identity.rekey("B").is_err()); + drop(operation); + assert!(!identity.is_closing()); + identity.rekey("B").unwrap(); + } +} From 2f094af4d40c35dda01f6b323cfe6a91f45890e3 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:08:05 -0700 Subject: [PATCH 057/100] test(freshclaude): observe real bootstrap and resume outcomes --- crates/freshell-freshagent/src/claude.rs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 1fc713be7..318a43ff3 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -10174,6 +10174,7 @@ pub(crate) mod tests { let env = FakeClaudeSidecarEnv::install(); let (st, mut rx) = state_with_bus(); let durable = "66666666-6666-4666-8666-666666666666"; + write_fake_transcript(&env.dir.join("claude"), durable); let mut msg = dedup_create_msg("req-sref-only-1"); msg.session_ref = Some(freshell_protocol::SessionLocator { provider: "claude".to_string(), @@ -10478,6 +10479,7 @@ pub(crate) mod tests { ); let durable = "77777777-7777-4777-8777-777777777777"; + write_fake_transcript(&env.dir.join("claude"), durable); 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; @@ -10505,8 +10507,8 @@ pub(crate) mod tests { #[tokio::test(flavor = "multi_thread")] async fn a_fresh_claude_create_spawn_failure_settles_the_durable_record() { let _guard = CLAUDE_ENV_LOCK.lock().await; - // The FAIL knob: the sidecar dies before `created` (a spawn-time - // provider rejection — EOF). + // The SDK initialization rejects before `created`, through the real + // sidecar's typed boot-failure path. let env = FakeClaudeSidecarEnv::install_with_knobs(Some(1500), true); let (mut st, mut rx) = state_with_bus(); let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); @@ -10551,7 +10553,7 @@ pub(crate) mod tests { } assert_eq!( failed_code.as_deref(), - Some("CLAUDE_CREATE_FAILED"), + Some("BOOT_FAILED"), "the spawn failure answers typed" ); assert!( @@ -17885,6 +17887,7 @@ pub(crate) mod tests { let create_req: serde_json::Value = serde_json::from_str(log.lines().next().expect("one spawn-logged create request")) .unwrap(); + let create_req = &create_req["options"]; assert_eq!(create_req["model"], "opus-x"); assert_eq!(create_req["permissionMode"], "plan"); assert_eq!(create_req["effort"], "high"); @@ -17966,10 +17969,8 @@ pub(crate) mod tests { let log = std::fs::read_to_string(env.spawn_log_path()).unwrap(); let create_req: serde_json::Value = serde_json::from_str(log.lines().next().unwrap()).unwrap(); - assert_eq!( - create_req["boot"], - json!({ "kind": "resume", "resume": DURABLE }) - ); + let create_req = &create_req["options"]; + assert_eq!(create_req["resume"], json!(DURABLE)); assert_eq!(create_req["model"], "opus-x"); assert_eq!(create_req["permissionMode"], "plan"); let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; @@ -18021,8 +18022,9 @@ pub(crate) mod tests { let create_req: serde_json::Value = serde_json::from_str(log.lines().next().expect("one spawn-logged create request")) .unwrap(); + let create_req = &create_req["options"]; assert_eq!( - create_req["boot"]["resume"] + create_req["resume"] .as_str() .expect("the resume on the wire"), DURABLE, From d5adc259e65b5f7c5aca762ec2b393375d29d0f6 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:08:06 -0700 Subject: [PATCH 058/100] test(claude): model transcript-path resume from pinned CLI evidence --- test/fixtures/claude-cli-sim/query.mjs | 3 ++ test/fixtures/claude-cli-sim/transcript.mjs | 5 ++- .../providers/claude-cli-contract.test.ts | 11 +++++++ .../providers/claude-cli-scenarios.mjs | 31 +++++++++++++++++++ 4 files changed, 49 insertions(+), 1 deletion(-) diff --git a/test/fixtures/claude-cli-sim/query.mjs b/test/fixtures/claude-cli-sim/query.mjs index 6f57b1b35..f1fbd0cfc 100644 --- a/test/fixtures/claude-cli-sim/query.mjs +++ b/test/fixtures/claude-cli-sim/query.mjs @@ -476,6 +476,9 @@ class SimulatedCli { if (resume) { const parentFile = findTranscript(this.configDir, resume) if (!parentFile) return { message: `No conversation found with session ID: ${resume}` } + // T5 path: the path selects a saved file; it never becomes the native + // conversation id or a second transcript filename after a resume. + if (!forkSession) this.sessionId = path.basename(parentFile, '.jsonl') const parentLines = readTranscriptLines(parentFile) let prefix try { prefix = forkPrefix(parentLines, resumeSessionAt) } catch (error) { return { message: error.message } } diff --git a/test/fixtures/claude-cli-sim/transcript.mjs b/test/fixtures/claude-cli-sim/transcript.mjs index 3497055d1..16b7595d3 100644 --- a/test/fixtures/claude-cli-sim/transcript.mjs +++ b/test/fixtures/claude-cli-sim/transcript.mjs @@ -26,8 +26,11 @@ export function projectSlug(cwd) { return String(cwd).replace(/[^A-Za-z0-9]/g, '-') } -/** Locate `.jsonl` in any project directory, or null. */ +/** Locate a saved conversation by native id or absolute transcript path. */ export function findTranscript(configDir, sessionId) { + if (typeof sessionId === 'string' && path.isAbsolute(sessionId)) { + try { return fs.statSync(sessionId).isFile() ? sessionId : null } catch { return null } + } const projects = path.join(configDir, 'projects') let dirs try { dirs = fs.readdirSync(projects, { withFileTypes: true }) } catch { return null } diff --git a/test/integration/providers/claude-cli-contract.test.ts b/test/integration/providers/claude-cli-contract.test.ts index 7304724da..f9baf0f30 100644 --- a/test/integration/providers/claude-cli-contract.test.ts +++ b/test/integration/providers/claude-cli-contract.test.ts @@ -169,6 +169,17 @@ describe(`pinned Claude CLI ${PINNED_CLI_VERSION} contract (real CLI, loopback p expect(r.observations.afterRefusals.files.filter((f: string) => /F2|F3|Z/.test(path.basename(f)))).toEqual([]) }, SCENARIO_TIMEOUT) + it('T5 path: a transcript path resumes after its cwd is removed, keeping its UUID and original saved file', async () => { + const r = await real('resume-path') + expect(r.observations.pathBoot).toEqual({ ok: true }) + expect(r.observations.pathResult.sessionIds).toEqual(['P']) + expect(r.observations.pathResult.shape).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'user', uuid: 'u1' }), + expect.objectContaining({ type: 'user', uuid: 'u2' }), + ])) + expect(r.observations.afterResume.files.map((file: string) => path.basename(file))).toEqual(['P.jsonl']) + }, SCENARIO_TIMEOUT) + it('T6: a lazy fork boots with no prompt, writes nothing until its first prompt, can be re-spawned before it, then holds the parent prefix with its uuids', async () => { const r = await real('fork') const { parent, forkBeforePrompt, forkAfterPrompt } = r.observations diff --git a/test/integration/providers/claude-cli-scenarios.mjs b/test/integration/providers/claude-cli-scenarios.mjs index e1f178631..bcbdede8e 100644 --- a/test/integration/providers/claude-cli-scenarios.mjs +++ b/test/integration/providers/claude-cli-scenarios.mjs @@ -421,6 +421,37 @@ const SCENARIOS = { await s.close() }, + /** T5: a saved transcript can resume by path after its original cwd is gone. */ + async 'resume-path'(ctx) { + const P = ctx.uuid('P') + const seed = ctx.open('seed', { options: { sessionId: P } }) + seed.push('seed the path-resumed history', 'u1') + await seed.waitForResults(1) + await seed.waitForCount(isStateIdle, 1, 5000, 'idle') + ctx.need(await ctx.pollTranscript(P, 5000, 2), 'seed history flushed') + await seed.close() + const file = path.join(ctx.fixture.claudeConfigDir, (await ctx.files()).find((f) => f.endsWith(`${P}.jsonl`))) + const replacement = path.join(ctx.fixture.root, 'replacement') + await fs.mkdir(replacement) + await fs.rm(ctx.fixture.cwd, { recursive: true }) + const resumed = ctx.open('path', { options: { resume: file, cwd: replacement } }) + const init = await resumed.init() + ctx.observe('pathBoot', { ok: init.ok, ...(init.message ? { message: init.message } : {}) }) + if (init.ok) { + resumed.push('continue the saved history', 'u2') + await resumed.waitForResults(1) + await resumed.waitForCount(isStateIdle, 1, 5000, 'idle') + const saved = ctx.need(await ctx.pollTranscript(P, 5000, 1, (lines) => + lines.some((line) => line.uuid === ctx.uuidOf('u2')) && lines.filter((line) => line.type === 'assistant').length >= 2), 'continued history flushed') + ctx.observe('pathResult', { + sessionIds: resumed.messages((message) => message.type === 'result').map((message) => message.session_id), + shape: transcriptShape(saved), + }) + } + await resumed.close() + ctx.observe('afterResume', { files: await ctx.files() }) + }, + /** T5 T6: lazy fork with a guard, re-spawned before its prompt; then the three boot refusals. */ async fork(ctx) { const P = ctx.uuid('P') From eb09e40d591eafa266594057c0e4c8a6fcb032f8 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:08:56 -0700 Subject: [PATCH 059/100] test(e2e): align Claude browser scenarios with SDK behavior --- .../specs/fresh-agent-control-rust.spec.ts | 11 +++- .../fresh-agent-error-turn-attention.spec.ts | 24 +++++++- ...fresh-agent-hidden-approval-reveal.spec.ts | 50 +++++++++------ .../fresh-agent-mobile-attention.spec.ts | 15 +++++ ...shclaude-identity-persistence-rust.spec.ts | 19 ++++-- .../freshclaude-restart-parity-rust.spec.ts | 30 ++++++--- ...freshclaude-zero-turn-restart-rust.spec.ts | 60 ++++++++++++------ .../specs/hidden-pane-rebind-rust.spec.ts | 61 ++++++++++++------- .../e2e-browser/specs/mcp-bridge-rust.spec.ts | 17 ++++-- .../specs/reconnect-revive-rust.spec.ts | 43 ++++++------- .../specs/restore-contract-wall-rust.spec.ts | 17 +++++- .../specs/wavea-interactions-rust.spec.ts | 43 ++++++++----- 12 files changed, 267 insertions(+), 123 deletions(-) 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 6646de4f9..1e544314c 100644 --- a/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts @@ -842,7 +842,11 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { test('compact: /compact instructions land on the same session; compacting shows; context retained', async ({ page }) => { // Give the simulator's real local /compact operation enough wall time for // the UI's compacting state to be observed. - const lane = await bootClaudeLane(page, 'freshclaude', { FRESHELL_CLAUDE_SIM_TIME_SCALE: '50' }) + // 20x keeps the 500 ms simulated /compact window observable without + // stretching the 600 ms SDK boot to 30 seconds (50x). At that scale the + // pane did not reach its initial ready state before the test's 30 second + // setup bound under cloud scheduling. + const lane = await bootClaudeLane(page, 'freshclaude', { FRESHELL_CLAUDE_SIM_TIME_SCALE: '20' }) try { await waitForPaneStatus(lane.harness, lane.tabId, ['connected', 'idle']) const durableBefore = await captureDurableId(lane.harness, lane.tabId, CANONICAL_UUID_RE) @@ -922,7 +926,10 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { await paneRoot.getByRole('button', { name: 'Slash commands' }).click() const menu = page.getByRole('menu', { name: 'Slash commands' }) await expect(menu).toBeVisible({ timeout: 10_000 }) - await expect(menu.getByRole('menuitem', { name: /^\/compact/ })).toBeVisible() + await expect( + menu.getByRole('group', { name: 'Pane actions' }) + .getByRole('menuitem', { name: /^\/compact Ask the agent to compact its current conversation context$/ }), + ).toBeVisible() await expect( menu.getByRole('menuitem', { name: /^\/fork/ }), 'claude capabilities.fork=true offers /fork in the menu (C14)', diff --git a/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts b/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts index f1cde3965..43b8f953e 100644 --- a/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts @@ -98,6 +98,23 @@ function findFreshAgentLeaf(node: any): any { return null } +/** Select a real boot terminal before opening a provider pane picker. The + * boot picker fades out after the choice, so opening another picker before + * the terminal surface appears can route the click to the fading picker. */ +async function settleBootPicker(page: Page): Promise { + const picker = page.getByRole('toolbar', { name: /pane type picker/i }).last() + if (await picker.isVisible().catch(() => false)) { + 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 }) + break + } + } + } + await expect(page.locator('.xterm').first()).toBeVisible({ timeout: 30_000 }) +} + /** * Boot a server whose Claude sidecar is the PRODUCTION sidecar over the CLI * simulator (with its ledger, and `extraEnv` such as a simulator program), and @@ -145,6 +162,7 @@ async function bootProductionClaudePane( await harness.waitForHarness() await harness.waitForConnection() + await settleBootPicker(page) await page.evaluate(() => { window.__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'connection/setAvailableClis', @@ -207,9 +225,9 @@ test.describe('fresh-agent error-turn attention (unified needs-attention signal) await harness.waitForHarness() await harness.waitForConnection() - // The boot tab shows the pane-type picker (no shell is selected first, - // so the freshclaude pane REPLACES the picker — no split sibling): make - // the fresh-agent options visible, then pick Freshclaude. + // Settle the initial picker and its fade-out before opening the + // fresh-agent picker. The boot shell remains a real sibling pane. + await settleBootPicker(page) await page.evaluate(() => { window.__FRESHELL_TEST_HARNESS__?.dispatch({ type: 'connection/setAvailableClis', diff --git a/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts b/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts index 24466dd66..0d5d95c14 100644 --- a/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts @@ -24,9 +24,11 @@ * itself. What this spec does catch is a regression to the blocking reveal * path (the overlay recorder) and a broken end-to-end journey. * - * Trigger: the hidden pane's session gets a `freshAgent.send` written straight - * to the socket (the pane's composer is hidden), and the simulator causes the - * production sidecar's canUseTool callback to raise one Bash approval. + * Trigger: a visible composer starts the turn, then the user switches tabs + * before the simulator causes the production sidecar's canUseTool callback + * to raise one Bash approval. The send itself must not be hidden: a hidden + * send acceptance legitimately dirties the transcript and tests the blocking + * transcript-refresh path instead of the approval-only reveal path. * Then the client's own `freshAgent.interrupt` frame for the session — the * exact frame the Stream Deck "stop" sends to a pane in a background tab — * cancels it (the sidecar's interrupt-time pending cancellation, mirroring @@ -68,6 +70,7 @@ const SIMULATOR_PROGRAM = { { kind: 'approval', data: { id: APPROVAL_REQUEST_ID, tool: 'Bash', input: { command: 'echo raised-while-hidden' } }, + delayMs: 3_000, }, ], }, @@ -96,6 +99,20 @@ async function receivedFreshAgentEvents(harness: TestHarness, eventType: string) return received.filter((msg) => msg?.type === 'freshAgent.event' && msg?.event?.type === eventType) } +/** POST /api/tabs returns an unfocused tab, leaving the current pane visible. */ +async function createBackgroundShellTab(info: { baseUrl: string; token: string }, cwd: string): Promise { + const res = await fetch(`${info.baseUrl}/api/tabs`, { + method: 'POST', + headers: { 'x-auth-token': info.token, 'content-type': 'application/json' }, + body: JSON.stringify({ mode: 'shell', cwd }), + }) + const payload = await res.json() + expect(res.ok, `POST /api/tabs: ${JSON.stringify(payload)}`).toBe(true) + const tabId = payload?.data?.tabId + expect(tabId, 'POST /api/tabs envelope data.tabId').toBeTruthy() + return tabId as string +} + /** Record whether the blocking reveal overlay is EVER drawn from now on. */ async function armRevealOverlayRecorder(page: Page): Promise { await page.evaluate((selector) => { @@ -231,24 +248,15 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without const visibleCard = page.getByRole('alert', { name: 'Permission request for Bash' }) await expect(cardInAnyTab, 'no approval before the trigger').toHaveCount(0) - // ── Tab B: the user moves away; tab A (and its pane) is now hidden ──── - await page.getByRole('button', { name: 'New shell tab' }).click() + // Create B without changing focus, then start the turn while A is + // visible. This keeps send acceptance out of the hidden-pane path. + const tabB = await createBackgroundShellTab(info, os.tmpdir()) await harness.waitForTabCount(2) - const tabB = (await harness.getActiveTabId())! expect(tabB).not.toBe(tabA) + expect(await harness.getActiveTabId()).toBe(tabA) const tabALocator = page.locator(`[data-context="tab"][data-tab-id="${tabA}"]`) const tabBLocator = page.locator(`[data-context="tab"][data-tab-id="${tabB}"]`) - const sendToHiddenPane = () => page.evaluate(({ sessionId, text }) => { - window.__FRESHELL_TEST_HARNESS__?.sendWsMessage({ - type: 'freshAgent.send', - requestId: 'send-while-hidden', - sessionId, - sessionType: 'freshclaude', - provider: 'claude', - text, - }) - }, { sessionId: bridgeSessionId, text: RAISE_TEXT }) const interruptHiddenPane = () => page.evaluate((sessionId) => { // The Stream Deck stop's exact frame for a fresh-agent pane. window.__FRESHELL_TEST_HARNESS__?.sendWsMessage({ @@ -260,7 +268,15 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without }, bridgeSessionId) // ── 1. An approval RAISED while hidden appears on reveal ───────────── - await sendToHiddenPane() + const composer = paneA.getByRole('textbox', { name: 'Chat message input' }) + await composer.fill(RAISE_TEXT) + await paneA.getByRole('button', { name: 'Send' }).click() + await expect.poll(() => readClaudeSimLedger(simLedger).some( + (row) => row.kind === 'prompt' && row.text === RAISE_TEXT, + ), { timeout: 15_000, message: 'the visible pane starts the approval-triggering prompt' }).toBe(true) + // The delayed canUseTool request now arrives after A is hidden. + await tabBLocator.click() + await expect.poll(() => harness.getActiveTabId(), { timeout: 10_000 }).toBe(tabB) await expect .poll(() => readClaudeSimLedger(simLedger).filter((row) => row.kind === 'approval').length, { timeout: 15_000, diff --git a/test/e2e-browser/specs/fresh-agent-mobile-attention.spec.ts b/test/e2e-browser/specs/fresh-agent-mobile-attention.spec.ts index 819f61f70..d1528462b 100644 --- a/test/e2e-browser/specs/fresh-agent-mobile-attention.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-mobile-attention.spec.ts @@ -232,6 +232,21 @@ test.describe('mobile tab surfaces show needs-attention marks (G17)', () => { // ── 3. An approval wait raised while the user is in another tab ─────── // Send from tab A, then move to tab B inside the 3 s window. Tab A is // first of two now: the strip's right action is "Next tab". + // The server's default Claude permission mode bypasses tool approval. + // Set the user-facing asking mode explicitly so this prompt reaches the + // real canUseTool approval path that the attention assertion covers. + await page.getByRole('button', { name: 'Agent settings', exact: true }).click() + await page.getByRole('combobox', { name: 'Permission mode' }).selectOption('default') + await page.keyboard.press('Escape') + await expect + .poll(async () => findFreshAgentLeaf(await harness.getPaneLayout(tabA))?.content?.permissionMode ?? null) + .toBe('default') + await expect + .poll(() => readClaudeSimLedger(simLedger).some( + (row) => row.kind === 'control' && row.name === 'setPermissionMode' && row.args?.[0] === 'default', + )) + .toBe(true) + await composer.fill(BACKGROUND_APPROVAL) await sendButton.click() await page.getByRole('button', { name: 'Next tab' }).click() diff --git a/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts b/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts index b6e8f55b7..892e46d09 100644 --- a/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts +++ b/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts @@ -24,7 +24,7 @@ import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' import { MACHINE_ID_STORAGE_KEY } from '../../../src/store/storage-keys.js' -import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' +import { claudeSimulatorServerEnv, onPrompt, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' // NOTE: `Page` comes from '@playwright/test' (fixtures.ts exports only // `test`/`expect`, as every donor spec does). `openPanePicker` is IMPORTED, // not copied -- the copied `createFreshclaudePane` body calls it (wall :29, @@ -421,9 +421,18 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { test('durable identity survives browser reload, then SIGKILL restart resumes the SAME conversation', async ({ page }) => { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-identity-freshclaude-')) const projectDir = path.join(sharedRoot, 'project') + const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') await fs.mkdir(projectDir, { recursive: true }) const { server, info, harness } = await bootWall(page, { - env: claudeSimulatorServerEnv({ ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl') }), + env: claudeSimulatorServerEnv({ + ledgerPath: simLedger, + program: { + rules: [ + onPrompt('first turn before reload', [{ kind: 'text', data: { text: 'IDENTITY_FIRST_REPLY' } }]), + onPrompt('second turn after restart', [{ kind: 'text', data: { text: 'IDENTITY_SECOND_REPLY' } }]), + ], + }, + }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -441,7 +450,7 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { // harness, exactly as the wall's leg G does (:1180). await createFreshclaudePane(page, harness, projectDir) await sendFreshAgentTurn(page, harness, tabId!, 'first turn before reload') - await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('Fixture claude turn', { timeout: 30_000 }) + await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('IDENTITY_FIRST_REPLY', { timeout: 30_000 }) // THE FOLD (shipped behavior: FreshAgentView.tsx's durable-identity // merge effect -- cited at plan-time base 7508149b as :1798-1830; on @@ -490,8 +499,8 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { .poll(async () => durableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(tabIdAfterReload!))), { timeout: 30_000 }) .toBe(originalDurable) await sendFreshAgentTurn(page, harness, tabIdAfterReload!, 'second turn after restart') - await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('Fixture claude turn', { timeout: 30_000 }) - await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('first turn before reload', { timeout: 30_000 }) + await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('IDENTITY_SECOND_REPLY', { timeout: 30_000 }) + await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('IDENTITY_FIRST_REPLY', { timeout: 30_000 }) // Every create sent across the reload+restart window targeted the // ORIGINAL session -- no identity-losing re-create. A create IS diff --git a/test/e2e-browser/specs/freshclaude-restart-parity-rust.spec.ts b/test/e2e-browser/specs/freshclaude-restart-parity-rust.spec.ts index b45490f25..54257a19c 100644 --- a/test/e2e-browser/specs/freshclaude-restart-parity-rust.spec.ts +++ b/test/e2e-browser/specs/freshclaude-restart-parity-rust.spec.ts @@ -22,7 +22,7 @@ import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' import type { Page } from '@playwright/test' -import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' +import { claudeSimulatorServerEnv, onPrompt, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' // ESM project ("type": "module" in package.json): __dirname does not exist in // ESM modules -- derive it, same shim as the donor spec @@ -209,8 +209,18 @@ test.describe('freshclaude restart parity (rust)', () => { test.setTimeout(120_000) // restart + resume + snapshot legs are slow const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshclaude-parity-')) const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') + const prompt = 'parity first turn' + const secondPrompt = 'parity second turn' const { server, harness } = await bootWall(page, { - env: claudeSimulatorServerEnv({ ledgerPath: simLedger }), + env: claudeSimulatorServerEnv({ + ledgerPath: simLedger, + program: { + rules: [ + onPrompt(prompt, [{ kind: 'text', data: { text: 'PARITY_FIRST_REPLY' } }]), + onPrompt(secondPrompt, [{ kind: 'text', data: { text: 'PARITY_SECOND_REPLY' } }]), + ], + }, + }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -225,9 +235,8 @@ test.describe('freshclaude restart parity (rust)', () => { const projectDir = path.join(sharedRoot, 'proj') await fsp.mkdir(projectDir, { recursive: true }) await createFreshclaudePane(page, harness, projectDir) - const prompt = `parity first turn ${Math.random().toString(36).slice(2, 10)}` await sendFreshAgentTurn(page, harness, tabId, prompt) - await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('Fixture claude turn') + await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('PARITY_FIRST_REPLY') // Durable identity = the simulator's canonical UUID (via liveDurableIdentity; // content.sessionId stays the create-time nanoid on a live claude pane). @@ -304,9 +313,8 @@ test.describe('freshclaude restart parity (rust)', () => { }) // Same conversation continues: next send round-trips on the resumed session. - const secondPrompt = `parity second turn ${Math.random().toString(36).slice(2, 10)}` await sendFreshAgentTurn(page, harness, tabId, secondPrompt) - await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('Fixture claude turn') + await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('PARITY_SECOND_REPLY') const layout = await harness.getPaneLayout(tabId) expect(liveDurableIdentity(findFreshAgentLeaf(layout))).toBe(originalDurable) } finally { @@ -319,11 +327,15 @@ test.describe('freshclaude restart parity (rust)', () => { test.setTimeout(120_000) // restart + resume legs are slow const sharedRoot = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshclaude-busy-')) const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') + const postWedgePrompt = 'post-wedge turn recovery' const { server, harness } = await bootWall(page, { env: claudeSimulatorServerEnv({ ledgerPath: simLedger, program: { - rules: [{ on: 'msg:prompt', match: { text: 'wedge me' }, emit: [{ kind: 'hang' }] }], + rules: [ + { on: 'msg:prompt', match: { text: 'wedge me' }, emit: [{ kind: 'hang' }] }, + onPrompt(postWedgePrompt, [{ kind: 'text', data: { text: 'POST_WEDGE_REPLY' } }]), + ], }, }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), @@ -378,8 +390,8 @@ test.describe('freshclaude restart parity (rust)', () => { .toBe('idle') // And the conversation is live again on the SAME durable session. - await sendFreshAgentTurn(page, harness, tabId, `post-wedge turn ${Date.now()}`) - await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('Fixture claude turn') + await sendFreshAgentTurn(page, harness, tabId, postWedgePrompt) + await expect(page.locator('[data-context="fresh-agent"]').last()).toContainText('POST_WEDGE_REPLY') const resumed = readClaudeSimLedger(simLedger) .some((row) => row.kind === 'query' && (row.options as any)?.resume === busyDurable) expect(resumed).toBe(true) diff --git a/test/e2e-browser/specs/freshclaude-zero-turn-restart-rust.spec.ts b/test/e2e-browser/specs/freshclaude-zero-turn-restart-rust.spec.ts index 26fbea987..1017f5d59 100644 --- a/test/e2e-browser/specs/freshclaude-zero-turn-restart-rust.spec.ts +++ b/test/e2e-browser/specs/freshclaude-zero-turn-restart-rust.spec.ts @@ -27,7 +27,7 @@ import type { Page } from '@playwright/test' import fs from 'node:fs/promises' import os from 'node:os' import path from 'node:path' -import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' +import { claudeSimulatorServerEnv, onPrompt, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' // --------------------------------------------------------------------------- // Shared helpers (per-spec copies -- donor: restore-contract-wall-rust.spec.ts) @@ -156,7 +156,12 @@ test.describe('freshclaude zero-turn restart (kata 09v1)', () => { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-e2e-zero-turn-')) const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const { server, harness } = await bootWall(page, { - env: claudeSimulatorServerEnv({ ledgerPath: simLedger }), + env: claudeSimulatorServerEnv({ + ledgerPath: simLedger, + program: { + rules: [onPrompt('zero-turn post-restart send', [{ kind: 'text', data: { text: 'ZERO_TURN_REPLY' } }])], + }, + }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -172,10 +177,9 @@ test.describe('freshclaude zero-turn restart (kata 09v1)', () => { return c?.sessionId && c?.createRequestId ? true : null }, { timeout: 30_000 }) .not.toBeNull() - // Wait for the durable identity assigned to this zero-turn pane. The - // production sidecar keeps the create-time identity until the CLI - // reports its session on a prompt, so gate on the - // canonical-UUID SHAPE and capture what this run minted. + // Rust assigns the durable UUID as part of create, before any turn. + // Capture that acknowledgement identity rather than depending on an + // SDK init event that only arrives after a prompt. await expect .poll(async () => { const c = findFreshAgentLeaf(await harness.getPaneLayout(freshTabId))?.content @@ -185,29 +189,40 @@ test.describe('freshclaude zero-turn restart (kata 09v1)', () => { const contentBefore = findFreshAgentLeaf(await harness.getPaneLayout(freshTabId))!.content! const originalDurable = (contentBefore.sessionRef?.sessionId ?? contentBefore.resumeSessionId) as string const createRequestIdBefore = contentBefore.createRequestId as string + const createdFrame = (await harness.getReceivedWsMessages() as any[]).find( + (message) => message?.type === 'freshAgent.created' && message.requestId === createRequestIdBefore, + ) + expect(createdFrame?.sessionRef, 'the create acknowledgement assigns the durable identity before any turn') + .toEqual({ provider: 'claude', sessionId: originalDurable }) + await expect.poll(async () => { + const owners = Object.values((await harness.getState())?.freshAgent?.runtimeOwners ?? {}) as any[] + return owners.find((owner) => owner.provider === 'claude' && owner.sessionId === originalDurable)?.ownerKind ?? null + }, { message: 'the create-time durable identity has a live fresh-agent owner' }).toBe('fresh-agent') // ZERO TURNS, pane stays VISIBLE (no extra tab, no hide): the // transcript on disk is the sidecar's create-time 0-byte file -- // exactly the R10b-excluded shape this regression pins. + await harness.clearSentWsMessages() await server.restartAbrupt() await waitForWsReady(page) - // Positive proof (same discriminator as the hidden-pane spec): the - // simulator ledger must contain a resumed CLI query naming originalDurable. - // post-restart evidence. - // The parity arm resumes by durable UUID or by the transcript's - // .jsonl PATH; both carry the durable UUID -- accept either shape. + // A new query is not required when the original live writer survives + // the server restart. Prove the post-restart attach carries the + // create-time durable reference and the ready handshake reports its + // live owner; both are server-originated lifecycle evidence. await expect .poll(async () => { - const c = findFreshAgentLeaf(await harness.getPaneLayout(freshTabId))?.content - const status = c?.status ?? '' - const usable = c?.sessionId && ['connected', 'idle', 'running'].includes(status) - const resumed = readClaudeSimLedger(simLedger) - .some((row) => row.kind === 'query' && (row.options as any)?.resume === originalDurable) - return usable && resumed ? `resumed:${status}` : null + const sent = await harness.getSentWsMessages() as any[] + return sent.find((message) => message?.type === 'freshAgent.attach' + && message?.sessionRef?.provider === 'claude' + && message?.sessionRef?.sessionId === originalDurable) ?? null }, { timeout: 30_000 }) - .not.toBeNull() + .toMatchObject({ type: 'freshAgent.attach', sessionRef: { provider: 'claude', sessionId: originalDurable } }) + await expect.poll(async () => { + const owners = Object.values((await harness.getState())?.freshAgent?.runtimeOwners ?? {}) as any[] + return owners.find((owner) => owner.provider === 'claude' && owner.sessionId === originalDurable)?.ownerKind ?? null + }, { timeout: 30_000, message: 'the restarted connection observes the fresh-agent owner' }).toBe('fresh-agent') // Negative proof (the pre-fix failure shape must be gone): the // zero-turn session must NOT be adjudicated dead. @@ -218,6 +233,15 @@ test.describe('freshclaude zero-turn restart (kata 09v1)', () => { deadEntries.some((e: any) => e?.sessionRef?.sessionId === originalDurable), 'zero-turn session must not appear in dead-session adjudication', ).toBe(false) + const paneRoot = page.locator('[data-context="fresh-agent"]').last() + const composer = paneRoot.getByRole('textbox', { name: 'Chat message input' }) + await composer.fill('zero-turn post-restart send') + await paneRoot.getByRole('button', { name: 'Send' }).click() + await expect(paneRoot.getByText('ZERO_TURN_REPLY', { exact: true })).toBeVisible({ timeout: 30_000 }) + await expect.poll(() => readClaudeSimLedger(simLedger).some( + (row) => row.kind === 'prompt' && row.text === 'zero-turn post-restart send' && row.sessionId === originalDurable, + ), { timeout: 15_000, message: 'the resumed zero-turn conversation receives the next send under its original UUID' }) + .toBe(true) const contentAfter = findFreshAgentLeaf(await harness.getPaneLayout(freshTabId))!.content! expect(contentAfter.restoreError?.reason ?? null).not.toBe('durable_artifact_missing') // In-place resume: the client's .lost re-create fallback must NOT have diff --git a/test/e2e-browser/specs/hidden-pane-rebind-rust.spec.ts b/test/e2e-browser/specs/hidden-pane-rebind-rust.spec.ts index 41598c0b1..9861bb8c9 100644 --- a/test/e2e-browser/specs/hidden-pane-rebind-rust.spec.ts +++ b/test/e2e-browser/specs/hidden-pane-rebind-rust.spec.ts @@ -24,7 +24,7 @@ import { RustServer } from '../helpers/rust-server.js' import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' -import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' +import { claudeSimulatorServerEnv, onPrompt, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' import type { Page } from '@playwright/test' import fs from 'node:fs/promises' import os from 'node:os' @@ -283,7 +283,12 @@ test.describe('hidden-pane rebind (F8 / P1.11)', () => { const sharedRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-hidden-rebind-')) const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const { server, harness, info } = await bootWall(page, { - env: claudeSimulatorServerEnv({ ledgerPath: simLedger }), + env: claudeSimulatorServerEnv({ + ledgerPath: simLedger, + program: { + rules: [onPrompt('hidden pane post-restart send', [{ kind: 'text', data: { text: 'HIDDEN_REBIND_REPLY' } }])], + }, + }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -304,11 +309,9 @@ test.describe('hidden-pane rebind (F8 / P1.11)', () => { return c?.sessionId && c?.createRequestId ? true : null }, { timeout: 30_000 }) .not.toBeNull() - // Wait for the DURABLE identity (sdk.session.init merge writes the - // canonical UUID to sessionRef.sessionId + resumeSessionId, - // FreshAgentView.tsx mergePaneContent) -- the post-restart attach must - // deterministically carry it so the server's restart-parity resume arm - // (claude.rs handle_attach decision table) can engage. + // Rust assigns the durable UUID in the create acknowledgement before + // any prompt, so the hidden rebind can carry it without waiting for an + // SDK init event. // The simulator mints a RANDOM canonical UUID per conversation (council // follow-up: the old static default was collision-blind), so gate on // the canonical-UUID SHAPE and capture what this run minted below. @@ -320,6 +323,11 @@ test.describe('hidden-pane rebind (F8 / P1.11)', () => { .toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) const contentBefore = findFreshAgentLeaf(await harness.getPaneLayout(freshTabId))!.content! const originalDurable = (contentBefore.sessionRef?.sessionId ?? contentBefore.resumeSessionId) as string + const createdFrame = (await harness.getReceivedWsMessages() as any[]).find( + (message) => message?.type === 'freshAgent.created' && message.requestId === contentBefore.createRequestId, + ) + expect(createdFrame?.sessionRef, 'the create acknowledgement carries the durable Claude identity') + .toEqual({ provider: 'claude', sessionId: originalDurable }) // COMBINED-TREE CONTRACT (F8 rebind x freshclaude restart parity): with // the restart-parity attach arm merged, a post-restart attach carrying // the durable UUID is resumed IN PLACE (sidecar respawn with @@ -337,32 +345,34 @@ test.describe('hidden-pane rebind (F8 / P1.11)', () => { await revealTab(page, harness, shellTabId) await expect.poll(async () => harness.getActiveTabId(), { timeout: 15_000 }).not.toBe(freshTabId) + await harness.clearSentWsMessages() await server.restartAbrupt() await waitForWsReady(page) // TARGET CONTRACT (F8): WITHOUT reveal, the hidden fresh-agent pane's // session recovers to a usable state. DISCRIMINATING evidence -- stale // pre-restart Redux state also shows `sessionId` + 'idle', so client - // state alone proves nothing. The discriminator is SERVER-SIDE: the - // simulator query ledger must contain a boot resuming originalDurable, - // which only the restart-parity - // resume arm emits, and it only runs when THIS hidden pane's - // post-restart freshAgent.attach actually went out (the rebind queue - // under test driving the attach arm). The initial pane create carries - // a fresh boot, so a matching entry is unambiguous post-restart - // evidence. Do not weaken either conjunct. + // state alone proves nothing. Require the hidden pane's actual + // post-restart attach and the server's ready-replayed live owner before + // revealing it. A surviving writer need not start another SDK query. + await expect.poll(async () => { + const sent = await harness.getSentWsMessages() as any[] + return sent.find((message) => message?.type === 'freshAgent.attach' + && message?.sessionRef?.provider === 'claude' + && message?.sessionRef?.sessionId === originalDurable) ?? null + }, { timeout: 30_000, message: 'the hidden pane reattaches under its durable session reference' }) + .toMatchObject({ type: 'freshAgent.attach', sessionRef: { provider: 'claude', sessionId: originalDurable } }) + await expect.poll(async () => { + const owners = Object.values((await harness.getState())?.freshAgent?.runtimeOwners ?? {}) as any[] + return owners.find((owner) => owner.provider === 'claude' && owner.sessionId === originalDurable)?.ownerKind ?? null + }, { timeout: 30_000, message: 'the restarted connection reports the hidden session live owner' }).toBe('fresh-agent') await expect .poll(async () => { const c = findFreshAgentLeaf(await harness.getPaneLayout(freshTabId))?.content const status = c?.status ?? '' const usable = c?.sessionId && ['connected', 'idle', 'running'].includes(status) - // The parity arm resumes by durable UUID when the session's original - // cwd survives, or by the transcript's `.jsonl` PATH when it does - // not (claude.rs decision table, ledger A15). Both carry the durable - // UUID; accept either shape. - const resumed = readClaudeSimLedger(simLedger) - .some((row) => row.kind === 'query' && (row.options as any)?.resume === originalDurable) - return usable && resumed ? `resumed:${status}` : null + const durable = c?.sessionRef?.sessionId ?? c?.resumeSessionId ?? '' + return usable && durable === originalDurable ? `usable:${status}` : null }, { timeout: 30_000 }) .not.toBeNull() @@ -379,6 +389,13 @@ test.describe('hidden-pane rebind (F8 / P1.11)', () => { return ['connected', 'idle', 'running'].includes(leaf?.content?.status ?? '') }, { timeout: 15_000 }) .toBe(true) + const paneRoot = page.locator('[data-context="fresh-agent"]').last() + await paneRoot.getByRole('textbox', { name: 'Chat message input' }).fill('hidden pane post-restart send') + await paneRoot.getByRole('button', { name: 'Send' }).click() + await expect(paneRoot.getByText('HIDDEN_REBIND_REPLY', { exact: true })).toBeVisible({ timeout: 30_000 }) + await expect.poll(() => readClaudeSimLedger(simLedger).some( + (row) => row.kind === 'prompt' && row.text === 'hidden pane post-restart send' && row.sessionId === originalDurable, + ), { timeout: 15_000, message: 'the revealed hidden pane still sends on the original durable session' }).toBe(true) } finally { await server.stop() await fs.rm(sharedRoot, { recursive: true, force: true }).catch(() => {}) diff --git a/test/e2e-browser/specs/mcp-bridge-rust.spec.ts b/test/e2e-browser/specs/mcp-bridge-rust.spec.ts index 413a46260..fc280c625 100644 --- a/test/e2e-browser/specs/mcp-bridge-rust.spec.ts +++ b/test/e2e-browser/specs/mcp-bridge-rust.spec.ts @@ -270,7 +270,16 @@ if (sid) { fs.mkdirSync(dir, { recursive: true }) const transcript = path.join(dir, sid + '.jsonl') if (!fs.existsSync(transcript)) { - fs.writeFileSync(transcript, JSON.stringify({ type: 'user', message: { role: 'user', content: 'seed' }, sessionId: sid, timestamp: new Date().toISOString() }) + '\\n') + // A recorded cwd lets Rust resume by the session UUID while the original + // workspace still exists. Without it, Rust correctly falls back to the + // transcript path, which the simulator treats as a distinct session id. + fs.writeFileSync(transcript, JSON.stringify({ + type: 'user', + message: { role: 'user', content: 'seed' }, + sessionId: sid, + cwd: process.cwd(), + timestamp: new Date().toISOString(), + }) + '\\n') } } process.stdout.write('fake-claude ready\\r\\n') @@ -441,9 +450,9 @@ process.stdin.resume() // LayoutStore-only), carrying the server-preallocated session id S. const picker = await openPanePicker(page) await picker.getByRole('button', { name: /^Claude CLI$/i }).click({ force: true }) - await page - .getByRole('combobox', { name: /Starting directory for Claude CLI/i }) - .press('Enter') + const startingDirectory = page.getByRole('combobox', { name: /Starting directory for Claude CLI/i }) + await startingDirectory.fill(projectDir) + await startingDirectory.press('Enter') const leaf = await expect.poll(async () => { const layout = await harness.getPaneLayout(tabId!) diff --git a/test/e2e-browser/specs/reconnect-revive-rust.spec.ts b/test/e2e-browser/specs/reconnect-revive-rust.spec.ts index b50a8ae58..f04c84cdc 100644 --- a/test/e2e-browser/specs/reconnect-revive-rust.spec.ts +++ b/test/e2e-browser/specs/reconnect-revive-rust.spec.ts @@ -44,7 +44,7 @@ import { RustServer } from '../helpers/rust-server.js' import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' -import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' +import { claudeSimulatorServerEnv, onPrompt, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' import type { Page } from '@playwright/test' import fs from 'node:fs/promises' import os from 'node:os' @@ -372,19 +372,6 @@ async function sendFreshAgentTurn( .toBe('idle') } -/** Count rendered copies of the fixture's fixed assistant reply text inside - * the LAST fresh-agent pane. Per-turn render count is >=1 per turn, so a - * strictly-greater count after the second turn is proof the second reply - * genuinely rendered (the fixture's reply text is constant, so a distinct- - * text assertion is impossible -- count growth is the discriminator). */ -async function fixtureReplyCount(page: Page): Promise { - return page - .locator('[data-context="fresh-agent"]') - .last() - .getByText('Fixture claude turn', { exact: false }) - .count() -} - /** Count prompts the simulated CLI actually started. */ function simulatedPromptCount(ledgerPath: string): number { return readClaudeSimLedger(ledgerPath).filter((row) => row.kind === 'prompt').length @@ -686,7 +673,15 @@ test.describe('reconnect revive (rust)', () => { await fs.mkdir(projectDir, { recursive: true }) const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const { server, harness } = await bootWall(page, { - env: claudeSimulatorServerEnv({ ledgerPath: simLedger }), + env: claudeSimulatorServerEnv({ + ledgerPath: simLedger, + program: { + rules: [ + onPrompt('rr fresh agent turn one', [{ kind: 'text', data: { text: 'RR_FIRST_REPLY' } }]), + onPrompt('rr fresh agent turn two', [{ kind: 'text', data: { text: 'RR_SECOND_REPLY' } }]), + ], + }, + }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -704,10 +699,8 @@ test.describe('reconnect revive (rust)', () => { // One completed turn BEFORE the drop (the plain happy path). await sendFreshAgentTurn(page, harness, freshTabId, 'rr fresh agent turn one') - const replyCountBeforeDrop: number = await expect - .poll(async () => fixtureReplyCount(page), { timeout: 30_000 }) - .toBeGreaterThan(0) - .then(() => fixtureReplyCount(page)) + await expect(page.locator('[data-context="fresh-agent"]').last().getByText('RR_FIRST_REPLY', { exact: true })) + .toBeVisible({ timeout: 30_000 }) const sendsBeforeDrop = simulatedPromptCount(simLedger) expect(sendsBeforeDrop).toBe(1) @@ -737,14 +730,12 @@ test.describe('reconnect revive (rust)', () => { contentBeforeDrop.resumeSessionId, ) - // Discriminating round trip: - // (a) the pane renders strictly MORE replies than survived the drop... + // Discriminating round trip: the scenario-specific second reply is + // visible after reconnect, and the ledger proves its prompt crossed the + // new server connection to the still-live sidecar. await sendFreshAgentTurn(page, harness, freshTabId, 'rr fresh agent turn two') - await expect - .poll(async () => fixtureReplyCount(page), { timeout: 30_000 }) - .toBeGreaterThan(replyCountBeforeDrop) - // ...(b) and the simulator ledger proves the second prompt genuinely - // crossed the (new) server connection to the still-live sidecar. + await expect(page.locator('[data-context="fresh-agent"]').last().getByText('RR_SECOND_REPLY', { exact: true })) + .toBeVisible({ timeout: 30_000 }) await expect .poll(async () => simulatedPromptCount(simLedger), { timeout: 15_000 }) .toBe(2) diff --git a/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts b/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts index e7cbf9f88..13daa874a 100644 --- a/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts +++ b/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts @@ -1325,7 +1325,18 @@ test.describe('Restore Contract Wall (P0.1)', () => { await fs.mkdir(projectDir, { recursive: true }) const { server, harness, info, context, page } = await bootWall(browser, { - env: claudeSimulatorServerEnv({ ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl') }), + env: claudeSimulatorServerEnv({ + ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl'), + program: { + rules: [ + { + on: 'msg:prompt', + match: { text: 'wall freshclaude turn' }, + emit: [{ kind: 'text', data: { text: 'RESTORE_WALL_REPLY' } }], + }, + ], + }, + }), setupHome: seedWallConfig({ providers: ['claude'], freshAgent: true }), }) try { @@ -1363,7 +1374,7 @@ test.describe('Restore Contract Wall (P0.1)', () => { (s?.turns ?? []).some((turn: any) => turn?.role === 'assistant' && (turn?.items ?? []).some( - (item: any) => typeof item?.text === 'string' && item.text.includes('Fixture claude turn'), + (item: any) => typeof item?.text === 'string' && item.text.includes('RESTORE_WALL_REPLY'), ), ), ) @@ -1404,7 +1415,7 @@ test.describe('Restore Contract Wall (P0.1)', () => { }) .toBe(originalSessionId) await expect( - page.locator('[data-context="fresh-agent"]').last().getByText('Fixture claude turn'), + page.locator('[data-context="fresh-agent"]').last().getByText('RESTORE_WALL_REPLY', { exact: true }), ).toBeVisible({ timeout: 30_000 }) const finalLeaf = findFreshAgentLeaf(await harness.getPaneLayout(rehydratedTabId)) expect(finalLeaf?.content?.status).not.toBe('error') diff --git a/test/e2e-browser/specs/wavea-interactions-rust.spec.ts b/test/e2e-browser/specs/wavea-interactions-rust.spec.ts index 32a9144ba..3789f7a3a 100644 --- a/test/e2e-browser/specs/wavea-interactions-rust.spec.ts +++ b/test/e2e-browser/specs/wavea-interactions-rust.spec.ts @@ -34,7 +34,7 @@ import type { E2eServerInfo } from '../helpers/server-fixture-support.js' import { TestHarness } from '../helpers/test-harness.js' import { openPanePicker } from '../helpers/pane-picker.js' import type { Page } from '@playwright/test' -import { claudeSimulatorServerEnv, readClaudeSimLedger } from '../helpers/claude-sim-lane.js' +import { claudeSimulatorServerEnv } from '../helpers/claude-sim-lane.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -320,13 +320,12 @@ test.describe('wave-A cross-lane interactions', () => { try { const fakeClaude = await installFakeCli(path.join(sharedRoot, 'bin'), 'claude', 'fake-claude-cli.mjs') const argvLog = path.join(sharedRoot, 'claude-argv.jsonl') - const simLedger = path.join(sharedRoot, 'claude-sim-ledger.jsonl') const seed = seedConfig({ providers: ['claude'], freshAgent: true }) const server = new RustServer({ env: { CLAUDE_CMD: fakeClaude, FAKE_CLAUDE_ARGV_LOG: argvLog, - ...claudeSimulatorServerEnv({ ledgerPath: simLedger }), + ...claudeSimulatorServerEnv({ ledgerPath: path.join(sharedRoot, 'claude-sim-ledger.jsonl') }), }, setupHome: async (homeDir: string) => { capturedHome = homeDir @@ -360,8 +359,8 @@ test.describe('wave-A cross-lane interactions', () => { }, { timeout: 15_000 }) .not.toBe('') - // Identity store 2 (A2): freshclaude pane -> sidecar cliSessionId index. - // Gate on the canonical-UUID SHAPE, then capture what this run minted. + // Identity store 2 (A2): freshclaude pane -> create-time durable UUID. + // Gate on the canonical-UUID SHAPE, then capture the create acknowledgement. await createFreshclaudePane(page, sharedRoot) await expect .poll(async () => { @@ -373,6 +372,16 @@ test.describe('wave-A cross-lane interactions', () => { const c = freshAgentLeaf(await harness.getPaneLayout(tabId))?.content FRESH_DURABLE = (c?.sessionRef?.sessionId ?? c?.resumeSessionId) as string } + const freshContent = freshAgentLeaf(await harness.getPaneLayout(tabId))!.content! + const createdFrame = (await harness.getReceivedWsMessages() as any[]).find( + (message) => message?.type === 'freshAgent.created' && message.requestId === freshContent.createRequestId, + ) + expect(createdFrame?.sessionRef, 'freshclaude create acknowledges its durable UUID before any prompt') + .toEqual({ provider: 'claude', sessionId: FRESH_DURABLE }) + await expect.poll(async () => { + const owners = Object.values((await harness.getState())?.freshAgent?.runtimeOwners ?? {}) as any[] + return owners.find((owner) => owner.provider === 'claude' && owner.sessionId === FRESH_DURABLE)?.ownerKind ?? null + }, { message: 'the freshclaude UUID is registered as a fresh-agent runtime owner' }).toBe('fresh-agent') expect(terminalSession).not.toBe(FRESH_DURABLE) // FIXTURE REALISM (reconcile adoption): real claude writes @@ -397,6 +406,7 @@ test.describe('wave-A cross-lane interactions', () => { ) // ── ONE abrupt restart; both stores must restore, independently. ── + await harness.clearSentWsMessages() await server.restartAbrupt() await waitForWsReady(page) @@ -414,15 +424,20 @@ test.describe('wave-A cross-lane interactions', () => { const rowsAfter = await readClaudeBindingRows(ledgerDir) expect(rowsAfter.some((r) => r.sessionId === terminalSession)).toBe(true) - // A2 path: the fresh-agent pane resumed in place — server-side proof - // is the simulator query carrying a resume boot (UUID or transcript - // path shape, claude.rs decision table). - await expect - .poll(async () => { - return readClaudeSimLedger(simLedger) - .some((row) => row.kind === 'query' && (row.options as any)?.resume === FRESH_DURABLE) - }, { timeout: 30_000 }) - .toBe(true) + // A2 path: the fresh-agent pane reattaches under the durable UUID. + // A surviving writer does not need to start a second SDK query; the + // post-restart attach and ready-replayed owner are the server evidence. + await expect.poll(async () => { + const sent = await harness.getSentWsMessages() as any[] + return sent.find((message) => message?.type === 'freshAgent.attach' + && message?.sessionRef?.provider === 'claude' + && message?.sessionRef?.sessionId === FRESH_DURABLE) ?? null + }, { timeout: 30_000, message: 'the fresh-agent pane attaches using its create-time UUID' }) + .toMatchObject({ type: 'freshAgent.attach', sessionRef: { provider: 'claude', sessionId: FRESH_DURABLE } }) + await expect.poll(async () => { + const owners = Object.values((await harness.getState())?.freshAgent?.runtimeOwners ?? {}) as any[] + return owners.find((owner) => owner.provider === 'claude' && owner.sessionId === FRESH_DURABLE)?.ownerKind ?? null + }, { timeout: 30_000, message: 'the restarted connection reports the fresh-agent owner' }).toBe('fresh-agent') await expect .poll(async () => { const c = freshAgentLeaf(await harness.getPaneLayout(tabId))?.content From 261254c7df7c16d757d6e1d39105f3cf4c3f1aed Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:16:44 -0700 Subject: [PATCH 060/100] test(freshclaude): exercise WS kill and Stop through the real sidecar --- .../src/claude/tests/test_sidecar_env.rs | 4 +- crates/freshell-ws/tests/common/claude_rig.rs | 3 +- .../tests/freshagent_claude_kill_interrupt.rs | 565 +++--------------- 3 files changed, 85 insertions(+), 487 deletions(-) diff --git a/crates/freshell-freshagent/src/claude/tests/test_sidecar_env.rs b/crates/freshell-freshagent/src/claude/tests/test_sidecar_env.rs index 7b1cd11ec..0069d0d09 100644 --- a/crates/freshell-freshagent/src/claude/tests/test_sidecar_env.rs +++ b/crates/freshell-freshagent/src/claude/tests/test_sidecar_env.rs @@ -66,7 +66,9 @@ impl ClaudeTestRuntime { std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); std::env::set_var( "FRESHELL_CLAUDE_SDK_QUERY_MODULE", - root.join("test/integration/providers/claude-sdk-for-rust-runtime.mjs"), + freshell_platform::opencode_plugin::plugin_file_spec( + &root.join("test/integration/providers/claude-sdk-for-rust-runtime.mjs"), + ), ); std::env::set_var("FRESHELL_TEST_CLAUDE_SPAWN_LOG", &spawn_log); std::env::set_var("FRESHELL_TEST_CLAUDE_INTERRUPT_LOG", &interrupt_log); diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index b67d87347..33a37989e 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -176,7 +176,7 @@ impl ClaudeRig { environment.extend([ ( "FRESHELL_CLAUDE_SDK_QUERY_MODULE".into(), - path_string(&simulator), + freshell_platform::opencode_plugin::plugin_file_spec(&simulator), ), ("FRESHELL_CLAUDE_SIM_LEDGER".into(), path_string(&ledger)), ("FRESHELL_CLAUDE_SIM_TIME_SCALE".into(), "1".into()), @@ -632,6 +632,7 @@ impl ClaudeRig { /// The first sidecar this rig booted dies on its own (SIGKILL of the Node /// process): its session is untracked, the state a reload or a handoff /// resumes from. Returns once `conn` has read the exit. + #[cfg(unix)] pub async fn kill_first_sidecar(&self, conn: &mut RigConnection) { let pid = self.ledger_rows("boot")[0]["pid"] .as_i64() diff --git a/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs b/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs index a0bd283ca..d9ad96c6d 100644 --- a/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs +++ b/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs @@ -1,510 +1,105 @@ -//! Integration-level regression coverage for the claude `freshAgent.kill` / -//! `freshAgent.interrupt` dispatch gap (review-confirmed parity bug): the -//! `terminal.rs` `ClientMessage::FreshAgentKill` / `FreshAgentInterrupt` arms only -//! routed `codex` + `opencode` -- a claude-provider frame fell through the `if`/`else -//! if` chain and was silently dropped. `FreshClaudeState::handle_kill` already existed -//! (9eaaf122, unit-tested) but was UNREACHABLE from the real WS dispatch, and its -//! `create_dedup` cache was therefore never evicted for claude sessions. -//! -//! These tests run a REAL axum server (ephemeral loopback port, never 3001/3002) and a -//! REAL `tokio-tungstenite` client -- the same harness convention as -//! `diag01_lifecycle_events.rs`/`keepalive.rs` -- so they exercise the actual -//! `handle_client_text` dispatch rather than calling `FreshClaudeState` methods -//! directly (that unit-level coverage already exists in `claude.rs`). The claude -//! sidecar itself is swapped for a minimal scripted fake via the SAME -//! `FRESHELL_CLAUDE_SIDECAR`/`FRESHELL_CLAUDE_NODE` production env-var overrides -//! `spawn_sidecar()` already honors -- no network, no cost, no real SDK. -//! -//! The load-bearing assertion (kill): a `freshAgent.create` resent with the SAME -//! `requestId` immediately after an explicit `freshAgent.kill` for the session it -//! created must mint a **fresh** session id, not replay the killed one -- proof that -//! the kill dispatch actually reached `FreshClaudeState::handle_kill` (which evicts the -//! create-dedup cache), not just that a `freshAgent.killed` frame happened to arrive. -//! -//! The interrupt coverage mirrors what legacy does -//! (`server/fresh-agent/adapters/claude/adapter.ts:163-168`'s -//! `interrupt(sessionId) { mapMissingResult(deps.sdkBridge.interrupt(sessionId), ...) }`, -//! `server/sdk-bridge.ts:785-793`'s `sp.query.interrupt().catch(warn)`): a known session -//! with a turn in flight (C18: an idle Stop sends nothing) forwards an `interrupt` -//! request to the sidecar (observed here via a log file the fake -//! sidecar appends to -- the process-boundary analog of "the SDK's `query.interrupt()` -//! was actually invoked"), and legacy's `mapMissingResult` throw (surfaced by -//! `ws-handler.ts:3513-3514`'s `catch` as an `INTERNAL_ERROR` frame) is mirrored for an -//! unknown session. +//! WebSocket kill and interrupt dispatch through the production Claude sidecar. +//! The contract simulator supplies the SDK boundary and records actual CLI +//! controls; every test uses an isolated rig and the real WS dispatcher. -use std::io::Write; -use std::sync::Arc; +use common::claude_rig::{ClaudeRig, RigKind, RIG_FRAME_BUDGET}; +use serde_json::json; use std::time::Duration; -use futures_util::{SinkExt, StreamExt}; -use serde_json::Value; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::Message as WsMessage; - -use freshell_ws::WsState; - -const AUTH_TOKEN: &str = "s3cr3t-token-abcdef"; +mod common; -/// Serializes every test in this file, all of which mutate the process-global -/// `FRESHELL_CLAUDE_SIDECAR`/`FRESHELL_CLAUDE_NODE` env vars (mirrors `claude.rs`'s -/// own `CLAUDE_ENV_LOCK` convention for the identical hazard). +// Retained for the existing control-outcome cases, which also install log capture. +#[cfg(unix)] static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); -// ── fake claude sidecar (production wire protocol only: create/interrupt/shutdown) ── - -/// A minimal scripted fake claude sidecar -- no real `@anthropic-ai/claude-agent-sdk`, -/// no network, no cost. Speaks the SAME newline-JSON protocol `spawn_sidecar()` (the -/// production code, `crates/freshell-freshagent/src/claude.rs`) drives real -/// `crates/freshell-claude-sidecar/index.mjs` with: -/// - `{"type":"create",...}` -> replies `{"type":"created",requestId,sessionId}` -/// with a fresh id every call (never replays), so a genuinely-new spawn is -/// observable by session id alone. -/// - `{"type":"send",...}` -> ignored: the turn stays in flight. -/// - `{"type":"interrupt",sessionId}` -> appends `sessionId` to -/// `FRESHELL_TEST_CLAUDE_INTERRUPT_LOG` (this test's observable proxy for "the -/// sidecar's `query.interrupt()` was actually invoked"). -/// - `{"type":"shutdown"}` -> exits (driven by `FreshClaudeState::handle_kill`'s -/// graceful-shutdown request). -const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" -import fs from 'node:fs' -import readline from 'node:readline' - -let counter = 0 -const rl = readline.createInterface({ input: process.stdin, terminal: false }) -rl.on('line', (line) => { - const trimmed = line.trim() - if (!trimmed) return - let msg - try { - msg = JSON.parse(trimmed) - } catch { - return - } - if (msg.type === 'create') { - counter += 1 - const sessionId = msg.sessionId ?? `fake-claude-session-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') - } else if (msg.type === 'interrupt') { - const log = process.env.FRESHELL_TEST_CLAUDE_INTERRUPT_LOG - if (log) fs.appendFileSync(log, `${msg.sessionId}\n`) - // ep4-r1 F1 wire parity: settle the awaited interrupt (the real sidecar - // emits this after query.interrupt() resolves). - console.log(JSON.stringify({ type: 'sdk.interrupt_settled', sessionId: msg.sessionId, stopId: msg.stopId, ok: true })) - } else if (msg.type === 'shutdown') { - process.exit(0) - } -}) -"#; - -/// A fresh temp dir holding the fake sidecar script + this test's interrupt-log file, -/// with `FRESHELL_CLAUDE_SIDECAR`/`FRESHELL_CLAUDE_NODE`/ -/// `FRESHELL_TEST_CLAUDE_INTERRUPT_LOG` pointed at it. Caller must hold -/// [`CLAUDE_ENV_LOCK`] for the lifetime of the returned guard. -struct FakeClaudeSidecarEnv { - dir: std::path::PathBuf, - interrupt_log: std::path::PathBuf, -} -impl FakeClaudeSidecarEnv { - fn install() -> Self { - let dir = std::env::temp_dir().join(format!( - "freshell-fake-claude-sidecar-ws-{}", - uuid_like_suffix() - )); - std::fs::create_dir_all(&dir).expect("create fake sidecar temp dir"); - let script = dir.join("fake-claude-sidecar.mjs"); - std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); - let interrupt_log = dir.join("interrupt.log"); - std::fs::write(&interrupt_log, "").expect("init interrupt log"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("FRESHELL_TEST_CLAUDE_INTERRUPT_LOG", &interrupt_log); - Self { dir, interrupt_log } - } - - fn interrupt_log_contents(&self) -> String { - std::fs::read_to_string(&self.interrupt_log).unwrap_or_default() - } -} -impl Drop for FakeClaudeSidecarEnv { - 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_INTERRUPT_LOG"); - let _ = std::fs::remove_dir_all(&self.dir); - } -} - -/// Dependency-free unique suffix (avoids pulling in `uuid` for this test crate). -fn uuid_like_suffix() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - format!("{nanos}-{:?}", std::thread::current().id()) -} - -// ── server harness (duplicated from diag01_lifecycle_events.rs's convention, with -// `freshAgent.enabled: true` so `freshAgent.create` actually dispatches) ── - -fn test_settings_value() -> serde_json::Value { - serde_json::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 } - }) -} - -async fn spawn_server() -> String { - 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 state = WsState { - pane_ledger: std::sync::Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), - 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), - serde_json::json!({ "freshAgent": { "enabled": true } }), - ), - fresh_claude: freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)), - 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, - }; - - let router = freshell_ws::router(state); - // Ephemeral loopback port only -- NEVER the self-hosted 3001/3002 ports. - 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") -} - -type TestWs = - tokio_tungstenite::WebSocketStream>; - -async fn connect_and_complete_handshake(url: &str) -> TestWs { - let (mut ws, _resp) = tokio_tungstenite::connect_async(url) - .await - .expect("ws connect"); - ws.send(WsMessage::Text( - serde_json::json!({ - "type": "hello", - "token": AUTH_TOKEN, - "protocolVersion": freshell_protocol::WS_PROTOCOL_VERSION, - }) - .to_string(), - )) - .await - .expect("send hello"); - - // Drain the handshake frames (ready + whatever else precedes it) until `ready`. - loop { - let msg = tokio::time::timeout(Duration::from_secs(5), ws.next()) - .await - .expect("handshake message within timeout") - .expect("stream not ended") - .expect("no ws error"); - let WsMessage::Text(text) = msg else { - continue; - }; - let value: Value = serde_json::from_str(&text).unwrap(); - if value["type"] == "ready" { - break; - } - } - ws -} - -async fn send_json(ws: &mut TestWs, value: &Value) { - ws.send(WsMessage::Text(value.to_string())) - .await - .expect("send frame"); -} - -/// Drain frames until one matching `predicate` arrives (or the budget expires). -async fn await_frame( - ws: &mut TestWs, - budget: Duration, - predicate: impl Fn(&Value) -> bool, -) -> Value { - tokio::time::timeout(budget, async { - loop { - let msg = ws - .next() - .await - .expect("stream not ended") - .expect("no ws error"); - let WsMessage::Text(text) = msg else { - continue; - }; - let value: Value = serde_json::from_str(&text).unwrap(); - if predicate(&value) { - return value; - } - } - }) - .await - .expect("expected frame did not arrive within budget") -} - -fn create_frame(request_id: &str) -> Value { - serde_json::json!({ - "type": "freshAgent.create", - "requestId": request_id, - "sessionType": "freshclaude", - "provider": "claude", - }) -} - -/// **RED before the terminal.rs fix**: a claude-provider `freshAgent.kill` frame falls -/// through the dispatch's `if is_codex_provider(...) else if ...Opencode` chain with no -/// claude arm, so `FreshClaudeState::handle_kill` never runs. Two observable -/// consequences prove the arm now fires (or don't, pre-fix): -/// 1. a `freshAgent.killed{success:true}` frame is broadcast at all (pre-fix: never -/// arrives -> this test times out), and -/// 2. THE LOAD-BEARING ASSERTION: a duplicate `freshAgent.create` resent with the SAME -/// `requestId` right after the kill mints a genuinely fresh session id, proving -/// `handle_kill`'s `create_dedup.clear_for_session` ran (pre-fix: the create-dedup -/// cache was never touched, so the duplicate replays the OLD session id verbatim -- -/// wrong even though no timeout occurs). -#[tokio::test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn claude_kill_frame_reaches_handle_kill_and_evicts_the_create_dedup_cache() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let _sidecar = FakeClaudeSidecarEnv::install(); - - let url = spawn_server().await; - let mut ws = connect_and_complete_handshake(&url).await; - - let request_id = "req-claude-ws-kill-dedup"; - send_json(&mut ws, &create_frame(request_id)).await; - let created = await_frame(&mut ws, Duration::from_secs(10), |v| { - v["type"] == "freshAgent.created" && v["requestId"] == request_id - }) - .await; - let first_session_id = created["sessionId"].as_str().unwrap().to_string(); - - send_json( - &mut ws, - &serde_json::json!({ - "type": "freshAgent.kill", - "provider": "claude", - "sessionId": first_session_id, - "sessionType": "freshclaude", - }), - ) - .await; - // Consequence 1: the `killed` broadcast must arrive at all -- pre-fix, this branch - // is never taken (silent no-op) and the test times out here. - let killed = await_frame(&mut ws, Duration::from_secs(10), |v| { - v["type"] == "freshAgent.killed" && v["sessionId"] == first_session_id - }) - .await; - assert_eq!(killed["success"], true, "{killed}"); - - // Consequence 2 (load-bearing): resend the SAME requestId. If the kill genuinely - // evicted the create-dedup cache, this mints a FRESH session id, not a replay of - // `first_session_id`. - send_json(&mut ws, &create_frame(request_id)).await; - let recreated = await_frame(&mut ws, Duration::from_secs(10), |v| { - v["type"] == "freshAgent.created" && v["requestId"] == request_id - }) - .await; + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let request = "req-claude-ws-kill-dedup"; + let first = conn.create(request).await; + let processes = rig.runtime_processes(&first); + conn.kill(&first).await; + processes + .wait_until_exited("the WS-killed sidecar and CLI") + .await; + let recreated = conn.request_create(request, json!({})).await; + assert_eq!(recreated["type"], "freshAgent.created", "{recreated}"); assert_ne!( - recreated["sessionId"], first_session_id, - "a duplicate create after an explicit claude kill must mint a fresh session, \ - not replay the killed one -- the create_dedup cache was not evicted: {recreated}" + recreated["sessionId"], first, + "kill must evict create dedup, so the same request creates a fresh runtime" ); + assert_eq!(rig.ledger_rows("query").len(), 2); + rig.fresh_claude.shutdown().await; } -/// **RED before the terminal.rs fix**: a claude-provider `freshAgent.interrupt` frame -/// falls through the same dispatch gap. This mirrors what legacy does -/// (`adapters/claude/adapter.ts:163-168` -> `server/sdk-bridge.ts:785-793`'s -/// `sp.query.interrupt().catch(warn)`, fire-and-forget with NO confirmation frame on -/// success): the observable proxy for "the interrupt actually reached the session -/// machinery" is the fake sidecar's interrupt log, since there is no `query.interrupt()` -/// broadcast to assert on (matching legacy's silence on success). -#[tokio::test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn claude_interrupt_frame_reaches_the_sidecar_for_a_known_session() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let sidecar = FakeClaudeSidecarEnv::install(); - - let url = spawn_server().await; - let mut ws = connect_and_complete_handshake(&url).await; - - let request_id = "req-claude-ws-interrupt"; - send_json(&mut ws, &create_frame(request_id)).await; - let created = await_frame(&mut ws, Duration::from_secs(10), |v| { - v["type"] == "freshAgent.created" && v["requestId"] == request_id - }) - .await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - // A turn in flight (the fake never answers it): C18 sends a Stop only then. - send_json( - &mut ws, - &serde_json::json!({ - "type": "freshAgent.send", - "provider": "claude", - "sessionId": session_id, - "sessionType": "freshclaude", - "requestId": "req-claude-ws-send", - "text": "a turn to stop", - }), - ) - .await; - await_frame(&mut ws, Duration::from_secs(10), |v| { - v["type"] == "freshAgent.send.accepted" && v["requestId"] == "req-claude-ws-send" + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let id = conn.create("req-claude-ws-interrupt").await; + conn.send_text(&id, "req-claude-ws-send", "HANG").await; + let accepted = conn + .wait_for("the accepted turn", |frame| { + frame["type"] == "freshAgent.send.accepted" + && frame["requestId"] == "req-claude-ws-send" + }) + .await; + tokio::time::timeout(RIG_FRAME_BUDGET, async { + while !rig + .ledger_rows("prompt") + .iter() + .any(|row| row["sessionId"] == id && row["uuid"] == accepted["submittedTurnId"]) + { + tokio::time::sleep(Duration::from_millis(10)).await; + } }) - .await; - - send_json( - &mut ws, - &serde_json::json!({ - "type": "freshAgent.interrupt", - "provider": "claude", - "sessionId": session_id, - "sessionType": "freshclaude", - }), + .await + .expect("the accepted command reached the simulated CLI"); + conn.send( + json!({"type":"freshAgent.interrupt", "provider":"claude", "sessionType":"freshclaude", + "sessionId":id, "requestId":"req-claude-ws-stop"}), ) .await; - - // Poll the fake sidecar's interrupt log (best-effort bounded wait -- the dispatch is - // a detached `tokio::spawn` task, same pattern as create/send/kill). Pre-fix: the - // claude branch never runs, so the sidecar never receives the interrupt request and - // this loop exhausts its budget with an empty log. - let deadline = tokio::time::Instant::now() + Duration::from_secs(10); - loop { - if sidecar.interrupt_log_contents().contains(&session_id) { - break; - } - if tokio::time::Instant::now() >= deadline { - panic!( - "the claude sidecar never received the interrupt request for {session_id} \ - within budget; log contents: {:?}", - sidecar.interrupt_log_contents() - ); - } - tokio::time::sleep(Duration::from_millis(20)).await; - } + conn.wait_for_event("freshAgent.result").await; + let controls = rig.ledger_rows("interrupt"); + assert_eq!( + controls.len(), + 1, + "the production sidecar called SDK interrupt: {controls:?}" + ); + assert_eq!(controls[0]["running"], true); + assert_eq!( + rig.fresh_claude.live_snapshot_overlay(&id).await["status"], + "idle" + ); + assert_eq!( + conn.events_seen("freshAgent.turn.complete"), + 0, + "the user's own Stop ends the turn silently" + ); + rig.fresh_claude.shutdown().await; } -/// Legacy's `mapMissingResult` throw for an unavailable session -/// (`adapter.ts:163-168`, surfaced by `ws-handler.ts:3513-3514`'s `catch` as an -/// `INTERNAL_ERROR` frame) mirrored: an interrupt for a session id this process never -/// created must produce an `error` frame, not silently vanish. -#[tokio::test] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn claude_interrupt_frame_errors_for_an_unknown_session() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let _sidecar = FakeClaudeSidecarEnv::install(); - - let url = spawn_server().await; - let mut ws = connect_and_complete_handshake(&url).await; - - send_json( - &mut ws, - &serde_json::json!({ - "type": "freshAgent.interrupt", - "provider": "claude", - "sessionId": "does-not-exist", - "sessionType": "freshclaude", - }), + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + conn.send( + json!({"type":"freshAgent.interrupt", "provider":"claude", "sessionType":"freshclaude", + "sessionId":"does-not-exist"}), ) .await; - - let frame = await_frame(&mut ws, Duration::from_secs(10), |v| { - v["event"]["type"] == "freshAgent.error" - }) - .await; - assert_eq!(frame["event"]["code"], "INVALID_SESSION_ID", "{frame}"); - assert_eq!(frame["sessionId"], "does-not-exist"); -} - -/// Sanity guard: writing to a closed log path never panics the harness (keeps clippy -/// happy about the unused `Write` import staying meaningful if the log-append strategy -/// changes; exercises the same append path production code takes). -#[test] -fn interrupt_log_append_is_plain_fs_no_special_handling_needed() { - let dir = std::env::temp_dir().join(format!( - "freshell-interrupt-log-sanity-{}", - uuid_like_suffix() - )); - std::fs::create_dir_all(&dir).unwrap(); - let log = dir.join("interrupt.log"); - let mut f = std::fs::OpenOptions::new() - .create(true) - .append(true) - .open(&log) - .unwrap(); - writeln!(f, "sanity").unwrap(); - assert!(std::fs::read_to_string(&log).unwrap().contains("sanity")); - let _ = std::fs::remove_dir_all(&dir); + let failure = conn.wait_for_event("freshAgent.error").await; + assert_eq!(failure["event"]["code"], "INVALID_SESSION_ID", "{failure}"); + assert_eq!(failure["sessionId"], "does-not-exist"); + assert!( + rig.ledger_rows("query").is_empty(), + "an unknown Stop creates no runtime" + ); + rig.fresh_claude.shutdown().await; } -#[cfg(unix)] -mod common; - #[cfg(unix)] mod control_outcomes { use super::*; From 32c42ca6b7f8f279d24e207593894428bd5e14d4 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:20:48 -0700 Subject: [PATCH 061/100] test(freshclaude): crash the real sidecar after an actual assistant frame --- crates/freshell-freshagent/src/claude.rs | 28 ++++++++++++++---------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 318a43ff3..0c4dfba2c 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -8959,18 +8959,15 @@ pub(crate) mod tests { let (log_events, _log_guard) = crate::claude_sidecar_io::test_capture::capture(); let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + env.program(json!([ + {"kind":"text", "data":{"text":"a partial answer before the crash"}}, + {"kind":"hang"}, + ])); let (st, mut rx) = state_with_bus(); - // One full death cycle: create → a plain send ARMS the turn (the - // fake's create-time idle can land at any point: an idle never ends a - // command the CLI has not reported, C17) → a mid-turn assistant frame - // models the truncated stream → - // the sidecar dies through its own exit hook (the consumer observes - // plain stdout EOF — indistinguishable from a SIGKILL mid-turn, the one - // death shape whose sidecar finally never runs). Returns the minted - // `at`. The `__exit__`/`__emit_assistant__` writes go through - // `inject_raw_send` (NOT handle_send) so no phantom second op arms — - // the armed state under test is exactly the one plain send. + // One accepted command produces an actual assistant block, then its + // model request stays pending. Kill the owned Node process at that + // boundary: stdout ends without sidecar finally or an SDK result. async fn death_cycle( st: &FreshClaudeState, rx: &mut tokio::sync::broadcast::Receiver, @@ -8982,8 +8979,15 @@ pub(crate) mod tests { st.handle_send(send_msg(&sid, "a turn that dies mid-flight")) .await; await_ledger_busy(st, &sid, true).await; - inject_raw_send(st, &sid, "__emit_assistant__").await; - inject_raw_send(st, &sid, "__exit__").await; + await_frame_of_inner_type(rx, "freshAgent.assistant").await; + st.sessions + .lock() + .await + .get_mut(&sid) + .expect("the running session") + .child + .start_kill() + .expect("kill only this test's sidecar"); // The death arm's two frames arrive in stream order (banner, then // the synthesized edge) but the scan is order-agnostic. Poll with a // deadline: a blocking recv could From 456a46dfc23d0e19eb58cf0afd00661cf22e49ed Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:43:21 -0700 Subject: [PATCH 062/100] test(freshclaude): fence checkpointed sends with create-time identity --- .../freshagent_claude_checkpointed_send.rs | 105 ++++++++++-------- 1 file changed, 61 insertions(+), 44 deletions(-) diff --git a/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs b/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs index 0cc848377..0df275ed1 100644 --- a/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs +++ b/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs @@ -7,11 +7,10 @@ //! (`FreshAgentView.tsx`'s `sendFreshAgentSendFrame`), and once a //! `session.runtimeOwner` frame named the pane's durable session id it also //! carries that record's observed `(epoch, generation)` pair. `handle_send` -//! fences such a send through the coordinator. The claude lane's pane id is the -//! sidecar's placeholder; the coordinator owns the session under the durable id -//! the CLI reports in `system/init` (`sdk.session.init`). The real CLI reports -//! it only once a prompt runs; the e2e fake reports it at create. Both orders -//! are covered here. +//! fences such a send through the coordinator. Creation establishes the native +//! UUID and its owner before acknowledgement. The CLI repeats that identity in +//! `system/init` (`sdk.session.init`), either after create or on the first prompt; +//! checkpointed sends must work with both reporting orders. //! //! The claude sidecar is a scripted fake (the production //! `FRESHELL_CLAUDE_SIDECAR`/`FRESHELL_CLAUDE_NODE` overrides): no SDK, no @@ -50,7 +49,6 @@ static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(( const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" import fs from 'node:fs' import readline from 'node:readline' -import { randomUUID } from 'node:crypto' const initAt = process.env.FRESHELL_TEST_CLAUDE_INIT_AT const sendLog = process.env.FRESHELL_TEST_CLAUDE_SEND_LOG @@ -71,8 +69,8 @@ rl.on('line', (line) => { try { msg = JSON.parse(trimmed) } catch { return } if (msg.type === 'create') { counter += 1 - const sessionId = `fake-claude-${process.pid}-${counter}` - const st = { cliSessionId: randomUUID() } + const sessionId = msg.sessionId || `fake-claude-${process.pid}-${counter}` + const st = { cliSessionId: msg.boot?.resume || sessionId } sessions.set(sessionId, st) emit({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) if (initAt === 'create') reportSession(sessionId, st) @@ -410,15 +408,24 @@ impl Conn { } } -/// The durable id the session's `freshAgent.session.init` reported. +/// The browser learns the native identity from create before the CLI reports it. fn durable_id(frames: &[Value], session_id: &str) -> Option { - frames.iter().find_map(|v| { - (v["type"] == "freshAgent.event" - && v["sessionId"] == session_id - && v["event"]["type"] == "freshAgent.session.init") - .then(|| v["event"]["cliSessionId"].as_str().map(str::to_string)) - .flatten() - }) + frames + .iter() + .find_map(|v| { + (v["type"] == "freshAgent.created" && v["sessionId"] == session_id) + .then(|| v["sessionRef"]["sessionId"].as_str().map(str::to_string)) + .flatten() + }) + .or_else(|| { + frames.iter().find_map(|v| { + (v["type"] == "freshAgent.event" + && v["sessionId"] == session_id + && v["event"]["type"] == "freshAgent.session.init") + .then(|| v["event"]["cliSessionId"].as_str().map(str::to_string)) + .flatten() + }) + }) } /// The owner pair the browser holds for the pane: the latest @@ -461,14 +468,16 @@ async fn a_new_panes_messages_each_run_once(init_at: InitAt) { let mut conn = Conn::connect(&url).await; let session_id = conn.create("create-1").await; - if matches!(init_at, InitAt::Create) { - // The session was reported at create: the pane learns its durable id and - // owner pair before the user types, so even the first message carries - // the pair. - conn.wait_until("the reported session's owner frame", |frames| { - observed_owner(frames, &session_id).is_some() - }) - .await; + conn.wait_until("the created session's owner frame", |frames| { + observed_owner(frames, &session_id).is_some() + }) + .await; + if matches!(init_at, InitAt::FirstPrompt) { + assert_eq!( + conn.events_of(&session_id, "freshAgent.session.init"), + 0, + "the first checkpointed send is fenced before any CLI init" + ); } let messages = ["first message", "second message", "third message"]; for (index, text) in messages.iter().enumerate() { @@ -511,8 +520,8 @@ async fn a_new_panes_messages_each_run_once(init_at: InitAt) { mod checkpointed_sends { use super::*; - /// The real CLI reports the session only once a prompt runs: nothing owns the - /// new pane's session in the coordinator when its first message is sent. + /// The first send carries the create-time UUID's owner pair even when the + /// CLI has not emitted its first init yet. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn run_when_the_session_is_reported_by_the_first_prompt() { a_new_panes_messages_each_run_once(InitAt::FirstPrompt).await; @@ -600,14 +609,11 @@ mod checkpointed_sends { assert_eq!(turn_completes(&conn.frames, &session_id).len(), 1); } - /// Before the CLI reports the session, the coordinator can still hold a - /// record under the pane's placeholder id: a lifecycle operation started from - /// a pane with no durable id addresses the session by that id. A message sent - /// while the operation is in flight is refused and never reaches the sidecar; - /// once the operation settles and nothing owns the placeholder, the same - /// message runs. + /// A lifecycle operation can own the newly created native UUID before the + /// first CLI init. Checkpointing must respect it; restoring the known-live + /// prior lets a freshly fenced retry run exactly once. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn wait_for_a_lifecycle_operation_on_the_placeholder_before_the_session_is_reported() { + async fn wait_for_a_lifecycle_operation_on_the_native_uuid_before_the_first_init() { let _env = CLAUDE_ENV_LOCK.lock().await; let sidecar = FakeSidecar::install(InitAt::FirstPrompt); let ownership = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); @@ -619,16 +625,21 @@ mod checkpointed_sends { "claude", &session_id, freshell_ownership::RuntimeOwnerKind::Terminal, - "test-placeholder-handoff", + "test-native-handoff", None, "test", freshell_ownership::now_epoch_ms(), ) else { - panic!("the handoff on the unreported session's placeholder must be granted") + panic!("the handoff on the created native session must be granted") }; - conn.send_like_the_browser(&session_id, "send-during", "message during the handoff") - .await; + conn.send_checkpointed( + &session_id, + "send-during", + "message during the handoff", + Some((ownership.boot_epoch(), generation)), + ) + .await; let refusal = conn.send_outcome("send-during").await; assert_eq!(refusal["type"], "error", "{refusal}"); let message = refusal["message"].as_str().unwrap_or_default(); @@ -636,7 +647,7 @@ mod checkpointed_sends { message.starts_with("SESSION_RESERVED") && message .contains(freshell_freshagent::ownership_lane::LIFECYCLE_OP_GUARD_MESSAGE), - "refused while the handoff owns the placeholder: {refusal}" + "refused while the handoff owns the native UUID: {refusal}" ); assert!( @@ -644,16 +655,22 @@ mod checkpointed_sends { ownership.fail( "claude", &session_id, - "test-placeholder-handoff", + "test-native-handoff", generation, - false + true ), - freshell_ownership::FailOutcome::Vacant { .. } + freshell_ownership::FailOutcome::RestoredPriorOwner ), - "fixture: the failed handoff leaves the placeholder vacant" + "fixture: the failed handoff restores the confirmed live prior" ); - conn.send_like_the_browser(&session_id, "send-after", "message after the handoff") - .await; + let restored = ownership.observe("claude", &session_id); + conn.send_checkpointed( + &session_id, + "send-after", + "message after the handoff", + Some((restored.epoch, restored.generation)), + ) + .await; conn.expect_send_runs(&session_id, "send-after", 1).await; conn.kill(&session_id).await; From a5dfafb67b59358ea9a991c62b72104ab030863d Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:56:53 -0700 Subject: [PATCH 063/100] test(freshclaude): observe create-time ownership before the first CLI prompt --- crates/freshell-ws/tests/common/claude_rig.rs | 31 +++++++++---------- .../tests/freshagent_claude_real_cli.rs | 14 ++++----- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index 33a37989e..33b0f6739 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -808,23 +808,22 @@ impl RigConnection { }) } - /// The owner pair the client would hold for this pane's session: the latest - /// `session.runtimeOwner` frame for the durable id its `session.init` named. + /// The current init identifies an existing conversation; before the first + /// init, create already supplied the native UUID and its owner record. fn observed_owner(&self, session_id: &str) -> Option<(u64, u64)> { - let durable = self.frames.iter().rev().find_map(|v| { - (v["type"] == "freshAgent.event" - && v["event"]["type"] == "freshAgent.session.init" - && v["event"]["sessionId"] == session_id) - .then(|| v["event"]["cliSessionId"].as_str().map(str::to_string)) - .flatten() - })?; - self.frames.iter().rev().find_map(|v| { - (v["type"] == "session.runtimeOwner" - && v["provider"] == "claude" - && v["sessionId"] == durable.as_str()) - .then(|| Some((v["epoch"].as_u64()?, v["generation"].as_u64()?))) - .flatten() - }) + let durable = self + .frames + .iter() + .rev() + .find_map(|v| { + (v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.session.init" + && v["event"]["sessionId"] == session_id) + .then(|| v["event"]["cliSessionId"].as_str().map(str::to_string)) + .flatten() + }) + .unwrap_or_else(|| session_id.to_string()); + self.owner_pair(&durable) } /// Top-level `error` frames answering this request id, read so far. diff --git a/crates/freshell-ws/tests/freshagent_claude_real_cli.rs b/crates/freshell-ws/tests/freshagent_claude_real_cli.rs index 81f1d51a5..cb2d1bfc5 100644 --- a/crates/freshell-ws/tests/freshagent_claude_real_cli.rs +++ b/crates/freshell-ws/tests/freshagent_claude_real_cli.rs @@ -55,18 +55,18 @@ async fn create_send_result_rings_once(kind: RigKind) { } } -/// A new pane's messages are sent the way the browser sends every message: with a -/// checkpoint intent, which the coordinator fences. The CLI reports the session -/// only once a prompt runs (T1), so nothing owns the new pane's session in the -/// coordinator yet: the first send must still run, not be refused as -/// SESSION_RESERVED. Once the session is reported the coordinator owns it under -/// its durable id, and the next send, fenced with the owner pair the client -/// observed for that id, runs too. +/// Both first and later checkpointed sends carry the owner pair of the native +/// UUID acknowledged at create. The first one must run before the CLI's init. async fn first_checkpointed_send_of_a_new_pane_runs(kind: RigKind) { let rig = ClaudeRig::start(kind).await; let mut conn = rig.connect().await; let session_id = conn.create("create-1").await; + assert!( + conn.owner_pair(&session_id).is_some(), + "creation announces ownership before the first SDK init" + ); + conn.send_checkpointed_text(&session_id, "send-1", "hello") .await; let result = conn.wait_for_event("freshAgent.result").await; From 3bd6280e0d0acad3ddbb8f915aea249fef4464c4 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:30:02 -0700 Subject: [PATCH 064/100] test(freshclaude): drive checkpointed sends through real SDK lifecycles --- crates/freshell-ws/tests/common/claude_rig.rs | 8 + .../freshagent_claude_checkpointed_send.rs | 826 +++++------------- 2 files changed, 234 insertions(+), 600 deletions(-) diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index 33b0f6739..ed7d91c93 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -112,6 +112,8 @@ pub struct ClaudeRig { /// (for example `has_live_session`). pub fresh_claude: freshell_freshagent::FreshClaudeState, pub fresh_codex: freshell_freshagent::FreshCodexState, + /// The same coordinator the WS dispatcher and Claude lane use. + pub ownership: Arc, /// The local fake Anthropic endpoint ([`RigKind::Cli`] only), killed on drop. _provider: Option, /// What the server was started with, for [`Self::restart`]. @@ -235,6 +237,7 @@ impl ClaudeRig { http_url, fresh_claude, fresh_codex, + ownership, server, } = spawn_rig_server(environment.clone(), &options, pane_ledger.clone()).await; eprintln!( @@ -254,6 +257,7 @@ impl ClaudeRig { ledger, fresh_claude, fresh_codex, + ownership, _provider: provider, environment, options, @@ -280,6 +284,7 @@ impl ClaudeRig { http_url, fresh_claude, fresh_codex, + ownership, server, } = spawn_rig_server(self.environment.clone(), &self.options, pane_ledger.clone()).await; eprintln!( @@ -290,6 +295,7 @@ impl ClaudeRig { self.http_url = http_url; self.fresh_claude = fresh_claude; self.fresh_codex = fresh_codex; + self.ownership = ownership; self.pane_ledger = pane_ledger; self.server = server; } @@ -1010,6 +1016,7 @@ struct RigServer { http_url: String, fresh_claude: freshell_freshagent::FreshClaudeState, fresh_codex: freshell_freshagent::FreshCodexState, + ownership: Arc, server: tokio::task::JoinHandle<()>, } @@ -1204,6 +1211,7 @@ async fn spawn_rig_server( http_url: format!("http://{addr}"), fresh_claude: lane, fresh_codex: state.fresh_codex.clone(), + ownership, server, } } diff --git a/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs b/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs index 0df275ed1..0b4c6069f 100644 --- a/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs +++ b/crates/freshell-ws/tests/freshagent_claude_checkpointed_send.rs @@ -1,684 +1,310 @@ -//! A new freshclaude/kilroy pane's messages run, sent the way the browser sends -//! them, on a server whose ownership coordinator is wired the way -//! `crates/freshell-server/src/main.rs` wires it (one -//! `RuntimeOwnershipRegistry` shared by the WS state and the claude lane). -//! -//! The browser sends every message with a checkpoint intent -//! (`FreshAgentView.tsx`'s `sendFreshAgentSendFrame`), and once a -//! `session.runtimeOwner` frame named the pane's durable session id it also -//! carries that record's observed `(epoch, generation)` pair. `handle_send` -//! fences such a send through the coordinator. Creation establishes the native -//! UUID and its owner before acknowledgement. The CLI repeats that identity in -//! `system/init` (`sdk.session.init`), either after create or on the first prompt; -//! checkpointed sends must work with both reporting orders. -//! -//! The claude sidecar is a scripted fake (the production -//! `FRESHELL_CLAUDE_SIDECAR`/`FRESHELL_CLAUDE_NODE` overrides): no SDK, no -//! network. It logs every prompt it receives, so "the message ran" is observed -//! at the process boundary, not inferred from a frame. +//! Browser-shaped checkpointed sends through the production sidecar and CLI simulator. +//! Creation owns the native UUID before SDK init. Exercise sends before the +//! first init, after a real warm-up turn, and across coordinator fencing. mod common; -use common::{TestWs, AUTH_TOKEN, FRAME_BUDGET}; - -use std::path::PathBuf; -use std::sync::Arc; - -use futures_util::{SinkExt, StreamExt}; +use common::claude_rig::{ClaudeRig, RigConnection, RigKind}; use serde_json::{json, Value}; -use tokio::net::TcpListener; -use tokio_tungstenite::tungstenite::Message as WsMessage; - -use freshell_ws::WsState; - -/// Serializes every test in this file: they set the process-global sidecar -/// env vars the claude lane reads at spawn time. -static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - -// ── fake claude sidecar ───────────────────────────────────────────────────── - -/// The production newline-JSON sidecar protocol, reduced to create / send / -/// interrupt / shutdown. The session is reported (`sdk.session.init`, the CLI's -/// `system/init`) at the start of every turn, as the real CLI does, and -/// `FRESHELL_TEST_CLAUDE_INIT_AT` picks whether it is also reported earlier: -/// - `first-prompt`: never before the first prompt runs, like the real CLI; -/// - `create`: also right after `created`, like the e2e fake sidecar. -/// -/// Every prompt is one turn: assistant text, a `success` result (the server -/// rings it, C18), then idle. Each prompt's text is appended to -/// `FRESHELL_TEST_CLAUDE_SEND_LOG`. -const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" -import fs from 'node:fs' -import readline from 'node:readline' - -const initAt = process.env.FRESHELL_TEST_CLAUDE_INIT_AT -const sendLog = process.env.FRESHELL_TEST_CLAUDE_SEND_LOG -const sessions = new Map() -let counter = 0 -const emit = (frame) => process.stdout.write(JSON.stringify(frame) + '\n') - -function reportSession(sessionId, st) { - emit({ type: 'sdk.session.init', sessionId, cliSessionId: st.cliSessionId, model: 'fake-model', cwd: process.cwd(), tools: [] }) -} - -const rl = readline.createInterface({ input: process.stdin, terminal: false }) -rl.on('close', () => process.exit(0)) -rl.on('line', (line) => { - const trimmed = line.trim() - if (!trimmed) return - let msg - try { msg = JSON.parse(trimmed) } catch { return } - if (msg.type === 'create') { - counter += 1 - const sessionId = msg.sessionId || `fake-claude-${process.pid}-${counter}` - const st = { cliSessionId: msg.boot?.resume || sessionId } - sessions.set(sessionId, st) - emit({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) - if (initAt === 'create') reportSession(sessionId, st) - emit({ type: 'sdk.status', sessionId, status: 'idle' }) - } else if (msg.type === 'send') { - const st = sessions.get(msg.sessionId) - if (!st) return - if (sendLog) fs.appendFileSync(sendLog, `${msg.text}\n`) - emit({ type: 'sdk.status', sessionId: msg.sessionId, status: 'running' }) - reportSession(msg.sessionId, st) - emit({ type: 'sdk.assistant', sessionId: msg.sessionId, content: [{ type: 'text', text: `echo: ${msg.text}` }], model: 'fake-model' }) - emit({ type: 'sdk.result', sessionId: msg.sessionId, result: 'success' }) - emit({ type: 'sdk.status', sessionId: msg.sessionId, status: 'idle' }) - } else if (msg.type === 'interrupt') { - emit({ type: 'sdk.interrupt_settled', sessionId: msg.sessionId, stopId: msg.stopId, ok: false, message: 'no in-flight SDK query' }) - } else if (msg.type === 'shutdown') { - process.exit(0) - } -}) -"#; - -/// When the fake sidecar reports the session. -#[derive(Clone, Copy)] -enum InitAt { - /// As the first prompt runs (the real CLI). - FirstPrompt, - /// Right after create (the e2e fake sidecar). - Create, -} - -/// The installed fake sidecar: script + prompt log in a temp dir, env vars set. -/// The caller holds [`CLAUDE_ENV_LOCK`] for its lifetime. -struct FakeSidecar { - _dir: tempfile::TempDir, - send_log: PathBuf, -} - -impl FakeSidecar { - fn install(init_at: InitAt) -> Self { - let dir = tempfile::tempdir().expect("fake sidecar temp dir"); - let script = dir.path().join("fake-claude-sidecar.mjs"); - std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); - let send_log = dir.path().join("sends.log"); - std::fs::write(&send_log, "").expect("init send log"); - let init_at = match init_at { - InitAt::FirstPrompt => "first-prompt", - InitAt::Create => "create", - }; - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("FRESHELL_TEST_CLAUDE_INIT_AT", init_at); - std::env::set_var("FRESHELL_TEST_CLAUDE_SEND_LOG", &send_log); - Self { - _dir: dir, - send_log, - } - } - /// Every prompt the sidecar received, in order. - fn prompts(&self) -> Vec { - std::fs::read_to_string(&self.send_log) - .unwrap_or_default() - .lines() - .map(str::to_string) - .collect() - } -} - -impl Drop for FakeSidecar { - 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_INIT_AT"); - std::env::remove_var("FRESHELL_TEST_CLAUDE_SEND_LOG"); - } -} - -// ── server: the ownership coordinator wired like main.rs ──────────────────── - -fn settings_value() -> Value { - let mut settings = common::test_settings_value(); - settings["freshAgent"]["enabled"] = json!(true); - settings -} - -/// A real axum server on an ephemeral loopback port (never 3001/3002) whose -/// claude lane and WS state share ONE ownership coordinator, as in main.rs. -async fn spawn_server(ownership: Arc) -> String { - let auth_token = Arc::new(AUTH_TOKEN.to_string()); - let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(256).0); - let mut fresh_claude = freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)); - fresh_claude.set_ownership(Arc::clone(&ownership)); - - let state = WsState { - pane_ledger: Arc::new(freshell_ws::pane_ledger::PaneLedger::disabled()), - 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: Arc::new(serde_json::from_value(settings_value()).expect("valid settings")), - handshake_settings: Arc::new(tokio::sync::RwLock::new( - serde_json::from_value(settings_value()).expect("valid settings"), - )), - 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_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: Arc::new(freshell_ws::spawn_gate::SpawnGate::new(4, 64)), - shutdown_started: Arc::new(std::sync::atomic::AtomicBool::new(false)), - create_dedupe: Arc::new(freshell_ws::create_dedupe::CreateDedupe::default()), - config_fallback: None, - opencode_locator: None, - codex_locator: None, - activity: None, - 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), - }; - - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("bind ephemeral loopback port"); - let addr = listener.local_addr().expect("local addr"); - let router = freshell_ws::router(state); - tokio::spawn(async move { - let _ = axum::serve(listener, router).await; - }); - format!("ws://{addr}/ws") +fn completions(conn: &RigConnection, id: &str) -> Vec { + conn.frames + .iter() + .filter(|frame| { + frame["sessionId"] == id && frame["event"]["type"] == "freshAgent.turn.complete" + }) + .map(|frame| frame["event"]["at"].as_i64().expect("numeric edge at")) + .collect() } -// ── a browser-shaped connection that keeps every frame it read ────────────── - -struct Conn { - ws: TestWs, - frames: Vec, +fn prompts(rig: &ClaudeRig, id: &str) -> Vec { + rig.ledger_rows("prompt") + .into_iter() + .filter(|row| row["sessionId"] == id) + .collect() } -impl Conn { - async fn connect(url: &str) -> Self { - let (mut ws, _) = tokio_tungstenite::connect_async(url) - .await - .expect("ws connect"); - ws.send(WsMessage::Text( - json!({ - "type": "hello", - "token": AUTH_TOKEN, - "protocolVersion": freshell_protocol::WS_PROTOCOL_VERSION, - }) - .to_string(), - )) - .await - .expect("send hello"); - let mut conn = Self { - ws, - frames: Vec::new(), - }; - conn.wait_for("the ready handshake frame", |v| v["type"] == "ready") - .await; - conn - } - - async fn send(&mut self, frame: Value) { - self.ws - .send(WsMessage::Text(frame.to_string())) - .await - .expect("send frame"); - } - - /// Read frames until `done` holds over every frame read so far. - async fn wait_until(&mut self, what: &str, done: impl Fn(&[Value]) -> bool) { - let deadline = tokio::time::Instant::now() + FRAME_BUDGET; - while !done(&self.frames) { - let remaining = deadline.saturating_duration_since(tokio::time::Instant::now()); - let msg = tokio::time::timeout(remaining, self.ws.next()) - .await - .unwrap_or_else(|_| { - panic!("timed out waiting for {what}; frames: {:#?}", self.frames) - }) - .expect("stream not ended") - .expect("no ws error"); - let WsMessage::Text(text) = msg else { continue }; - self.frames - .push(serde_json::from_str(&text).expect("json frame")); - } - } - - /// The first frame (already read or read now) matching `pred`. - async fn wait_for(&mut self, what: &str, pred: impl Fn(&Value) -> bool) -> Value { - self.wait_until(what, |frames| frames.iter().any(&pred)) - .await; - self.frames - .iter() - .find(|v| pred(v)) - .cloned() - .expect("the frame wait_until saw") - } - - async fn create(&mut self, request_id: &str) -> String { - self.send(json!({ - "type": "freshAgent.create", - "requestId": request_id, - "provider": "claude", - "sessionType": "freshclaude", - })) - .await; - let created = self - .wait_for("freshAgent.created", |v| { - v["type"] == "freshAgent.created" && v["requestId"] == request_id - }) - .await; - created["sessionId"] - .as_str() - .expect("created carries a session id") - .to_string() - } - - fn durable_id(&self, session_id: &str) -> Option { - durable_id(&self.frames, session_id) - } - - fn observed_owner(&self, session_id: &str) -> Option<(u64, u64)> { - observed_owner(&self.frames, session_id) - } - - /// A `freshAgent.send` shaped like the browser's: a checkpoint intent, and - /// the given observed owner pair when there is one. - async fn send_checkpointed( - &mut self, - session_id: &str, - request_id: &str, - text: &str, - observed: Option<(u64, u64)>, - ) { - let mut frame = json!({ - "type": "freshAgent.send", - "provider": "claude", - "sessionType": "freshclaude", - "sessionId": session_id, - "requestId": request_id, - "checkpointIntent": { "logicalAttemptId": format!("attempt-{request_id}") }, - "text": text, - }); - if let Some((epoch, generation)) = observed { - frame["observedEpoch"] = json!(epoch); - frame["observedGeneration"] = json!(generation); - } - self.send(frame).await; - } - - /// The browser's send: the observed pair rides along once it holds one. - async fn send_like_the_browser(&mut self, session_id: &str, request_id: &str, text: &str) { - let observed = self.observed_owner(session_id); - self.send_checkpointed(session_id, request_id, text, observed) - .await; - } - - /// The request's outcome: its `freshAgent.send.accepted` or its `error`. - async fn send_outcome(&mut self, request_id: &str) -> Value { - self.wait_for(&format!("{request_id} to be accepted or refused"), |v| { - v["requestId"] == request_id - && (v["type"] == "error" || v["type"] == "freshAgent.send.accepted") - }) - .await - } - - /// The request ran: it was accepted (never refused) and the session's - /// `turn`-th turn-complete edge arrived. - async fn expect_send_runs(&mut self, session_id: &str, request_id: &str, turn: usize) { - let outcome = self.send_outcome(request_id).await; - assert_eq!( - outcome["type"], "freshAgent.send.accepted", - "{request_id} must run, not be refused: {outcome}" - ); - self.wait_until(&format!("turn-complete edge #{turn}"), |frames| { - turn_completes(frames, session_id).len() >= turn - }) - .await; - } - - /// Kill the session and read through its `freshAgent.killed`, so every frame - /// its turns produced has been read before the counts are taken. - async fn kill(&mut self, session_id: &str) { - self.send(json!({ - "type": "freshAgent.kill", - "provider": "claude", - "sessionType": "freshclaude", - "sessionId": session_id, - })) - .await; - self.wait_for("freshAgent.killed", |v| { - v["type"] == "freshAgent.killed" && v["sessionId"] == session_id +async fn expect_turn( + rig: &ClaudeRig, + conn: &mut RigConnection, + id: &str, + start: usize, + request: &str, + text: &str, + turn: usize, +) { + let outcome = conn + .wait_for_since(start, "the send outcome", |frame| { + frame["requestId"] == request + && matches!( + frame["type"].as_str(), + Some("error" | "freshAgent.send.accepted") + ) }) .await; - } - - fn events_of(&self, session_id: &str, event_type: &str) -> usize { - self.frames - .iter() - .filter(|v| { - v["type"] == "freshAgent.event" - && v["sessionId"] == session_id - && v["event"]["type"] == event_type - }) - .count() - } -} - -/// The browser learns the native identity from create before the CLI reports it. -fn durable_id(frames: &[Value], session_id: &str) -> Option { - frames - .iter() - .find_map(|v| { - (v["type"] == "freshAgent.created" && v["sessionId"] == session_id) - .then(|| v["sessionRef"]["sessionId"].as_str().map(str::to_string)) - .flatten() - }) - .or_else(|| { - frames.iter().find_map(|v| { - (v["type"] == "freshAgent.event" - && v["sessionId"] == session_id - && v["event"]["type"] == "freshAgent.session.init") - .then(|| v["event"]["cliSessionId"].as_str().map(str::to_string)) - .flatten() - }) - }) -} - -/// The owner pair the browser holds for the pane: the latest -/// `session.runtimeOwner` frame for the pane's durable id. -fn observed_owner(frames: &[Value], session_id: &str) -> Option<(u64, u64)> { - let durable = durable_id(frames, session_id)?; - frames.iter().rev().find_map(|v| { - (v["type"] == "session.runtimeOwner" - && v["provider"] == "claude" - && v["sessionId"] == durable.as_str()) - .then(|| Some((v["epoch"].as_u64()?, v["generation"].as_u64()?))) - .flatten() + assert_eq!(outcome["type"], "freshAgent.send.accepted", "{outcome}"); + conn.wait_for_since(start, "the completed turn", |frame| { + frame["sessionId"] == id && frame["event"]["type"] == "freshAgent.turn.complete" + }) + .await; + conn.wait_for_since(start, "the real SDK result", |frame| { + frame["sessionId"] == id && frame["event"]["type"] == "freshAgent.result" }) + .await; + let seen = prompts(rig, id); + assert_eq!( + seen.len(), + turn, + "every accepted turn ran exactly once: {seen:?}" + ); + assert_eq!(seen.last().unwrap()["text"], text); + assert_eq!( + seen.last().unwrap()["uuid"], + outcome["submittedTurnId"], + "the accepted command reached the actual SDK input stream" + ); + assert_eq!(completions(conn, id).len(), turn); } -/// The `at` of every `freshAgent.turn.complete` edge for the session, in order. -fn turn_completes(frames: &[Value], session_id: &str) -> Vec { - frames - .iter() - .filter(|v| { - v["type"] == "freshAgent.event" - && v["sessionId"] == session_id - && v["event"]["type"] == "freshAgent.turn.complete" - }) - .map(|v| v["event"]["at"].as_u64().expect("numeric at")) - .collect() +async fn send_fenced( + conn: &mut RigConnection, + id: &str, + request: &str, + text: &str, + observed: (u64, u64), +) -> usize { + let start = conn.frames.len(); + conn.send( + json!({"type":"freshAgent.send", "provider":"claude", "sessionType":"freshclaude", + "sessionId":id, "requestId":request, "text":text, + "checkpointIntent":{"logicalAttemptId":format!("attempt-{request}")}, + "observedEpoch":observed.0, "observedGeneration":observed.1}), + ) + .await; + start } -// ── tests ─────────────────────────────────────────────────────────────────── - -/// Three messages sent the way the browser sends them each run once: each -/// reaches the sidecar, is accepted, and rings exactly one turn-complete edge. -/// The CLI reports the session again at the start of every turn, so the later -/// messages are sent after a repeated report of the session they belong to. -async fn a_new_panes_messages_each_run_once(init_at: InitAt) { - let _env = CLAUDE_ENV_LOCK.lock().await; - let sidecar = FakeSidecar::install(init_at); - let ownership = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); - let url = spawn_server(Arc::clone(&ownership)).await; - let mut conn = Conn::connect(&url).await; - - let session_id = conn.create("create-1").await; - conn.wait_until("the created session's owner frame", |frames| { - observed_owner(frames, &session_id).is_some() - }) - .await; - if matches!(init_at, InitAt::FirstPrompt) { - assert_eq!( - conn.events_of(&session_id, "freshAgent.session.init"), - 0, - "the first checkpointed send is fenced before any CLI init" - ); - } - let messages = ["first message", "second message", "third message"]; - for (index, text) in messages.iter().enumerate() { - let turn = index + 1; - let request_id = format!("send-{turn}"); - conn.send_like_the_browser(&session_id, &request_id, text) +async fn messages_each_run_once(after_init: bool) { + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let id = conn.create("create-checkpointed").await; + assert!( + conn.owner_pair(&id).is_some(), + "create announced ownership before SDK init" + ); + assert_eq!(conn.events_seen("freshAgent.session.init"), 0); + let mut expected = Vec::new(); + if after_init { + let start = conn.frames.len(); + conn.send_text(&id, "warm-up", "initialize the existing conversation") .await; - conn.expect_send_runs(&session_id, &request_id, turn).await; - // The first prompt made the CLI report the session (in either order), so - // the browser now holds the durable id's owner pair and sends it. - assert!( - conn.observed_owner(&session_id).is_some(), - "the coordinator announced the reported session's owner: {:#?}", - conn.frames - ); + expect_turn( + &rig, + &mut conn, + &id, + start, + "warm-up", + "initialize the existing conversation", + 1, + ) + .await; + assert_eq!(conn.events_seen("freshAgent.session.init"), 1); + expected.push("initialize the existing conversation"); } - - conn.kill(&session_id).await; - assert_eq!( - sidecar.prompts(), - messages.map(str::to_string).to_vec(), - "each message reached the sidecar exactly once" - ); - let edges = turn_completes(&conn.frames, &session_id); + for (index, text) in ["first message", "second message", "third message"] + .into_iter() + .enumerate() + { + let request = format!("send-{index}"); + let start = conn.frames.len(); + conn.send_checkpointed_text(&id, &request, text).await; + expected.push(text); + expect_turn(&rig, &mut conn, &id, start, &request, text, expected.len()).await; + assert!(conn.owner_pair(&id).is_some()); + } + conn.kill(&id).await; assert_eq!( - edges.len(), - messages.len(), - "one turn-complete edge per send: {edges:?}" + prompts(&rig, &id) + .iter() + .map(|row| row["text"].as_str().unwrap()) + .collect::>(), + expected ); + let edges = completions(&conn, &id); assert!( edges.windows(2).all(|pair| pair[0] < pair[1]), - "edges are strictly increasing: {edges:?}" - ); - assert_eq!( - conn.events_of(&session_id, "freshAgent.result"), - messages.len() + "strictly increasing edges: {edges:?}" ); + assert_eq!(conn.events_seen("freshAgent.result"), expected.len()); + rig.fresh_claude.shutdown().await; } mod checkpointed_sends { use super::*; - /// The first send carries the create-time UUID's owner pair even when the - /// CLI has not emitted its first init yet. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn run_when_the_session_is_reported_by_the_first_prompt() { - a_new_panes_messages_each_run_once(InitAt::FirstPrompt).await; + messages_each_run_once(false).await; } - /// The e2e fake reports the session at create: every message is sent after - /// the coordinator owns the session under its durable id, with that record's - /// observed pair. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn run_when_the_session_is_reported_at_create() { - a_new_panes_messages_each_run_once(InitAt::Create).await; + async fn run_after_the_cli_has_initialized_the_existing_conversation() { + messages_each_run_once(true).await; } - /// The fence still guards a reported session, on the record that owns it: a - /// message carrying an owner pair from before a lifecycle transition is - /// refused as stale and never reaches the sidecar; the same message with the - /// re-observed pair runs. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn with_a_stale_owner_pair_are_refused_once_the_session_is_reported() { - let _env = CLAUDE_ENV_LOCK.lock().await; - let sidecar = FakeSidecar::install(InitAt::Create); - let ownership = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); - let url = spawn_server(Arc::clone(&ownership)).await; - let mut conn = Conn::connect(&url).await; - - let session_id = conn.create("create-1").await; - conn.wait_until("the reported session's owner frame", |frames| { - observed_owner(frames, &session_id).is_some() - }) - .await; - let observed = conn - .observed_owner(&session_id) - .expect("observed owner pair"); - let durable = conn.durable_id(&session_id).expect("reported durable id"); - - // A lifecycle transition advances the owner record's generation while the - // session stays live here (a handoff that failed back to this runtime). - let freshell_ownership::BeginOutcome::Granted { generation } = ownership.begin_handoff( + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let id = conn.create("create-stale-fence").await; + let observed = conn.owner_pair(&id).expect("create-time owner pair"); + let freshell_ownership::BeginOutcome::Granted { generation } = rig.ownership.begin_handoff( "claude", - &durable, + &id, freshell_ownership::RuntimeOwnerKind::Terminal, - "test-generation-bump", + "generation-bump", None, "test", freshell_ownership::now_epoch_ms(), ) else { - panic!("the generation-bump handoff must be granted") + panic!("the handoff must start") }; - let _ = ownership.fail("claude", &durable, "test-generation-bump", generation, true); - let current = ownership.observe("claude", &durable); + assert!(matches!( + rig.ownership + .fail("claude", &id, "generation-bump", generation, true), + freshell_ownership::FailOutcome::RestoredPriorOwner + )); + let current = rig.ownership.observe("claude", &id); assert!( matches!( current.state, freshell_ownership::OwnershipState::Live { .. } - ) && current.generation > observed.1, - "fixture: the record is still live at a newer generation: {current:?}" + ) && current.generation > observed.1 ); - - conn.send_checkpointed(&session_id, "send-stale", "stale message", Some(observed)) + let start = send_fenced(&mut conn, &id, "stale", "stale message", observed).await; + let refused = conn + .wait_for_since(start, "the stale refusal", |frame| { + frame["requestId"] == "stale" + && matches!( + frame["type"].as_str(), + Some("error" | "freshAgent.send.accepted") + ) + }) .await; - let refusal = conn.send_outcome("send-stale").await; - assert_eq!(refusal["type"], "error", "{refusal}"); - let message = refusal["message"].as_str().unwrap_or_default(); + assert_eq!(refused["type"], "error", "{refused}"); + let message = refused["message"].as_str().unwrap(); assert!( message.starts_with("SESSION_RESERVED") && message.contains(freshell_freshagent::ownership_lane::STALE_OP_GUARD_MESSAGE), - "refused as stale against the record that owns the session: {refusal}" + "{refused}" ); - - conn.send_checkpointed( - &session_id, - "send-current", + let start = send_fenced( + &mut conn, + &id, + "current", "current message", - Some((current.epoch, current.generation)), + (current.epoch, current.generation), ) .await; - conn.expect_send_runs(&session_id, "send-current", 1).await; - - conn.kill(&session_id).await; + expect_turn(&rig, &mut conn, &id, start, "current", "current message", 1).await; + // These fence-only fixtures bypass the handoff runner's retained-stamp + // repair. They own their rig, so shut it down and prove its processes + // exit without pretending the complete handoff adapter ran. + let processes = rig.runtime_processes(&id); + rig.fresh_claude.shutdown().await; + processes + .wait_until_exited("the registry-transition fixture") + .await; assert_eq!( - sidecar.prompts(), - vec!["current message".to_string()], - "the stale message never reached the sidecar" + prompts(&rig, &id) + .iter() + .map(|row| row["text"].as_str().unwrap()) + .collect::>(), + ["current message"] ); - assert_eq!(turn_completes(&conn.frames, &session_id).len(), 1); + assert_eq!(completions(&conn, &id).len(), 1); } - /// A lifecycle operation can own the newly created native UUID before the - /// first CLI init. Checkpointing must respect it; restoring the known-live - /// prior lets a freshly fenced retry run exactly once. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn wait_for_a_lifecycle_operation_on_the_native_uuid_before_the_first_init() { - let _env = CLAUDE_ENV_LOCK.lock().await; - let sidecar = FakeSidecar::install(InitAt::FirstPrompt); - let ownership = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); - let url = spawn_server(Arc::clone(&ownership)).await; - let mut conn = Conn::connect(&url).await; - - let session_id = conn.create("create-1").await; - let freshell_ownership::BeginOutcome::Granted { generation } = ownership.begin_handoff( + let rig = ClaudeRig::start(RigKind::Sim).await; + let mut conn = rig.connect().await; + let id = conn.create("create-lifecycle-fence").await; + assert_eq!(conn.events_seen("freshAgent.session.init"), 0); + let freshell_ownership::BeginOutcome::Granted { generation } = rig.ownership.begin_handoff( "claude", - &session_id, + &id, freshell_ownership::RuntimeOwnerKind::Terminal, - "test-native-handoff", + "native-handoff", None, "test", freshell_ownership::now_epoch_ms(), ) else { - panic!("the handoff on the created native session must be granted") + panic!("the native handoff must start") }; - - conn.send_checkpointed( - &session_id, - "send-during", + let start = send_fenced( + &mut conn, + &id, + "during", "message during the handoff", - Some((ownership.boot_epoch(), generation)), + (rig.ownership.boot_epoch(), generation), ) .await; - let refusal = conn.send_outcome("send-during").await; - assert_eq!(refusal["type"], "error", "{refusal}"); - let message = refusal["message"].as_str().unwrap_or_default(); + let refused = conn + .wait_for_since(start, "the lifecycle refusal", |frame| { + frame["requestId"] == "during" + && matches!( + frame["type"].as_str(), + Some("error" | "freshAgent.send.accepted") + ) + }) + .await; + assert_eq!(refused["type"], "error", "{refused}"); + let message = refused["message"].as_str().unwrap(); assert!( message.starts_with("SESSION_RESERVED") && message .contains(freshell_freshagent::ownership_lane::LIFECYCLE_OP_GUARD_MESSAGE), - "refused while the handoff owns the native UUID: {refusal}" + "{refused}" ); - - assert!( - matches!( - ownership.fail( - "claude", - &session_id, - "test-native-handoff", - generation, - true - ), - freshell_ownership::FailOutcome::RestoredPriorOwner - ), - "fixture: the failed handoff restores the confirmed live prior" - ); - let restored = ownership.observe("claude", &session_id); - conn.send_checkpointed( - &session_id, - "send-after", + assert!(matches!( + rig.ownership + .fail("claude", &id, "native-handoff", generation, true), + freshell_ownership::FailOutcome::RestoredPriorOwner + )); + let restored = rig.ownership.observe("claude", &id); + let start = send_fenced( + &mut conn, + &id, + "after", "message after the handoff", - Some((restored.epoch, restored.generation)), + (restored.epoch, restored.generation), ) .await; - conn.expect_send_runs(&session_id, "send-after", 1).await; - - conn.kill(&session_id).await; + expect_turn( + &rig, + &mut conn, + &id, + start, + "after", + "message after the handoff", + 1, + ) + .await; + // These fence-only fixtures bypass the handoff runner's retained-stamp + // repair. They own their rig, so shut it down and prove its processes + // exit without pretending the complete handoff adapter ran. + let processes = rig.runtime_processes(&id); + rig.fresh_claude.shutdown().await; + processes + .wait_until_exited("the registry-transition fixture") + .await; assert_eq!( - sidecar.prompts(), - vec!["message after the handoff".to_string()], - "the message sent during the handoff never reached the sidecar" + prompts(&rig, &id) + .iter() + .map(|row| row["text"].as_str().unwrap()) + .collect::>(), + ["message after the handoff"] ); - assert_eq!(turn_completes(&conn.frames, &session_id).len(), 1); + assert_eq!(completions(&conn, &id).len(), 1); } } From 2e6694ed56af2032fe38809a2551250a9e9014fb Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:33:10 -0700 Subject: [PATCH 065/100] refactor(freshclaude): route native lineage without placeholder machinery Replace parallel alias, broadcast identity, and close-gate maps with per-runtime native history and an owned close guard. Retain persisted legacy alias reads and consume them on explicit reopen. Library check and unwired two-reset recovery regression pass; legacy test fixture migration follows this checkpoint. --- crates/freshell-freshagent/src/claude.rs | 1745 ++--------------- .../freshell-freshagent/src/claude/deadman.rs | 12 +- .../src/claude/establish.rs | 60 +- crates/freshell-freshagent/src/claude/fork.rs | 29 +- .../freshell-freshagent/src/claude/genesis.rs | 17 +- .../src/claude/pending_runtime.rs | 12 +- .../src/claude/recovery.rs | 4 - .../src/claude/recovery_stop.rs | 25 +- .../freshell-freshagent/src/claude/rekey.rs | 24 +- .../src/claude/rollback.rs | 20 +- crates/freshell-freshagent/src/claude/stop.rs | 6 +- .../src/claude/teardown_tests.rs | 2 +- .../src/claude/tests/deadman_tests.rs | 43 + .../src/claude/turn_status.rs | 20 +- .../src/claude_sidecar_io.rs | 35 +- .../src/session_handoff.rs | 2 - 16 files changed, 262 insertions(+), 1794 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 1fc713be7..c022f6bc6 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -12,8 +12,13 @@ //! //! | Client→server | Behaviour | //! |---|---| -//! | `freshAgent.create {sessionType:'freshclaude'\|'kilroy',…}` | spawn the Node sidecar (ownership-tagged, isolated HOME inherited) → SDK `query()` → the SDK bridge's **BARE nanoid** placeholder id (NO placeholder→durable materialization — claude's send returns void), broadcast `freshAgent.created`, start the stdout consumer | -//! | `freshAgent.send {sessionId,text}` | push the user turn into the sidecar's SDK input stream, broadcast `freshAgent.send.accepted` (NO `submittedTurnId` — claude) | +//! | `freshAgent.create {sessionType:'freshclaude'\|'kilroy',…}` | allocate a native Claude UUID, establish ownership and recovery metadata, then acknowledge the sidecar and broadcast `freshAgent.created` with its durable ref | +//! | `freshAgent.send {sessionId,text}` | recover an ended runtime when needed, apply settings, then push the user turn into the SDK input stream and broadcast its correlated acceptance | +//! +//! The live map uses the current native UUID. A conversation reset moves the +//! same runtime to its new UUID; its native history keeps late requests routed +//! to that runtime through further resets and process recovery. Each observer +//! receives a private attach acknowledgement without changing event identity. //! //! ## Events + the attention edges //! @@ -75,6 +80,7 @@ use freshell_protocol::{ use crate::claude_handshake::{read_created, BootContext}; use crate::claude_launch::{ClaudeBoot, ClaudeLaunchSpec}; use crate::{FreshAgentCreateDedup, FreshAgentCreateOutcome, SharedPaneIdentitySink}; +use runtime_identity::RuntimeIdentity; use turn_status::LedgerStep; /// The runtime provider (`AGENT_SESSION_TYPES.claude.provider`). @@ -148,13 +154,8 @@ pub struct FreshClaudeState { broadcast_tx: Arc>, /// Server-owned durability edge for checkpoint-related provider frames. checkpoint_event_sink: crate::CheckpointEventSink, - /// placeholder-nanoid → live claude session (sidecar stdin + owned child + consumer). + /// Current native Claude UUID → owned sidecar runtime and consumer. sessions: Arc>>, - /// durable Claude UUID (`cliSessionId` from `sdk.session.init`) -> sessions-map key. - /// THE restart-parity index (plan §2.8 item 2): lets attach/snapshot find a live - /// session by its durable identity instead of the process-ephemeral placeholder. - /// pub(crate) so in-crate tests (and snapshot wiring) can inspect it. - pub(crate) cli_index: Arc>>, /// `freshAgent.create` requestId dedup (parity gap fix -- see the module doc on /// [`crate::FreshAgentCreateDedup`]): single-flight + replay cache so a client /// resending the SAME `requestId` on every reconnect while a pane is @@ -209,20 +210,6 @@ pub struct FreshClaudeState { /// target-spawn-window abort test's deterministic hold. `None` in /// production and every other test. handoff_resume_pause: Option>, - /// b8ke focused episode-2 round-2 F3 test seam: when set, the - /// session-init adoption parks BETWEEN its phase-1 read scope and the - /// claim/publication — the deterministic hold for the - /// kill-between-read-and-publish window the publication's incarnation - /// revalidation closes. `None` in production and every other test. - #[cfg(test)] - adoption_pause: Option>, - /// b8ke focused episode-2 post-cap F3 test seam: park AFTER the claim - /// and publication, immediately BEFORE the ownership commit — the - /// deterministic stale-commit window (the test fences the claimed key - /// while parked; the released commit must fail, tear down, and - /// answer Abandoned with NOTHING persisted). - #[cfg(test)] - adoption_commit_pause: Option>, /// b8ke e3r2 F2 test seam: park in the KILL path between the stop /// claim and the retained-stamp take — the deterministic /// watcher-race window (the test removes the stamp while parked; @@ -277,38 +264,6 @@ pub struct FreshClaudeState { /// 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 - /// here (placeholder map key -> every durable id it answered to), TTL'd, - /// instead of dropped. Read for RETIRE resolution ONLY by - /// [`Self::handle_kill`]: the client closes a pane by its ORIGINAL bare - /// placeholder sessionId, and without this record a kill arriving after - /// the sidecar's exit-eviction cannot recover the durable id the ledger - /// row is keyed on — the retire silently missed it (the finding). Round - /// 3 lifecycle (focused-ep5-r2 Finding 2): a genuine claim (resume/ - /// attach of the durable) CONSUMES its records - /// ([`AliasTombstones::clear_for_durable`] via `commit_session_claim`), - /// and `collect_kill_retire_ids` additionally skips any surviving record - /// whose durable id is live on a DIFFERENT session — a stale alias can - /// never retire a reopened session. Never consulted by live-resolution - /// paths (`resolve_session_key`, send/interrupt/attach, snapshots): a - /// tombstoned session is DEAD, and this record answers ids for retire - /// writes only — it makes nothing look alive. - alias_tombstones: Arc>, - /// Focused-episode-6 round 4 (Finding F5): the kill's enumeration gate — - /// map_key -> in-flight close count. A kill INCREMENTS this under the - /// same one critical section (cli_index → sessions → alias_tombstones → - /// this map — the only acquire order anywhere) that enumerates the - /// session's complete durable identity set, so that set is COMPLETE by - /// construction: any adoption minting a durable id behind the - /// enumeration reads a nonzero gate under the identical locks and - /// ABANDONS (never a post-envelope discovery). Decremented only when - /// the envelope fails Clean (the kill aborts and the session is - /// genuinely untouched — sends/mints resume); a successful or - /// persisted-durably close tears the session down and the gate entry - /// dies with it. - close_pending: Arc>>, /// Unified agent names (Task 2): the injected naming authority — same /// set-once/shared model as [`Self::identity_sink`]. Wired by /// `freshell-server::main`; unwired = scoped naming fails unavailable. @@ -327,185 +282,6 @@ pub struct FreshClaudeState { test_holds: Arc, } -/// Focused-ep5-r1 Finding 1: retention for demoted alias records. The -/// kill-after-exit window is human-scale (the user notices the crashed pane -/// and closes it) — hours cover it with margin while bounding memory. The -/// CAP is a deliberate second bound for a pathological crash storm (records -/// are tiny; eviction is oldest-freshest-first). -/// -/// Retire-on-kill round 5 (focused-ep5-r4 Finding 2): the alias lifetime IS -/// the row lifetime. A long-lived pane (days) whose sidecar exit-demoted its -/// alias is still closed by its bare placeholder, so the placeholder→durable -/// mapping must survive as long as the row it can resolve to. TTL expiry and -/// capacity eviction therefore consult the ROW STATE (via the `row_bound` -/// probe every public method takes): a record whose durable row is still -/// Bound is never expired and never an eviction candidate; only a record -/// whose row is already Retired-or-GC'd may be discarded (its close can no -/// longer miss anything live). A wholly bound set may exceed the cap -/// (logged; correctness outranks the memory bound — the records are tiny). -const ALIAS_TOMBSTONE_TTL_MS: i64 = 6 * 60 * 60 * 1000; -const ALIAS_TOMBSTONE_CAP: usize = 1024; - -/// One demoted alias: the durable claude UUID the placeholder map key -/// aliased, and when the demotion happened (the TTL clock). -struct AliasTombstone { - durable: String, - at_ms: i64, -} - -/// The demoted-alias store (Finding 1). Writes sweep lazily (TTL + cap); the -/// kill path's read sweeps too, so expired records never answer a kill. -#[derive(Default)] -struct AliasTombstones { - by_placeholder: HashMap>, -} - -impl AliasTombstones { - /// Demote one (placeholder, durable) alias. A repeat demotion refreshes - /// the record's time (the newest evidence wins the TTL). `row_bound` is - /// the retention probe (round 5, Finding 2): the durable row's raw Bound - /// state — see [`Self::sweep`]. - fn record( - &mut self, - placeholder: &str, - durable: &str, - now_ms: i64, - row_bound: &dyn Fn(&str) -> bool, - ) { - let entries = self - .by_placeholder - .entry(placeholder.to_string()) - .or_default(); - if let Some(existing) = entries.iter_mut().find(|e| e.durable == durable) { - existing.at_ms = now_ms; - } else { - entries.push(AliasTombstone { - durable: durable.to_string(), - at_ms: now_ms, - }); - } - self.sweep(now_ms, row_bound); - } - - /// Every durable id this placeholder has answered to within the TTL — - /// the kill path's evicted-alias resolution (retire set material, never - /// a liveness answer). Round 5 (Finding 2): TTL-discard consults the row - /// state — a still-Bound row's records never age out (the pane's close - /// still needs them). - fn durables_for( - &mut self, - placeholder: &str, - now_ms: i64, - row_bound: &dyn Fn(&str) -> bool, - ) -> Vec { - self.sweep(now_ms, row_bound); - self.by_placeholder - .get(placeholder) - .map(|entries| entries.iter().map(|e| e.durable.clone()).collect()) - .unwrap_or_default() - } - - /// Round 6 (focused-ep5-r5 Finding 2): fold a DURABLE alias record into - /// the in-memory store — the kill consult's persisted arm. The persisted - /// `at_ms` is kept as the TTL truth (never refreshed to now by the fold; - /// a fresher in-memory demotion keeps its newer stamp). The sweep - /// follows, so the same row-state retention rule decides. - fn record_persisted( - &mut self, - placeholder: &str, - durable: &str, - at_ms: i64, - now_ms: i64, - row_bound: &dyn Fn(&str) -> bool, - ) { - let entries = self - .by_placeholder - .entry(placeholder.to_string()) - .or_default(); - if let Some(existing) = entries.iter_mut().find(|e| e.durable == durable) { - existing.at_ms = existing.at_ms.max(at_ms); - } else { - entries.push(AliasTombstone { - durable: durable.to_string(), - at_ms, - }); - } - self.sweep(now_ms, row_bound); - } - - /// Focused-ep5-r2 Finding 2 (retire-on-kill round 3): a genuine claim of - /// `durable` CONSUMES every demoted alias record pointing to it — the - /// reopened identity is live again, so a later kill naming one of those - /// old placeholders must no longer retire the live durable row. Returns - /// the placeholder keys whose records pointed at `durable` (the claim - /// clears their LEDGER kill fences too — every known alias of the - /// reopened identity reopens together). Other durables' records under - /// the same placeholder (a same-key re-registration answered to several) - /// are untouched. - fn clear_for_durable( - &mut self, - durable: &str, - now_ms: i64, - row_bound: &dyn Fn(&str) -> bool, - ) -> Vec { - self.sweep(now_ms, row_bound); - let mut cleared = Vec::new(); - self.by_placeholder.retain(|placeholder, entries| { - let before = entries.len(); - entries.retain(|e| e.durable != durable); - if entries.len() != before { - cleared.push(placeholder.clone()); - } - !entries.is_empty() - }); - cleared - } - - /// Round 5 (focused-ep5-r4 Finding 2): the sweep's discard rule consults - /// ROW STATE, never age alone. TTL expiry drops only records whose - /// durable row is already Retired-or-GC'd (`row_bound` false — the - /// mapping can no longer miss a live close); a record resolving to a - /// still-Bound row survives any age because the pane's close (by the - /// bare placeholder, days after the exit-eviction) still needs it. - /// Capacity eviction likewise only ever takes an UNBOUND placeholder - /// (oldest-newest first); if every record is bound, the store runs over - /// the cap (loudly — records are tiny, and correctness outranks the - /// memory bound). - fn sweep(&mut self, now_ms: i64, row_bound: &dyn Fn(&str) -> bool) { - self.by_placeholder.retain(|_, entries| { - entries.retain(|e| row_bound(&e.durable) || now_ms - e.at_ms < ALIAS_TOMBSTONE_TTL_MS); - !entries.is_empty() - }); - while self.by_placeholder.len() > ALIAS_TOMBSTONE_CAP { - // Evict the placeholder whose NEWEST record is oldest — but only - // among placeholders with NO bound-row record (a bound-row record - // outlives the cap by the finding's rule). - let oldest = self - .by_placeholder - .iter() - .filter(|(_, entries)| entries.iter().all(|e| !row_bound(&e.durable))) - .map(|(k, entries)| { - ( - k.clone(), - entries.iter().map(|e| e.at_ms).max().unwrap_or(i64::MIN), - ) - }) - .min_by_key(|(_, newest)| *newest) - .map(|(k, _)| k); - let Some(oldest) = oldest else { - tracing::warn!(target: "freshell_freshagent::claude", - size = self.by_placeholder.len(), - cap = ALIAS_TOMBSTONE_CAP, - "freshagent.claude.alias_tombstones_over_cap: every record resolves to a \ - still-Bound row; the alias lifetime is the row lifetime (finding 2)"); - break; - }; - self.by_placeholder.remove(&oldest); - } - } -} - -/// The cached result of a completed claude/kilroy `freshAgent.create`, keyed by /// `requestId` in [`FreshClaudeState::create_dedup`]: what the original /// `freshAgent.created` said, so the replay branch re-answers identically (a resume /// create's `sessionRef` included). @@ -527,7 +303,7 @@ fn claude_session_ref(session_id: &str) -> freshell_protocol::SessionLocator { /// The per-session pending approval/question set folded from the sidecar's stdout /// stream (Task 2). Feeds the respond handlers' membership check and the Task 3 -/// snapshot overlay. Lives behind a shared handle (the `broadcast_id` precedent): the +/// snapshot overlay. Lives behind a shared handle (the `identity` precedent): the /// detached stdout consumer folds into it while the respond handlers read/mutate it. /// Session eviction on EOF/exit drops the Arc with the record — no extra handling. #[derive(Default)] @@ -621,40 +397,12 @@ struct ClaudeSession { /// (Task 6), where the map key is the CLIENT's original id. `handle_send`/ /// `handle_interrupt` MUST address the sidecar with this id, never the map key. sidecar_session_id: String, - /// Best-effort copy of the durable Claude UUID, recorded from `sdk.session.init` - /// by the stdout consumer. Attach/eviction resolve durable ids through - /// [`FreshClaudeState::cli_index`], and the snapshot adapter is disk-only; the - /// settings paths (a settings apply, the CLI's own permission-mode change) read - /// it to write the session's binding row once the session has one. - cli_session_id: Option, - /// b8ke focused episode-2 round-2 F2: the operation id of the lifecycle - /// op that OWNS this runtime's coordinator claim — the create-resume - /// claim's op id, or the handoff runner's op id for an under-ticket - /// target resume. The session-init adoption's Blocked arm correlates - /// the blocking coordinator state's operation id against this marker: - /// only the runtime's OWN operation (which will resolve the key — the - /// create's commit, the runner's commit) may proceed without a claim; - /// an UNRELATED blocking operation (another device's handoff on the - /// still-vacant canonical key, a foreign start) fails CLOSED — the - /// freshly spawned runtime is torn down, never left running unowned - /// while the other operation believes there was no prior owner. - #[cfg(test)] - owning_operation: Option, - /// b8ke ext r27 F2: the GENERATION of the lifecycle op that owns this - /// runtime's coordinator transition (the handoff runner's under-ticket - /// target resume) — `owning_operation`'s numeric twin. The session-init - /// adoption's durable binding write stamps the row with this generation - /// when the adoption holds no own ticket (the under-ticket shape), so - /// the target row carries the HANDOFF's generation instead of - /// preserving the prior one (pre-r27 the under-ticket write carried no - /// pair and a delayed prior-generation write could pass the ledger's - /// comparison and replace the new owner's recovery metadata). + /// A handoff target's supplied generation, used to bind recovery metadata + /// before the runner commits its replacement ownership. owning_generation: Option, - /// The envelope-stamp id the stdout consumer reads PER EVENT (Task 10b). Starts as - /// the sessions-map key; an attach-by-durable REBIND flips it to the durable id so - /// the pane keyed on the durable receives events. A shared mutable handle because - /// the consumer task runs detached from this record. - broadcast_id: Arc>, + /// Current native UUID, prior native IDs, and process-local close guards. + /// Consumers and diagnostics share it so a reset changes identity once. + identity: RuntimeIdentity, /// The folded pending approval/question set (Task 2) — see [`ClaudePending`]. pending: Arc>, /// The session's busy truth (design §5.2, C17): every command the server @@ -762,21 +510,6 @@ pub struct AdoptionTestPause { pub reached: std::sync::atomic::AtomicBool, } -/// b8ke focused episode-2 round-3 F2: the adoption's tri-state answer. -/// `Published` — the alias publication landed (the session is reachable by -/// its advertised durable id). `Abandoned` — the adoption definitively -/// landed NOTHING (the mint gate, a session that is gone or replaced, a -/// refused claim) — the caller must FAIL, never continue with an -/// unreachable live runtime. `Deferred` — the session is not yet -/// registered; the publication lands post-registration and the completion -/// hook carries the final answer. -#[cfg(test)] -enum SessionInitAdoptionOutcome { - Published, - Abandoned, - Deferred, -} - impl FreshClaudeState { /// Build the state around the shared broadcast bus. pub fn new(broadcast_tx: Arc>) -> Self { @@ -788,7 +521,6 @@ impl FreshClaudeState { broadcast_tx, checkpoint_event_sink: crate::CheckpointEventSink::default(), sessions: Arc::new(TokioMutex::new(HashMap::new())), - cli_index: Arc::new(TokioMutex::new(HashMap::new())), create_dedup: Arc::new(FreshAgentCreateDedup::new()), resuming: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())), resuming_changed: Arc::new(tokio::sync::Notify::new()), @@ -799,8 +531,6 @@ impl FreshClaudeState { 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(), dead_sessions: Default::default(), test_holds: Arc::default(), @@ -812,9 +542,6 @@ impl FreshClaudeState { handoff_resume_pause: None, condemned_priors: Arc::new(std::sync::Mutex::new(HashMap::new())), #[cfg(test)] - adoption_pause: None, - #[cfg(test)] - adoption_commit_pause: None, #[cfg(test)] kill_stop_take_pause: None, #[cfg(test)] @@ -946,27 +673,6 @@ impl FreshClaudeState { self.resolve_session_key(session_id).await } - /// b8ke focused episode-2 round-3 F3: crate-visible adoption driver for - /// 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, - ) { - self.adopt_session_init(cli_id, session_id, session_type, None, None, None) - .await; - } - - /// b8ke focused episode-2 round-2 F3 test seam installer. - #[cfg(test)] - pub fn set_adoption_test_pause(&mut self, pause: Option>) { - self.adoption_pause = pause; - } - /// b8ke e3r3 F8 test seam installer (the clean-failure pre-restore park). #[cfg(test)] pub fn set_kill_close_failure_test_pause( @@ -994,16 +700,6 @@ impl FreshClaudeState { self.kill_after_resolve_pause = pause; } - /// b8ke focused episode-2 post-cap F3 test seam installer (the - /// pre-commit park). - #[cfg(test)] - pub fn set_adoption_commit_test_pause( - &mut self, - pause: Option>, - ) { - self.adoption_commit_pause = pause; - } - /// b8ke focused episode-2 round-3 F3: resolve a wire session id to /// its canonical coordinator key. The COORDINATOR is the single /// source of truth: a re-keyed session's old key holds an @@ -1207,60 +903,19 @@ impl FreshClaudeState { &self.leases } - /// The coordinator key a claude session is owned under. The coordinator - /// keys a claude session by its durable id (the map key and the pane's wire - /// id are the lane's own placeholder), so once the CLI has reported the - /// session (`system/init`, which it emits only once a prompt runs, T1) that - /// id is the key, as in `handle_attach`. Before the report, or when - /// `map_key` names no live session, the wire id is. Either resolves through - /// the coordinator's alias walk. Kill and send both address the owner - /// through this one rule. C23 (durable id minted at create) makes the - /// durable id known from the start and removes the wire-id fallback. - async fn claude_owner_key(&self, map_key: Option<&str>, wire_id: &str) -> ClaudeOwnerKey { - let durable = match map_key { - Some(map_key) => self + /// Every registered runtime has a native UUID before create returns. + /// Unknown IDs still consult the coordinator's persisted rekey chain. + async fn claude_owner_key(&self, map_key: Option<&str>, wire_id: &str) -> String { + let native = match map_key { + Some(key) => self .sessions .lock() .await - .get(map_key) - .and_then(|session| session.cli_session_id.clone()), + .get(key) + .map(|runtime| runtime.identity.current()), None => None, }; - match durable { - Some(durable) => ClaudeOwnerKey { - key: self.resolve_ownership_key(&durable), - reported: true, - }, - None => ClaudeOwnerKey { - key: self.resolve_ownership_key(wire_id), - reported: false, - }, - } - } - - /// The coordinator key that fences a send to the session behind `map_key`, - /// or `None` when the send runs unfenced. A reported session is always - /// fenced on its durable id, and a supplied observed pair is always checked - /// (against a record the coordinator does not hold, it is stale and the - /// guard refuses it typed). The one unfenced case is a session the CLI has - /// not reported, with no record in the coordinator and no pair on the - /// send: a new pane's first message, which is what makes the CLI report - /// it. Such a session has no durable id to hand off, reattach or roll back, - /// and a close removes it from the map. - async fn send_owner_key( - &self, - map_key: &str, - wire_id: &str, - fence: Option, - ) -> Option { - let owner = self.claude_owner_key(Some(map_key), wire_id).await; - if owner.reported || fence.is_some() { - return Some(owner.key); - } - match self.ownership_snapshot(PROVIDER, &owner.key).state { - freshell_ownership::OwnershipState::Vacant => None, - _ => Some(owner.key), - } + self.resolve_ownership_key(native.as_deref().unwrap_or(wire_id)) } /// Side-effect-free coordinator read for `(provider, session_id)` @@ -1348,10 +1003,6 @@ impl FreshClaudeState { return crate::session_handoff::StopResult::AlreadyGone; }; let removed = self.sessions.lock().await.remove(&map_key); - self.close_pending - .lock() - .expect("close-pending lock") - .remove(&map_key); let Some(session) = removed else { return crate::session_handoff::StopResult::AlreadyGone; }; @@ -1379,7 +1030,7 @@ impl FreshClaudeState { .unwrap_or(TREE_DEATH_CONFIRM_ROUNDS), ) .await; - self.evict_cli_index_aliases(&map_key).await; + self.leases.clear_binding(PROVIDER, session_id); match confirmation { TeardownConfirmation::Confirmed => { @@ -1455,7 +1106,6 @@ impl FreshClaudeState { cwd: Option<&str>, tab_id: Option<&str>, pane_id: Option<&str>, - operation_id: &str, generation: u64, ) -> Result { let Some(flavor) = session_type_from_flavor(session_type) else { @@ -1523,13 +1173,6 @@ impl FreshClaudeState { }, &mut lease_guard, &mut own_ticket, - // b8ke focused episode-2 round-2 F2: the RUNNER's handoff op - // owns this target resume's coordinator transition — the - // session records it for the adoption's Blocked correlation. - Some(operation_id.to_string()), - // b8ke ext r27 F2: the SUPPLIED handoff generation — the - // session-init adoption's binding write stamps the target - // row with it (the under-ticket shape holds no own ticket). Some(generation), ) .await; @@ -1713,7 +1356,7 @@ impl FreshClaudeState { // Retain the stamp under the NEW id with the SAME // consistent identity the registry committed (the lane's // kill/exit paths resolve the canonical durable through - // the session's current cli_session_id — the new id — and + // the runtime's current native UUID — the new id — and // take the stamp from exactly this key). let (stamp_generation, stamp_operation_id) = match ticket.as_ref() { Some(ticket_ref) => ( @@ -1936,13 +1579,7 @@ impl FreshClaudeState { let interrupted_ids: Vec = drained .iter() .filter(|session| session.ledger.lock().expect("turn ledger lock").busy()) - .map(|session| { - session - .cli_session_id - .as_deref() - .unwrap_or(&session.sidecar_session_id) - .to_string() - }) + .map(|session| session.identity.current()) .collect(); futures_util::future::join_all( interrupted_ids @@ -1974,7 +1611,6 @@ impl FreshClaudeState { ); } } - self.cli_index.lock().await.clear(); } fn broadcast(&self, msg: &ServerMessage) { @@ -2388,7 +2024,6 @@ impl FreshClaudeState { // 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 { @@ -2401,7 +2036,6 @@ impl FreshClaudeState { }), rekey_provisional_claim: false, recorded_settings: Some(settings), - owning_operation, owning_generation: None, provenance, naming: Some(&msg), @@ -2537,7 +2171,7 @@ impl FreshClaudeState { /// 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 + /// (send/attach route to it via Task 10b's native runtime history resolution) under the /// loser's own `requestId` — no spawn, no second writer. async fn adopt_live_create( &self, @@ -2656,52 +2290,8 @@ impl FreshClaudeState { } }; - // Retire-on-kill (delta-review round 5 + focused-ep5-r1 round 2, - // restore-open-sessions-only): an explicit kill is an intentional - // session END — every durable id this kill covers retires its - // pane-ledger row `Closed`, so the recovery inventory (Bound-only at - // its `row_is_bound` pre-filter) can never re-offer a pane the user - // just closed inside the 7s creation-race grace window. The row is - // keyed on the DURABLE claude UUID while the wire kill names the - // pane's ORIGINAL bare placeholder (or a durable alias), so the - // retire set is { the wire id } ∪ { the removed session's - // cli_session_id } ∪ { every LIVE cli_index alias of the map key } ∪ - // { every EVICTED alias the placeholder ever answered to (the round-2 - // alias tombstones — Finding 1: the client closes the pane by its - // bare placeholder even after the sidecar's exit-eviction dropped the - // alias map entry; the round-1 retire set silently missed the row - // in exactly that case). An UNKNOWN/evicted durable id is still - // retired by name: the session map is process memory while the row - // is durable. - // - // Finding 2's closing fence rides WITH the retire: the ledger folds a - // durable KILL TOMBSTONE into `retire_closed`, and the ONE binder - // choke point (`record_fresh_agent_binding`) consults it under the - // same index guard as the write — so a binding write already in - // flight at kill time (an aborted consumer's orphaned spawn_blocking - // closure, which NO task abort can cancel) suppresses itself instead - // of restoring Bound after the retire. - // - // Delta-r6-r3 (focused-episode-6 round 2, Finding 4) — ONE close - // envelope, then teardown. The close's identity set is computed - // FIRST (the Task-10b alias resolution + the pre-destruction - // collection below — no await ever interleaves per-identity DURABLE - // writes while live state is being destroyed), then the WHOLE set - // closes as a single failure-atomic ledger envelope - // (`retire_closed_batch` → `PaneLedger::close_identities`): - // tombstones + row retires under ONE guard hold, rolling the - // envelope's OWN writes back on any failure. The delta-r6/r6-r2 - // two-pass shape could close the wire/alias identities first and — a - // later authoritative-session close failing — report `success:false` - // with the live session preserved and standing tombstones still on - // disk: a tombstone with no matching row reads as CLOSED in the - // recovery inventory, suppressing a genuinely open session. A FAILED - // envelope here aborts the kill the same way but leaves NOTHING - // durable; a retried kill re-attempts idempotently. - // - // Task 10b: durable ids resolve through `cli_index` (alias, don't move) -- - // a kill addressed by the durable id must tear down the live aliased session. - // Unresolvable ids keep today's idempotent success path. + // Discover the full native lineage before durable close or teardown. + // The ledger closes it atomically; a failed close preserves the writer. let mut map_key = self .resolve_session_key(&session_id) .await @@ -2713,38 +2303,15 @@ impl FreshClaudeState { .store(true, std::sync::atomic::Ordering::SeqCst); let _ = pause.notify.notified().await; } - // Focused-episode-6 round 4 (Finding F5) — discovery completes - // BEFORE the one envelope and BEFORE teardown. ONE critical section - // (cli_index → sessions → alias_tombstones → close_pending — the - // exact acquisition `adopt_session_init` duplicates) arms the mint - // gate AND enumerates the session's COMPLETE durable identity set: - // the wire id, the recorded cli id, every live cli_index alias, - // every evicted/persisted alias record pointing at the map key. - // From this instant every adoption abandons, so the enumerated set - // is complete by construction — nothing durable exists yet (the - // envelope writes next), and a Clean failure simply releases the - // gate with the session untouched. + // Serialize identity discovery with native rekeys. The owned guard + // prevents further rekeys until this close settles or is cancelled. let mut retire_ids = vec![session_id.clone()]; let mut expected_stop_stamp = None; + let _close_guard; { - let index = self.cli_index.lock().await; let sessions = self.sessions.lock().await; - let mut tombstones = self.alias_tombstones.lock().expect("alias tombstones lock"); - // `resolve_session_key` ran before this critical section. A - // conversation reset can move the same runtime in between and - // leave the addressed id as an alias to its new map key. Refresh - // the key against these exact index+map guards so the close gate, - // retire set, and eventual teardown all name the same runtime. - let current_map_key = if sessions.contains_key(&map_key) { - Some(map_key.clone()) - } else { - index - .get(&session_id) - .filter(|mapped| sessions.contains_key(*mapped)) - .cloned() - }; - if let Some(current_map_key) = current_map_key { - map_key = current_map_key; + if let Some(current_key) = Self::session_key_in(&sessions, &session_id) { + map_key = current_key; } if let Some(expected_owner) = expected_owner { let Some(session) = sessions @@ -2758,19 +2325,19 @@ impl FreshClaudeState { return Ok(()); }; if self.ownership.is_some() { - let durable = session.cli_session_id.as_deref().unwrap_or(&map_key); - let Some(stamp) = - crate::ownership_lane::peek_retained_stamp(&self.ownership_stamps, durable) - .filter(|stamp| { - // The receipt names the process-tree tag; the - // coordinator names a lifecycle operation. The - // exact runtime record ties them together. - stamp.owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent - && stamp.owner.live_session_key.as_deref() - == Some(map_key.as_str()) - && stamp.owner.pid == session.child.id() - }) - else { + let durable = session.identity.current(); + let Some(stamp) = crate::ownership_lane::peek_retained_stamp( + &self.ownership_stamps, + &durable, + ) + .filter(|stamp| { + // The receipt names the process-tree tag; the + // coordinator names a lifecycle operation. The + // exact runtime record ties them together. + stamp.owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent + && stamp.owner.live_session_key.as_deref() == Some(map_key.as_str()) + && stamp.owner.pid == session.child.id() + }) else { tracing::warn!(target: "freshell_freshagent::claude", event = "freshagent.claude.abandoned_create_stamp_missing", session_id, expected_owner, @@ -2782,17 +2349,14 @@ impl FreshClaudeState { expected_stop_stamp = Some((durable.to_string(), stamp)); } } - if sessions.contains_key(&map_key) { - let mut gates = self.close_pending.lock().expect("close-pending lock"); - *gates.entry(map_key.clone()).or_insert(0) += 1; - } - self.collect_kill_retire_ids_under_guards( + _close_guard = sessions + .get(&map_key) + .map(|session| session.identity.begin_close()); + self.collect_kill_retire_ids_under_guard( &session_id, &map_key, &mut retire_ids, - &index, &sessions, - &mut tombstones, ); } // b8ke delta round-3 F1: THE COORDINATOR CLAIM COMES FIRST — @@ -2849,7 +2413,7 @@ impl FreshClaudeState { .lock() .await .get(&map_key) - .and_then(|s| s.cli_session_id.clone()); + .map(|s| s.identity.current()); durable.and_then(|d| { crate::ownership_lane::peek_retained_stamp( &self.ownership_stamps, @@ -2950,25 +2514,8 @@ impl FreshClaudeState { "fresh_agent_kill_refused: the ownership coordinator refused the \ stop (kata b8ke) — nothing is killed" ); - // Task 3 review I-1: the refusal killed NOTHING, so it - // must roll the pre-kill mint gate back exactly like - // the Clean-failure abort arm above — a held gate makes - // `adopt_session_init` abandon forever, suppressing the - // still-live session's alias/binding corrector writes, - // and the refusal's own "retry after it settles" - // message promises a retryable, fully operational - // session. - { - let _index = self.cli_index.lock().await; - let _sessions = self.sessions.lock().await; - let mut gates = self.close_pending.lock().expect("close-pending lock"); - if let Some(n) = gates.get_mut(&map_key) { - *n -= 1; - if *n == 0 { - gates.remove(&map_key); - } - } - } + // Returning releases this runtime's close guard, so a + // refused stop leaves it able to accept a later reset. self.broadcast(&ServerMessage::FreshAgentKilled(FreshAgentKilled { provider: PROVIDER.to_string(), session_id, @@ -2992,8 +2539,7 @@ impl FreshClaudeState { let live_key = self.resolve_session_key(&session_id).await; let canonical = self .claude_owner_key(live_key.as_deref(), &session_id) - .await - .key; + .await; let snap = registry.observe(PROVIDER, &canonical); match snap.state.clone() { freshell_ownership::OwnershipState::Live { @@ -3015,18 +2561,6 @@ impl FreshClaudeState { owner is NOT this lane's Fresh Agent runtime — a delayed \ kill never fabricates a foreign-kind claim" ); - { - let _index = self.cli_index.lock().await; - let _sessions = self.sessions.lock().await; - let mut gates = - self.close_pending.lock().expect("close-pending lock"); - if let Some(n) = gates.get_mut(&map_key) { - *n -= 1; - if *n == 0 { - gates.remove(&map_key); - } - } - } self.broadcast(&ServerMessage::FreshAgentKilled(FreshAgentKilled { provider: PROVIDER.to_string(), session_id, @@ -3150,18 +2684,6 @@ impl FreshClaudeState { coordinator refused the observed-owner stop — nothing \ is killed, nothing durable is touched" ); - { - let _index = self.cli_index.lock().await; - let _sessions = self.sessions.lock().await; - let mut gates = - self.close_pending.lock().expect("close-pending lock"); - if let Some(n) = gates.get_mut(&map_key) { - *n -= 1; - if *n == 0 { - gates.remove(&map_key); - } - } - } self.broadcast(&ServerMessage::FreshAgentKilled( FreshAgentKilled { provider: PROVIDER.to_string(), @@ -3191,17 +2713,6 @@ impl FreshClaudeState { "fresh_agent_kill_refused_no_stamp: a handoff owns this \ session's transition — the kill is refused typed" ); - { - let _index = self.cli_index.lock().await; - let _sessions = self.sessions.lock().await; - let mut gates = self.close_pending.lock().expect("close-pending lock"); - if let Some(n) = gates.get_mut(&map_key) { - *n -= 1; - if *n == 0 { - gates.remove(&map_key); - } - } - } self.broadcast(&ServerMessage::FreshAgentKilled(FreshAgentKilled { provider: PROVIDER.to_string(), session_id, @@ -3222,17 +2733,6 @@ impl FreshClaudeState { "fresh_agent_kill_refused_no_stamp: a lifecycle operation is \ in flight — the kill is refused typed" ); - { - let _index = self.cli_index.lock().await; - let _sessions = self.sessions.lock().await; - let mut gates = self.close_pending.lock().expect("close-pending lock"); - if let Some(n) = gates.get_mut(&map_key) { - *n -= 1; - if *n == 0 { - gates.remove(&map_key); - } - } - } self.broadcast(&ServerMessage::FreshAgentKilled(FreshAgentKilled { provider: PROVIDER.to_string(), session_id, @@ -3252,17 +2752,6 @@ impl FreshClaudeState { "fresh_agent_kill_refused_no_stamp: the key is FENCED — the \ kill is refused typed (the fence owns the transition)" ); - { - let _index = self.cli_index.lock().await; - let _sessions = self.sessions.lock().await; - let mut gates = self.close_pending.lock().expect("close-pending lock"); - if let Some(n) = gates.get_mut(&map_key) { - *n -= 1; - if *n == 0 { - gates.remove(&map_key); - } - } - } self.broadcast(&ServerMessage::FreshAgentKilled(FreshAgentKilled { provider: PROVIDER.to_string(), session_id, @@ -3373,17 +2862,6 @@ impl FreshClaudeState { } } } - { - let _index = self.cli_index.lock().await; - let _sessions = self.sessions.lock().await; - let mut gates = self.close_pending.lock().expect("close-pending lock"); - if let Some(n) = gates.get_mut(&map_key) { - *n -= 1; - if *n == 0 { - gates.remove(&map_key); - } - } - } self.broadcast(&ServerMessage::FreshAgentKilled(FreshAgentKilled { provider: PROVIDER.to_string(), session_id, @@ -3420,12 +2898,6 @@ impl FreshClaudeState { sessions.remove(&map_key) } }; - // The gate entry dies with the session (the map rule was the gate's - // whole meaning; an aborted session adopts nothing more). - self.close_pending - .lock() - .expect("close-pending lock") - .remove(&map_key); if let Some(session) = &removed { session.consumer.abort(); } @@ -3480,12 +2952,6 @@ impl FreshClaudeState { .clear_for_session(|record| record.session_id == map_key) .await; - // Evict the killed session's durable-id index entries — DEMOTED to - // the alias tombstones (Finding 1), so a repeat kill naming the bare - // placeholder still resolves the durable row (the `sdk.session.init` - // race-in case is covered: the alias collection above ran first). - self.evict_cli_index_aliases(&map_key).await; - // Round 5 (focused-ep5-r4 Finding 1), the kill's post-close SWEEP: // the durable close and the session-map removal are not one atomic // step, and a concurrent claim lane registers through the same edge @@ -3745,7 +3211,6 @@ impl FreshClaudeState { // Keep the alias bookkeeping consistent with the removal // (demote, never drop — a later kill naming this placeholder // still resolves the durable row). - self.evict_cli_index_aliases(&map_key).await; } } } @@ -3792,122 +3257,51 @@ impl FreshClaudeState { } } - /// Retire-on-kill round 3 (focused-ep5-r2 Findings 2+3): the kill's - /// retire-set completion — ONE critical section across all three - /// alias-resolution sources, with the lock order `cli_index` → - /// `sessions` → `alias_tombstones` held for the whole consult (no awaits - /// inside). Focused-episode-6 round 4 (Finding F5): the kill calls it - /// ONCE, under the SAME acquisition that arms the close-pending mint - /// gate — identity discovery is complete BEFORE the one durable close - /// envelope is written, so post-envelope discovery is impossible by - /// construction (the round's "discovery before teardown" rule). A - /// post-removal re-run (the defensive probe) sees the demoted - /// tombstones/persisted records where the live copies went (focused- - /// ep5-r2 Finding 3's exit-eviction gap). - /// - /// Finding 2 (the tightened tombstone rule): a TOMBSTONE-sourced - /// candidate is followed only when live session state does not - /// contradict it — if the durable id currently resolves to a DIFFERENT - /// placeholder whose session is live, the identity was genuinely - /// reopened after the close, and the stale alias record is dead. (The - /// genuine claim normally CONSUMES those records at reopen — - /// `commit_session_claim`; this check is the belt to that suspenders, - /// catching any record that escapes consumption.) Live aliases of THIS - /// kill's own map key and the session's recorded cli id always retire: - /// they ARE the identity the kill is closing. + /// Close every native identity retained by the runtime or its death + /// record. Persisted legacy placeholder records remain readable so panes + /// saved by older servers can still close their durable conversation. async fn collect_kill_retire_ids( &self, wire_session_id: &str, map_key: &str, retire_ids: &mut Vec, ) { - // Lock order: cli_index -> sessions -> alias_tombstones (-> the - // kill's close-pending gate). All reads are synchronous once the - // guards are held (the std Mutexes are never held across an await). - let index = self.cli_index.lock().await; let sessions = self.sessions.lock().await; - let mut tombstones = self.alias_tombstones.lock().expect("alias tombstones lock"); - self.collect_kill_retire_ids_under_guards( - wire_session_id, - map_key, - retire_ids, - &index, - &sessions, - &mut tombstones, - ); + self.collect_kill_retire_ids_under_guard(wire_session_id, map_key, retire_ids, &sessions); } - /// The consult body, running under the caller's already-held guard trio - /// (focused-episode-6 round 4, Finding F5): the kill's enumeration and - /// its close-pending gate increment share ONE critical section — the - /// exact acquisition `adopt_session_init`'s mint gate duplicates — so - /// the enumerated set is complete by construction. - fn collect_kill_retire_ids_under_guards( + fn collect_kill_retire_ids_under_guard( &self, wire_session_id: &str, map_key: &str, retire_ids: &mut Vec, - index: &HashMap, sessions: &HashMap, - tombstones: &mut AliasTombstones, ) { - let now = crate::session_lease::now_epoch_ms() as i64; - // Round 5 (Finding 2): the tombstone consult carries the retention - // probe — a still-Bound row's alias records never age out, so the - // kill reaches the durable row however old the demotion is. - let row_bound = self.alias_row_bound_probe(); - // Round 6 (focused-ep5-r5 Finding 2): fold the DURABLE alias records - // into the consult — the restart-surviving half of the mapping (the - // in-memory store dies with the process; the ledger's - // `alias-tombstones/` subtree does not). The fold preserves the - // persisted stamps and the same row-state sweep decides retention, - // so a close naming the bare placeholder reaches the durable row - // even when the server restarted after every in-memory alias died. - // The sink read is memory-fast + sync (the ledger's write-through - // index), permitted inside this no-await section. - if let Some(sink) = self.identity_sink() { - for (durable, at_ms) in sink.alias_tombstone_records(PROVIDER, wire_session_id) { - tombstones.record_persisted(wire_session_id, &durable, at_ms, now, &row_bound); - } - } - // The session's own recorded cli id (the live map holds it: keyed by - // the placeholder OR the durable, whichever addresses the session) — - // delta-r6-r2: read HERE inside the consult, since the kills lane - // calls this both pre-removal (the entry stands) and post-abort (a - // repurposed consult has nothing newly recorded to add). - if let Some(cli_id) = sessions + let mut candidates = sessions .get(map_key) - .and_then(|s| s.cli_session_id.as_deref()) - { - if cli_id != map_key && !retire_ids.iter().any(|id| id == cli_id) { - retire_ids.push(cli_id.to_string()); - } - } - for (durable, mapped) in index.iter() { - if mapped == map_key && !retire_ids.iter().any(|id| id == durable) { - retire_ids.push(durable.clone()); + .map(|session| session.identity.ids()) + .or_else(|| { + self.dead_sessions + .find(wire_session_id) + .map(|dead| dead.ids) + }) + .unwrap_or_default(); + if let Some(sink) = self.identity_sink() { + for (durable, _) in sink.alias_tombstone_records(PROVIDER, wire_session_id) { + // An old placeholder cannot close a genuinely reopened writer. + if Self::session_key_in(sessions, &durable).is_some_and(|key| key != map_key) { + tracing::info!(target: "freshell_freshagent::claude", + event = "freshagent.claude.stale_legacy_alias_skipped", + session_id = durable, previous_session_id = wire_session_id, + "the old placeholder no longer owns the live conversation"); + continue; + } + candidates.push(durable); } } - for durable in tombstones.durables_for(wire_session_id, now, &row_bound) { - // The live-contradiction check: a stale alias tombstone is - // followed only when the durable id it names is not now owned by - // a DIFFERENT live session. `mk == map_key` is this kill's own - // identity (its session entry is gone — removed above, or the - // eviction mid-flight took it), so it always retires. - let contradicted = match index.get(&durable) { - Some(mk) if mk != map_key => sessions.contains_key(mk), - _ => false, - }; - if contradicted { - tracing::info!(target: "freshell_freshagent::claude", - durable = %durable, - placeholder = %wire_session_id, - "freshagent.claude.stale_alias_tombstone_skipped: the durable is live on \ - another session; the old placeholder's close cannot reach it"); - continue; - } - if !retire_ids.iter().any(|id| id == &durable) { - retire_ids.push(durable); + for id in candidates { + if !retire_ids.contains(&id) { + retire_ids.push(id); } } } @@ -4005,13 +3399,7 @@ impl FreshClaudeState { return false; } }; - let now = crate::session_lease::now_epoch_ms() as i64; - let row_bound = self.alias_row_bound_probe(); - let mut cleared_aliases = self - .alias_tombstones - .lock() - .expect("alias tombstones lock") - .clear_for_durable(durable_id, now, &row_bound); + let mut cleared_aliases = Vec::new(); // Round 6: the DURABLE half of the consumption — the accepted claim // consumes the persisted alias records too (restart-surviving // evidence), before their kill fences clear below. @@ -4097,96 +3485,6 @@ impl FreshClaudeState { } } - /// Focused-ep5-r1 Finding 1 (retire-on-kill round 2): evict every live - /// `cli_index` alias of this map key, DEMOTING each to the retained alias - /// tombstones (`alias_tombstones`) instead of dropping it — so a later - /// kill naming the bare placeholder resolves the durable row anyway. The - /// ONE eviction funnel for the index (consumer exit, kill, - /// lease-revoked teardown, rollback-fork teardown); the process-wide - /// `shutdown` reset deliberately drops instead (in-memory state dies - /// with the process — there is no later kill to serve). Returns the - /// demoted durable ids (the consumer-exit lane reopens their leases). - /// - /// Round 5 (focused-ep5-r4 Finding 2): the alias-tombstone retention - /// probe — the durable row's raw Bound state through the identity sink - /// ([`PaneIdentitySink::row_is_bound`]. With no sink wired the probe - /// answers false and retention falls back to the pre-round-5 TTL/cap - /// regime (the sink is always wired in server builds; the alias records - /// are in-memory best-effort there anyway). - fn alias_row_bound_probe(&self) -> impl Fn(&str) -> bool + 'static { - let sink = self.identity_sink(); - move |durable: &str| { - sink.as_ref() - .is_some_and(|s| s.row_is_bound(PROVIDER, durable)) - } - } - - /// Focused-ep5-r1 Finding 1 (retire-on-kill round 2): evict every live - /// `cli_index` alias of this map key, DEMOTING each to the retained alias - /// tombstones (`alias_tombstones`) instead of dropping it — so a later - /// kill naming the bare placeholder resolves the durable row anyway. The - /// ONE eviction funnel for the index (consumer exit, kill, - /// lease-revoked teardown, rollback-fork teardown); the process-wide - /// `shutdown` reset deliberately drops instead (in-memory state dies - /// with the process — there is no later kill to serve). Returns the - /// demoted durable ids (the consumer-exit lane reopens their leases). - /// - /// Focused-ep5-r3 Finding 2 (retire-on-kill round 4): the demote and the - /// remove are ONE CRITICAL SECTION — `cli_index` is HELD while the - /// tombstone records land (lock order `cli_index` then - /// `alias_tombstones`, exactly the order the kill's consult - /// (`collect_kill_retire_ids`) takes them — no inversion exists: - /// `alias_tombstones` is never acquired first anywhere). In the round-3 - /// shape (index released between the two map ops) a kill landing in the - /// gap observed NEITHER the removed live alias NOR the not-yet-recorded - /// tombstone and retired only the placeholder, leaving the durable Bound - /// row recovery-eligible. No `await` inside the section (the std Mutex - /// never crosses one). - /// Round 6 (focused-ep5-r5 Finding 2): the demotion has a DURABLE half — - /// the same records are re-stamped into the ledger's `alias-tombstones/` - /// subtree (post-section: the lock discipline above stays synchronous), - /// so the mapping outlives the process the way the close that needs it - /// can outlast a restart. Warn-loud on failure, never a lane blocker - /// (the in-memory record still answers this process's kills). - async fn evict_cli_index_aliases(&self, map_key: &str) -> Vec { - let now = crate::session_lease::now_epoch_ms() as i64; - let row_bound = self.alias_row_bound_probe(); - let demoted: Vec = { - // The ONE critical section (lock order cli_index → - // alias_tombstones, no await inside — the std-mutex guard never - // crosses an await; the block scopes strictly so). - let mut index = self.cli_index.lock().await; - let mut tombstones = self.alias_tombstones.lock().expect("alias tombstones lock"); - let mut demoted: Vec = Vec::new(); - index.retain(|durable, mapped| { - if mapped == map_key { - demoted.push(durable.clone()); - false - } else { - true - } - }); - for durable in demoted.iter().filter(|durable| durable.as_str() != map_key) { - tombstones.record(map_key, durable, now, &row_bound); - } - demoted - }; - if !demoted.is_empty() { - if let Some(sink) = self.identity_sink() { - for durable in demoted.iter().filter(|durable| durable.as_str() != map_key) { - if let Err(e) = sink - .record_alias_tombstone(PROVIDER, map_key, durable, now) - .await - { - tracing::warn!(error = %e, session = %durable, placeholder = %map_key, - "freshagent.claude.alias_tombstone_write_failed"); - } - } - } - } - demoted - } - // ── freshAgent.interrupt (WS) ──────────────────────────────────────────── /// Handle a `freshAgent.interrupt` (the user's Stop) for claude/kilroy (design @@ -4254,7 +3552,7 @@ impl FreshClaudeState { key, session.turn_lock.clone(), session.ledger.clone(), - session.cli_session_id.clone(), + Some(session.identity.current()), session.quiet.state.clone(), )) } @@ -4391,51 +3689,28 @@ impl FreshClaudeState { msg.session_type, ); }; - // Arm the coordinator guard only after the send has waited behind any - // earlier turn. A lifecycle transition during that wait advances the - // generation, so the captured fence is checked against the owner that - // will actually receive this prompt. Checkpointed sends go through the - // guard when the coordinator is wired, and any supplied fence is - // honored; ordinary legacy sends keep their existing unfenced - // behavior. The fence keys on the record that owns the session (its - // durable id once reported), never on a new pane's placeholder wire id - // (`send_owner_key`). The one exception is `send_owner_key`'s: an - // unfenced send to a session the CLI has not reported and the - // coordinator holds no record for (a new pane's first message) runs - // unguarded. A lane without a coordinator (the session host's) has - // nothing to fence. + // Fence the runtime that actually receives the prompt after the + // turn wait. Native ownership already exists before its first send. let fenced = msg.checkpoint_intent.is_some() || send_fence.is_some(); let ownership_guard = if fenced && self.ownership.is_some() { - if let Some(owner_key) = self.send_owner_key(&map_key, &session_id, send_fence).await { - match crate::ownership_lane::arm_reclaimless_op_guard( - &self.ownership, - PROVIDER, - &owner_key, - &format!("send-{}", uuid::Uuid::new_v4()), - send_fence, - "freshclaude/send", - ) { - crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), - crate::ownership_lane::LaneOpGuard::Unwired => None, - crate::ownership_lane::LaneOpGuard::Refused { message, owner } => { - tracing::warn!(target: "freshell_freshagent::claude", + let owner_key = self.claude_owner_key(Some(&map_key), &session_id).await; + match crate::ownership_lane::arm_reclaimless_op_guard( + &self.ownership, + PROVIDER, + &owner_key, + &format!("send-{}", uuid::Uuid::new_v4()), + send_fence, + "freshclaude/send", + ) { + crate::ownership_lane::LaneOpGuard::Armed(guard) => Some(guard), + crate::ownership_lane::LaneOpGuard::Unwired => None, + crate::ownership_lane::LaneOpGuard::Refused { message, owner } => { + tracing::warn!(target: "freshell_freshagent::claude", session_id = %session_id, owner_key = %owner_key, request_id = ?request_id, "fresh_agent_send_refused: ownership guard refused"); - return self.refuse_reserved_send(&request_id, &message, owner); - } + return self.refuse_reserved_send(&request_id, &message, owner); } - } else { - // Nothing owns the session in the coordinator yet and the send - // names no owner: the CLI reports the session only once a - // prompt runs (T1), and this send is what makes it run. - // Refusing it would refuse every new pane's first message. - tracing::debug!(target: "freshell_freshagent::claude", - session_id = %session_id, request_id = ?request_id, - event = "freshagent.claude.send_before_session_report", - "the send runs unfenced: the CLI has not reported the session yet \ - and the coordinator holds no record for it"); - None } } else { None @@ -4475,19 +3750,13 @@ impl FreshClaudeState { // (the rollback-row MOVE old→new) while we parked. Keying the destroy by // the pre-MOVE id would no-op `destroy_redo_on_submit` and leak redo // (decision 5): `destroy_redo_on_submit` keys the record by its CURRENT - // durable id, i.e. the session record's `cli_session_id` after the + // durable id, i.e. the runtime's current native UUID after the // adoption re-key. A bare pre-init placeholder can have no rollback // record — the addressed canonical id is its only possible key then. let destroy_target = { let guard = self.sessions.lock().await; match guard.get(&map_key) { - Some(s) => s.cli_session_id.clone().or_else(|| { - if is_canonical_claude_uuid(&session_id) { - Some(session_id.clone()) - } else { - None - } - }), + Some(s) => Some(s.identity.current()), None => { drop(guard); return self.refuse_send_after_runtime_stopped( @@ -4829,7 +4098,7 @@ impl FreshClaudeState { /// The shared Task 2 handler prologue: resolve the client-addressed id to the /// sessions-map key (`resolve_session_key` discipline — map key OR durable UUID via - /// `cli_index`) and clone session handles for the sidecar write. A miss at + /// native runtime history) and clone session handles for the sidecar write. A miss at /// either step broadcasts the nested `INVALID_SESSION_ID` lost-session envelope /// (engaging the client's recovery) and yields `None`. async fn response_session( @@ -5093,11 +4362,11 @@ impl FreshClaudeState { }; let map_key = live_key; let _ownership_guard = if fence.is_some() && self.ownership.is_some() { - let owner_key = self.send_owner_key(&map_key, &session_id, fence).await; + let owner_key = self.claude_owner_key(Some(&map_key), &session_id).await; match crate::ownership_lane::arm_reclaimless_op_guard( &self.ownership, PROVIDER, - owner_key.as_deref().unwrap_or(&map_key), + &owner_key, &format!("compact-{}", uuid::Uuid::new_v4()), fence, "freshclaude/compact", @@ -5176,7 +4445,7 @@ impl FreshClaudeState { /// `.strict()` contract shape (`{requestId, toolName?, toolUseID?, blockedPath?, /// decisionReason?, input?, suggestions?}` / `{requestId, questions}`, no extra /// keys, absent keys omitted). `any_id` resolves like every handler here: the map - /// key OR the durable Claude UUID via `cli_index`. An untracked id yields two empty + /// key OR the durable Claude UUID via native runtime history. An untracked id yields two empty /// vecs. pub(crate) async fn snapshot_pending_overlay(&self, any_id: &str) -> (Vec, Vec) { let Some(map_key) = self.resolve_session_key(any_id).await else { @@ -5282,7 +4551,7 @@ impl FreshClaudeState { /// The Task 4 handler keys the ledger row by the CURRENT durable id and /// re-keys it old→new inside the adoption write batch, so the record always /// lives under the live durable id. `any_id` resolves like every handler - /// here (map key or durable UUID via `cli_index`); an UNTRACKED id (e.g. a + /// here (map key or durable UUID via native runtime history); an UNTRACKED id (e.g. a /// disk-only read after a server restart) falls back to the raw id so a /// rolled-back session keeps its marked bucket without a live session. pub(crate) async fn load_rollback_record( @@ -5294,15 +4563,10 @@ impl FreshClaudeState { .and_then(|s| s.load_rollback(PROVIDER, &durable)) } - /// Reconcile liveness probe (campaign §4.3, Task 13): resolve the DURABLE - /// claude UUID through [`Self::cli_index`] to its sessions-map key, then - /// check the map. The sessions map is read UNFILTERED — no session_type - /// filter, so kilroy sessions count for free (V2 N-A17-1). + /// Reconcile liveness follows current and prior native UUIDs for both + /// Freshclaude and Kilroy runtimes. pub async fn has_live_session(&self, session_id: &str) -> bool { - let Some(key) = self.cli_index.lock().await.get(session_id).cloned() else { - return false; - }; - self.sessions.lock().await.contains_key(&key) + self.resolve_session_key(session_id).await.is_some() } /// A runtime that disappears after a successful resolution is a transient @@ -5329,12 +4593,12 @@ impl FreshClaudeState { let key = self.resolve_session_key(native_id).await?; let sessions = self.sessions.lock().await; let session = sessions.get(&key)?; - let canonical = session.cli_session_id.as_deref().unwrap_or(native_id); + let canonical = session.identity.current(); crate::ownership_lane::current_local_snapshot_owner( &self.ownership, &self.ownership_stamps, PROVIDER, - canonical, + &canonical, &key, session.child.id(), ) @@ -5345,9 +4609,8 @@ impl FreshClaudeState { /// 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. - /// Unified agent names: the frame carries the durable record's current - /// projection — the pending→durable transfer commits in - /// [`Self::adopt_session_init`] BEFORE this publishes the identity. + /// The frame carries the durable naming projection after its identity + /// binding has been established. async fn materialized_message( &self, old_id: &str, @@ -5401,20 +4664,23 @@ impl FreshClaudeState { self.broadcast(&message); } - /// 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) { + /// Native history belongs to one runtime and follows it through resets + /// and recovery. Direct current keys take precedence over older IDs. + fn session_key_in( + sessions: &HashMap, + session_id: &str, + ) -> Option { + if sessions.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) + sessions + .iter() + .find_map(|(key, runtime)| runtime.identity.contains(session_id).then(|| key.clone())) + } + + async fn resolve_session_key(&self, session_id: &str) -> Option { + let sessions = self.sessions.lock().await; + Self::session_key_in(&sessions, session_id) } /// The not-tracked resume (codex `ensure_session_resumable` analog, file-store @@ -5433,23 +4699,8 @@ impl FreshClaudeState { // at the registration tail below; every error return leaves it armed // for the caller's drop (RAII typed fail). own_ticket: &mut Option, - // b8ke focused episode-2 round-2 F2: the operation id of the - // lifecycle op that OWNS this runtime's coordinator transition — - // the attach's own claim, or the handoff runner's op for the - // under-ticket target resume. Recorded on the session so the - // session-init adoption's Blocked arm can correlate a blocking - // coordinator state with the runtime being adopted (only the - // runtime's OWN operation may proceed claim-free; an unrelated - // blocker fails closed). - owning_operation: Option, - // b8ke ext r27 F2: the GENERATION of the lifecycle op that owns - // this runtime's coordinator transition (the handoff runner's - // under-ticket target resume) — `owning_operation`'s numeric - // twin, recorded on the session beside it. The session-init - // adoption's durable binding write stamps the row with this - // generation when the adoption holds no own ticket (the - // under-ticket shape), so the target row carries the HANDOFF's - // generation instead of preserving the prior one. + // Under-ticket resumes bind recovery metadata at the handoff's + // generation before the runner commits the replacement runtime. handoff_generation: Option, ) -> Result<(), ResumeClaudeError> { let durable = claim.durable.as_str(); @@ -5562,7 +4813,6 @@ impl FreshClaudeState { }), 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. @@ -5617,681 +4867,6 @@ impl FreshClaudeState { })); } - /// 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. - #[cfg(test)] - async fn adopt_session_init( - &self, - cli_id: &str, - session_id: &str, - session_type: &str, - settings: Option<&crate::identity_sink::FreshAgentSettings>, - identity_sink: Option, - provenance: Option<&crate::BindProvenance>, - ) -> SessionInitAdoptionOutcome { - self.adopt_session_init_inner( - cli_id, - session_id, - session_type, - settings, - identity_sink, - provenance, - false, - ) - .await - } - - /// [`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, - cli_id: &str, - session_id: &str, - session_type: &str, - settings: Option<&crate::identity_sink::FreshAgentSettings>, - identity_sink: Option, - provenance: Option<&crate::BindProvenance>, - on_settled: Option>>, - ) -> SessionInitAdoptionOutcome { - // 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( - cli_id, - session_id, - session_type, - settings, - identity_sink, - provenance, - on_settled, - ); - return SessionInitAdoptionOutcome::Deferred; - } - let outcome = self - .adopt_session_init_inner( - cli_id, - session_id, - session_type, - settings, - identity_sink, - provenance, - false, - ) - .await; - if let Some(tx) = on_settled { - match outcome { - SessionInitAdoptionOutcome::Published => { - let _ = tx.send(Some(cli_id.to_string())); - } - _ => { - let _ = tx.send(None); - } - } - } - outcome - } - - /// b8ke focused episode-2 round-1 F2: the pre-registration deferral. - /// `handle_create` starts the detached consumer BEFORE it inserts the - /// `ClaudeSession` into `self.sessions`, so the consumer's - /// `sdk.session.init` adoption can run while the runtime is NOT yet - /// registered/killable — committing a Live{FreshAgent} owner that a - /// handoff cannot stop (kill_for_handoff answers AlreadyGone, the - /// terminal target starts, and the create then inserts its still-live - /// sidecar with no later commit: two writers). The deferred task - /// waits (bounded) for the registration and re-runs the FULL - /// adoption — gate, claim, publish-or-teardown — once the session is - /// killable. A timeout means the create failed before registering - /// (its own failure path killed the child): nothing adopted, nothing - /// committed. - #[allow(clippy::too_many_arguments)] // the adoption field set + the completion channel - #[cfg(test)] - fn defer_session_init_adoption( - &self, - cli_id: &str, - session_id: &str, - session_type: &str, - settings: Option<&crate::identity_sink::FreshAgentSettings>, - identity_sink: Option, - provenance: Option<&crate::BindProvenance>, - on_settled: Option>>, - ) { - let state = self.clone(); - let cli_id = cli_id.to_string(); - let session_id = session_id.to_string(); - let session_type = session_type.to_string(); - let settings = settings.cloned(); - let provenance = provenance.cloned(); - tokio::spawn(async move { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - if state.sessions.lock().await.contains_key(&session_id) { - let outcome = state - .adopt_session_init_inner( - &cli_id, - &session_id, - &session_type, - settings.as_ref(), - identity_sink.clone(), - provenance.as_ref(), - true, - ) - .await; - if let Some(tx) = on_settled { - let answer = match outcome { - SessionInitAdoptionOutcome::Published => Some(cli_id.clone()), - _ => None, - }; - let _ = tx.send(answer); - } - return; - } - if tokio::time::Instant::now() >= deadline { - tracing::warn!(target: "freshell_freshagent::claude", - cli_id = %cli_id, placeholder = %session_id, - "freshagent.claude.init_ownership_adoption_deferred_timeout: the \ - session never registered (the create failed and tore its child \ - down) — nothing adopted, nothing committed" - ); - if let Some(tx) = on_settled { - let _ = tx.send(None); - } - return; - } - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - }); - } - - /// b8ke focused episode-2 round-1 F3: the refused-adoption teardown. - /// A refused canonical claim (a foreign owner or an in-flight - /// transition) must NEVER leave the freshly spawned runtime live and - /// unowned — the pre-fix path only logged and the sidecar kept - /// running while a competitor acquired the vacant key. Fail closed: - /// remove the registered session, run the standard typed teardown, - /// evict its placeholder aliases, and engage the client's - /// lost-session recovery. - #[cfg(test)] - async fn teardown_unadopted_session(&self, session_id: &str, session_type: &str) { - tracing::error!(target: "invariant", - placeholder = %session_id, - "freshagent.claude.init_ownership_adoption_torn_down: the canonical key moved \ - on — the freshly spawned runtime is torn down, never left unowned" - ); - // DETACHED: the adoption's caller is usually the session's OWN - // consumer task, and the standard teardown aborts + joins the - // consumer — running it inline would abort the caller mid-teardown - // (the self-abort drops the teardown before the reap or the - // lost-session frame). The spawned task performs the whole - // fail-closed sequence; the consumer unwinds at its next await. - let state = self.clone(); - let session_id = session_id.to_string(); - let session_type = session_type.to_string(); - tokio::spawn(async move { - if let Some(session) = state.sessions.lock().await.remove(&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 = - state.teardown_removed_session_logged(session).await; - } - state.evict_cli_index_aliases(&session_id).await; - let typed = match session_type.as_str() { - "kilroy" => SessionType::Kilroy, - _ => SessionType::Freshclaude, - }; - state.broadcast(&lost_session_frame(&session_id, typed)); - }); - } - - /// The adoption's alias publication (the mint-gated section): inserts - /// `cli_index[durable] = map key` and stamps the session's durable id. - /// Re-checks the kill's close gate under the SAME lock acquisition the - /// gate discipline requires — a kill that armed the gate between the - /// adoption's first read and this publication owns the session, so the - /// publication abandons (`false`). Returns whether the publication - /// landed. - /// - /// b8ke focused episode-2 round-2 F3: the publication ALSO revalidates - /// the session's INCARNATION — the session must still exist AND its - /// sidecar child must still be the exact runtime the adoption read at - /// phase 1 (`expect_pid`). An explicit kill that completed between the - /// read and here removed the session AND its close-pending entry, so - /// the gate check alone cannot see it; without this validation the - /// adoption would mint a dangling alias and commit `Live` over the - /// captured pid of a KILLED runtime (snapshots then reported the dead - /// runtime as the authoritative owner). Missing/incarnation-changed → - /// `false`: no alias, no commit — the kill owns the session. - #[cfg(test)] - async fn publish_session_init_alias( - &self, - cli_id: &str, - session_id: &str, - expect_pid: Option, - ) -> bool { - let mut index = self.cli_index.lock().await; - let mut sessions = self.sessions.lock().await; - let gated = { - let gates = self.close_pending.lock().expect("close-pending lock"); - gates.get(session_id).copied().unwrap_or(0) > 0 - }; - if gated { - tracing::info!(target: "freshell_freshagent::claude", - cli_id = %cli_id, - session_id = %session_id, - "freshagent.claude.adoption_abandoned_by_kill_gate: the kill's one \\ - envelope already enumerated this session; the mint lands nowhere" - ); - return false; - } - // F3: the session must still exist with the SAME runtime incarnation - // the adoption observed — a completed kill (or a re-registered - // replacement) invalidates the adoption's captured identity. - match sessions.get_mut(session_id) { - None => { - tracing::warn!(target: "freshell_freshagent::claude", - cli_id = %cli_id, placeholder = %session_id, - "freshagent.claude.adoption_abandoned_session_gone: the session was \\ - removed (a completed kill) before the publication — no alias, no \\ - commit of the dead runtime's identity" - ); - return false; - } - Some(session) => { - if session.child.id() != expect_pid { - tracing::warn!(target: "freshell_freshagent::claude", - cli_id = %cli_id, placeholder = %session_id, - expected_pid = ?expect_pid, - current_pid = ?session.child.id(), - "freshagent.claude.adoption_abandoned_incarnation_changed: the \\ - session's runtime was replaced since the adoption's read — no \\ - alias, no commit of a stale identity" - ); - return false; - } - session.cli_session_id = Some(cli_id.to_string()); - } - } - index.insert(cli_id.to_string(), session_id.to_string()); - true - } - - #[allow(clippy::too_many_arguments)] // the adoption field set (the deferral adds the re-entry flag) - #[cfg(test)] - async fn adopt_session_init_inner( - &self, - cli_id: &str, - session_id: &str, - session_type: &str, - settings: Option<&crate::identity_sink::FreshAgentSettings>, - 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 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 - // HERE — inside the pre-publication read scope — so the binding - // pair's derivation (below) adds NO await between the publication - // and the commit (a mid-flow sessions-lock await there widened - // the publication→commit window a concurrent stamp take could - // land inside). - let (gated, existing_cli_id, registered, sidecar_pid, owning_generation); - { - let _index = self.cli_index.lock().await; - let sessions = self.sessions.lock().await; - gated = { - let gates = self.close_pending.lock().expect("close-pending lock"); - gates.get(session_id).copied().unwrap_or(0) > 0 - }; - if gated { - tracing::info!(target: "freshell_freshagent::claude", - cli_id = %cli_id, - session_id = %session_id, - "freshagent.claude.adoption_abandoned_by_kill_gate: the kill's one \ - envelope already enumerated this session; the mint lands nowhere" - ); - return SessionInitAdoptionOutcome::Abandoned; - } - existing_cli_id = sessions - .get(session_id) - .and_then(|s| s.cli_session_id.clone()); - registered = sessions.contains_key(session_id); - sidecar_pid = sessions.get(session_id).and_then(|s| s.child.id()); - owning_generation = sessions.get(session_id).and_then(|s| s.owning_generation); - } - // b8ke focused episode-2 round-2 F3 test seam: the deterministic - // kill-between-read-and-publication hold (never armed in - // production). e2r3 F10: `reached` flips BEFORE the park, so the - // test can prove the adoption reached this exact point before - // releasing — never a vacuous pass. - #[cfg(test)] - if let Some(pause) = &self.adoption_pause { - pause - .reached - .store(true, std::sync::atomic::Ordering::SeqCst); - let _ = pause.notify.notified().await; - } - // 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 — - // defer the whole adoption (claim + publication) until the - // create registered the runtime. A deferred re-entry that - // STILL finds the session absent gives up (the create died; - // its failure path killed the child). - if deferred_reentry { - tracing::warn!(target: "freshell_freshagent::claude", - cli_id = %cli_id, placeholder = %session_id, - "freshagent.claude.init_ownership_adoption_deferred_unregistered: \ - the session vanished before the deferred adoption — nothing \ - published, nothing committed" - ); - return SessionInitAdoptionOutcome::Abandoned; - } - tracing::info!(target: "freshell_freshagent::claude", - cli_id = %cli_id, placeholder = %session_id, - "freshagent.claude.init_ownership_adoption_deferred: the session is not \ - yet registered (the create's insert window) — the adoption waits for \ - the registration before claiming or publishing" - ); - self.defer_session_init_adoption( - cli_id, - session_id, - session_type, - settings, - identity_sink, - provenance, - None, - ); - return SessionInitAdoptionOutcome::Deferred; - } - // b8ke focused episode-2 round-1 F3: CLAIM BEFORE PUBLISHING. The - // pre-fix order minted the alias + the session's durable field - // BEFORE the coordinator claim, so a REFUSED claim left the - // freshly spawned sidecar running unowned under a published - // identity while a competitor acquired the key. The publication - // below happens only after the claim decision; a refused claim - // tears the runtime down instead (fail closed). - let mut adoption_ticket = None; - if !fork_rekey { - adoption_ticket = match self.begin_lane_claim_at( - cli_id, - &format!("claude-init-adopt-{}", uuid::Uuid::new_v4()), - None, - // b8ke focused episode-2 round-1 F7: the provenance-derived - // initiator (the device id), never a fixed lane string. - &Self::initiator_for(provenance, "claude/session-init-adoption"), - ) { - // Granted: the canonical key is vacant (a fresh create's - // first init) — THIS adoption claims it. - crate::ownership_lane::LaneClaim::Granted(ticket) => Some(ticket), - // Unwired (no coordinator): the legacy behavior. - crate::ownership_lane::LaneClaim::Unwired => None, - // Adopt: the canonical key is ALREADY Live{FreshAgent} — a - // resume/attach committed at create time; the record is - // already authoritative. - crate::ownership_lane::LaneClaim::Adopt => None, - // Refused: the canonical key moved on (a foreign owner or - // an in-flight transition). NEVER steal — and NEVER leave - // the freshly spawned runtime live and unowned: tear it - // down and engage the client's lost-session recovery, - // publishing nothing. - crate::ownership_lane::LaneClaim::Refused(outcome) => { - match &outcome { - // A BLOCKED coordinator state: correlate the - // blocking operation with the runtime being - // adopted (b8ke focused episode-2 round-2 F2). - // ONLY the runtime's OWN operation — recorded on - // the session as its `owning_operation` (the - // create-resume claim, the attach's claim, or the - // handoff runner's under-ticket target op) — may - // proceed: that operation owns the key's - // resolution and will perform the ONE commit, so - // the adoption claims nothing, tears nothing - // down, and the publication keeps the alias - // bookkeeping consistent with its commit. - // An UNRELATED blocker (another device's handoff - // on the still-vacant canonical key, a foreign - // start, a fenced/stop transition that does not - // name this runtime's owner) fails CLOSED: the - // freshly spawned runtime is torn down — never - // left running unowned while the other operation - // believes there was no prior owner. - freshell_ownership::BeginOutcome::Blocked { state, .. } => { - let blocking_op = state.operation_id().map(str::to_string); - let (own_op, own_live_key) = { - let sessions = self.sessions.lock().await; - match sessions.get(session_id) { - Some(session) => ( - session.owning_operation.clone(), - Some(session.sidecar_session_id.clone()), - ), - None => (None, None), - } - }; - // The handoff stopping THIS runtime as its - // PRIOR is also "our" transition: the runner's - // Handoff state captures this session's runtime - // identity as the prior it is stopping — the - // deferred adoption (a create whose bookkeeping - // landed mid-handoff) must neither claim nor - // tear down; the handoff's kill_for_handoff is - // the one stopping this session, and the - // runner resolves the key. - let handoff_stops_this_runtime = matches!( - &state, - freshell_ownership::OwnershipState::Handoff { - prior: Some((prior_owner, _)), - .. - } if own_live_key.is_some() - && prior_owner.live_session_key == own_live_key - ); - if (blocking_op.is_some() && blocking_op == own_op) - || handoff_stops_this_runtime - { - tracing::info!(target: "freshell_freshagent::claude", - cli_id = %cli_id, placeholder = %session_id, - operation_id = ?blocking_op, - "freshagent.claude.init_ownership_adoption_skipped_own_operation: \\ - the runtime's OWN lifecycle operation owns this key — the \\ - adoption claims nothing, the owning operation resolves it" - ); - None - } else { - tracing::warn!(target: "freshell_freshagent::claude", - cli_id = %cli_id, placeholder = %session_id, - outcome = ?outcome, - owning_operation = ?own_op, - "freshagent.claude.init_ownership_adoption_blocked_foreign: an \\ - UNRELATED operation owns this key — the freshly spawned \\ - runtime is torn down (fail closed), nothing published" - ); - self.teardown_unadopted_session(session_id, session_type) - .await; - return SessionInitAdoptionOutcome::Abandoned; - } - } - // A genuinely FOREIGN owner (another kind holds - // the key): fail closed — the freshly spawned - // runtime is torn down, never left live and - // unowned beside the foreign owner. - _ => { - tracing::warn!(target: "freshell_freshagent::claude", - cli_id = %cli_id, placeholder = %session_id, - outcome = ?outcome, - "freshagent.claude.init_ownership_adoption_refused: the \\ - canonical key is held by a foreign owner — the freshly \\ - spawned runtime is torn down (fail closed), nothing published" - ); - self.teardown_unadopted_session(session_id, session_type) - .await; - return SessionInitAdoptionOutcome::Abandoned; - } - } - } - }; - } - // b8ke ext r22 F2: the binding row's fence pair — the pre-commit - // ticket's generation (the delayed-write fence baseline the durable - // row carries below). `None` when no ticket was claimed (the Adopt - // arms — the record is already authoritative) or the coordinator - // is unwired. - // b8ke ext r27 F2: the UNDER-TICKET handoff continuation holds NO - // own ticket — the session's recorded HANDOFF generation (the - // runner's supplied pair, read in the phase-1 scope above) stamps - // the row instead, so the target's durable row carries the - // handoff's generation. - let (binding_epoch, binding_generation) = ( - self.ownership.as_ref().map(|r| r.boot_epoch()), - adoption_ticket - .as_ref() - .map(|t| t.generation()) - .or(owning_generation), - ); - // The (re-gated) publication: abandons when the kill gate armed - // between the read scope and here — the kill owns the session. - if !self - .publish_session_init_alias(cli_id, session_id, sidecar_pid) - .await - { - return SessionInitAdoptionOutcome::Abandoned; - } - // b8ke ext r27 F4: the durable binding write runs BEFORE the - // commit-Live, inside the SAME coordinator operation (the claim - // held above). Pre-r27 the adoption committed Live FIRST and then - // wrote the binding, so once the commit released the coordinator - // operation a handoff or stop could begin while the binding write - // was blocked, and a later failure tore the runtime down only - // after the owner was already authoritative. The write stays - // AFTER the alias publication deliberately: the publication is - // what makes the durable id resolvable to a concurrent kill (the - // tombstone fold + the sink's orphan-write suppression key off - // it), so a kill landing while the binding write is in flight - // still fences the released write. The row is also UNCONDITIONAL - // now: an all-blank settings snapshot records a LINEAGE-only row - // (blank settings verbatim) — pre-r27 default-settings sessions - // skipped the binding entirely; the sink's was_recorded/ - // load_settings keying (Task 3) keeps a legitimately-default - // session from ever arming a false SETTINGS_RESET on a later - // resume (the codex REST lane's unconditional-lineage discipline). - 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: None, - // Unified agent names (Task 4, T2-M5 wired): the init lane's - // DECLARED classification — the same transition the direct - // naming fold above consumed (InitialMaterialization), so - // the identity event carries the naming fact alongside the - // ledger write. - name_transition: None, - provenance: provenance.cloned().into(), - observed_epoch: binding_epoch, - observed_generation: binding_generation, - // b8ke focused ep5 r2 F1: the UNDER-TICKET shape (no own - // adoption ticket; the session's recorded HANDOFF - // generation supplied the pair) is the handoff runner's - // OWN authoritative target binding — the ordinary - // create's own-ticket write stays a lane write. - authoritative: adoption_ticket.is_none() && owning_generation.is_some(), - settings: settings.cloned().unwrap_or_default(), - }) - .await - { - // b8ke ext r27 F4: the binding failure PROPAGATES — the - // freshly adopted runtime is torn down and the adoption is - // ABANDONED BEFORE the publish and the commit (the ticket's - // RAII drop settles the claim typed; the key never goes - // Live), never a bare LEDGER_WRITE_FAILED notification and - // never a committed Live with no recoverable registration. - tracing::error!(target: "invariant", - error = %e, session = %cli_id, - "freshagent.claude.binding_write_failed_typed: the durable row write \ - failed — the freshly adopted runtime is torn down (kata b8ke ext r27 F4)" - ); - self.teardown_unadopted_session(session_id, session_type) - .await; - return SessionInitAdoptionOutcome::Abandoned; - } - } - // b8ke delta round-2 F1: the canonical-key adoption commit — for - // the ordinary-create path (no fork re-key), a Granted claim - // commits the authoritative Live{FreshAgent} ownership under the - // durable id and broadcasts the owner record so every device - // converges. - if let Some(ticket) = adoption_ticket.as_ref() { - let generation = ticket.generation(); - let operation_id = ticket.operation_id().to_string(); - // b8ke focused episode-2 post-cap F3 test seam: the pre-commit - // park (never armed in production). - #[cfg(test)] - if let Some(pause) = &self.adoption_commit_pause { - pause - .reached - .store(true, std::sync::atomic::Ordering::SeqCst); - let _ = pause.notify.notified().await; - } - match self.commit_lane_claim_at(&mut adoption_ticket, session_id, sidecar_pid) { - Ok(()) => { - // Broadcast the authoritative owner record: every device - // holding the durable sessionRef converges on the - // committed Live{FreshAgent} owner (the ready replay - // covers reconnects; the broadcast covers the live ones). - self.broadcast(&ServerMessage::SessionRuntimeOwner(SessionRuntimeOwner { - provider: PROVIDER.to_string(), - session_id: cli_id.to_string(), - epoch: self.ownership.as_ref().map(|r| r.boot_epoch()).unwrap_or(0), - generation, - owner_kind: "fresh-agent".into(), - previous_kind: None, - terminal_id: None, - operation_id, - transition: "handoff-committed".into(), - reason: None, - fenced: None, - alias_of: None, - })); - } - Err(outcome) => { - // Stale/foreign commit: the coordinator moved on between - // the claim and the commit. Fail closed exactly like the - // refused claim — the freshly spawned runtime is never - // left live and unowned (the ticket's Drop performs the - // typed fail on the claimed key). - tracing::warn!(target: "freshell_freshagent::claude", - cli_id = %cli_id, placeholder = %session_id, - outcome = ?outcome, - "freshagent.claude.init_ownership_adoption_commit_stale: the \ - coordinator moved on while the adoption committed — the freshly \ - spawned runtime is torn down (fail closed)" - ); - self.teardown_unadopted_session(session_id, session_type) - .await; - // b8ke focused episode-2 post-cap F3: the runtime IS - // torn down — the adoption is ABANDONED, HERE. The - // pre-fix fall-through persisted the alias - // tombstone/binding and reported Published for a - // runtime that no longer exists. - return SessionInitAdoptionOutcome::Abandoned; - } - } - } - // Round 6 (focused-ep5-r5 Finding 2): persist the alias AT MINT. The - // finding's window needs the mapping durably recorded before ANY - // demotion — a server restart kills the consumers (no exit-eviction - // ever demotes), so mint-time persistence is the ONLY write that - // reaches the post-restart kill naming this pane's bare placeholder. - // Best-effort: warn-loud on failure, never a lane blocker (the - // in-memory store still answers this process's kills). - if let Some(sink) = identity_sink.as_ref().filter(|_| session_id != cli_id) { - if let Err(e) = sink - .record_alias_tombstone( - PROVIDER, - session_id, - cli_id, - crate::session_lease::now_epoch_ms() as i64, - ) - .await - { - tracing::warn!(error = %e, session = %cli_id, placeholder = %session_id, - "freshagent.claude.alias_tombstone_write_failed"); - } - } - SessionInitAdoptionOutcome::Published - } - // ── stdout consumer (the completion edge normalization) ────────────────────────── /// Consume the sidecar's stdout event stream (one `sdk.*` JSON per line), normalize @@ -6324,7 +4899,7 @@ impl FreshClaudeState { ownership_id: String, session_type: String, sidecar_session_id: String, - broadcast_id: Arc>, + identity: RuntimeIdentity, pending: Arc>, ledger: crate::claude_turns::SharedLedger, quiet: deadman::SharedQuiet, @@ -6387,7 +4962,7 @@ impl FreshClaudeState { // C17: fold the line into the session's turn ledger (design // §5.2) before anything else reads it: a gate, an attach // acknowledgement or the status published below sees it. - let step = state.fold_into_ledger(&ledger, &value, &broadcast_id, &session_type); + let step = state.fold_into_ledger(&ledger, &value, &identity, &session_type); if !is_pending_frame(&value) && value["type"] != "sdk.send.rejected" { state .note_claude_stdout(&session_id, &ownership_id, Some(&value)) @@ -6398,7 +4973,7 @@ impl FreshClaudeState { // disarmed (the turn keeps running and rings when it ends). match &step { LedgerStep::Reissue(reissue) => { - let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); + let stamp = identity.current(); state .reissue_stop( &session_id, @@ -6413,7 +4988,7 @@ impl FreshClaudeState { stop_id, request_id, } => { - let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); + let stamp = identity.current(); state.report_stop_refused( &stamp, &session_type, @@ -6433,7 +5008,7 @@ impl FreshClaudeState { // back. C17: nor is any status the sidecar reports: the server // publishes the session's status itself. if let Some(fact) = crate::claude_turns::SidecarFact::from_line(&value) { - let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); + let stamp = identity.current(); match fact { crate::claude_turns::SidecarFact::SendRejected(rejection) => state .take_back_rejected_send(&ledger, &stamp, &session_type, &rejection), @@ -6486,8 +5061,7 @@ impl FreshClaudeState { if let Err(message) = state.persist_runtime_settings(&ownership_id).await { - let stamp = - broadcast_id.lock().expect("broadcast id lock").clone(); + let stamp = identity.current(); state.emit_fresh_agent_error( &stamp, &session_type, @@ -6573,7 +5147,7 @@ impl FreshClaudeState { match &step { LedgerStep::Settled(settled) => value["sessionBusy"] = json!(settled.busy), _ => { - let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); + let stamp = identity.current(); state.publish_claude_status(&ledger, &quiet, &stamp, &session_type, false); } } @@ -6589,7 +5163,7 @@ impl FreshClaudeState { .map(|session| { session.configuration.runtime_cwd = value["cwd"].as_str().map(str::to_string); - session.cli_session_id.as_deref() == Some(cli_id) + session.identity.current() == cli_id }) .unwrap_or(false) }; @@ -6621,7 +5195,7 @@ impl FreshClaudeState { } // Task 10b: stamp the envelope from the SHARED handle (not the captured // map key) so an attach-by-durable rebind flips live event routing. - let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); + let stamp = identity.current(); crate::claude_live_events::log_provider_event(&value, &stamp, &session_type); if let Some(frame) = sdk_line_to_frame(&value, &stamp, &session_type) { if value.get("type").and_then(Value::as_str) @@ -6673,7 +5247,7 @@ impl FreshClaudeState { if s.sidecar_session_id == sidecar_session_id && Arc::ptr_eq(&s.pending, &pending) => { - let stamp = s.broadcast_id.lock().expect("broadcast id lock").clone(); + let stamp = s.identity.current(); let removed = map.remove(&session_id); // The old runtime has no actionable pending cards. Clear // waiting attention before any teardown await or new @@ -6686,15 +5260,9 @@ impl FreshClaudeState { } }; if let Some(evicted) = evicted { - // Focused-ep5-r1 Finding 1: the aliases are DEMOTED to the - // retained alias tombstones, never dropped — the client closes - // a crashed pane by its ORIGINAL bare placeholder, and a kill - // arriving after this eviction must still resolve the durable - // id the ledger row is keyed on. The tombstone answers that - // resolution for the RETIRE write only; it never feeds live - // resolution (the session is dead — sends/attaches keep - // missing it exactly as before). - let removed_durables = state.evict_cli_index_aliases(&session_id).await; + // Native history is retained by the death record and copied + // into a replacement process when this conversation resumes. + let removed_durables = evicted.identity.ids(); // Task 12: a dead BOUND session must reopen its durable id, or the // sessionRef stays adopt-only forever. for durable in &removed_durables { @@ -6712,7 +5280,7 @@ impl FreshClaudeState { "freshclaude/consumer-exit", ); } - let stamp = broadcast_id.lock().expect("broadcast id lock").clone(); + let stamp = identity.current(); // G05 (C09): the ended session stays known as ended — its ids // resolve to a dead record, not to nothing. It is recorded // before the stop on purpose: a resume (an attach, which is a @@ -6737,7 +5305,7 @@ impl FreshClaudeState { stamp: &stamp, session_type: &session_type, durables: &removed_durables, - cli_session_id: evicted.cli_session_id.as_deref(), + cli_session_id: Some(&stamp), settings: &evicted.configuration.settings, ownership_id: &evicted.ownership_id, launch_genesis: evicted.launch_genesis.as_ref(), @@ -7073,14 +5641,6 @@ fn frame_request_id(value: &Value) -> Option { .or_else(|| value.as_i64().map(|n| n.to_string())) } -/// The coordinator key a claude session is owned under -/// ([`FreshClaudeState::claude_owner_key`]). -struct ClaudeOwnerKey { - key: String, - /// The key is the durable id the CLI reported (else the pane's wire id). - reported: bool, -} - // ── sdk.* → freshAgent.event frame (port of sdk-events.ts normalizeFreshAgentProviderEvent) ─ /// Map an `sdk.*` event line from the sidecar to a `freshAgent.event` wire frame. Renames @@ -7949,6 +6509,7 @@ mod history; mod pending_runtime; mod rekey; mod restart_notice; +mod runtime_identity; use pending_runtime::{is_pending_frame, PendingResponse}; mod rollback; mod sidecar_writer; @@ -8087,7 +6648,7 @@ pub(crate) mod tests { cli_session_id: None, owning_operation: None, owning_generation: None, - broadcast_id: Arc::new(std::sync::Mutex::new(session_id.to_string())), + identity: RuntimeIdentity::new(session_id, Vec::new()), pending: Arc::new(std::sync::Mutex::new(ClaudePending::default())), ledger: crate::claude_turns::SharedLedger::default(), clock: crate::attention::AttentionClock::default(), @@ -8558,7 +7119,7 @@ pub(crate) mod tests { .get(durable) .expect("the canonical runtime stays registered"); assert_eq!( - session.broadcast_id.lock().unwrap().as_str(), + session.identity.current().as_str(), durable, "attaching a page preserves the runtime's canonical envelope" ); @@ -16549,7 +15110,7 @@ pub(crate) mod tests { cli_session_id: None, owning_operation: None, owning_generation: None, - broadcast_id: Arc::new(std::sync::Mutex::new(session_id.to_string())), + identity: RuntimeIdentity::new(session_id, Vec::new()), pending: Arc::new(std::sync::Mutex::new(pending)), ledger: crate::claude_turns::SharedLedger::default(), clock: crate::attention::AttentionClock::default(), @@ -18777,14 +17338,14 @@ rl.on('line', (line) => { let clock = crate::attention::AttentionClock::default(); let turn_lock = Arc::new(TokioMutex::new(())); let quiet = deadman::QuietWatch::default(); - let broadcast_id = Arc::new(std::sync::Mutex::new(map_key.to_string())); + let identity = RuntimeIdentity::new(map_key, Vec::new()); let consumer = st.spawn_consumer( reader, map_key.to_string(), format!("test-{map_key}"), "freshclaude".to_string(), map_key.to_string(), - Arc::clone(&broadcast_id), + identity.clone(), Arc::clone(&pending), Arc::clone(&ledger), quiet.state.clone(), @@ -18805,7 +17366,7 @@ rl.on('line', (line) => { cli_session_id: Some(durable.to_string()), owning_operation: None, owning_generation: None, - broadcast_id, + identity, pending, ledger, clock, diff --git a/crates/freshell-freshagent/src/claude/deadman.rs b/crates/freshell-freshagent/src/claude/deadman.rs index 889bd4223..91bc2d2f4 100644 --- a/crates/freshell-freshagent/src/claude/deadman.rs +++ b/crates/freshell-freshagent/src/claude/deadman.rs @@ -123,11 +123,7 @@ impl FreshClaudeState { .lock() .expect("quiet deadman lock") .feed(busy && !pending, window); - let stamp = session - .broadcast_id - .lock() - .expect("broadcast id lock") - .clone(); + let stamp = session.identity.current(); if let Some(waiter) = session.quiet.waiter.take() { waiter.abort(); if busy && pending { @@ -202,11 +198,7 @@ impl FreshClaudeState { if !ledger.busy() || session.pending.lock().expect("pending lock").len() > 0 { return; } - let stamp = session - .broadcast_id - .lock() - .expect("broadcast id lock") - .clone(); + let stamp = session.identity.current(); self.publish_locked_status(&mut ledger, &stamp, &session.session_type, false, true); tracing::warn!(provider = PROVIDER, session_id = %stamp, ownership_id = %ownership_id, phase = "fired", generation, "freshagent.claude.quiet_deadman"); diff --git a/crates/freshell-freshagent/src/claude/establish.rs b/crates/freshell-freshagent/src/claude/establish.rs index 8a7459c9d..c75bdc5be 100644 --- a/crates/freshell-freshagent/src/claude/establish.rs +++ b/crates/freshell-freshagent/src/claude/establish.rs @@ -135,8 +135,6 @@ pub(super) struct EstablishPlan<'a> { /// 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 @@ -597,7 +595,6 @@ impl FreshClaudeState { claim, rekey_provisional_claim, recorded_settings, - owning_operation: _owning_operation, owning_generation, provenance, naming, @@ -734,8 +731,9 @@ impl FreshClaudeState { .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 identity = RuntimeIdentity::new(map_key.clone(), Vec::new()); + let log_identity = identity.clone(); + log_labels.bind_session(move || log_identity.current()); let pending = Arc::new(std::sync::Mutex::new(ClaudePending::default())); // A resume after an unrequested death continues the dead session's // attention clock (design §5.5): its edges stay above the death's. @@ -745,6 +743,9 @@ impl FreshClaudeState { .flatten() .find_map(|id| self.dead_sessions.find(id)) }; + if let Some(dead) = resumed_runtime() { + identity.inherit(dead.ids); + } let InheritedRuntime { turn_lock, ledger, @@ -776,7 +777,7 @@ impl FreshClaudeState { ownership_id.clone(), spec.session_type.clone(), minted.clone(), - Arc::clone(&broadcast_id), + identity.clone(), Arc::clone(&pending), Arc::clone(&ledger), quiet.state.clone(), @@ -799,16 +800,11 @@ impl FreshClaudeState { ownership_id: ownership_id.clone(), 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. - #[cfg(test)] - owning_operation: _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, + identity, pending, ledger, clock, @@ -828,15 +824,6 @@ impl FreshClaudeState { .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 { @@ -844,28 +831,6 @@ impl FreshClaudeState { 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()) - .filter(|durable| *durable != map_key) - { - 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 @@ -889,7 +854,7 @@ impl FreshClaudeState { } // 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(); } @@ -915,7 +880,7 @@ impl FreshClaudeState { let _teardown: LoggedTeardown = self.teardown_removed_session_logged(session).await; } - self.evict_cli_index_aliases(&map_key).await; + if let Some(mut guard) = lease_guard.take() { guard.fail(); } @@ -930,7 +895,7 @@ impl FreshClaudeState { let _teardown: LoggedTeardown = self.teardown_removed_session_logged(session).await; } - self.evict_cli_index_aliases(&map_key).await; + if let Some(mut guard) = lease_guard.take() { guard.fail(); } @@ -964,7 +929,6 @@ impl FreshClaudeState { // 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); @@ -993,7 +957,7 @@ impl FreshClaudeState { let _teardown: LoggedTeardown = self.teardown_removed_session_logged(session).await; } - self.evict_cli_index_aliases(&map_key).await; + return Err(EstablishError::OwnershipChanged); } } diff --git a/crates/freshell-freshagent/src/claude/fork.rs b/crates/freshell-freshagent/src/claude/fork.rs index c434f6966..f03df61e6 100644 --- a/crates/freshell-freshagent/src/claude/fork.rs +++ b/crates/freshell-freshagent/src/claude/fork.rs @@ -164,13 +164,7 @@ impl FreshClaudeState { 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()), - ), + Some(session) => (Arc::clone(&session.turn_lock), session.identity.current()), None => { drop(sessions); reply_sink(lost_session_frame(&msg.session_id, msg.session_type)); @@ -383,7 +377,6 @@ impl FreshClaudeState { 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, @@ -532,10 +525,7 @@ impl FreshClaudeState { } else { live_key.clone() }, - durable: session - .cli_session_id - .clone() - .unwrap_or_else(|| live_key.clone()), + durable: session.identity.current(), ledger: Arc::clone(&session.ledger), settings: session.configuration.settings.clone(), freshell_context: session.configuration.freshell_context.clone(), @@ -547,17 +537,8 @@ impl FreshClaudeState { /// 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; + if let Some(session) = self.sessions.lock().await.get(parent_key) { + return session.identity.is_closing(); } self.dead_sessions.find(parent_key).is_none() } @@ -592,7 +573,7 @@ impl FreshClaudeState { 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() { diff --git a/crates/freshell-freshagent/src/claude/genesis.rs b/crates/freshell-freshagent/src/claude/genesis.rs index f5bf19fdc..d718ba8d4 100644 --- a/crates/freshell-freshagent/src/claude/genesis.rs +++ b/crates/freshell-freshagent/src/claude/genesis.rs @@ -140,7 +140,7 @@ fn log_genesis_clear(session_id: &str, genesis: &str, trigger: &str, outcome: &G 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 + /// (map key or durable UUID through native runtime history); 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 { @@ -149,7 +149,7 @@ impl FreshClaudeState { .lock() .await .get(&map_key) - .and_then(|session| session.cli_session_id.clone()) + .map(|session| session.identity.current()) .unwrap_or(map_key), None => any_id.to_string(), } @@ -369,15 +369,7 @@ impl FreshClaudeState { sessions .get(&session_key) .filter(|session| session.ownership_id == ownership_id) - .map(|session| { - ( - session - .cli_session_id - .clone() - .unwrap_or_else(|| session_key.clone()), - session.launch_genesis.clone(), - ) - }) + .map(|session| (session.identity.current(), session.launch_genesis.clone())) } .or_else(|| { state @@ -412,8 +404,7 @@ impl FreshClaudeState { if matches!(outcome, GenesisClear::Cleared { .. }) { let mut sessions = state.sessions.lock().await; if let Some(session) = sessions.get_mut(&session_key).filter(|session| { - session.ownership_id == ownership_id - && session.cli_session_id.as_deref().unwrap_or(&session_key) == durable + session.ownership_id == ownership_id && session.identity.current() == durable }) { session.launch_genesis = None; } diff --git a/crates/freshell-freshagent/src/claude/pending_runtime.rs b/crates/freshell-freshagent/src/claude/pending_runtime.rs index 185b8a3cf..c6c0b2704 100644 --- a/crates/freshell-freshagent/src/claude/pending_runtime.rs +++ b/crates/freshell-freshagent/src/claude/pending_runtime.rs @@ -42,11 +42,7 @@ impl FreshClaudeState { PendingResponse::Question => pending.questions.retain(|q| q.request_id != request_id), } if PendingChange::between(before, pending.len()) == Some(PendingChange::Drained) { - let stamp = session - .broadcast_id - .lock() - .expect("broadcast id lock") - .clone(); + let stamp = session.identity.current(); self.edges.pending_drained(&stamp); } drop(pending); @@ -67,11 +63,7 @@ impl FreshClaudeState { else { return; }; - let stamp = session - .broadcast_id - .lock() - .expect("broadcast id lock") - .clone(); + let stamp = session.identity.current(); self.publish_claude_status( &session.ledger, &session.quiet.state, diff --git a/crates/freshell-freshagent/src/claude/recovery.rs b/crates/freshell-freshagent/src/claude/recovery.rs index e4693ec43..fdbbd0ef9 100644 --- a/crates/freshell-freshagent/src/claude/recovery.rs +++ b/crates/freshell-freshagent/src/claude/recovery.rs @@ -126,9 +126,6 @@ impl FreshClaudeState { }; } }; - let owning_operation = ticket - .as_ref() - .map(|ticket| ticket.operation_id().to_string()); let outcome = self .resume_for_attach( msg, @@ -138,7 +135,6 @@ impl FreshClaudeState { }, &mut lease, &mut ticket, - owning_operation, None, ) .await; diff --git a/crates/freshell-freshagent/src/claude/recovery_stop.rs b/crates/freshell-freshagent/src/claude/recovery_stop.rs index 34cce56a1..623356a75 100644 --- a/crates/freshell-freshagent/src/claude/recovery_stop.rs +++ b/crates/freshell-freshagent/src/claude/recovery_stop.rs @@ -128,7 +128,6 @@ impl FreshClaudeState { }; // Match the established alias->map lock order. No process I/O or await // follows the claim until the removal, pending drain and end are visible. - let index = self.cli_index.lock().await; let mut sessions = self.sessions.lock().await; let Some(session) = sessions.get(&map_key) else { return Err(( @@ -136,30 +135,16 @@ impl FreshClaudeState { "Claude changed before recovery stopped it. Try again.".into(), )); }; - let durable = session - .cli_session_id - .clone() - .unwrap_or_else(|| map_key.clone()); + let durable = session.identity.current(); let operation = self.claim_recovery_stop(session, &map_key, &durable, fence)?; - let mut aliases: Vec = index - .iter() - .filter(|(_, mapped)| **mapped == map_key) - .map(|(id, _)| id.clone()) - .collect(); - if !aliases.contains(&durable) { - aliases.push(durable.clone()); - } + let aliases = session.identity.ids(); let mut session = sessions .remove(&map_key) .expect("validated recovery runtime"); session.consumer.abort(); session.quiet.cancel(); session.ledger.lock().expect("turn ledger lock").eof(); - let stamp = session - .broadcast_id - .lock() - .expect("broadcast id lock") - .clone(); + let stamp = session.identity.current(); { let mut pending = session.pending.lock().expect("pending lock"); pending.permissions.clear(); @@ -172,7 +157,7 @@ impl FreshClaudeState { stamp: &stamp, session_type: &session.session_type, durables: &aliases, - cli_session_id: session.cli_session_id.as_deref(), + cli_session_id: Some(&stamp), settings: &session.configuration.settings, ownership_id: &session.ownership_id, launch_genesis: session.launch_genesis.as_ref(), @@ -328,7 +313,7 @@ impl FreshClaudeState { record.session_id == map_key || aliases.contains(&record.session_id) }) .await; - self.evict_cli_index_aliases(&map_key).await; + let teardown = self.teardown_removed_session_logged(session).await; let teardown = self.release_leases_after_teardown( teardown, diff --git a/crates/freshell-freshagent/src/claude/rekey.rs b/crates/freshell-freshagent/src/claude/rekey.rs index 6e9dce715..be519e246 100644 --- a/crates/freshell-freshagent/src/claude/rekey.rs +++ b/crates/freshell-freshagent/src/claude/rekey.rs @@ -44,7 +44,6 @@ impl FreshClaudeState { let settings = { // The same map-lock order as kill/adoption. Ownership, routing, // and the envelope move together before metadata IO starts. - let mut index = self.cli_index.lock().await; let mut sessions = self.sessions.lock().await; let session = sessions .get(old) @@ -53,15 +52,7 @@ impl FreshClaudeState { if sessions.contains_key(new) { return Err("the new conversation already has a live runtime".into()); } - if self - .close_pending - .lock() - .expect("close-pending lock") - .get(old) - .copied() - .unwrap_or(0) - > 0 - { + if session.identity.is_closing() { return Err("the conversation is closing".into()); } self.commit_lane_claim_rekey_at( @@ -84,21 +75,16 @@ impl FreshClaudeState { previous_session_id = old, session_id = new, "the runtime moved but its lease binding could not follow"); } - session.cli_session_id = Some(new.into()); - *session.broadcast_id.lock().expect("broadcast id lock") = new.into(); + session + .identity + .rekey(new) + .map_err(|error| error.to_string())?; session.launch_genesis = Some(crate::identity_sink::RecordedGenesis { genesis: crate::identity_sink::ClaudeGenesis::Fresh, updated_at_ms: now, }); let settings = session.configuration.settings.clone(); sessions.insert(new.into(), session); - for target in index.values_mut() { - if target == old { - *target = new.into(); - } - } - index.insert(old.into(), new.into()); - index.insert(new.into(), new.into()); self.ownership_stamps .lock() .expect("ownership stamps lock") diff --git a/crates/freshell-freshagent/src/claude/rollback.rs b/crates/freshell-freshagent/src/claude/rollback.rs index 19c59d06a..a9a937083 100644 --- a/crates/freshell-freshagent/src/claude/rollback.rs +++ b/crates/freshell-freshagent/src/claude/rollback.rs @@ -209,7 +209,7 @@ impl FreshClaudeState { 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.identity.current(), s.ledger.clone(), s.turn_lock.clone(), session_type_str(op.session_type), @@ -928,7 +928,6 @@ impl FreshClaudeState { // 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. @@ -1099,25 +1098,22 @@ impl FreshClaudeState { /// 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` + /// original's record is returned for its teardown. Lock order native runtime history /// then `sessions`, as the adoption's publication takes them. async fn replace_runtime( &self, original_key: &str, - original_durable: &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(); - } + if let (Some(original), Some(replacement)) = + (original.as_ref(), sessions.get(replacement)) + { + replacement.identity.inherit(original.identity.ids()); } - index.insert(original_key.to_string(), replacement.to_string()); - index.insert(original_durable.to_string(), replacement.to_string()); original }; // A replayed create requestId of the original must not answer with @@ -1140,7 +1136,7 @@ impl FreshClaudeState { 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/stop.rs b/crates/freshell-freshagent/src/claude/stop.rs index ef4cc0dba..1f072e654 100644 --- a/crates/freshell-freshagent/src/claude/stop.rs +++ b/crates/freshell-freshagent/src/claude/stop.rs @@ -28,11 +28,7 @@ impl FreshClaudeState { let Some(session) = sessions.get_mut(map_key) else { return false; }; - let stamp = session - .broadcast_id - .lock() - .expect("broadcast id lock") - .clone(); + let stamp = session.identity.current(); let stop_id = uuid::Uuid::new_v4().to_string(); // Latched under the ledger lock before the write: the receipt and the // stopped turn's result can come back before this task runs again. diff --git a/crates/freshell-freshagent/src/claude/teardown_tests.rs b/crates/freshell-freshagent/src/claude/teardown_tests.rs index 56567aada..5569b8d43 100644 --- a/crates/freshell-freshagent/src/claude/teardown_tests.rs +++ b/crates/freshell-freshagent/src/claude/teardown_tests.rs @@ -111,7 +111,7 @@ async fn fake_session( cli_session_id: None, owning_operation: None, owning_generation: None, - broadcast_id: Arc::new(std::sync::Mutex::new(session_id.to_string())), + identity: RuntimeIdentity::new(session_id, Vec::new()), pending: Arc::new(std::sync::Mutex::new(ClaudePending::default())), ledger: crate::claude_turns::SharedLedger::default(), clock: crate::attention::AttentionClock::default(), diff --git a/crates/freshell-freshagent/src/claude/tests/deadman_tests.rs b/crates/freshell-freshagent/src/claude/tests/deadman_tests.rs index 409b64d22..b9bfc5046 100644 --- a/crates/freshell-freshagent/src/claude/tests/deadman_tests.rs +++ b/crates/freshell-freshagent/src/claude/tests/deadman_tests.rs @@ -159,3 +159,46 @@ async fn an_idle_runtime_never_arms_a_quiet_timeout() { tokio::time::resume(); st.shutdown().await; } + +#[tokio::test(start_paused = true)] +async fn an_armed_quiet_deadline_follows_the_same_runtime_across_a_native_reset() { + let (st, mut rx) = state_with_bus(); + let st = st + .with_sidecar_environment([("FRESHELL_FRESHCLAUDE_QUIET_WINDOW_MS".into(), "100".into())]); + let old = uuid::Uuid::new_v4().to_string(); + let new = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&st, &old).await; + let (owner, pending) = { + let sessions = st.sessions.lock().await; + let runtime = &sessions[&old]; + runtime.ledger.lock().unwrap().arm( + "reset-turn".into(), + None, + crate::claude_turns::SendKind::Turn, + ); + (runtime.ownership_id.clone(), runtime.pending.clone()) + }; + st.note_claude_stdout(&old, &owner, None).await; + st.rekey_conversation(&old, &new, "freshclaude", &pending, "test-reset") + .await + .unwrap(); + clock_forward(150).await; + let frames: Vec = std::iter::from_fn(|| rx.try_recv().ok()) + .map(|raw| serde_json::from_str(&raw).unwrap()) + .collect(); + let completed: Vec<_> = frames + .iter() + .filter(|frame| frame["event"]["type"] == "freshAgent.turn.complete") + .collect(); + assert_eq!( + completed.len(), + 1, + "the old deadline must still publish its stuck edge: {frames:?}" + ); + assert_eq!(completed[0]["sessionId"], new); + assert!(frames + .iter() + .any(|frame| frame["sessionId"] == new && frame["event"]["status"] == "stuck")); + tokio::time::resume(); + st.shutdown().await; +} diff --git a/crates/freshell-freshagent/src/claude/turn_status.rs b/crates/freshell-freshagent/src/claude/turn_status.rs index 22fd67bc0..a891bad6c 100644 --- a/crates/freshell-freshagent/src/claude/turn_status.rs +++ b/crates/freshell-freshagent/src/claude/turn_status.rs @@ -6,7 +6,7 @@ use serde_json::{json, Value}; -use super::{FreshClaudeState, PROVIDER}; +use super::{FreshClaudeState, RuntimeIdentity, PROVIDER}; use crate::claude_turns::{ EffectiveStatus, Folded, ResultVerdict, SharedLedger, StopReissue, TurnFact, }; @@ -62,13 +62,7 @@ impl FreshClaudeState { .lock() .await .get(map_key) - .map(|session| { - session - .broadcast_id - .lock() - .expect("broadcast id lock") - .clone() - }) + .map(|session| session.identity.current()) .unwrap_or_else(|| map_key.to_string()) } @@ -114,11 +108,7 @@ impl FreshClaudeState { let Some(session) = sessions.get(&key) else { return; }; - let session_id = session - .broadcast_id - .lock() - .expect("broadcast id lock") - .clone(); + let session_id = session.identity.current(); let quiet = session.quiet.state.lock().expect("quiet deadman lock"); let mut ledger = session.ledger.lock().expect("turn ledger lock"); let status = ledger @@ -141,7 +131,7 @@ impl FreshClaudeState { &self, ledger: &SharedLedger, line: &Value, - broadcast_id: &std::sync::Mutex, + identity: &RuntimeIdentity, session_type: &str, ) -> LedgerStep { let Some(fact) = TurnFact::from_line(line) else { @@ -152,7 +142,7 @@ impl FreshClaudeState { let folded = ledger.fold(&fact); (folded, ledger.busy()) }; - let session_id = broadcast_id.lock().expect("broadcast id lock").clone(); + let session_id = identity.current(); match folded { Folded::Applied => LedgerStep::Nothing, Folded::UnknownCommand => { diff --git a/crates/freshell-freshagent/src/claude_sidecar_io.rs b/crates/freshell-freshagent/src/claude_sidecar_io.rs index 8298244c2..b9b21b20c 100644 --- a/crates/freshell-freshagent/src/claude_sidecar_io.rs +++ b/crates/freshell-freshagent/src/claude_sidecar_io.rs @@ -305,9 +305,9 @@ struct LabelsInner { /// `None` for a sidecar process that serves no session (the model catalog probe). session_type: Option, pid: Option, - /// The session's envelope id handle (the id clients address it by; an - /// attach by durable id may change it), once the sidecar has named it. - session: OnceLock>>, + /// Read the current native conversation id without depending on the + /// runtime's identity representation. Observers never change this reader. + session: OnceLock String + Send + Sync>>, } impl SidecarLogLabels { @@ -344,11 +344,11 @@ impl SidecarLogLabels { self.inner.pid } - /// Bind the session this sidecar serves, by its shared envelope id - /// handle: every later event carries the handle's current value. Logged + /// Bind the session this sidecar serves: every later event reads its + /// current native id, including after conversation reset. Logged /// once, as the join between the sidecar's ownership id and its session. - pub(crate) fn bind_session(&self, envelope: &Arc>) { - if self.inner.session.set(Arc::clone(envelope)).is_ok() { + pub(crate) fn bind_session(&self, current_id: impl Fn() -> String + Send + Sync + 'static) { + if self.inner.session.set(Arc::new(current_id)).is_ok() { let session_id = self.session_id(); tracing::info!( provider = PROVIDER, @@ -361,14 +361,9 @@ impl SidecarLogLabels { } } - /// The bound session's current envelope id. + /// The bound session's current native id. fn session_id(&self) -> Option { - self.inner.session.get().map(|envelope| { - envelope - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - .clone() - }) + self.inner.session.get().map(|current_id| current_id()) } } @@ -907,12 +902,13 @@ mod tests { "unbound until the sidecar names its session" ); let envelope = Arc::new(StdMutex::new("placeholder-id".to_string())); - labels.bind_session(&envelope); + let current_id = Arc::clone(&envelope); + labels.bind_session(move || current_id.lock().unwrap().clone()); assert_eq!(labels.session_id().as_deref(), Some("placeholder-id")); *envelope.lock().unwrap() = "durable-id".to_string(); assert_eq!(labels.session_id().as_deref(), Some("durable-id")); // A second bind changes nothing: one sidecar serves one session. - labels.bind_session(&Arc::new(StdMutex::new("other".to_string()))); + labels.bind_session(|| "other".to_string()); assert_eq!(labels.session_id().as_deref(), Some("durable-id")); } @@ -921,8 +917,9 @@ mod tests { let (events, _guard) = capture(); let labels = labels(); let envelope = Arc::new(StdMutex::new("session-a".to_string())); - labels.bind_session(&envelope); - labels.bind_session(&envelope); + let current_id = Arc::clone(&envelope); + labels.bind_session(move || current_id.lock().unwrap().clone()); + labels.bind_session(|| "ignored-second-binding".into()); let bound = with_message(&events, "freshagent.claude.sidecar_bound"); assert_eq!(bound.len(), 1, "{bound:?}"); assert_eq!(bound[0].level, Level::INFO); @@ -1038,7 +1035,7 @@ mod tests { .await .unwrap(); wait_until(|| !sidecar_logs(&events).is_empty()).await; - labels.bind_session(&Arc::new(StdMutex::new("session-b".to_string()))); + labels.bind_session(|| "session-b".to_string()); writer .write_all(b"{\"severity\":\"warn\",\"event\":\"configure.failed\"}\n") .await diff --git a/crates/freshell-freshagent/src/session_handoff.rs b/crates/freshell-freshagent/src/session_handoff.rs index 0d760a8a9..c6de26542 100644 --- a/crates/freshell-freshagent/src/session_handoff.rs +++ b/crates/freshell-freshagent/src/session_handoff.rs @@ -4112,7 +4112,6 @@ impl SessionHandoffRunner { req.cwd.as_deref(), req.tab_id.as_deref(), req.pane_id.as_deref(), - operation_id, generation, ) .await @@ -4125,7 +4124,6 @@ impl SessionHandoffRunner { req.cwd.as_deref(), req.tab_id.as_deref(), req.pane_id.as_deref(), - operation_id, generation, ) .await From eb3b2cf1f72699b9088f86d33e2d69a771733c3a Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:35:03 -0700 Subject: [PATCH 066/100] test(freshclaude): prove REST ownership parity with a real SDK runtime --- .../freshell-ws/tests/rest_claude_identity.rs | 171 ++++++++++-------- 1 file changed, 91 insertions(+), 80 deletions(-) diff --git a/crates/freshell-ws/tests/rest_claude_identity.rs b/crates/freshell-ws/tests/rest_claude_identity.rs index cfef38980..ece994700 100644 --- a/crates/freshell-ws/tests/rest_claude_identity.rs +++ b/crates/freshell-ws/tests/rest_claude_identity.rs @@ -54,6 +54,12 @@ fn unique_temp_dir(label: &str) -> std::path::PathBuf { /// and the REST pane-ops surface resolve the same store), exactly like /// `main.rs`. async fn spawn_merged_server() -> Harness { + spawn_merged_server_with_claude_environment(None).await +} + +async fn spawn_merged_server_with_claude_environment( + environment: Option>, +) -> Harness { let _ = common::isolate_amplifier_home(); let ledger_dir = unique_temp_dir("ledger"); let signal_root = unique_temp_dir("signals"); @@ -76,6 +82,9 @@ async fn spawn_merged_server() -> Harness { let layout_store = freshell_freshagent::layout_store::LayoutStore::default(); let mut fresh_claude = freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)); + if let Some(environment) = environment { + fresh_claude = fresh_claude.with_sidecar_environment(environment); + } fresh_claude.set_ownership(Arc::clone(&ownership)); let fresh_agent_state = freshell_freshagent::FreshAgentState::new( Arc::clone(&auth_token), @@ -566,89 +575,70 @@ async fn rest_claude_resume_create_writes_identity_row_and_ledger_binding() { // ── kata b8ke Task 10: REST + WS + browser-shaped parity on ONE coordinator ── -// The lease-suite fake claude sidecar (request-log knob), duplicated -// per-file per the cross_kind_liveness.rs convention: sets -// FRESHELL_CLAUDE_SIDECAR/FRESHELL_CLAUDE_NODE so a `freshAgent.create` -// over WS genuinely establishes a live freshclaude owner, and logs every -// sidecar request for the no-second-runtime assertions. -const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" -import readline from 'node:readline' -import fs from 'node:fs' - -const logPath = process.env.FAKE_SIDECAR_REQUEST_LOG || '' -function logReq(msg) { - if (!logPath) return - try { fs.appendFileSync(logPath, JSON.stringify({ pid: process.pid, msg }) + '\n') } catch {} -} - -let counter = 0 -const rl = readline.createInterface({ input: process.stdin, terminal: false }) -rl.on('line', (line) => { - const trimmed = line.trim() - if (!trimmed) return - let msg - try { msg = JSON.parse(trimmed) } catch { return } - logReq(msg) - if (msg.type === 'create') { - counter += 1 - const sessionId = `fake-claude-session-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') - const cliSessionId = msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' - process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') - process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') - } else if (msg.type === 'shutdown') { - process.exit(0) - } -}) -"#; - -/// The fake sidecar env (cross_kind_liveness.rs's `FakeSidecarEnv`, copied -/// per the per-file convention). Only the Task 10 parity test below reads -/// the env vars, so no cross-test env locking is needed within THIS file. -struct FakeSidecarEnv { +// A real sidecar environment local to this lane: no process-global env changes. +struct ClaudeSimEnvironment { dir: std::path::PathBuf, } -impl FakeSidecarEnv { - fn install() -> Self { - let dir = std::env::temp_dir().join(format!( - "rest-claude-identity-r10-sidecar-{}-{}", - std::process::id(), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_nanos()) - .unwrap_or(0) - )); - std::fs::create_dir_all(&dir).expect("create fake sidecar temp dir"); - let script = dir.join("fake-claude-sidecar.mjs"); - std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("FAKE_SIDECAR_REQUEST_LOG", dir.join("requests.jsonl")); +impl ClaudeSimEnvironment { + fn new() -> Self { + let dir = unique_temp_dir("sidecar"); + for child in ["home", "workspace", "tmp"] { + std::fs::create_dir_all(dir.join(child)).unwrap(); + } + std::fs::write(dir.join("active"), "").unwrap(); Self { dir } } - fn create_rows(&self) -> Vec { - let Ok(raw) = std::fs::read_to_string(self.dir.join("requests.jsonl")) else { - return Vec::new(); - }; - raw.lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| serde_json::from_str::(l).expect("request log row parses")) - .filter(|r| r["msg"]["type"] == "create") + fn environment(&self) -> Vec<(String, String)> { + let adapter = common::claude_rig::repo_root() + .join("test/integration/providers/claude-sdk-for-rust-runtime.mjs"); + vec![ + ( + "HOME".into(), + self.dir.join("home").to_string_lossy().into(), + ), + ("PATH".into(), std::env::var("PATH").unwrap_or_default()), + ( + "TMPDIR".into(), + self.dir.join("tmp").to_string_lossy().into(), + ), + ( + "CLAUDE_CONFIG_DIR".into(), + self.dir.join("home/.claude").to_string_lossy().into(), + ), + ( + "FRESHELL_CLAUDE_SDK_QUERY_MODULE".into(), + freshell_platform::opencode_plugin::plugin_file_spec(&adapter), + ), + ( + "FRESHELL_CLAUDE_SIM_LEDGER".into(), + self.dir.join("ledger.jsonl").to_string_lossy().into(), + ), + ("FRESHELL_CLAUDE_SIM_TIME_SCALE".into(), "0.01".into()), + ( + "FRESHELL_TEST_CLAUDE_RUNTIME_GUARD".into(), + self.dir.join("active").to_string_lossy().into(), + ), + ] + } + + fn rows(&self, kind: &str) -> Vec { + std::fs::read_to_string(self.dir.join("ledger.jsonl")) + .unwrap_or_default() + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str::(line).expect("simulator row")) + .filter(|row| row["kind"] == kind) .collect() } } -impl Drop for FakeSidecarEnv { +impl Drop for ClaudeSimEnvironment { fn drop(&mut self) { - for var in [ - "FRESHELL_CLAUDE_SIDECAR", - "FRESHELL_CLAUDE_NODE", - "FAKE_SIDECAR_REQUEST_LOG", - ] { - std::env::remove_var(var); - } + // The shared SDK adapter closes its query when the guard ends or this + // unique directory disappears; cleanup never needs a PID signal. + let _ = std::fs::write(self.dir.join("active.stop"), ""); let _ = std::fs::remove_dir_all(&self.dir); } } @@ -683,22 +673,23 @@ async fn next_frame_of_type(ws: &mut common::TestWs, wanted: &str) -> serde_json /// second runtime. #[tokio::test(flavor = "multi_thread")] async fn rest_ws_and_browser_paths_share_the_one_coordinator() { - let sidecar = FakeSidecarEnv::install(); - let mut h = spawn_merged_server().await; - let sid = format!("r10-parity-{}", uuid::Uuid::new_v4()); + let sidecar = ClaudeSimEnvironment::new(); + let mut h = spawn_merged_server_with_claude_environment(Some(sidecar.environment())).await; // 1. A live freshclaude owner for (claude, sid), established over WS. h.ws.send(WsMessage::Text( json!({ "type": "freshAgent.create", "requestId": "req-r10-parity-1", "sessionType": "freshclaude", "provider": "claude", - "sessionRef": { "provider": "claude", "sessionId": sid }, + "cwd": sidecar.dir.join("workspace").to_string_lossy(), }) .to_string(), )) .await .unwrap(); - let _created = next_frame_of_type(&mut h.ws, "freshAgent.created").await; + let created = next_frame_of_type(&mut h.ws, "freshAgent.created").await; + let sid = created["sessionId"].as_str().unwrap().to_string(); + uuid::Uuid::parse_str(&sid).expect("the native create-time UUID"); // 2. The REST terminal door: POST /api/tabs for the same sessionRef is // the typed 409 carrying the fresh-agent owner fields. @@ -798,11 +789,24 @@ async fn rest_ws_and_browser_paths_share_the_one_coordinator() { .count(); assert_eq!(ptys_for_sid, 0, "no second writer for {sid}"); let sidecar_creates = sidecar - .create_rows() + .rows("query") .iter() - .filter(|r| r["msg"]["boot"]["resume"].as_str() == Some(sid.as_str())) + .filter(|r| r["options"]["sessionId"].as_str() == Some(sid.as_str())) .count(); assert_eq!(sidecar_creates, 1, "exactly the one freshclaude owner"); + let boots = sidecar.rows("boot"); + assert_eq!(boots.len(), 1); + assert_eq!(boots[0]["sessionId"], sid); + let processes = [ + boots[0]["pid"].as_u64().unwrap(), + boots[0]["cliPid"].as_u64().unwrap(), + ] + .map(|pid| { + ( + pid, + common::process_probe::ProcessProbe::open(u32::try_from(pid).unwrap()), + ) + }); // Tear the freshclaude owner down through its own lane so the sidecar // child does not outlive the harness. @@ -815,6 +819,13 @@ async fn rest_ws_and_browser_paths_share_the_one_coordinator() { )) .await .unwrap(); - let _killed = next_frame_of_type(&mut h.ws, "freshAgent.killed").await; + let killed = next_frame_of_type(&mut h.ws, "freshAgent.killed").await; + assert_eq!(killed["success"], true, "{killed}"); + for (pid, process) in processes { + assert!( + process.exits_within(common::FRAME_BUDGET).await, + "the owned sidecar/CLI process {pid} survived its acknowledged kill" + ); + } cleanup(&h); } From 1263237d63552eefb9b5895d5366b8470a51cb9b Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:36:25 -0700 Subject: [PATCH 067/100] refactor(freshclaude): establish every runtime under its native UUID --- crates/freshell-freshagent/src/claude.rs | 7 +- .../src/claude/establish.rs | 132 +++--------------- crates/freshell-freshagent/src/claude/fork.rs | 7 +- .../src/claude/rollback.rs | 19 +-- 4 files changed, 29 insertions(+), 136 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 65d7a9f85..ea37c3ee4 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -2029,12 +2029,11 @@ impl FreshClaudeState { EstablishPlan { lane: EstablishLane::Create, spec, - register_under: RegisterUnder::Adopted(launch_id.clone()), + native_id: launch_id.clone(), claim: Some(DurableClaim { durable: launch_id.clone(), dead_state: claim_dead_state, }), - rekey_provisional_claim: false, recorded_settings: Some(settings), owning_generation: None, provenance, @@ -4806,12 +4805,11 @@ impl FreshClaudeState { EstablishPlan { lane: EstablishLane::AttachResume, spec, - register_under: RegisterUnder::Pane(msg.session_id.clone()), + native_id: msg.session_id.clone(), claim: Some(DurableClaim { durable: durable.to_string(), dead_state: claim_dead_state, }), - rekey_provisional_claim: false, recorded_settings: recovered, owning_generation: handoff_generation, // Conn-less lane (D8): attach-resume is not a create; the ledger @@ -6517,7 +6515,6 @@ mod stop; mod turn_status; use establish::{ BootRetry, DurableClaim, EstablishLane, EstablishPlan, InheritedRuntime, LaunchLease, - RegisterUnder, }; #[cfg(test)] diff --git a/crates/freshell-freshagent/src/claude/establish.rs b/crates/freshell-freshagent/src/claude/establish.rs index c75bdc5be..953e6e1c8 100644 --- a/crates/freshell-freshagent/src/claude/establish.rs +++ b/crates/freshell-freshagent/src/claude/establish.rs @@ -7,17 +7,15 @@ //! 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) +//! boot native UUID → close-fence claim commit (resumes) +//! → create binding and ownership → register runtime and consumer +//! → 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 +//! the replacement under its new native UUID 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). @@ -49,17 +47,6 @@ impl EstablishLane { } } -/// The map key a new session registers under. -pub(super) enum RegisterUnder { - /// The attaching pane's id: an attach-resume (the client routes the pane's - /// frames by it). - Pane(String), - /// A durable id the server minted and the CLI boots under (a rollback's - /// replacement): registered as already adopted, so it is the map key, the - /// envelope stamp and the session's durable id at once. - Adopted(String), -} - /// The per-session state a replacement runtime takes over from the runtime it /// replaces (an undo or redo, design §4.6): the turn lock a parked send waits /// on, the turn ledger and the attention clock. Only the process changes. @@ -123,14 +110,10 @@ pub(super) struct DurableClaim { 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) native_id: String, + /// A resumed conversation's close-state fence. Fresh UUIDs have no prior + /// close to consume. 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. @@ -192,8 +175,6 @@ pub(super) enum EstablishError { 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 { @@ -212,10 +193,6 @@ impl EstablishError { "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() - } } } @@ -226,10 +203,9 @@ impl EstablishError { Self::Write(_) => "CLAUDE_SIDECAR_WRITE_FAILED", Self::Binding(_) => "LEDGER_WRITE_FAILED", Self::Boot(failure) => failure.code(), - Self::ClosedInFlight - | Self::LeaseRevoked - | Self::OwnershipChanged - | Self::MintedIdClaimed => "FRESH_AGENT_CREATE_FAILED", + Self::ClosedInFlight | Self::LeaseRevoked | Self::OwnershipChanged => { + "FRESH_AGENT_CREATE_FAILED" + } } } @@ -250,7 +226,6 @@ impl EstablishError { Self::ClosedInFlight => "closed_in_flight", Self::LeaseRevoked => "lease_revoked", Self::OwnershipChanged => "ownership_changed", - Self::MintedIdClaimed => "minted_id_claimed", } } } @@ -591,9 +566,8 @@ impl FreshClaudeState { let EstablishPlan { lane, mut spec, - register_under, + native_id: map_key, claim, - rekey_provisional_claim, recorded_settings, owning_generation, provenance, @@ -650,23 +624,10 @@ impl FreshClaudeState { // into the session record). let sidecar_pid = child.id(); let minted = created.session_id; - let (map_key, adopted) = match register_under { - RegisterUnder::Pane(pane) => (pane, None), - RegisterUnder::Adopted(id) => (id.clone(), Some(id)), - }; - let mut naming_projection = 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). + // A close that landed while this resume booted invalidates its + // captured fence. Refuse it before starting the consumer. if let Some(claim) = claim.as_ref() { if !self .commit_session_claim( @@ -685,51 +646,11 @@ impl FreshClaudeState { } } - // 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 durable = map_key.clone(); let configuration_settings = recorded_settings.clone().unwrap_or_default(); let identity = RuntimeIdentity::new(map_key.clone(), Vec::new()); let log_identity = identity.clone(); @@ -737,12 +658,7 @@ impl FreshClaudeState { let pending = Arc::new(std::sync::Mutex::new(ClaudePending::default())); // A resume after an unrequested death continues the dead session's // attention clock (design §5.5): its edges stay above the death's. - let resumed_runtime = || { - [Some(map_key.as_str()), durable.as_deref()] - .into_iter() - .flatten() - .find_map(|id| self.dead_sessions.find(id)) - }; + let resumed_runtime = || self.dead_sessions.find(&map_key); if let Some(dead) = resumed_runtime() { identity.inherit(dead.ids); } @@ -800,9 +716,6 @@ impl FreshClaudeState { ownership_id: ownership_id.clone(), consumer, sidecar_session_id: minted, - // b8ke focused episode-2 round-2 F2: the lifecycle op that owns - // this runtime's coordinator transition, so the session-init - // b8ke ext r27 F2: the handoff's generation (the under-ticket owning_generation, identity, pending, @@ -819,10 +732,7 @@ impl FreshClaudeState { // 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"); - } + self.dead_sessions.forget(&[map_key.as_str()], "resumed"); } // Test seam (Task 6 round-3 review I-1): park with the target session // registered but the resume unreturned (the handoff abort-window tests). @@ -935,10 +845,8 @@ impl FreshClaudeState { } } - // 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. + // Registration survived every teardown gate. Resumed targets now + // commit their lane claim; fresh creates committed before their ACK. if claim.is_some() { if let Err(outcome) = self.commit_lane_claim_at(own_ticket, &map_key, sidecar_pid) { let event = match lane { diff --git a/crates/freshell-freshagent/src/claude/fork.rs b/crates/freshell-freshagent/src/claude/fork.rs index f03df61e6..e0701f12d 100644 --- a/crates/freshell-freshagent/src/claude/fork.rs +++ b/crates/freshell-freshagent/src/claude/fork.rs @@ -20,9 +20,7 @@ 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::establish::{EstablishError, EstablishLane, EstablishPlan, GenesisBinding}; use super::history::EffectiveTranscriptError; /// A fork while the parent runs a turn (`BUSY_TURN`). @@ -373,9 +371,8 @@ impl FreshClaudeState { EstablishPlan { lane: EstablishLane::Fork, spec, - register_under: RegisterUnder::Adopted(child.clone()), + native_id: child.clone(), claim: None, - rekey_provisional_claim: false, recorded_settings: Some(launch_settings.clone()), owning_generation: None, provenance: fork_provenance.clone(), diff --git a/crates/freshell-freshagent/src/claude/rollback.rs b/crates/freshell-freshagent/src/claude/rollback.rs index a9a937083..e6af7e883 100644 --- a/crates/freshell-freshagent/src/claude/rollback.rs +++ b/crates/freshell-freshagent/src/claude/rollback.rs @@ -15,7 +15,7 @@ use crate::claude_snapshot as snap; use crate::rollback_record::*; use super::establish::{ - EstablishError, EstablishLane, EstablishPlan, GenesisBinding, InheritedRuntime, RegisterUnder, + EstablishError, EstablishLane, EstablishPlan, GenesisBinding, InheritedRuntime, }; use super::history::EffectiveTranscriptError; @@ -921,9 +921,8 @@ impl FreshClaudeState { EstablishPlan { lane: EstablishLane::Rollback, spec, - register_under: RegisterUnder::Adopted(replacement.clone()), + native_id: 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 @@ -1041,10 +1040,7 @@ impl FreshClaudeState { // 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 - { + if let Some(original) = self.replace_runtime(&map_key, &replacement).await { let _teardown: LoggedTeardown = self.teardown_removed_session_logged(original).await; } if !lease_held { @@ -1094,16 +1090,11 @@ impl FreshClaudeState { )); } - /// 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 native runtime history - /// then `sessions`, as the adoption's publication takes them. + /// Move the original's native history to the replacement under the map + /// lock. Its close counter belongs to the new process and starts empty. async fn replace_runtime( &self, original_key: &str, - _original_durable: &str, replacement: &str, ) -> Option { let original = { From 04d1fe1f532ab64d62d7ca3fba3e8fc549ad507e Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:39:01 -0700 Subject: [PATCH 068/100] refactor(freshclaude): remove obsolete identity comments and attributes --- crates/freshell-freshagent/src/claude.rs | 1 - crates/freshell-freshagent/src/claude/rekey.rs | 4 ++-- crates/freshell-freshagent/src/claude/rollback.rs | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index ea37c3ee4..314552ddb 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -542,7 +542,6 @@ impl FreshClaudeState { handoff_resume_pause: None, condemned_priors: Arc::new(std::sync::Mutex::new(HashMap::new())), #[cfg(test)] - #[cfg(test)] kill_stop_take_pause: None, #[cfg(test)] kill_after_resolve_pause: None, diff --git a/crates/freshell-freshagent/src/claude/rekey.rs b/crates/freshell-freshagent/src/claude/rekey.rs index be519e246..d3e30a279 100644 --- a/crates/freshell-freshagent/src/claude/rekey.rs +++ b/crates/freshell-freshagent/src/claude/rekey.rs @@ -42,8 +42,8 @@ impl FreshClaudeState { }; let now = crate::session_lease::now_epoch_ms() as i64; let settings = { - // The same map-lock order as kill/adoption. Ownership, routing, - // and the envelope move together before metadata IO starts. + // The map lock serializes close guards and identity moves. + // Ownership and event identity move before metadata IO starts. let mut sessions = self.sessions.lock().await; let session = sessions .get(old) diff --git a/crates/freshell-freshagent/src/claude/rollback.rs b/crates/freshell-freshagent/src/claude/rollback.rs index e6af7e883..d752c6c65 100644 --- a/crates/freshell-freshagent/src/claude/rollback.rs +++ b/crates/freshell-freshagent/src/claude/rollback.rs @@ -926,7 +926,6 @@ impl FreshClaudeState { 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_generation: None, // Conn-less lane (D8): the ledger merge inherits the // superseded parent's stamps. From 819eeb5072f1e40cd55e7b3cc99abe784868e796 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:39:09 -0700 Subject: [PATCH 069/100] fix(fresh-agent): retain durable identity with restore errors --- shared/fresh-agent.ts | 12 +++- src/store/paneTreeValidation.ts | 13 +++- src/store/panesSlice.ts | 9 ++- src/store/persistedState.ts | 13 ++++ .../client/fresh-agent-pane-migration.test.ts | 32 +++++++++ test/unit/client/store/panesSlice.test.ts | 67 +++++++++++++++++++ .../store/persisted-state.fresh-agent.test.ts | 23 +++++++ 7 files changed, 165 insertions(+), 4 deletions(-) diff --git a/shared/fresh-agent.ts b/shared/fresh-agent.ts index 433576e0b..c9341fd90 100644 --- a/shared/fresh-agent.ts +++ b/shared/fresh-agent.ts @@ -218,8 +218,8 @@ export type FreshAgentIdentityFold = { * with the pane provider, and classify placeholder (a DURABLE locator means * the fold carries a real identity — never stale, so a restoreError on a * genuinely broken durable pane is left alone). - * - Locator absent (the restoreError migration strips it — the incident's - * normalized shape): ANY present scalar identity field (sessionId or + * - Locator absent (including older normalized restoreError payloads whose + * migration stripped it): ANY present scalar identity field (sessionId or * resumeSessionId) classifying placeholder marks the fold stale. * * Returns the previous durable identity tuple — the sessionRef OBJECT @@ -295,6 +295,12 @@ export function migrateLegacyFreshAgentContent { return !!value && typeof value === 'object' @@ -69,6 +70,16 @@ function isPaneContentShape(content: unknown): boolean { const hasSessionRef = content.sessionRef !== undefined && (typeof content.sessionRef === 'object' || !!(content.sessionRef as object)) const hasRestoreError = content.restoreError !== undefined + const sessionRef = isRecord(content.sessionRef) ? content.sessionRef : undefined + const restoreError = isRecord(content.restoreError) ? content.restoreError : undefined + const restoreErrorHasDurableIdentity = hasSessionRef + && hasRestoreError + && restoreError?.reason !== 'invalid_legacy_restore_target' + && sessionRef?.provider === content.provider + && isDurableProviderSessionId( + typeof content.provider === 'string' ? content.provider : undefined, + typeof sessionRef?.sessionId === 'string' ? sessionRef.sessionId : undefined, + ) return !!sessionType && providerValid && typeof content.createRequestId === 'string' @@ -86,7 +97,7 @@ function isPaneContentShape(content: unknown): boolean { && isOptionalString(content.effort) && isSessionRefShape(content.sessionRef) && isRestoreErrorShape(content.restoreError) - && !(hasSessionRef && hasRestoreError) + && (!(hasSessionRef && hasRestoreError) || restoreErrorHasDurableIdentity) && (content.plugins === undefined || (Array.isArray(content.plugins) && content.plugins.every((plugin) => typeof plugin === 'string'))) && (content.settingsDismissed === undefined || typeof content.settingsDismissed === 'boolean') diff --git a/src/store/panesSlice.ts b/src/store/panesSlice.ts index 8db8d28e9..aa53302cc 100644 --- a/src/store/panesSlice.ts +++ b/src/store/panesSlice.ts @@ -194,6 +194,12 @@ function normalizePaneContent( ? previous.createRequestId : undefined const existingRestoreError = sanitizeRestoreError(rawFreshAgent.restoreError) + const restoreErrorSessionRef = sanitizeSessionRef(rawFreshAgent.sessionRef) + const retainedRestoreErrorSessionRef = existingRestoreError?.reason !== 'invalid_legacy_restore_target' + && restoreErrorSessionRef?.provider === input.provider + && isDurableProviderSessionId(input.provider, restoreErrorSessionRef.sessionId) + ? restoreErrorSessionRef + : undefined const style = normalizeFreshAgentStyleOverride((input as { style?: unknown }).style) const pendingLocalEcho = normalizeFreshAgentPendingLocalEcho(rawFreshAgent.pendingLocalEcho) const modelEffortLevels = normalizeFreshAgentModelEffortLevels(rawFreshAgent.modelEffortLevels) @@ -250,7 +256,8 @@ function normalizePaneContent( ...normalizeManagedRuntimeProjection(rawFreshAgent), sessionType: input.sessionType, provider: input.provider, - sessionId: input.sessionId, + sessionId: retainedRestoreErrorSessionRef?.sessionId ?? input.sessionId, + ...(retainedRestoreErrorSessionRef ? { sessionRef: retainedRestoreErrorSessionRef } : {}), createRequestId: typeof input.createRequestId === 'string' && input.createRequestId ? input.createRequestId : previousCreateRequestId || nanoid(), diff --git a/src/store/persistedState.ts b/src/store/persistedState.ts index 720912c19..be8e12c06 100644 --- a/src/store/persistedState.ts +++ b/src/store/persistedState.ts @@ -28,6 +28,7 @@ import { } from './paneTypes' import { createLogger } from '@/lib/client-logger' import { isValidMachineIdStamp } from '@/lib/machine-identity' +import { isDurableProviderSessionId } from '@shared/session-flavor' export { TABS_STORAGE_KEY, PANES_STORAGE_KEY } @@ -357,6 +358,16 @@ function normalizeFreshAgentContent(content: Record): Record): Record { expect((migrated as { sessionRef?: unknown }).sessionRef).toBeUndefined() }) + it('retains a canonical same-provider sessionRef when an existing pane has a recoverable restore error', () => { + const sessionId = '00000000-0000-4000-8000-000000000789' + const migrated = migrateLegacyFreshAgentContent({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-lost-durable', + status: 'idle', + sessionId: 'old-runtime-placeholder', + sessionRef: { provider: 'claude', sessionId }, + restoreError: { code: 'RESTORE_UNAVAILABLE', reason: 'durable_artifact_missing' }, + }) + + expect(migrated).toMatchObject({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + sessionId, + sessionRef: { provider: 'claude', sessionId }, + restoreError: { code: 'RESTORE_UNAVAILABLE', reason: 'durable_artifact_missing' }, + }) + expect(validatePaneTree({ type: 'leaf', id: 'pane-lost', content: migrated } as never).valid).toBe(true) + expect(validatePaneTree({ + type: 'leaf', + id: 'pane-invalid-restore-identity', + content: { + ...(migrated as Record), + sessionRef: { provider: 'codex', sessionId: 'codex-durable-id' }, + }, + } as never).valid).toBe(false) + }) + it('keeps a bad Claude sessionRef alias as a restore error even when resumeSessionId is canonical', () => { const canonical = '00000000-0000-4000-8000-000000000777' const migrated = migrateLegacyFreshAgentContent({ diff --git a/test/unit/client/store/panesSlice.test.ts b/test/unit/client/store/panesSlice.test.ts index 3b6dcec27..16ca051c0 100644 --- a/test/unit/client/store/panesSlice.test.ts +++ b/test/unit/client/store/panesSlice.test.ts @@ -3458,6 +3458,73 @@ describe('panesSlice', () => { expect(content.sessionId).toBe(OPENCODE_DURABLE_ID) }) + it('hydrate: retains a valid same-provider Claude sessionRef with a recoverable restore error', () => { + const claudeSessionId = VALID_CLAUDE_SESSION_ID + const brokenClaude: FreshAgentPaneContent = { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-claude-hydrate-restore-error', + status: 'idle', + sessionId: claudeSessionId, + sessionRef: { provider: 'claude', sessionId: claudeSessionId }, + restoreError: { code: 'RESTORE_UNAVAILABLE', reason: 'durable_artifact_missing' }, + } + + const hydrated = panesReducer( + initialState, + hydratePanes(stateWithLayout({ + 'tab-1': { type: 'leaf', id: 'pane-1', content: brokenClaude }, + })), + ) + const content = freshAgentLeaf(hydrated) + expect(content.sessionRef).toEqual({ provider: 'claude', sessionId: claudeSessionId }) + expect(content.sessionId).toBe(claudeSessionId) + expect(content.restoreError).toEqual({ + code: 'RESTORE_UNAVAILABLE', + reason: 'durable_artifact_missing', + }) + }) + + it('keeps a validated same-provider Claude sessionRef through restore-error folds and later updates', () => { + const claudeSessionId = VALID_CLAUDE_SESSION_ID + const claudeContent: FreshAgentPaneContent = { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-claude-restore-error', + status: 'idle', + sessionId: claudeSessionId, + resumeSessionId: claudeSessionId, + sessionRef: { provider: 'claude', sessionId: claudeSessionId }, + } + const seeded = panesReducer( + initialState, + initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: claudeContent }), + ) + + const markedLost = panesReducer( + seeded, + mergePaneContent({ + tabId: 'tab-1', + paneId: 'pane-1', + updates: { restoreError: { code: 'RESTORE_UNAVAILABLE', reason: 'durable_artifact_missing' } }, + }), + ) + const afterRefresh = panesReducer( + markedLost, + mergePaneContent({ tabId: 'tab-1', paneId: 'pane-1', updates: { status: 'idle' } }), + ) + + const content = freshAgentLeaf(afterRefresh) + expect(content.sessionRef).toEqual({ provider: 'claude', sessionId: claudeSessionId }) + expect(content.sessionId).toBe(claudeSessionId) + expect(content.restoreError).toEqual({ + code: 'RESTORE_UNAVAILABLE', + reason: 'durable_artifact_missing', + }) + }) + it('deliberate reset: restoreError + placeholder under a NEW createRequestId applies unchanged', () => { const resetStale = placeholderOpencodeContent('req-reset-generation', { restoreError: DEAD_HANDLE_RESTORE_ERROR, diff --git a/test/unit/client/store/persisted-state.fresh-agent.test.ts b/test/unit/client/store/persisted-state.fresh-agent.test.ts index 2d23bd6d9..a04dc6b6d 100644 --- a/test/unit/client/store/persisted-state.fresh-agent.test.ts +++ b/test/unit/client/store/persisted-state.fresh-agent.test.ts @@ -100,6 +100,29 @@ describe('persistedState fresh-agent migration', () => { }) }) + it('retains a validated same-provider Claude sessionRef alongside a persisted restore error', () => { + const sessionId = '00000000-0000-4000-8000-000000000780' + const parsed = parsePersistedLayoutRaw(layoutRaw({ + 'tab-1': leaf('lost-claude', { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + sessionId: 'legacy-runtime-placeholder', + sessionRef: { provider: 'claude', sessionId }, + restoreError: { code: 'RESTORE_UNAVAILABLE', reason: 'durable_artifact_missing' }, + }), + })) + + expect(collectLeafContents(parsed!.panes.layouts['tab-1'])[0]).toMatchObject({ + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + sessionId, + sessionRef: { provider: 'claude', sessionId }, + restoreError: { code: 'RESTORE_UNAVAILABLE', reason: 'durable_artifact_missing' }, + }) + }) + it('leaves a legacy zero-turn placeholder without a durable reference available for lost-session recovery', () => { const parsed = parsePersistedLayoutRaw(layoutRaw({ 'tab-1': leaf('legacy', { From 0f42fb7c93d38d0df6507ea8238aa82a9b9c9eb3 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:39:13 -0700 Subject: [PATCH 070/100] test(e2e): assert real Claude recovery and reveal behavior --- .../helpers/unified-agent-names.ts | 12 +++-- .../specs/fresh-agent-control-rust.spec.ts | 16 ++++++- .../fresh-agent-error-turn-attention.spec.ts | 5 +++ ...fresh-agent-hidden-approval-reveal.spec.ts | 14 ++++++ ...shclaude-identity-persistence-rust.spec.ts | 44 +++++++++++++++++-- .../specs/restore-contract-wall-rust.spec.ts | 38 ++++------------ 6 files changed, 92 insertions(+), 37 deletions(-) diff --git a/test/e2e-browser/helpers/unified-agent-names.ts b/test/e2e-browser/helpers/unified-agent-names.ts index 97aa97281..7b60b2bb3 100644 --- a/test/e2e-browser/helpers/unified-agent-names.ts +++ b/test/e2e-browser/helpers/unified-agent-names.ts @@ -1079,9 +1079,15 @@ export async function expectSharedName( server, ) if (sessionId) { - await expect( - page.locator(`[data-context="sidebar-session"][data-session-id="${sessionId}"]`), - ).toContainText(expected, { timeout: 20_000 }) + const row = page.locator(`[data-context="sidebar-session"][data-session-id="${sessionId}"]`) + if (!(await row.isVisible().catch(() => false))) { + // Conversation switches and rename surfaces can leave the sidebar on + // another view. The assertion is about the session directory row, so + // select its owning view before deciding that the old session is + // absent from the directory. + await page.getByTitle('Coding Agents (Ctrl+B T)').click() + } + await expect(row).toContainText(expected, { timeout: 20_000 }) } } 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 1e544314c..dde2375d2 100644 --- a/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts @@ -904,7 +904,21 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { const transcriptLines = readJsonl(transcriptFile) const userTexts = transcriptLines .filter((l) => l.type === 'user') - .map((l) => l.message?.content?.[0]?.text) + .map((line) => { + const content = line.message?.content + // Ordinary prompts use the simulator's string content shape while + // real CLI message blocks (including tool results) use text blocks. + // The local /compact command is written to native history as a + // string, so inspect both supported Claude transcript forms. + if (typeof content === 'string') return content + if (Array.isArray(content)) { + return content + .map((block: any) => typeof block?.text === 'string' ? block.text : '') + .filter(Boolean) + .join('\n') + } + return '' + }) expect(userTexts).toEqual( expect.arrayContaining(['warm-up turn', '/compact focus the diff', 'follow-up after compact']), ) diff --git a/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts b/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts index 43b8f953e..d83aa5d61 100644 --- a/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts @@ -113,6 +113,11 @@ async function settleBootPicker(page: Page): Promise { } } await expect(page.locator('.xterm').first()).toBeVisible({ timeout: 30_000 }) + // The terminal becomes visible before the picker exit transition ends. + // Wait for that old toolbar to leave before openPanePicker looks for an + // existing picker; otherwise a forced Freshclaude click can land on the + // fading boot picker and the directory form never creates a pane. + await expect(picker).toBeHidden({ timeout: 10_000 }) } /** diff --git a/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts b/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts index 0d5d95c14..39f82dc8a 100644 --- a/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts @@ -274,6 +274,20 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without await expect.poll(() => readClaudeSimLedger(simLedger).some( (row) => row.kind === 'prompt' && row.text === RAISE_TEXT, ), { timeout: 15_000, message: 'the visible pane starts the approval-triggering prompt' }).toBe(true) + let sendRequestId = '' + await expect.poll(async () => { + const sends = (await harness.getSentWsMessages()) + .filter((message: any) => message?.type === 'freshAgent.send' && message?.text === RAISE_TEXT) + sendRequestId = sends.at(-1)?.requestId ?? '' + return sendRequestId + }, { timeout: 10_000, message: 'the visible turn has a request id before the tab switch' }).toBeTruthy() + await expect.poll(async () => (await harness.getReceivedWsMessages()).some( + (message: any) => message?.type === 'freshAgent.send.accepted' && message?.requestId === sendRequestId, + ), { timeout: 10_000, message: 'the server accepted the send while tab A is still visible' }).toBe(true) + await expect.poll(async () => (await leafA())?.content?.status, { + timeout: 10_000, + message: 'the visible pane observed the turn start before it is hidden', + }).toBe('running') // The delayed canUseTool request now arrives after A is hidden. await tabBLocator.click() await expect.poll(() => harness.getActiveTabId(), { timeout: 10_000 }).toBe(tabB) diff --git a/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts b/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts index 892e46d09..661c35fd1 100644 --- a/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts +++ b/test/e2e-browser/specs/freshclaude-identity-persistence-rust.spec.ts @@ -157,6 +157,18 @@ function findFreshAgentLeaf(node: any): any { return null } +function findPaneById(node: any, paneId: string): any { + if (!node) return null + if (node.type === 'leaf' && node.id === paneId) return node + if (node.type === 'split') { + for (const child of node.children ?? []) { + const found = findPaneById(child, paneId) + if (found) return found + } + } + return null +} + /** The durable session id this window's persisted layout names for the tab's * fresh-agent pane — read from the per-window envelope the product actually * writes (freshell.layout.v3., derived exactly like @@ -760,7 +772,10 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { await expect .poll(async () => durableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(tabId!))), { timeout: 15_000 }) .toMatch(CANONICAL_UUID_RE) - const staleDurable: string = durableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(tabId!))) + const originalPane = findFreshAgentLeaf(await harness.getPaneLayout(tabId!)) + const originalPaneId = originalPane?.id as string | undefined + expect(originalPaneId).toBeTruthy() + const staleDurable: string = durableIdentity(originalPane) await flushPersistence(page) // Make the persisted identity STALE: delete every server-side artifact @@ -777,6 +792,7 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { await server.restartAbrupt() await reloadAndReconnect(page, harness) const tabIdAfter = await harness.getActiveTabId() + expect(tabIdAfter).toBe(tabId) // LOUD: the dead-session adjudication surfaces the stale claim. await expect @@ -786,7 +802,8 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { return entries.some((e: any) => e?.kind === 'fresh-agent' && e?.sessionRef?.sessionId === staleDurable) }, { timeout: 30_000 }) .toBe(true) - const leaf = findFreshAgentLeaf(await harness.getPaneLayout(tabIdAfter!)) + const leaf = findPaneById(await harness.getPaneLayout(tabIdAfter!), originalPaneId!) + expect(leaf?.content?.kind).toBe('fresh-agent') expect(leaf?.content?.restoreError?.reason).toBe('durable_artifact_missing') // Batched dead-session adjudication is a real modal dialog // (DeadSessionPanel.tsx:20-24, role="dialog" aria-label="Dead sessions"), @@ -811,7 +828,7 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { // create that might land during the settle window, so asserting on it // here would not actually prove "never silent" (follow-up from the PR // #562 council review). - const leafAfterSettle = findFreshAgentLeaf(await harness.getPaneLayout(tabIdAfter!)) + const leafAfterSettle = findPaneById(await harness.getPaneLayout(tabIdAfter!), originalPaneId!) // NEVER silent: identity not swapped, the loud restoreError still // standing (post-settle re-check -- council follow-up, PR #562/#563 // close-out: the pre-settle restoreError read alone could not prove @@ -821,6 +838,27 @@ test.describe('Freshclaude identity persistence (P0.2)', () => { expect(leafAfterSettle?.content?.restoreError?.reason).toBe('durable_artifact_missing') const sent = await harness.getSentWsMessages() expect(sent.filter((m: any) => m?.type === 'freshAgent.create')).toEqual([]) + + // Reload once more to prove the failed durable claim remains attached + // to this exact pane and stays visible after hydration, rather than + // existing only in the first page's transient adjudication state. + await harness.clearSentWsMessages() + await reloadAndReconnect(page, harness) + const secondReloadTabId = await harness.getActiveTabId() + expect(secondReloadTabId).toBe(tabId) + const leafAfterSecondReload = findPaneById( + await harness.getPaneLayout(secondReloadTabId!), + originalPaneId!, + ) + expect(leafAfterSecondReload?.content?.kind).toBe('fresh-agent') + expect(leafAfterSecondReload?.content?.sessionRef?.sessionId).toBe(staleDurable) + expect(leafAfterSecondReload?.content?.restoreError?.reason).toBe('durable_artifact_missing') + await expect(page.getByRole('alert').filter({ + hasText: 'This session cannot be resumed because the saved session artifact is no longer available.', + })).toBeVisible({ timeout: 10_000 }) + await page.waitForTimeout(1_000) + const sentAfterSecondReload = await harness.getSentWsMessages() + expect(sentAfterSecondReload.filter((m: any) => m?.type === 'freshAgent.create')).toEqual([]) } finally { await server.stop() await fs.rm(sharedRoot, { recursive: true, force: true }) diff --git a/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts b/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts index 13daa874a..cc8af4400 100644 --- a/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts +++ b/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts @@ -1350,36 +1350,14 @@ test.describe('Restore Contract Wall (P0.1)', () => { await expect(page.locator('.xterm').first()).toBeVisible({ timeout: 30_000 }) await createFreshclaudePane(page, harness, projectDir) await sendFreshAgentTurn(page, harness, tabId, 'wall freshclaude turn') - // Pre-kill turn proof via the HARNESS (Redux), not UI chrome (corrected, - // council fix round): the fresh-agent transcript renders exclusively - // from the REST thread snapshot (FreshAgentView.tsx:1302,1782 -- - // getFreshAgentThreadSnapshot -> snapshot?.turns). The claim that "the - // Rust router has NO claude snapshot adapter -> 503" is FALSE at HEAD -- - // crates/freshell-freshagent/src/snapshot.rs:133-146 routes - // freshclaude/kilroy + claude through get_claude_snapshot(), a - // disk+env adapter over the CLI's own transcript store; - // FRESH_AGENT_RUNTIME_UNAVAILABLE now only fires for session types - // with no adapter registered at all. This assertion still reads via - // the harness (not DOM) because the snapshot fetch can race pane - // creation, not because the adapter is missing. The sidecar protocol - // itself is verified end-to-end regardless: freshAgent.event/ - // freshAgent.assistant arrives on the wire and folds into the - // freshAgent slice (turns[]) -- assert THAT (createFreshclaudePane's - // note: assert pane state via the harness, never error-free UI chrome - // for freshclaude). - await expect - .poll(async () => { - const sessions = (await harness.getState())?.freshAgent?.sessions ?? {} - return Object.values(sessions).some((s: any) => - (s?.turns ?? []).some((turn: any) => - turn?.role === 'assistant' - && (turn?.items ?? []).some( - (item: any) => typeof item?.text === 'string' && item.text.includes('RESTORE_WALL_REPLY'), - ), - ), - ) - }, { timeout: 20_000 }) - .toBe(true) + // Verify the unique scripted reply in the pane's live transcript. The + // SDK assistant frame is folded into the live-transcript slice; the + // `freshAgent.sessions` Redux cache is populated by REST snapshots and + // is not the live-stream source. After restart, the later assertion + // checks the same reply was rehydrated from the durable transcript. + await expect( + page.locator('[data-context="fresh-agent"]').last().getByText('RESTORE_WALL_REPLY', { exact: true }), + ).toBeVisible({ timeout: 20_000 }) const originalSessionId: string = await expect .poll(async () => leafDurableIdentity(findFreshAgentLeaf(await harness.getPaneLayout(tabId))) ?? null, { From 8ba12d27838ef723d19212f1f1c2e9213ef1f0b6 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:40:19 -0700 Subject: [PATCH 071/100] refactor(freshclaude): retain a native identity in ended runtime records --- crates/freshell-freshagent/src/claude.rs | 1 - .../freshell-freshagent/src/claude/genesis.rs | 7 +------ .../src/claude/recovery.rs | 6 +----- .../src/claude/recovery_stop.rs | 1 - .../src/claude_dead_sessions.rs | 20 +++++++------------ 5 files changed, 9 insertions(+), 26 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 314552ddb..121f36152 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -5302,7 +5302,6 @@ impl FreshClaudeState { stamp: &stamp, session_type: &session_type, durables: &removed_durables, - cli_session_id: Some(&stamp), settings: &evicted.configuration.settings, ownership_id: &evicted.ownership_id, launch_genesis: evicted.launch_genesis.as_ref(), diff --git a/crates/freshell-freshagent/src/claude/genesis.rs b/crates/freshell-freshagent/src/claude/genesis.rs index d718ba8d4..03c8420b1 100644 --- a/crates/freshell-freshagent/src/claude/genesis.rs +++ b/crates/freshell-freshagent/src/claude/genesis.rs @@ -376,12 +376,7 @@ impl FreshClaudeState { .dead_sessions .find(&session_key) .filter(|dead| dead.ownership_id == ownership_id) - .map(|dead| { - ( - dead.cli_session_id.unwrap_or_else(|| session_key.clone()), - dead.launch_genesis, - ) - }) + .map(|dead| (dead.session_id, dead.launch_genesis)) }); let Some((durable, runtime_genesis)) = captured else { return; diff --git a/crates/freshell-freshagent/src/claude/recovery.rs b/crates/freshell-freshagent/src/claude/recovery.rs index fdbbd0ef9..b3d0e578a 100644 --- a/crates/freshell-freshagent/src/claude/recovery.rs +++ b/crates/freshell-freshagent/src/claude/recovery.rs @@ -278,11 +278,7 @@ impl FreshClaudeState { let candidate = sink .as_ref() .and_then(|sink| sink.chain_terminus(PROVIDER, wire_id)) - .or_else(|| { - self.dead_sessions - .find(wire_id) - .and_then(|dead| dead.cli_session_id) - }) + .or_else(|| self.dead_sessions.find(wire_id).map(|dead| dead.session_id)) .or_else(|| is_canonical_claude_uuid(wire_id).then(|| wire_id.to_string()))?; Some( sink.and_then(|sink| sink.chain_terminus(PROVIDER, &candidate)) diff --git a/crates/freshell-freshagent/src/claude/recovery_stop.rs b/crates/freshell-freshagent/src/claude/recovery_stop.rs index 623356a75..f89c9af01 100644 --- a/crates/freshell-freshagent/src/claude/recovery_stop.rs +++ b/crates/freshell-freshagent/src/claude/recovery_stop.rs @@ -157,7 +157,6 @@ impl FreshClaudeState { stamp: &stamp, session_type: &session.session_type, durables: &aliases, - cli_session_id: Some(&stamp), settings: &session.configuration.settings, ownership_id: &session.ownership_id, launch_genesis: session.launch_genesis.as_ref(), diff --git a/crates/freshell-freshagent/src/claude_dead_sessions.rs b/crates/freshell-freshagent/src/claude_dead_sessions.rs index 967c4be05..c77e0cd51 100644 --- a/crates/freshell-freshagent/src/claude_dead_sessions.rs +++ b/crates/freshell-freshagent/src/claude_dead_sessions.rs @@ -8,9 +8,8 @@ //! a send to it is refused visibly instead of vanishing. Resuming the session //! (attach or a create that resumes it) or closing it forgets the record. //! -//! Until the durable id is minted at create (C23) a session answers to several -//! ids — its sessions-map key (the pane's id from create or attach), its envelope -//! stamp and its durable claude UUIDs — so a record answers to all of them. +//! A record retains the current native UUID and the native history of its +//! runtime, so late requests using an earlier UUID can recover the same writer. use std::collections::VecDeque; use std::sync::{Arc, Mutex}; @@ -31,7 +30,7 @@ pub(crate) struct DeadClaudeSession { pub(crate) ids: Vec, /// The settings the session last ran under. pub(crate) settings: FreshAgentSettings, - pub(crate) cli_session_id: Option, + pub(crate) session_id: String, pub(crate) ownership_id: String, pub(crate) launch_genesis: Option, pub(crate) turn_lock: Arc>, @@ -103,8 +102,6 @@ pub(crate) struct Death<'a> { pub(crate) session_type: &'a str, /// The durable ids the session's aliases named. pub(crate) durables: &'a [String], - /// The durable id the session itself held. - pub(crate) cli_session_id: Option<&'a str>, pub(crate) settings: &'a FreshAgentSettings, pub(crate) ownership_id: &'a str, pub(crate) launch_genesis: Option<&'a crate::identity_sink::RecordedGenesis>, @@ -133,10 +130,7 @@ impl SharedDeadSessions { /// map key, its envelope stamp and every durable id it held. pub(crate) fn record(&self, death: Death<'_>) { let mut ids = vec![death.map_key.to_string()]; - for id in std::iter::once(death.stamp) - .chain(death.durables.iter().map(String::as_str)) - .chain(death.cli_session_id) - { + for id in std::iter::once(death.stamp).chain(death.durables.iter().map(String::as_str)) { if !ids.iter().any(|known| known == id) { ids.push(id.to_string()); } @@ -144,7 +138,7 @@ impl SharedDeadSessions { let dead = DeadClaudeSession { ids: ids.clone(), settings: death.settings.clone(), - cli_session_id: death.cli_session_id.map(str::to_string), + session_id: death.stamp.to_string(), ownership_id: death.ownership_id.to_string(), launch_genesis: death.launch_genesis.cloned(), turn_lock: death.turn_lock.clone(), @@ -193,7 +187,7 @@ impl SharedDeadSessions { let mut store = self.0.lock().expect("dead sessions lock"); for dead in &mut store.entries { if dead.answers_to(id) - && dead.cli_session_id.as_deref().is_none_or(|cli| cli == id) + && dead.session_id == id && ownership_id.is_none_or(|owner| dead.ownership_id == owner) { dead.launch_genesis = None; @@ -232,7 +226,7 @@ mod tests { DeadClaudeSession { ids: ids.iter().map(|id| id.to_string()).collect(), settings: FreshAgentSettings::default(), - cli_session_id: None, + session_id: ids.last().expect("a native identity").to_string(), ownership_id: "dead-runtime".into(), launch_genesis: None, turn_lock: Arc::new(tokio::sync::Mutex::new(())), From efd4248b47763bf5567d53dbcb6cd95c489085a7 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:41:33 -0700 Subject: [PATCH 072/100] test(freshclaude): reconcile actual native sidecar sessions --- .../tests/common/claude_sim_environment.rs | 84 ++++++ crates/freshell-ws/tests/common/mod.rs | 1 + .../tests/pane_reconcile_freshagent.rs | 255 +++++++++--------- .../freshell-ws/tests/rest_claude_identity.rs | 70 +---- 4 files changed, 210 insertions(+), 200 deletions(-) create mode 100644 crates/freshell-ws/tests/common/claude_sim_environment.rs diff --git a/crates/freshell-ws/tests/common/claude_sim_environment.rs b/crates/freshell-ws/tests/common/claude_sim_environment.rs new file mode 100644 index 000000000..269f515ce --- /dev/null +++ b/crates/freshell-ws/tests/common/claude_sim_environment.rs @@ -0,0 +1,84 @@ +//! An isolated production-sidecar environment for tests with custom WS wiring. +//! SDK recording and shutdown live at the query boundary; no wire frames are faked. + +use serde_json::Value; +use std::path::{Path, PathBuf}; +pub struct ClaudeSimEnvironment { + dir: PathBuf, +} + +impl ClaudeSimEnvironment { + pub fn new() -> Self { + let dir = + std::env::temp_dir().join(format!("freshell-claude-sdk-{}", uuid::Uuid::new_v4())); + for child in ["home", "workspace", "tmp"] { + std::fs::create_dir_all(dir.join(child)).unwrap(); + } + std::fs::write(dir.join("active"), "").unwrap(); + Self { dir } + } + + pub fn workspace(&self) -> PathBuf { + self.dir.join("workspace") + } + + pub fn config_dir(&self) -> PathBuf { + self.dir.join("home/.claude") + } + + pub fn dir(&self) -> &Path { + &self.dir + } + + pub fn environment(&self) -> Vec<(String, String)> { + let adapter = super::claude_rig::repo_root() + .join("test/integration/providers/claude-sdk-for-rust-runtime.mjs"); + vec![ + ( + "HOME".into(), + self.dir.join("home").to_string_lossy().into(), + ), + ("PATH".into(), std::env::var("PATH").unwrap_or_default()), + ( + "TMPDIR".into(), + self.dir.join("tmp").to_string_lossy().into(), + ), + ( + "CLAUDE_CONFIG_DIR".into(), + self.dir.join("home/.claude").to_string_lossy().into(), + ), + ( + "FRESHELL_CLAUDE_SDK_QUERY_MODULE".into(), + freshell_platform::opencode_plugin::plugin_file_spec(&adapter), + ), + ( + "FRESHELL_CLAUDE_SIM_LEDGER".into(), + self.dir.join("ledger.jsonl").to_string_lossy().into(), + ), + ("FRESHELL_CLAUDE_SIM_TIME_SCALE".into(), "0.01".into()), + ( + "FRESHELL_TEST_CLAUDE_RUNTIME_GUARD".into(), + self.dir.join("active").to_string_lossy().into(), + ), + ] + } + + pub fn rows(&self, kind: &str) -> Vec { + std::fs::read_to_string(self.dir.join("ledger.jsonl")) + .unwrap_or_default() + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str::(line).expect("simulator row")) + .filter(|row| row["kind"] == kind) + .collect() + } +} + +impl Drop for ClaudeSimEnvironment { + fn drop(&mut self) { + // The shared SDK adapter closes its query when the guard ends or this + // unique directory disappears; cleanup never needs a PID signal. + let _ = std::fs::write(self.dir.join("active.stop"), ""); + let _ = std::fs::remove_dir_all(&self.dir); + } +} diff --git a/crates/freshell-ws/tests/common/mod.rs b/crates/freshell-ws/tests/common/mod.rs index 3bc61b782..3daff9b7d 100644 --- a/crates/freshell-ws/tests/common/mod.rs +++ b/crates/freshell-ws/tests/common/mod.rs @@ -18,6 +18,7 @@ use tokio_tungstenite::tungstenite::Message as WsMessage; use freshell_ws::WsState; pub mod claude_rig; +pub mod claude_sim_environment; pub mod ledger_sink; pub mod log_capture; pub mod opencode_home; diff --git a/crates/freshell-ws/tests/pane_reconcile_freshagent.rs b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs index 301ac9976..3cd8e30af 100644 --- a/crates/freshell-ws/tests/pane_reconcile_freshagent.rs +++ b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs @@ -13,6 +13,9 @@ //! in-request dedupe, the respawn cap + reset-on-live, and the G3 //! supersession-chain reader rule end-to-end through the real ledger. +mod common; + +use common::claude_sim_environment::ClaudeSimEnvironment; use std::collections::HashMap; use std::sync::{Arc, Mutex, OnceLock}; use std::time::Duration; @@ -28,12 +31,6 @@ use freshell_ws::WsState; const AUTH_TOKEN: &str = "s3cr3t-token-abcdef"; -/// Serializes every test in this file that mutates the process-global -/// `FRESHELL_CLAUDE_SIDECAR` / `FRESHELL_CLAUDE_NODE` / `CLAUDE_CONFIG_DIR` -/// env vars, mirroring `freshagent_claude_attach.rs`'s convention for the -/// same hazard. -static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - // ── scripted disk-truth probe ───────────────────────────────────────────────── #[derive(Default)] @@ -66,89 +63,6 @@ impl freshell_ws::existence::SessionExistenceProbe for StubProbe { } } -// ── fake claude sidecar (donor: freshagent_claude_attach.rs) ────────────────── - -/// A minimal scripted fake claude sidecar (no real SDK, no network, no cost) -/// speaking the SAME newline-JSON protocol `spawn_sidecar()` drives the -/// vendored package with. On `create` it replies `created`, then -/// `sdk.session.init` echoing the resume boot's id (or a fixed durable UUID the -/// tests control) as the durable `cliSessionId`, then `sdk.status idle`; on -/// `shutdown` it exits. -const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" -import readline from 'node:readline' - -let counter = 0 -const rl = readline.createInterface({ input: process.stdin, terminal: false }) -rl.on('line', (line) => { - const trimmed = line.trim() - if (!trimmed) return - let msg - try { - msg = JSON.parse(trimmed) - } catch { - return - } - if (msg.type === 'create') { - counter += 1 - const sessionId = `fake-claude-session-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') - const cliSessionId = msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' - process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') - process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') - } else if (msg.type === 'shutdown') { - process.exit(0) - } -}) -"#; - -/// The fixed durable `cliSessionId` the fake sidecar mints on a plain (non -/// resume) `create` — the id the live-session tests key their probe on. -const FAKE_DURABLE_ID: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - -/// A fresh temp dir holding the fake sidecar script, with -/// `FRESHELL_CLAUDE_SIDECAR`/`FRESHELL_CLAUDE_NODE` pointed at it, PLUS an -/// empty claude store with `CLAUDE_CONFIG_DIR` pointed at it (so no test ever -/// touches the real home). Caller must hold [`CLAUDE_ENV_LOCK`] for the -/// lifetime of the returned guard. -struct FakeClaudeEnv { - dir: std::path::PathBuf, -} -impl FakeClaudeEnv { - fn install() -> Self { - let dir = std::env::temp_dir().join(format!( - "freshell-fake-claude-reconcile-ws-{}", - uuid_like_suffix() - )); - std::fs::create_dir_all(&dir).expect("create fake sidecar temp dir"); - let script = dir.join("fake-claude-sidecar.mjs"); - std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); - let store = dir.join("claude-store"); - std::fs::create_dir_all(&store).expect("create claude store dir"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("CLAUDE_CONFIG_DIR", &store); - Self { dir } - } -} -impl Drop for FakeClaudeEnv { - fn drop(&mut self) { - std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); - std::env::remove_var("FRESHELL_CLAUDE_NODE"); - std::env::remove_var("CLAUDE_CONFIG_DIR"); - let _ = std::fs::remove_dir_all(&self.dir); - } -} - -/// Dependency-free unique suffix (avoids pulling in `uuid` for this test crate). -fn uuid_like_suffix() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - format!("{nanos}-{:?}", std::thread::current().id()) -} - fn test_settings_value() -> serde_json::Value { serde_json::json!({ "ai": {}, @@ -192,6 +106,13 @@ struct Server { /// so tests can seed and assert deterministically (the §9.1 headless /// convention). async fn spawn_server_with_probe(probe: Arc) -> Server { + spawn_server_with_claude_environment(probe, None).await +} + +async fn spawn_server_with_claude_environment( + probe: Arc, + environment: Option>, +) -> Server { let auth_token = Arc::new(AUTH_TOKEN.to_string()); let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(64).0); let settings = @@ -204,6 +125,11 @@ async fn spawn_server_with_probe(probe: Arc) -> Server { ))); let respawn_counts: Arc>> = Arc::default(); + let mut fresh_claude = freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)); + if let Some(environment) = environment { + fresh_claude = fresh_claude.with_sidecar_environment(environment); + } + let state = WsState { layout: Default::default(), terminal_meta: Default::default(), @@ -224,7 +150,7 @@ async fn spawn_server_with_probe(probe: Arc) -> Server { Arc::clone(&broadcast_tx), serde_json::json!({ "freshAgent": { "enabled": true } }), ), - fresh_claude: freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)), + fresh_claude, fresh_opencode: freshell_freshagent::FreshOpencodeState::new( freshell_freshagent::FreshAgentState::new( Arc::clone(&auth_token), @@ -389,28 +315,71 @@ async fn await_frame( .expect("expected frame did not arrive within budget") } -/// Drive a claude `freshAgent.create` through the fake sidecar and return the -/// durable `cliSessionId` from the `freshAgent.session.init` event. -async fn create_live_claude_session(ws: &mut TestWs, request_id: &str) -> String { - ws.send(WsMessage::Text( - serde_json::json!({ - "type": "freshAgent.create", - "requestId": request_id, - "sessionType": "freshclaude", - "provider": "claude", - }) - .to_string(), - )) - .await - .expect("send freshAgent.create"); - let init = await_frame(ws, Duration::from_secs(15), |v| { - v["type"] == "freshAgent.event" && v["event"]["type"] == "freshAgent.session.init" +/// Create or resume through the production sidecar. Native identity is established +/// before ACK; the CLI emits its init only after an actual prompt (T3). +async fn create_live_claude_session( + ws: &mut TestWs, + environment: &ClaudeSimEnvironment, + request_id: &str, + resume: Option<&str>, +) -> String { + let mut request = serde_json::json!({ + "type": "freshAgent.create", "requestId": request_id, + "sessionType": "freshclaude", "provider": "claude", + "cwd": environment.workspace(), + }); + if let Some(id) = resume { + request["sessionRef"] = serde_json::json!({"provider":"claude", "sessionId":id}); + } + ws.send(WsMessage::Text(request.to_string())).await.unwrap(); + let created = await_frame(ws, common::FRAME_BUDGET, |v| { + v["requestId"] == request_id + && matches!( + v["type"].as_str(), + Some("freshAgent.created" | "freshAgent.create.failed" | "error") + ) }) .await; - init["event"]["cliSessionId"] - .as_str() - .expect("session.init carries the durable cliSessionId") - .to_string() + assert_eq!(created["type"], "freshAgent.created", "{created}"); + let id = created["sessionId"].as_str().unwrap().to_string(); + assert!( + uuid::Uuid::parse_str(&id).is_ok(), + "native conversation UUID: {created}" + ); + assert_eq!(created["sessionRef"]["sessionId"], id); + assert!(environment + .rows("boot") + .iter() + .any(|row| row["sessionId"] == id)); + id +} + +async fn kill_live_claude_session(ws: &mut TestWs, environment: &ClaudeSimEnvironment, id: &str) { + let boot = environment + .rows("boot") + .into_iter() + .rev() + .find(|row| row["sessionId"] == id) + .unwrap(); + let processes = [ + boot["pid"].as_u64().unwrap(), + boot["cliPid"].as_u64().unwrap(), + ] + .map(|pid| common::process_probe::ProcessProbe::open(u32::try_from(pid).unwrap())); + ws.send(WsMessage::Text(serde_json::json!({ + "type":"freshAgent.kill", "provider":"claude", "sessionType":"freshclaude", "sessionId":id + }).to_string())).await.unwrap(); + let killed = await_frame(ws, common::FRAME_BUDGET, |v| { + v["type"] == "freshAgent.killed" && v["sessionId"] == id + }) + .await; + assert_eq!(killed["success"], true, "{killed}"); + for process in processes { + assert!( + process.exits_within(common::FRAME_BUDGET).await, + "owned runtime survived kill" + ); + } } // --- negotiation --------------------------------------------------------------- @@ -499,14 +468,16 @@ async fn fresh_agent_verdicts_cover_the_four_states() { #[tokio::test] async fn live_fresh_agent_session_gets_attach() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let _env = FakeClaudeEnv::install(); - - let server = spawn_server().await; + let environment = ClaudeSimEnvironment::new(); + let server = spawn_server_with_claude_environment( + Arc::new(StubProbe::default()), + Some(environment.environment()), + ) + .await; let (mut ws, _ready) = connect(&server.url, true, true).await; - let cli_session_id = create_live_claude_session(&mut ws, "req-live-attach").await; - assert_eq!(cli_session_id, FAKE_DURABLE_ID); + let cli_session_id = + create_live_claude_session(&mut ws, &environment, "req-live-attach", None).await; let verdicts = reconcile_request( &mut ws, @@ -519,6 +490,7 @@ async fn live_fresh_agent_session_gets_attach() { assert_eq!(verdicts[0]["verdict"], "attach"); assert_eq!(verdicts[0]["sessionRef"]["sessionId"], cli_session_id); assert!(verdicts[0].get("terminalId").is_none()); + kill_live_claude_session(&mut ws, &environment, &cli_session_id).await; } /// WAVE-B B1xB4 seam pin (V9 3.6): a mixed request whose TERMINAL pane @@ -648,16 +620,29 @@ async fn a_session_resolving_live_clears_the_respawn_counter() { // Reset-on-live (V2/A7): a successful respawn is OBSERVED as the session // going live; the counter must clear so healthy sessions are never // exhausted by reconnect/reload storms. - let _guard = CLAUDE_ENV_LOCK.lock().await; - let _env = FakeClaudeEnv::install(); - - let probe = std::sync::Arc::new(StubProbe::default()); + let environment = ClaudeSimEnvironment::new(); + let probe = Arc::new(StubProbe::default()); + let server = + spawn_server_with_claude_environment(Arc::clone(&probe), Some(environment.environment())) + .await; + let (mut ws, _ready) = connect(&server.url, true, true).await; + let cli_session_id = + create_live_claude_session(&mut ws, &environment, "req-counter-original", None).await; + // Materialize a real saved conversation, then close it so reconcile sees a + // resumable session with no live runtime before the subsequent resume. + ws.send(WsMessage::Text(serde_json::json!({ + "type":"freshAgent.send", "requestId":"counter-first-turn", "provider":"claude", + "sessionType":"freshclaude", "sessionId":cli_session_id, "text":"remember this conversation" + }).to_string())).await.unwrap(); + await_frame(&mut ws, common::FRAME_BUDGET, |v| { + v["sessionId"] == cli_session_id && v["event"]["type"] == "freshAgent.result" + }) + .await; + kill_live_claude_session(&mut ws, &environment, &cli_session_id).await; probe.answers.lock().unwrap().insert( - ("claude".into(), FAKE_DURABLE_ID.into()), + ("claude".into(), cli_session_id.clone()), SessionExistence::Present, ); - let server = spawn_server_with_probe(probe).await; - let (mut ws, _ready) = connect(&server.url, true, true).await; // TWO reconcile requests while the session is NOT yet live → both respawn. for i in 0..2 { @@ -665,7 +650,7 @@ async fn a_session_resolving_live_clears_the_respawn_counter() { &mut ws, serde_json::json!([ { "paneKey": "p", "kind": "fresh-agent", - "sessionRef": {"provider": "claude", "sessionId": FAKE_DURABLE_ID} } + "sessionRef": {"provider": "claude", "sessionId": cli_session_id} } ]), ) .await; @@ -676,22 +661,27 @@ async fn a_session_resolving_live_clears_the_respawn_counter() { .respawn_counts .lock() .unwrap() - .get(&("claude".to_string(), FAKE_DURABLE_ID.to_string())) + .get(&("claude".to_string(), cli_session_id.clone())) .copied(), Some(2), "two respawn answers burned two" ); - // Drive freshAgent.create through the fake sidecar so has_live_session - // becomes true for the durable id. - let cli_session_id = create_live_claude_session(&mut ws, "req-reset-on-live").await; - assert_eq!(cli_session_id, FAKE_DURABLE_ID); + // Resume the same saved conversation through the real SDK boundary. + let resumed = create_live_claude_session( + &mut ws, + &environment, + "req-reset-on-live", + Some(&cli_session_id), + ) + .await; + assert_eq!(resumed, cli_session_id); let verdicts = reconcile_request( &mut ws, serde_json::json!([ { "paneKey": "p", "kind": "fresh-agent", - "sessionRef": {"provider": "claude", "sessionId": FAKE_DURABLE_ID} } + "sessionRef": {"provider": "claude", "sessionId": cli_session_id} } ]), ) .await; @@ -701,9 +691,10 @@ async fn a_session_resolving_live_clears_the_respawn_counter() { .respawn_counts .lock() .unwrap() - .contains_key(&("claude".to_string(), FAKE_DURABLE_ID.to_string())), + .contains_key(&("claude".to_string(), cli_session_id.clone())), "counter cleared (not merely un-incremented) when presence resolved Live" ); + kill_live_claude_session(&mut ws, &environment, &cli_session_id).await; } #[tokio::test] @@ -985,7 +976,7 @@ fn log_capture() -> Arc>> { #[tokio::test] async fn dead_session_verdict_is_warn_logged_with_claimed_identity() { let events = log_capture(); - let marker = uuid_like_suffix(); + let marker = uuid::Uuid::new_v4().to_string(); let pane_key = format!("deadpane-{marker}"); let session_id = format!("gone-{marker}"); let probe = std::sync::Arc::new(StubProbe::default()); diff --git a/crates/freshell-ws/tests/rest_claude_identity.rs b/crates/freshell-ws/tests/rest_claude_identity.rs index ece994700..8e4b2a766 100644 --- a/crates/freshell-ws/tests/rest_claude_identity.rs +++ b/crates/freshell-ws/tests/rest_claude_identity.rs @@ -575,73 +575,7 @@ async fn rest_claude_resume_create_writes_identity_row_and_ledger_binding() { // ── kata b8ke Task 10: REST + WS + browser-shaped parity on ONE coordinator ── -// A real sidecar environment local to this lane: no process-global env changes. -struct ClaudeSimEnvironment { - dir: std::path::PathBuf, -} - -impl ClaudeSimEnvironment { - fn new() -> Self { - let dir = unique_temp_dir("sidecar"); - for child in ["home", "workspace", "tmp"] { - std::fs::create_dir_all(dir.join(child)).unwrap(); - } - std::fs::write(dir.join("active"), "").unwrap(); - Self { dir } - } - - fn environment(&self) -> Vec<(String, String)> { - let adapter = common::claude_rig::repo_root() - .join("test/integration/providers/claude-sdk-for-rust-runtime.mjs"); - vec![ - ( - "HOME".into(), - self.dir.join("home").to_string_lossy().into(), - ), - ("PATH".into(), std::env::var("PATH").unwrap_or_default()), - ( - "TMPDIR".into(), - self.dir.join("tmp").to_string_lossy().into(), - ), - ( - "CLAUDE_CONFIG_DIR".into(), - self.dir.join("home/.claude").to_string_lossy().into(), - ), - ( - "FRESHELL_CLAUDE_SDK_QUERY_MODULE".into(), - freshell_platform::opencode_plugin::plugin_file_spec(&adapter), - ), - ( - "FRESHELL_CLAUDE_SIM_LEDGER".into(), - self.dir.join("ledger.jsonl").to_string_lossy().into(), - ), - ("FRESHELL_CLAUDE_SIM_TIME_SCALE".into(), "0.01".into()), - ( - "FRESHELL_TEST_CLAUDE_RUNTIME_GUARD".into(), - self.dir.join("active").to_string_lossy().into(), - ), - ] - } - - fn rows(&self, kind: &str) -> Vec { - std::fs::read_to_string(self.dir.join("ledger.jsonl")) - .unwrap_or_default() - .lines() - .filter(|line| !line.trim().is_empty()) - .map(|line| serde_json::from_str::(line).expect("simulator row")) - .filter(|row| row["kind"] == kind) - .collect() - } -} - -impl Drop for ClaudeSimEnvironment { - fn drop(&mut self) { - // The shared SDK adapter closes its query when the guard ends or this - // unique directory disappears; cleanup never needs a PID signal. - let _ = std::fs::write(self.dir.join("active.stop"), ""); - let _ = std::fs::remove_dir_all(&self.dir); - } -} +use common::claude_sim_environment::ClaudeSimEnvironment; /// Read text frames until one with `type == wanted` arrives (bounded). async fn next_frame_of_type(ws: &mut common::TestWs, wanted: &str) -> serde_json::Value { @@ -681,7 +615,7 @@ async fn rest_ws_and_browser_paths_share_the_one_coordinator() { json!({ "type": "freshAgent.create", "requestId": "req-r10-parity-1", "sessionType": "freshclaude", "provider": "claude", - "cwd": sidecar.dir.join("workspace").to_string_lossy(), + "cwd": sidecar.workspace().to_string_lossy(), }) .to_string(), )) From 078b97692d5ee57e5ae80ed95e42b3ccff6a4a7d Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:42:32 -0700 Subject: [PATCH 073/100] test(freshclaude): verify signal conflicts with a real sidecar owner --- .../tests/claude_session_rebind.rs | 121 +++++------------- 1 file changed, 34 insertions(+), 87 deletions(-) diff --git a/crates/freshell-ws/tests/claude_session_rebind.rs b/crates/freshell-ws/tests/claude_session_rebind.rs index 60430885f..735cccf8e 100644 --- a/crates/freshell-ws/tests/claude_session_rebind.rs +++ b/crates/freshell-ws/tests/claude_session_rebind.rs @@ -9,7 +9,7 @@ //! restore:true and CLAUDE_ARGV capture -> argv contains ["--resume","B"]. //! Phase 3 (hijack): second live claude pane bound to "C"; drop a signal //! for pane1 claiming "C" -> refused; both panes' meta unchanged (A13). -//! Phase 4 (cross-kind, D7): a LIVE freshclaude sidecar owns "D" (fake +//! Phase 4 (cross-kind, D7): a LIVE freshclaude sidecar owns "D" (real //! sidecar, in-memory session map only -- no ledger row); a signal claiming //! "D" for a terminal pane -> refused (live-sidecar session-map probe). //! Phase 5 (multi-instance, P4): a signal naming a terminal id unknown to @@ -155,6 +155,7 @@ fn registry_resume_id( #[cfg(unix)] async fn spawn_server_returning_state( cli_commands: Vec, + claude_environment: Vec<(String, String)>, ) -> ( String, freshell_terminal::TerminalRegistry, @@ -196,7 +197,8 @@ async fn spawn_server_returning_state( }, fresh_claude: { let fresh_claude = - freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)); + freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)) + .with_sidecar_environment(claude_environment); fresh_claude.set_session_naming(sink.clone()); fresh_claude }, @@ -318,72 +320,6 @@ async fn send_create(ws: &mut common::TestWs, body: serde_json::Value) -> serde_ common::next_frame_of_type(ws, "terminal.created").await } -/// Minimal fake claude sidecar speaking the newline-JSON protocol: answers -/// `create` with `created` + `sdk.session.init` (echoing the resume boot's id as -/// the durable cliSessionId), exits on `shutdown`.#[cfg(unix)] -const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#"import readline from 'node:readline' - -let counter = 0 -const rl = readline.createInterface({ input: process.stdin, terminal: false }) -rl.on('line', (line) => { - const trimmed = line.trim() - if (!trimmed) return - let msg - try { - msg = JSON.parse(trimmed) - } catch { - return - } - if (msg.type === 'create') { - counter += 1 - const sessionId = `fake-claude-session-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') - const cliSessionId = msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' - process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') - process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') - } else if (msg.type === 'shutdown') { - process.exit(0) - } -}) -"#; - -/// A fresh temp dir holding the fake sidecar script, with -/// `FRESHELL_CLAUDE_SIDECAR`/`FRESHELL_CLAUDE_NODE` pointed at it, PLUS an -/// empty claude store with `CLAUDE_CONFIG_DIR` pointed at it (so the test -/// never touches the real home). This file is `#[cfg(unix)]` and its single -/// test fn owns process env, so no env lock is needed. -#[cfg(unix)] -struct FakeClaudeEnv { - dir: std::path::PathBuf, -} -#[cfg(unix)] -impl FakeClaudeEnv { - fn install() -> Self { - let dir = std::env::temp_dir().join(format!( - "freshell-fake-claude-rebind-{}", - std::process::id() - )); - std::fs::create_dir_all(&dir).expect("create fake sidecar temp dir"); - let script = dir.join("fake-claude-sidecar.mjs"); - std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); - let store = dir.join("claude-store"); - std::fs::create_dir_all(&store).expect("create claude store dir"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("CLAUDE_CONFIG_DIR", &store); - Self { dir } - } -} -#[cfg(unix)] -impl Drop for FakeClaudeEnv { - fn drop(&mut self) { - std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); - std::env::remove_var("FRESHELL_CLAUDE_NODE"); - std::env::remove_var("CLAUDE_CONFIG_DIR"); - let _ = std::fs::remove_dir_all(&self.dir); - } -} - /// Unified agent names (phases 8-10): plant /// `/projects//.jsonl` — the exact direct-layout /// file `locate_transcript_selected` accepts as VERIFIED durability evidence @@ -437,8 +373,12 @@ async fn session_start_signal_rebinds_and_restores_the_new_id() { std::fs::create_dir_all(&signal_root).expect("signal root"); let watcher = freshell_ws::claude_signal::ClaudeSignalWatcher::new(signal_root.clone()); - let (url, registry, state, sink) = - spawn_server_returning_state(vec![claude_capture_spec()]).await; + let claude_environment = common::claude_sim_environment::ClaudeSimEnvironment::new(); + let (url, registry, state, sink) = spawn_server_returning_state( + vec![claude_capture_spec()], + claude_environment.environment(), + ) + .await; let (mut ws, _inventory) = common::connect_and_capture_inventory(&url).await; // ── Phase 1 -- mid-session rebind: fresh claude pane (identity A via the @@ -607,31 +547,32 @@ async fn session_start_signal_rebinds_and_restores_the_new_id() { .expect("terminalId") .to_string(); - let _fake_env = FakeClaudeEnv::install(); - let sidecar_sid = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; ws.send(WsMessage::Text( json!({ "type": "freshAgent.create", "requestId": "req-live-owner", "sessionType": "freshclaude", "provider": "claude", - "cwd": "/tmp", - "sessionRef": { "provider": "claude", "sessionId": sidecar_sid }, + "cwd": claude_environment.workspace(), }) .to_string(), )) .await .expect("send freshAgent.create"); - assert!( - frame_seen_within(&mut ws, Duration::from_secs(15), |v| { - v["type"] == "freshAgent.created" && v["requestId"] == "req-live-owner" - }) - .await, - "fake sidecar must come live" - ); - // The resume path inserts cli_index[S] synchronously before `created` - // (claude.rs:436), so the probe is authoritative now: - assert!(state.fresh_claude.has_live_session(sidecar_sid).await); + let created = common::next_frame_of_type(&mut ws, "freshAgent.created").await; + assert_eq!(created["requestId"], "req-live-owner", "{created}"); + let sidecar_sid = created["sessionId"].as_str().unwrap().to_string(); + assert!(uuid::Uuid::parse_str(&sidecar_sid).is_ok()); + assert_eq!(created["sessionRef"]["sessionId"], sidecar_sid); + assert!(state.fresh_claude.has_live_session(&sidecar_sid).await); + let boot = claude_environment.rows("boot").into_iter().next().unwrap(); + assert_eq!(boot["sessionId"], sidecar_sid); + assert_eq!(claude_environment.rows("query").len(), 1); + let sidecar_processes = [ + boot["pid"].as_u64().unwrap(), + boot["cliPid"].as_u64().unwrap(), + ] + .map(|pid| common::process_probe::ProcessProbe::open(u32::try_from(pid).unwrap())); // Forge a SessionStart signal from the phase-4 pane claiming the // sidecar-owned session. @@ -646,7 +587,7 @@ async fn session_start_signal_rebinds_and_restores_the_new_id() { // Refusal proof: the pane's identity did not move... assert_ne!( registry_resume_id(®istry, &tid4).as_deref(), - Some(sidecar_sid), + Some(sidecar_sid.as_str()), "a live-sidecar-owned session must never be claimed by a terminal pane" ); // ...no association frame was emitted for it... @@ -867,7 +808,7 @@ async fn session_start_signal_rebinds_and_restores_the_new_id() { // The CLI reports a NEW session (B2) whose transcript already exists on // disk — the signal lane's verified first-bind. The naming phases own - // CLAUDE_CONFIG_DIR from here (phase 4's fake sidecar is long done). + // CLAUDE_CONFIG_DIR from here; the sidecar keeps its isolated per-state store. let naming_store = tempfile::tempdir().expect("naming transcript store"); std::env::set_var("CLAUDE_CONFIG_DIR", naming_store.path()); let b2 = "88888888-9999-4aaa-8bbb-cccccccc0001"; @@ -1161,7 +1102,13 @@ async fn session_start_signal_rebinds_and_restores_the_new_id() { must re-stamp the persisted pre-durable handle" ); - state.fresh_claude.shutdown().await; // reap the fake node child + state.fresh_claude.shutdown().await; + for process in sidecar_processes { + assert!( + process.exits_within(common::FRAME_BUDGET).await, + "owned sidecar/CLI survived shutdown" + ); + } registry.kill(&tid2); registry.kill(&tid3); From 000993245215c60a088e062ddc32a8dd2a4f7ea4 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:46:05 -0700 Subject: [PATCH 074/100] test(freshclaude): attach and send through native SDK sessions --- .../tests/common/claude_sim_environment.rs | 13 + .../tests/freshagent_claude_attach.rs | 337 +++++------------- 2 files changed, 97 insertions(+), 253 deletions(-) diff --git a/crates/freshell-ws/tests/common/claude_sim_environment.rs b/crates/freshell-ws/tests/common/claude_sim_environment.rs index 269f515ce..01e86532b 100644 --- a/crates/freshell-ws/tests/common/claude_sim_environment.rs +++ b/crates/freshell-ws/tests/common/claude_sim_environment.rs @@ -3,6 +3,7 @@ use serde_json::Value; use std::path::{Path, PathBuf}; + pub struct ClaudeSimEnvironment { dir: PathBuf, } @@ -30,6 +31,18 @@ impl ClaudeSimEnvironment { &self.dir } + /// A saved conversation in the native JSONL format, for cold-resume tests. + pub fn seed_transcript(&self, id: &str) { + let project = self.config_dir().join("projects/fixture"); + std::fs::create_dir_all(&project).unwrap(); + let row = serde_json::json!({ + "type":"user", "sessionId":id, "uuid":uuid::Uuid::new_v4().to_string(), + "parentUuid":null, "cwd":self.workspace(), "isSidechain":false, + "message":{"role":"user", "content":[{"type":"text", "text":"saved conversation"}]} + }); + std::fs::write(project.join(format!("{id}.jsonl")), format!("{row}\n")).unwrap(); + } + pub fn environment(&self) -> Vec<(String, String)> { let adapter = super::claude_rig::repo_root() .join("test/integration/providers/claude-sdk-for-rust-runtime.mjs"); diff --git a/crates/freshell-ws/tests/freshagent_claude_attach.rs b/crates/freshell-ws/tests/freshagent_claude_attach.rs index e223174dc..63efbce28 100644 --- a/crates/freshell-ws/tests/freshagent_claude_attach.rs +++ b/crates/freshell-ws/tests/freshagent_claude_attach.rs @@ -6,6 +6,9 @@ //! it is unreachable from the wire until the dispatch arm exists. Harness duplicated //! from that file per the repo's per-test-file convention. +mod common; + +use common::claude_sim_environment::ClaudeSimEnvironment; use std::sync::Arc; use std::time::Duration; @@ -18,131 +21,6 @@ use freshell_ws::WsState; const AUTH_TOKEN: &str = "s3cr3t-token-abcdef"; -/// Serializes every test in this file that mutates process-global env vars -/// (`FRESHELL_CLAUDE_SIDECAR` / `FRESHELL_CLAUDE_NODE` / `CLAUDE_CONFIG_DIR`), -/// mirroring `freshagent_claude_kill_interrupt.rs`'s convention for the same hazard. -static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); - -// ── fake claude sidecar (resume flavor: created + sdk.session.init + sdk.status) ── - -/// A minimal scripted fake claude sidecar (no real SDK, no network, no cost) speaking -/// the SAME newline-JSON protocol `spawn_sidecar()` drives the vendored package with. -/// On `create` it replies `created`, then `sdk.session.init` echoing the resume boot's id -/// as the durable `cliSessionId` (resume continuity -- exactly what the real sidecar's -/// SDK init does), then `sdk.status idle`; on `send` it emits an `sdk.assistant` content -/// frame (the Task 10b event-after-rebind knob; the server publishes statuses itself, so -/// a sidecar status is never forwarded, C17); on `shutdown` it exits. Every inbound request is -/// appended (with the sidecar's pid) to the JSONL file named by -/// `FAKE_SIDECAR_REQUEST_LOG` -- distinct pids == spawn count, `msg.type` rows prove -/// what each sidecar actually received. -const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" -import readline from 'node:readline' -import fs from 'node:fs' - -const logPath = process.env.FAKE_SIDECAR_REQUEST_LOG || '' -function logReq(msg) { - if (!logPath) return - try { fs.appendFileSync(logPath, JSON.stringify({ pid: process.pid, msg }) + '\n') } catch {} -} - -let counter = 0 -const rl = readline.createInterface({ input: process.stdin, terminal: false }) -rl.on('line', (line) => { - const trimmed = line.trim() - if (!trimmed) return - let msg - try { - msg = JSON.parse(trimmed) - } catch { - return - } - logReq(msg) - if (msg.type === 'create') { - counter += 1 - const sessionId = `fake-claude-session-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') - const cliSessionId = msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' - process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') - process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') - } else if (msg.type === 'send') { - process.stdout.write(JSON.stringify({ type: 'sdk.assistant', sessionId: msg.sessionId, content: [{ type: 'text', text: 'pong' }] }) + '\n') - } else if (msg.type === 'shutdown') { - process.exit(0) - } -}) -"#; - -/// A fresh temp dir holding the fake sidecar script, with `FRESHELL_CLAUDE_SIDECAR`/ -/// `FRESHELL_CLAUDE_NODE` pointed at it, PLUS a seeded claude transcript store with -/// `CLAUDE_CONFIG_DIR` pointed at it. Caller must hold [`CLAUDE_ENV_LOCK`] for the -/// lifetime of the returned guard. -struct FakeClaudeResumeEnv { - dir: std::path::PathBuf, -} -impl FakeClaudeResumeEnv { - fn install(durable: &str) -> Self { - let dir = std::env::temp_dir().join(format!( - "freshell-fake-claude-resume-ws-{}", - uuid_like_suffix() - )); - std::fs::create_dir_all(&dir).expect("create fake sidecar temp dir"); - let script = dir.join("fake-claude-sidecar.mjs"); - std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); - std::env::set_var("FAKE_SIDECAR_REQUEST_LOG", dir.join("requests.jsonl")); - // Seed the transcript store: one user line carrying an EXISTING cwd ("/tmp"), - // so the resume request goes by durable UUID + original cwd (ledger A15). - let store = dir.join("claude-store"); - let project = store.join("projects").join("-t"); - std::fs::create_dir_all(&project).expect("create transcript project dir"); - std::fs::write( - project.join(format!("{durable}.jsonl")), - r#"{"type":"user","cwd":"/tmp","message":{"role":"user","content":[{"type":"text","text":"hi"}]}}"#, - ) - .expect("seed transcript"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("CLAUDE_CONFIG_DIR", &store); - Self { dir } - } -} -impl FakeClaudeResumeEnv { - /// The JSONL request log the fake sidecar appends every inbound request to - /// (`{ pid, msg }` per line) -- distinct pids == spawn count. - fn request_log_path(&self) -> std::path::PathBuf { - self.dir.join("requests.jsonl") - } - - fn request_log_rows(&self) -> Vec { - let Ok(raw) = std::fs::read_to_string(self.request_log_path()) else { - return Vec::new(); - }; - raw.lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| serde_json::from_str(l).expect("request log row parses")) - .collect() - } -} - -impl Drop for FakeClaudeResumeEnv { - fn drop(&mut self) { - std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); - std::env::remove_var("FRESHELL_CLAUDE_NODE"); - std::env::remove_var("CLAUDE_CONFIG_DIR"); - std::env::remove_var("FAKE_SIDECAR_REQUEST_LOG"); - let _ = std::fs::remove_dir_all(&self.dir); - } -} - -/// Dependency-free unique suffix (avoids pulling in `uuid` for this test crate). -fn uuid_like_suffix() -> String { - use std::time::{SystemTime, UNIX_EPOCH}; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - format!("{nanos}-{:?}", std::thread::current().id()) -} - // ── server harness (duplicated from diag01_lifecycle_events.rs's convention, with // `freshAgent.enabled: true` so `freshAgent.create` actually dispatches) ── @@ -166,7 +44,7 @@ fn test_settings_value() -> serde_json::Value { }) } -async fn spawn_server() -> String { +async fn spawn_server(environment: &ClaudeSimEnvironment) -> String { let auth_token = Arc::new(AUTH_TOKEN.to_string()); let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(64).0); let settings = @@ -192,7 +70,8 @@ async fn spawn_server() -> String { Arc::clone(&broadcast_tx), serde_json::json!({ "freshAgent": { "enabled": true } }), ), - fresh_claude: freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)), + fresh_claude: freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)) + .with_sidecar_environment(environment.environment()), fresh_opencode: freshell_freshagent::FreshOpencodeState::new( freshell_freshagent::FreshAgentState::new( Arc::clone(&auth_token), @@ -317,7 +196,8 @@ async fn await_frame( /// (this test then fails with `await_frame` panicking on its timeout budget). #[tokio::test] async fn claude_attach_for_untracked_session_emits_lost_session_frame_over_ws() { - let url = spawn_server().await; + let env = ClaudeSimEnvironment::new(); + let url = spawn_server(&env).await; let mut ws = connect_and_complete_handshake(&url).await; send_json( @@ -347,15 +227,15 @@ async fn claude_attach_for_untracked_session_emits_lost_session_frame_over_ws() /// server spawns a sidecar with a resume boot and emits the idle /// `freshAgent.session.snapshot` whose `timelineSessionId` is the durable UUID (the /// frozen client persists it unvalidated -- NEVER a nanoid), all under the CLIENT's -/// original session id. Before the fix this attach produced the lost frame instead +/// canonical native session id. Before the fix this attach produced the lost frame instead /// (this test then fails with `await_frame` panicking on its timeout budget). #[tokio::test] async fn claude_attach_with_resumable_transcript_resumes_and_emits_snapshot_over_ws() { - let _guard = CLAUDE_ENV_LOCK.lock().await; let durable = "abababab-abab-4bab-8bab-abababababab"; - let _env = FakeClaudeResumeEnv::install(durable); + let env = ClaudeSimEnvironment::new(); + env.seed_transcript(durable); - let url = spawn_server().await; + let url = spawn_server(&env).await; let mut ws = connect_and_complete_handshake(&url).await; send_json( @@ -379,37 +259,13 @@ async fn claude_attach_with_resumable_transcript_resumes_and_emits_snapshot_over assert_eq!(frame["sessionType"], "freshclaude"); assert_eq!(frame["event"]["status"], "idle"); assert_eq!(frame["event"]["timelineSessionId"], durable); -} - -/// Makes a directory unreadable (mode 000) and restores it on drop -- declared -/// AFTER the env guard so it drops first and the env guard's `remove_dir_all` -/// can clean up. `None` when the process bypasses permission bits (root), where -/// the EACCES under test cannot be produced. -#[cfg(unix)] -struct UnreadableDir(std::path::PathBuf); -#[cfg(unix)] -impl UnreadableDir { - fn make(dir: &std::path::Path) -> Option { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o000)).unwrap(); - let guard = Self(dir.to_path_buf()); - if std::fs::read_dir(dir).is_ok() { - eprintln!("skipping: euid bypasses permission checks"); - return None; - } - Some(guard) - } -} -#[cfg(unix)] -impl Drop for UnreadableDir { - fn drop(&mut self) { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o755)); - } + assert_eq!(env.rows("query").len(), 1); + assert_eq!(env.rows("query")[0]["options"]["resume"], durable); + assert_eq!(env.rows("boot")[0]["sessionId"], durable); } /// G28: an attach by durable UUID while the transcript store cannot be READ -/// (EACCES on `projects/`) must not be adjudicated "lost". The server cannot +/// (the projects path cannot be traversed) must not be adjudicated "lost". The server cannot /// prove the transcript gone, so it answers the transient /// `CLAUDE_ATTACH_RESUME_FAILED` error -- never the `INVALID_SESSION_ID` frame /// the client folds into `markSessionLost` and starts a recovery for -- and it @@ -417,14 +273,14 @@ impl Drop for UnreadableDir { #[cfg(unix)] #[tokio::test] async fn claude_attach_with_an_unreadable_store_is_transient_not_lost() { - let _guard = CLAUDE_ENV_LOCK.lock().await; let durable = "cdcdcdcd-cdcd-4dcd-8dcd-cdcdcdcdcdcd"; - let env = FakeClaudeResumeEnv::install(durable); - let Some(_locked) = UnreadableDir::make(&env.dir.join("claude-store").join("projects")) else { - return; - }; + let env = ClaudeSimEnvironment::new(); + std::fs::create_dir_all(env.config_dir()).unwrap(); + // A loop produces a real traversal error even when the sandbox user may + // bypass Unix permission bits. Missing/non-directory paths are clean misses. + std::os::unix::fs::symlink("projects", env.config_dir().join("projects")).unwrap(); - let url = spawn_server().await; + let url = spawn_server(&env).await; let mut ws = connect_and_complete_handshake(&url).await; send_json( @@ -455,11 +311,7 @@ async fn claude_attach_with_an_unreadable_store_is_transient_not_lost() { "the attach answers with the transient resume failure: {frame}" ); - let creates = env - .request_log_rows() - .into_iter() - .filter(|r| r["msg"]["type"] == "create") - .count(); + let creates = env.rows("query").len(); assert_eq!( creates, 0, "no sidecar may spawn against an unchecked store" @@ -471,7 +323,8 @@ async fn claude_attach_with_an_unreadable_store_is_transient_not_lost() { /// unit tests bypass) or the client builds the wrong session locator. #[tokio::test] async fn kilroy_attach_for_untracked_session_emits_lost_session_frame_over_ws() { - let url = spawn_server().await; + let env = ClaudeSimEnvironment::new(); + let url = spawn_server(&env).await; let mut ws = connect_and_complete_handshake(&url).await; send_json( @@ -495,20 +348,14 @@ async fn kilroy_attach_for_untracked_session_emits_lost_session_frame_over_ws() assert_eq!(frame["event"]["code"], "INVALID_SESSION_ID"); } -// ── Task 10b: claude durable→live resolution (attach rebind + ack, send routing) ── -// -// claude keys live sessions by a sidecar-minted placeholder nanoid, while the reconcile -// attach verdict names the DURABLE ref. On base, `attach{durable}` on a live session is -// a silent no-op and `send{durable}` misses the sessions map (SESSION_NOT_FOUND) while -// events keep broadcasting under the placeholder — stranding the pane the fold rebound. - -/// The fake sidecar's baked-in durable `cliSessionId` for a create WITHOUT resume. -const DURABLE_FALLBACK: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; +// Native session identity is established at create, before the CLI's first init. +// Attach, send and events must all keep addressing that same conversation. -/// Drive a claude `freshAgent.create` (no resume) through the fake sidecar; wait for -/// `freshAgent.session.init` so `cli_index[DURABLE_FALLBACK] = placeholder` is recorded. -/// Returns the placeholder session id. -async fn create_live_claude_session(ws: &mut TestWs, request_id: &str) -> String { +async fn create_live_claude_session( + ws: &mut TestWs, + env: &ClaudeSimEnvironment, + request_id: &str, +) -> String { send_json( ws, &serde_json::json!({ @@ -516,7 +363,7 @@ async fn create_live_claude_session(ws: &mut TestWs, request_id: &str) -> String "requestId": request_id, "sessionType": "freshclaude", "provider": "claude", - "cwd": "/tmp", + "cwd": env.workspace(), }), ) .await; @@ -524,28 +371,24 @@ async fn create_live_claude_session(ws: &mut TestWs, request_id: &str) -> String v["type"] == "freshAgent.created" && v["requestId"] == request_id }) .await; - let placeholder = created["sessionId"] + let native_id = created["sessionId"] .as_str() .expect("created carries sessionId") .to_string(); - await_frame(ws, Duration::from_secs(15), |v| { - v["type"] == "freshAgent.event" && v["event"]["type"] == "freshAgent.session.init" - }) - .await; - placeholder + assert!(uuid::Uuid::parse_str(&native_id).is_ok(), "{created}"); + assert_eq!(created["sessionRef"]["sessionId"], native_id); + assert_eq!(env.rows("boot")[0]["sessionId"], native_id); + native_id } -/// Attach by the DURABLE id on a LIVE session must REBIND (alias) + ACK — at least one -/// frame stamped with the durable id must reach the attaching connection. On base the -/// cli_index-hit arm is a silent no-op (this test then fails on `await_frame`'s budget). +/// A warm attach acknowledges the same native conversation without a second query. #[tokio::test] -async fn attach_by_durable_id_on_a_live_session_rebinds_and_acks() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeResumeEnv::install(DURABLE_FALLBACK); +async fn attach_by_native_id_on_a_live_session_acks() { + let env = ClaudeSimEnvironment::new(); - let url = spawn_server().await; + let url = spawn_server(&env).await; let mut ws = connect_and_complete_handshake(&url).await; - let _placeholder = create_live_claude_session(&mut ws, "req-10b-attach").await; + let native_id = create_live_claude_session(&mut ws, &env, "req-10b-attach").await; let mut ws2 = connect_and_complete_handshake(&url).await; send_json( @@ -553,47 +396,39 @@ async fn attach_by_durable_id_on_a_live_session_rebinds_and_acks() { &serde_json::json!({ "type": "freshAgent.attach", "provider": "claude", - "sessionId": DURABLE_FALLBACK, + "sessionId": native_id, "sessionType": "freshclaude", - "sessionRef": { "provider": "claude", "sessionId": DURABLE_FALLBACK }, + "sessionRef": { "provider": "claude", "sessionId": native_id }, }), ) .await; let frame = await_frame(&mut ws2, Duration::from_secs(10), |v| { - v["sessionId"] == DURABLE_FALLBACK + v["sessionId"] == native_id && v["event"]["type"] == "freshAgent.session.snapshot" }) .await; assert_eq!(frame["type"], "freshAgent.event"); - // No second sidecar: exactly ONE create row in the fake's request log. - let creates = env - .request_log_rows() - .into_iter() - .filter(|r| r["msg"]["type"] == "create") - .count(); + // No second sidecar: exactly one actual SDK query. + let creates = env.rows("query").len(); assert_eq!(creates, 1, "attach-to-live must not spawn a second sidecar"); } -/// `freshAgent.send` addressed by the DURABLE id must resolve through `cli_index` to the -/// live session — the sidecar receives the send line and `freshAgent.send.accepted` -/// lands. On base the sessions-map miss broadcasts SESSION_NOT_FOUND and no accepted -/// frame ever arrives (this test then fails on `await_frame`'s budget). +/// A send addressed by the native ID reaches the already live SDK query exactly once. #[tokio::test] -async fn send_by_durable_id_routes_to_the_live_session() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeResumeEnv::install(DURABLE_FALLBACK); +async fn send_by_native_id_routes_to_the_live_session() { + let env = ClaudeSimEnvironment::new(); - let url = spawn_server().await; + let url = spawn_server(&env).await; let mut ws = connect_and_complete_handshake(&url).await; - let _placeholder = create_live_claude_session(&mut ws, "req-10b-send").await; + let native_id = create_live_claude_session(&mut ws, &env, "req-10b-send").await; send_json( &mut ws, &serde_json::json!({ "type": "freshAgent.send", "provider": "claude", - "sessionId": DURABLE_FALLBACK, + "sessionId": native_id, "sessionType": "freshclaude", "text": "ping", "requestId": "send-10b", @@ -605,34 +440,36 @@ async fn send_by_durable_id_routes_to_the_live_session() { v["type"] == "freshAgent.send.accepted" && v["requestId"] == "send-10b" }) .await; - assert_eq!(accepted["sessionId"], DURABLE_FALLBACK); + assert_eq!(accepted["sessionId"], native_id); - // The sidecar answers every `send` with an assistant frame -- awaiting it proves - // the sidecar PROCESSED the routed send (the accepted broadcast races the log append). + // An actual SDK assistant frame proves the sidecar processed the routed send. await_frame(&mut ws, Duration::from_secs(10), |v| { v["type"] == "freshAgent.event" && v["event"]["type"] == "freshAgent.assistant" }) .await; - // The fake sidecar actually received the send line (routing, not just the ack). - let sends = env - .request_log_rows() - .into_iter() - .filter(|r| r["msg"]["type"] == "send") - .count(); - assert_eq!(sends, 1, "the live sidecar must receive the routed send"); + let prompts = env.rows("prompt"); + assert_eq!( + prompts.len(), + 1, + "the SDK must receive exactly one routed send" + ); + assert_eq!(prompts[0]["sessionId"], native_id); + assert_eq!(prompts[0]["uuid"], accepted["submittedTurnId"]); + assert_eq!( + prompts[0]["content"], + serde_json::json!([{"type":"text", "text":"ping"}]) + ); } -/// After the durable rebind, event envelopes must be stamped with the DURABLE id (the -/// broadcast stamp flips), or the pane keyed on the durable never receives events. +/// Event envelopes after a warm attach retain the same native session identity. #[tokio::test] -async fn events_after_durable_rebind_are_stamped_with_the_durable_id() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let _env = FakeClaudeResumeEnv::install(DURABLE_FALLBACK); +async fn events_after_native_attach_are_stamped_with_the_native_id() { + let env = ClaudeSimEnvironment::new(); - let url = spawn_server().await; + let url = spawn_server(&env).await; let mut ws = connect_and_complete_handshake(&url).await; - let _placeholder = create_live_claude_session(&mut ws, "req-10b-events").await; + let native_id = create_live_claude_session(&mut ws, &env, "req-10b-events").await; let mut ws2 = connect_and_complete_handshake(&url).await; send_json( @@ -640,25 +477,25 @@ async fn events_after_durable_rebind_are_stamped_with_the_durable_id() { &serde_json::json!({ "type": "freshAgent.attach", "provider": "claude", - "sessionId": DURABLE_FALLBACK, + "sessionId": native_id, "sessionType": "freshclaude", - "sessionRef": { "provider": "claude", "sessionId": DURABLE_FALLBACK }, + "sessionRef": { "provider": "claude", "sessionId": native_id }, }), ) .await; - // The rebind ack gates the ordering (attach handled before the send below). + // The snapshot acknowledgement gates the ordering (attach handled before the send below). await_frame(&mut ws2, Duration::from_secs(10), |v| { - v["sessionId"] == DURABLE_FALLBACK + v["sessionId"] == native_id && v["event"]["type"] == "freshAgent.session.snapshot" }) .await; - // Drive the fake to emit a content frame (it answers every `send` with one). + // Run an actual turn after attach and observe its assistant event. send_json( &mut ws2, &serde_json::json!({ "type": "freshAgent.send", "provider": "claude", - "sessionId": DURABLE_FALLBACK, + "sessionId": native_id, "sessionType": "freshclaude", "text": "poke", "requestId": "send-10b-events", @@ -671,8 +508,8 @@ async fn events_after_durable_rebind_are_stamped_with_the_durable_id() { }) .await; assert_eq!( - event["sessionId"], DURABLE_FALLBACK, - "post-rebind envelopes must be stamped with the durable id" + event["sessionId"], native_id, + "post-attach envelopes must be stamped with the native id" ); } @@ -682,9 +519,8 @@ async fn events_after_durable_rebind_are_stamped_with_the_durable_id() { /// rejection rides the freshAgent.error event channel keyed by sessionId. #[tokio::test] async fn legacy_reject_freshagent_attach() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeResumeEnv::install("legacy-durable-id"); - let url = spawn_server().await; + let env = ClaudeSimEnvironment::new(); + let url = spawn_server(&env).await; let mut ws = connect_and_complete_handshake(&url).await; // ejh6 presence (finding 9): rejection fires for any string, including "". @@ -718,12 +554,7 @@ async fn legacy_reject_freshagent_attach() { "{label}: frozen text: {err}" ); } - // V3: this file has NO create_rows(); use request_log_rows() and filter for create rows. - let create_count = env - .request_log_rows() - .into_iter() - .filter(|r| r["msg"]["type"] == "create") - .count(); + let create_count = env.rows("query").len(); assert_eq!( create_count, 0, "no sidecar may spawn for a rejected legacy attach" From 98564ccff796112058a83e32bbefa1790c7686fb Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:47:57 -0700 Subject: [PATCH 075/100] fix(fresh-agent): preserve non-Claude session id on restore error --- src/store/panesSlice.ts | 4 +++- test/unit/client/store/panesSlice.test.ts | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/store/panesSlice.ts b/src/store/panesSlice.ts index aa53302cc..8e7600c67 100644 --- a/src/store/panesSlice.ts +++ b/src/store/panesSlice.ts @@ -256,7 +256,9 @@ function normalizePaneContent( ...normalizeManagedRuntimeProjection(rawFreshAgent), sessionType: input.sessionType, provider: input.provider, - sessionId: retainedRestoreErrorSessionRef?.sessionId ?? input.sessionId, + sessionId: retainedRestoreErrorSessionRef?.provider === 'claude' + ? retainedRestoreErrorSessionRef.sessionId + : input.sessionId, ...(retainedRestoreErrorSessionRef ? { sessionRef: retainedRestoreErrorSessionRef } : {}), createRequestId: typeof input.createRequestId === 'string' && input.createRequestId ? input.createRequestId diff --git a/test/unit/client/store/panesSlice.test.ts b/test/unit/client/store/panesSlice.test.ts index 16ca051c0..8634c6a8e 100644 --- a/test/unit/client/store/panesSlice.test.ts +++ b/test/unit/client/store/panesSlice.test.ts @@ -3436,6 +3436,24 @@ describe('panesSlice', () => { expect(content.resumeSessionId).toBe(OPENCODE_DURABLE_ID) }) + it('retains a non-Claude durable ref without rewriting its distinct live sessionId', () => { + const liveSessionId = 'opencode-live-session-handle' + const brokenDurable: FreshAgentPaneContent = { + ...durableOpencodeContent(), + sessionId: liveSessionId, + restoreError: DEAD_HANDLE_RESTORE_ERROR, + } + + const updated = panesReducer( + initialState, + initLayout({ tabId: 'tab-1', paneId: 'pane-1', content: brokenDurable }), + ) + const content = freshAgentLeaf(updated) + expect(content.sessionRef).toEqual({ provider: 'opencode', sessionId: OPENCODE_DURABLE_ID }) + expect(content.sessionId).toBe(liveSessionId) + expect(content.restoreError).toEqual(DEAD_HANDLE_RESTORE_ERROR) + }) + it('hydrate: restoreError on a DURABLE incoming identity applies unchanged', () => { const brokenDurable: FreshAgentPaneContent = { ...durableOpencodeContent(), From 19d45f90098bd19f2f0dc0cb6a1c89a6f0359d3a Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:49:58 -0700 Subject: [PATCH 076/100] test(freshclaude): exercise lease races through real SDK initialization --- .../tests/freshagent_session_lease.rs | 359 ++++++++---------- .../providers/claude-sdk-lease-runtime.mjs | 41 ++ 2 files changed, 207 insertions(+), 193 deletions(-) create mode 100644 test/integration/providers/claude-sdk-lease-runtime.mjs diff --git a/crates/freshell-ws/tests/freshagent_session_lease.rs b/crates/freshell-ws/tests/freshagent_session_lease.rs index 1e9360727..1a8710520 100644 --- a/crates/freshell-ws/tests/freshagent_session_lease.rs +++ b/crates/freshell-ws/tests/freshagent_session_lease.rs @@ -5,16 +5,17 @@ //! naming the winner's durable session, no spawn). Expired holders are TREE-killed //! (child + ownership sweep) before any release. //! -//! Harness duplicated from `freshagent_claude_attach.rs` per the repo's per-test-file -//! convention, with the Step 1a fake-sidecar knobs: `FAKE_SIDECAR_CREATE_DELAY_MS` -//! (slow spawn), `FAKE_SIDECAR_FAIL_ONCE_MARKER` (first create exits nonzero), -//! `FAKE_SIDECAR_REQUEST_LOG` (JSONL `{pid, msg}` per inbound request — distinct pids == -//! spawn count), `FAKE_SIDECAR_SPAWN_GRANDCHILD` (tagged decoy grandchild for the -//! kill-sweep test). The codex leg points `CODEX_CMD` at the e2e fake app-server. +//! Claude sessions run the production sidecar over the contract simulator. SDK +//! initialization gates hold the winner while a competitor arrives; first-launch +//! crashes and an owned detached descendant exercise failure and expiry cleanup. +//! The Codex/OpenCode fixtures retain their provider-specific transport seams. //! //! DESTRUCTIVE SUITE: the expired-lease path kills real processes by recorded pid — //! run via `scripts/sandbox-test.sh "cargo test -p freshell-ws --test freshagent_session_lease"`. +mod common; + +use common::claude_sim_environment::ClaudeSimEnvironment; use std::sync::Arc; use std::time::Duration; @@ -28,7 +29,7 @@ use freshell_ws::WsState; const AUTH_TOKEN: &str = "s3cr3t-token-abcdef"; /// Serializes every test in this file that mutates process-global env vars -/// (`FRESHELL_CLAUDE_SIDECAR` / `FAKE_SIDECAR_*` / `CODEX_CMD` / lease TTL), +/// (`CODEX_CMD` / `OPENCODE_CMD` / lease TTL), /// mirroring `freshagent_claude_attach.rs`'s convention for the same hazard. static LEASE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); @@ -114,104 +115,79 @@ fn isolated_codex_env_restores_every_mutated_variable_during_unwind() { assert_eq!(after_unwind, original); } -// ── fake claude sidecar with the Step 1a knobs ────────────────────────────────────── - -const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" -import readline from 'node:readline' -import fs from 'node:fs' -import { spawn } from 'node:child_process' - -const logPath = process.env.FAKE_SIDECAR_REQUEST_LOG || '' -function logReq(msg) { - if (!logPath) return - try { fs.appendFileSync(logPath, JSON.stringify({ pid: process.pid, msg }) + '\n') } catch {} +// Production sidecar over the contract simulator; pauses/crashes stay at SDK init. +struct LeaseClaudeRuntime { + runtime: ClaudeSimEnvironment, + prior_ttl: Option, } -let counter = 0 -const rl = readline.createInterface({ input: process.stdin, terminal: false }) -rl.on('line', (line) => { - const trimmed = line.trim() - if (!trimmed) return - let msg - try { msg = JSON.parse(trimmed) } catch { return } - logReq(msg) - if (msg.type === 'create') { - const failOnce = process.env.FAKE_SIDECAR_FAIL_ONCE_MARKER || '' - if (failOnce && !fs.existsSync(failOnce)) { - fs.writeFileSync(failOnce, '1') - process.exit(1) - } - if (process.env.FAKE_SIDECAR_SPAWN_GRANDCHILD === '1') { - // Detached decoy grandchild inheriting env (so /proc//environ carries the - // ownership tag the kill sweep scans for). It IGNORES SIGTERM (an ignored signal - // stays ignored across exec), so the claude lane's own one-shot SIGTERM reap of - // its tagged tree cannot end it: only the expired-lease sweep's SIGKILL - // escalation can. It is logged only once the shell has armed the ignore. - const gc = spawn('sh', ['-c', 'trap "" TERM; echo armed; exec sleep 300 >/dev/null'], { - detached: true, - stdio: ['ignore', 'pipe', 'ignore'], - }) - gc.stdout.once('data', () => { - gc.stdout.destroy() - logReq({ type: 'grandchild', grandchildPid: gc.pid }) - }) - gc.unref() - } - counter += 1 - const sessionId = `fake-claude-session-${process.pid}-${counter}` - const reply = () => { - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') - const cliSessionId = msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' - process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') - process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') +impl LeaseClaudeRuntime { + fn new() -> Self { + Self { + runtime: ClaudeSimEnvironment::new(), + prior_ttl: std::env::var_os("FRESHELL_FRESH_AGENT_LEASE_TTL_MS"), + } } - const delayMs = Number(process.env.FAKE_SIDECAR_CREATE_DELAY_MS || 0) - if (delayMs > 0) setTimeout(reply, delayMs) - else reply() - } else if (msg.type === 'send') { - process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId: msg.sessionId, status: 'busy' }) + '\n') - } else if (msg.type === 'shutdown') { - process.exit(0) - } -}) -"#; -/// Fresh temp dir holding the knob-extended fake sidecar; installs the env vars. -/// Caller must hold [`LEASE_ENV_LOCK`] for the guard's lifetime. -struct FakeLeaseSidecarEnv { - dir: std::path::PathBuf, -} + fn hold_initialization(&self) { + std::fs::write(self.runtime.dir().join("initialize-gate"), "").unwrap(); + } -impl FakeLeaseSidecarEnv { - fn install() -> Self { - let dir = std::env::temp_dir().join(format!( - "freshell-fake-claude-lease-ws-{}", - uuid_like_suffix() - )); - std::fs::create_dir_all(&dir).expect("create fake sidecar temp dir"); - let script = dir.join("fake-claude-sidecar.mjs"); - std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("FAKE_SIDECAR_REQUEST_LOG", dir.join("requests.jsonl")); - Self { dir } + fn release_initialization(&self) { + std::fs::remove_file(self.runtime.dir().join("initialize-gate")).unwrap(); } - fn request_log_rows(&self) -> Vec { - let Ok(raw) = std::fs::read_to_string(self.dir.join("requests.jsonl")) else { - return Vec::new(); - }; - raw.lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| serde_json::from_str(l).expect("request log row parses")) - .collect() + fn environment(&self) -> Vec<(String, String)> { + let adapter = common::claude_rig::repo_root() + .join("test/integration/providers/claude-sdk-lease-runtime.mjs"); + let mut environment = self.runtime.environment(); + environment.retain(|(key, _)| key != "FRESHELL_CLAUDE_SDK_QUERY_MODULE"); + environment.extend([ + ( + "FRESHELL_CLAUDE_SDK_QUERY_MODULE".into(), + freshell_platform::opencode_plugin::plugin_file_spec(&adapter), + ), + ( + "FRESHELL_TEST_CLAUDE_FIRST_LAUNCH".into(), + self.runtime + .dir() + .join("first-launch") + .to_string_lossy() + .into(), + ), + ( + "FRESHELL_TEST_CLAUDE_INITIALIZATION_GATE".into(), + self.runtime + .dir() + .join("initialize-gate") + .to_string_lossy() + .into(), + ), + ]); + if self.runtime.dir().join("crash-first").exists() { + environment.push(("FRESHELL_TEST_CLAUDE_CRASH_FIRST".into(), "1".into())); + } + if self.runtime.dir().join("spawn-decoy").exists() { + environment.push(("FRESHELL_TEST_CLAUDE_DECOY".into(), "1".into())); + } + environment } fn create_rows(&self) -> Vec { - self.request_log_rows() - .into_iter() - .filter(|r| r["msg"]["type"] == "create") - .collect() + self.runtime.rows("query") + } + + async fn await_row(&self, kind: &str) -> Value { + tokio::time::timeout(common::FRAME_BUDGET, async { + loop { + if let Some(row) = self.runtime.rows(kind).into_iter().next() { + return row; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("the real SDK did not record {kind}")) } /// SIGKILL every decoy grandchild this env's sidecars logged that is still @@ -219,19 +195,16 @@ impl FakeLeaseSidecarEnv { /// single-pid kill of its sidecar), so nothing else stops it when a test /// ends before the ownership sweep ran — or when a later sidecar spawned /// one of its own. Each pid is pinned through a pidfd and must carry this - /// env's unique request-log path in `/proc//environ`, so a recycled + /// env's unique runtime guard path in `/proc//environ`, so a recycled /// pid is never signalled. #[cfg(target_os = "linux")] fn kill_logged_decoys(&self) { let tag = format!( - "FAKE_SIDECAR_REQUEST_LOG={}", - self.dir.join("requests.jsonl").display() + "FRESHELL_TEST_CLAUDE_RUNTIME_GUARD={}", + self.runtime.dir().join("active").display() ); - for row in self.request_log_rows() { - if row["msg"]["type"] != "grandchild" { - continue; - } - let Some(pid) = row["msg"]["grandchildPid"].as_i64() else { + for row in self.runtime.rows("decoy") { + let Some(pid) = row["childPid"].as_i64() else { continue; }; // SAFETY: plain syscalls on an owned descriptor; the pidfd is @@ -257,39 +230,16 @@ impl FakeLeaseSidecarEnv { unsafe { libc::close(pidfd) }; } } - - /// Poll the request log until `pred` matches a row (or the budget expires). - async fn await_log_row(&self, budget: Duration, pred: impl Fn(&Value) -> bool) -> Value { - let deadline = tokio::time::Instant::now() + budget; - loop { - if let Some(row) = self.request_log_rows().into_iter().find(&pred) { - return row; - } - assert!( - tokio::time::Instant::now() < deadline, - "request-log row did not appear within budget" - ); - tokio::time::sleep(Duration::from_millis(50)).await; - } - } } -impl Drop for FakeLeaseSidecarEnv { +impl Drop for LeaseClaudeRuntime { fn drop(&mut self) { #[cfg(target_os = "linux")] self.kill_logged_decoys(); - for var in [ - "FRESHELL_CLAUDE_SIDECAR", - "FRESHELL_CLAUDE_NODE", - "FAKE_SIDECAR_REQUEST_LOG", - "FAKE_SIDECAR_CREATE_DELAY_MS", - "FAKE_SIDECAR_FAIL_ONCE_MARKER", - "FAKE_SIDECAR_SPAWN_GRANDCHILD", - "FRESHELL_FRESH_AGENT_LEASE_TTL_MS", - ] { - std::env::remove_var(var); + match &self.prior_ttl { + Some(value) => std::env::set_var("FRESHELL_FRESH_AGENT_LEASE_TTL_MS", value), + None => std::env::remove_var("FRESHELL_FRESH_AGENT_LEASE_TTL_MS"), } - let _ = std::fs::remove_dir_all(&self.dir); } } @@ -325,11 +275,22 @@ fn test_settings_value() -> serde_json::Value { } async fn spawn_server() -> String { + spawn_server_with_claude_environment(None).await +} + +async fn spawn_server_with_claude_environment( + environment: Option>, +) -> String { 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 mut fresh_claude = freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)); + if let Some(environment) = environment { + fresh_claude = fresh_claude.with_sidecar_environment(environment); + } + let state = WsState { layout: Default::default(), terminal_meta: Default::default(), @@ -350,7 +311,7 @@ async fn spawn_server() -> String { Arc::clone(&broadcast_tx), serde_json::json!({ "freshAgent": { "enabled": true } }), ), - fresh_claude: freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)), + fresh_claude, fresh_opencode: freshell_freshagent::FreshOpencodeState::new( freshell_freshagent::FreshAgentState::new( Arc::clone(&auth_token), @@ -490,19 +451,19 @@ fn claude_create_resume(request_id: &str, durable: &str) -> Value { #[tokio::test] async fn two_clients_same_freshagent_session_ref_yield_exactly_one_sidecar() { let _guard = LEASE_ENV_LOCK.lock().await; - let env = FakeLeaseSidecarEnv::install(); - std::env::set_var("FAKE_SIDECAR_CREATE_DELAY_MS", "3000"); // second create lands mid-resume + let env = LeaseClaudeRuntime::new(); + env.hold_initialization(); let durable = "d1d1d1d1-d1d1-4d1d-8d1d-d1d1d1d1d1d1"; - let url = spawn_server().await; + env.runtime.seed_transcript(durable); + let url = spawn_server_with_claude_environment(Some(env.environment())).await; let mut ws_a = connect(&url, true).await; let mut ws_b = connect(&url, true).await; send_json(&mut ws_a, &claude_create_resume("req-lease-a", durable)).await; // Gate on the sidecar RECEIVING the create (the winner provably holds the lease and // is mid-delay) before the loser fires. - env.await_log_row(Duration::from_secs(10), |r| r["msg"]["type"] == "create") - .await; + env.await_row("query").await; send_json(&mut ws_b, &claude_create_resume("req-lease-b", durable)).await; let failed = await_frame(&mut ws_b, Duration::from_secs(10), |v| { @@ -512,16 +473,16 @@ async fn two_clients_same_freshagent_session_ref_yield_exactly_one_sidecar() { assert_eq!(failed["code"], "SESSION_RESERVED"); assert_eq!(failed["retryable"], true); + env.release_initialization(); // The winner binds. let created = await_frame(&mut ws_a, Duration::from_secs(15), |v| { v["type"] == "freshAgent.created" && v["requestId"] == "req-lease-a" }) .await; - assert!(created["sessionId"].is_string()); + assert_eq!(created["sessionId"], durable); // Loser re-sends the SAME create (~the client's 1s floor): the ADOPT arm answers it // naming the winner's durable session -- and NO second sidecar spawned. - tokio::time::sleep(Duration::from_millis(200)).await; send_json(&mut ws_b, &claude_create_resume("req-lease-b", durable)).await; let adopted = await_frame(&mut ws_b, Duration::from_secs(10), |v| { v["type"] == "freshAgent.created" && v["requestId"] == "req-lease-b" @@ -549,12 +510,12 @@ async fn two_clients_same_freshagent_session_ref_yield_exactly_one_sidecar() { #[tokio::test] async fn winner_dies_mid_resume_releases_the_lease_for_the_loser() { let _guard = LEASE_ENV_LOCK.lock().await; - let env = FakeLeaseSidecarEnv::install(); - let marker = env.dir.join("fail-once-marker"); - std::env::set_var("FAKE_SIDECAR_FAIL_ONCE_MARKER", &marker); + let env = LeaseClaudeRuntime::new(); + std::fs::write(env.runtime.dir().join("crash-first"), "").unwrap(); let durable = "d2d2d2d2-d2d2-4d2d-8d2d-d2d2d2d2d2d2"; - let url = spawn_server().await; + env.runtime.seed_transcript(durable); + let url = spawn_server_with_claude_environment(Some(env.environment())).await; let mut ws_a = connect(&url, true).await; let mut ws_b = connect(&url, true).await; @@ -575,7 +536,17 @@ async fn winner_dies_mid_resume_releases_the_lease_for_the_loser() { v["type"] == "freshAgent.created" && v["requestId"] == "req-die-b" }) .await; - assert!(created["sessionId"].is_string()); + assert_eq!(created["sessionId"], durable); + let queries = env.create_rows(); + assert_eq!( + queries.len(), + 2, + "failed and replacement SDK queries: {queries:?}" + ); + assert_ne!(queries[0]["pid"], queries[1]["pid"]); + assert!(queries + .iter() + .all(|row| row["options"]["resume"] == durable)); } /// DESIGN DECISION (owner ratification flagged in the plan's "Validated decisions"): @@ -588,17 +559,17 @@ async fn winner_dies_mid_resume_releases_the_lease_for_the_loser() { #[tokio::test] async fn lease_applies_to_legacy_clients_and_retry_converges() { let _guard = LEASE_ENV_LOCK.lock().await; - let env = FakeLeaseSidecarEnv::install(); - std::env::set_var("FAKE_SIDECAR_CREATE_DELAY_MS", "3000"); + let env = LeaseClaudeRuntime::new(); + env.hold_initialization(); let durable = "d3d3d3d3-d3d3-4d3d-8d3d-d3d3d3d3d3d3"; - let url = spawn_server().await; + env.runtime.seed_transcript(durable); + let url = spawn_server_with_claude_environment(Some(env.environment())).await; let mut ws_a = connect(&url, false).await; // legacy: no capabilities in hello let mut ws_b = connect(&url, false).await; send_json(&mut ws_a, &claude_create_resume("req-legacy-a", durable)).await; - env.await_log_row(Duration::from_secs(10), |r| r["msg"]["type"] == "create") - .await; + env.await_row("query").await; send_json(&mut ws_b, &claude_create_resume("req-legacy-b", durable)).await; let failed = await_frame(&mut ws_b, Duration::from_secs(10), |v| { @@ -608,6 +579,7 @@ async fn lease_applies_to_legacy_clients_and_retry_converges() { assert_eq!(failed["code"], "SESSION_RESERVED"); assert_eq!(failed["retryable"], true); + env.release_initialization(); await_frame(&mut ws_a, Duration::from_secs(15), |v| { v["type"] == "freshAgent.created" && v["requestId"] == "req-legacy-a" }) @@ -724,36 +696,38 @@ async fn codex_loser_create_resume_adopts_and_never_clobbers_the_winner() { #[tokio::test] async fn expired_lease_kill_sweeps_the_sidecar_tree_before_release() { let _guard = LEASE_ENV_LOCK.lock().await; - let env = FakeLeaseSidecarEnv::install(); + let env = LeaseClaudeRuntime::new(); std::env::set_var("FRESHELL_FRESH_AGENT_LEASE_TTL_MS", "100"); - std::env::set_var("FAKE_SIDECAR_CREATE_DELAY_MS", "5000"); - std::env::set_var("FAKE_SIDECAR_SPAWN_GRANDCHILD", "1"); + env.hold_initialization(); + std::fs::write(env.runtime.dir().join("spawn-decoy"), "").unwrap(); let durable = "d4d4d4d4-d4d4-4d4d-8d4d-d4d4d4d4d4d4"; - let url = spawn_server().await; + env.runtime.seed_transcript(durable); + let url = spawn_server_with_claude_environment(Some(env.environment())).await; let mut ws_a = connect(&url, true).await; let mut ws_b = connect(&url, true).await; // Client A claims + spawns (kill handle set), never completes within the 100ms TTL. send_json(&mut ws_a, &claude_create_resume("req-ttl-a", durable)).await; - let grandchild = env - .await_log_row(Duration::from_secs(10), |r| { - r["msg"]["type"] == "grandchild" - }) - .await; - let gpid = grandchild["msg"]["grandchildPid"] - .as_i64() - .expect("grandchild pid logged"); + let grandchild = env.await_row("decoy").await; + let gpid = grandchild["childPid"].as_i64().expect("decoy pid logged"); assert!( std::path::Path::new(&format!("/proc/{gpid}")).exists(), "decoy grandchild must be alive before expiry" ); - // A's sidecar captured its environment at spawn. Client B's replacement sidecar - // must be a plain one: no decoy of its own (nothing would ever reap it — the - // detached `sleep 300` outlived every run) and no artificial create delay on the - // path whose convergence this test times. - std::env::remove_var("FAKE_SIDECAR_SPAWN_GRANDCHILD"); - std::env::remove_var("FAKE_SIDECAR_CREATE_DELAY_MS"); + let boot = env.await_row("boot").await; + assert_eq!(boot["sessionId"], durable); + let old_processes = [ + boot["pid"].as_u64().unwrap(), + boot["cliPid"].as_u64().unwrap(), + gpid as u64, + ] + .map(|pid| common::process_probe::ProcessProbe::open(u32::try_from(pid).unwrap())); + assert!(old_processes + .iter() + .all(common::process_probe::ProcessProbe::running)); + // Faults apply only to the first real query. The replacement performs a + // normal SDK initialization and never spawns another detached decoy. // Let the TTL expire, then client B contends -> ExpiredNeedsKill -> tree kill. tokio::time::sleep(Duration::from_millis(300)).await; send_json(&mut ws_b, &claude_create_resume("req-ttl-b", durable)).await; @@ -770,27 +744,25 @@ async fn expired_lease_kill_sweeps_the_sidecar_tree_before_release() { outcome["type"], "freshAgent.created", "client B's create must converge after the expired holder's tree kill: {outcome}" ); - assert!(outcome["sessionId"].is_string()); - - // The DECOY GRANDCHILD was dead (ownership sweep / tree-kill) BEFORE client B's - // created landed: the release follows the sweep's confirmation, so it is already - // dead now — no polling. NOTE: a dead-but-unreaped ZOMBIE keeps its /proc entry (it - // reparents to the container's pid 1, which never reaps it), so "dead" is read from - // the stat STATE — gone, Z, or X — not from /proc existence. - let grandchild_dead = |pid: i64| -> bool { - let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else { - return true; // gone entirely - }; - matches!( - stat.rsplit(')') - .next() - .and_then(|rest| rest.split_whitespace().next()), - Some("Z") | Some("X") | None - ) - }; + assert_eq!(outcome["sessionId"], durable); + + // All old writers are already dead at the new create ACK: release must + // follow tree confirmation, including the real simulated CLI and decoy. assert!( - grandchild_dead(gpid), - "grandchild survived B's convergence: a raw single-pid kill orphans the writer (V6)" + old_processes + .iter() + .all(common::process_probe::ProcessProbe::exited), + "an old sidecar/CLI/decoy survived replacement ACK" + ); + assert_eq!( + env.runtime.rows("decoy").len(), + 1, + "replacement has no extra decoy" + ); + assert_eq!( + env.create_rows().len(), + 2, + "one expired and one replacement query" ); } @@ -803,18 +775,18 @@ async fn expired_lease_kill_sweeps_the_sidecar_tree_before_release() { #[tokio::test] async fn loser_attach_after_winner_binds_converges_to_the_live_session() { let _guard = LEASE_ENV_LOCK.lock().await; - let env = FakeLeaseSidecarEnv::install(); - std::env::set_var("FAKE_SIDECAR_CREATE_DELAY_MS", "3000"); + let env = LeaseClaudeRuntime::new(); + env.hold_initialization(); let durable = "d5d5d5d5-d5d5-4d5d-8d5d-d5d5d5d5d5d5"; - let url = spawn_server().await; + env.runtime.seed_transcript(durable); + let url = spawn_server_with_claude_environment(Some(env.environment())).await; let mut ws_a = connect(&url, true).await; let mut ws_b = connect(&url, true).await; // Winner: create-with-resume, slow spawn (holds the lease mid-flight). send_json(&mut ws_a, &claude_create_resume("req-att-a", durable)).await; - env.await_log_row(Duration::from_secs(10), |r| r["msg"]["type"] == "create") - .await; + env.await_row("query").await; // Loser: attach for the SAME durable id mid-spawn. let attach = serde_json::json!({ @@ -837,6 +809,7 @@ async fn loser_attach_after_winner_binds_converges_to_the_live_session() { ); // Winner binds. + env.release_initialization(); await_frame(&mut ws_a, Duration::from_secs(15), |v| { v["type"] == "freshAgent.created" && v["requestId"] == "req-att-a" }) @@ -1070,8 +1043,8 @@ async fn opencode_attach_resume_is_serialized_without_touching_the_shared_sideca #[tokio::test] async fn legacy_reject_freshagent_create() { let _guard = LEASE_ENV_LOCK.lock().await; - let env = FakeLeaseSidecarEnv::install(); - let url = spawn_server().await; + let env = LeaseClaudeRuntime::new(); + let url = spawn_server_with_claude_environment(Some(env.environment())).await; let mut ws = connect(&url, true).await; // ejh6 presence (finding 9): rejection fires for any string, including "". diff --git a/test/integration/providers/claude-sdk-lease-runtime.mjs b/test/integration/providers/claude-sdk-lease-runtime.mjs new file mode 100644 index 000000000..bf2e6d6b7 --- /dev/null +++ b/test/integration/providers/claude-sdk-lease-runtime.mjs @@ -0,0 +1,41 @@ +// Lifecycle faults at the SDK query boundary for Rust lease integration tests. +// The production sidecar owns every protocol frame and response. +import fs from 'node:fs' +import { spawn } from 'node:child_process' +import { query as recordedQuery } from './claude-sdk-for-rust-runtime.mjs' + +export function query(params) { + let first = false + try { + fs.closeSync(fs.openSync(process.env.FRESHELL_TEST_CLAUDE_FIRST_LAUNCH, 'wx')) + first = true + } catch (error) { + if (error.code !== 'EEXIST') throw error + } + const runtime = recordedQuery(params) + if (first && process.env.FRESHELL_TEST_CLAUDE_DECOY === '1') { + // A tagged detached descendant ignores TERM so the lease expiry must + // confirm its SIGKILL sweep before allowing a replacement writer. + const child = spawn('sh', ['-c', 'trap "" TERM; echo armed; exec sleep 300 >/dev/null'], { + detached: true, + stdio: ['ignore', 'pipe', 'ignore'], + }) + child.stdout.once('data', () => { + child.stdout.destroy() + fs.appendFileSync(process.env.FRESHELL_CLAUDE_SIM_LEDGER, `${JSON.stringify({ + kind: 'decoy', pid: process.pid, childPid: child.pid, + })}\n`) + }) + child.unref() + } + const initialize = runtime.initializationResult.bind(runtime) + runtime.initializationResult = async () => { + if (first && process.env.FRESHELL_TEST_CLAUDE_CRASH_FIRST === '1') process.exit(1) + const gate = process.env.FRESHELL_TEST_CLAUDE_INITIALIZATION_GATE + while (first && gate && fs.existsSync(gate)) { + await new Promise((resolve) => setTimeout(resolve, 10)) + } + return initialize() + } + return runtime +} From 439eaeac6e5a6ab9eb41fcb3d58cc79619277ca7 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:54:36 -0700 Subject: [PATCH 077/100] test(freshclaude): run cross-kind ownership races with native SDK sessions --- .../tests/common/claude_sim_environment.rs | 6 +- .../freshell-ws/tests/cross_kind_liveness.rs | 316 +++++++----------- 2 files changed, 132 insertions(+), 190 deletions(-) diff --git a/crates/freshell-ws/tests/common/claude_sim_environment.rs b/crates/freshell-ws/tests/common/claude_sim_environment.rs index 01e86532b..9c7e1bf83 100644 --- a/crates/freshell-ws/tests/common/claude_sim_environment.rs +++ b/crates/freshell-ws/tests/common/claude_sim_environment.rs @@ -33,11 +33,15 @@ impl ClaudeSimEnvironment { /// A saved conversation in the native JSONL format, for cold-resume tests. pub fn seed_transcript(&self, id: &str) { + self.seed_transcript_at(id, &self.workspace()); + } + + pub fn seed_transcript_at(&self, id: &str, cwd: &Path) { let project = self.config_dir().join("projects/fixture"); std::fs::create_dir_all(&project).unwrap(); let row = serde_json::json!({ "type":"user", "sessionId":id, "uuid":uuid::Uuid::new_v4().to_string(), - "parentUuid":null, "cwd":self.workspace(), "isSidechain":false, + "parentUuid":null, "cwd":cwd, "isSidechain":false, "message":{"role":"user", "content":[{"type":"text", "text":"saved conversation"}]} }); std::fs::write(project.join(format!("{id}.jsonl")), format!("{row}\n")).unwrap(); diff --git a/crates/freshell-ws/tests/cross_kind_liveness.rs b/crates/freshell-ws/tests/cross_kind_liveness.rs index e64cb699d..7b95535d7 100644 --- a/crates/freshell-ws/tests/cross_kind_liveness.rs +++ b/crates/freshell-ws/tests/cross_kind_liveness.rs @@ -17,7 +17,7 @@ //! is refused with the D7 guard's EXISTING rejection frame //! (`error { code: "RESTORE_UNAVAILABLE" }`), same as a live PTY. //! -//! Harness: the lease-suite fake claude sidecar (request-log knob) + the common +//! Harness: the production Claude sidecar over the contract CLI simulator + the common //! sleeper CLI spec so terminal claude creates genuinely spawn a Running PTY. //! Run via `scripts/sandbox-test.sh` per the destructive-suite convention of the //! sibling lease suite (shared file-level ruling; the kata-b8ke Task 7 races @@ -40,91 +40,15 @@ use freshell_freshagent::{FreshAgentBindingUpsert, PaneIdentitySink}; use freshell_ws::pane_ledger::PaneLedger; use freshell_ws::WsState; +use common::claude_sim_environment::ClaudeSimEnvironment; use common::ledger_sink::LedgerSink; const AUTH_TOKEN: &str = "s3cr3t-token-abcdef"; /// Serializes the tests in this file: they mutate process-global env vars -/// (`FRESHELL_CLAUDE_SIDECAR` / `FAKE_SIDECAR_*`). +/// (Codex/OpenCode process fixtures and terminal spawn controls). static ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); -// ── fake claude sidecar (request-log knob only; duplicated per-file per convention) ── - -const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" -import readline from 'node:readline' -import fs from 'node:fs' - -const logPath = process.env.FAKE_SIDECAR_REQUEST_LOG || '' -function logReq(msg) { - if (!logPath) return - try { fs.appendFileSync(logPath, JSON.stringify({ pid: process.pid, msg }) + '\n') } catch {} -} - -let counter = 0 -const rl = readline.createInterface({ input: process.stdin, terminal: false }) -rl.on('line', (line) => { - const trimmed = line.trim() - if (!trimmed) return - let msg - try { msg = JSON.parse(trimmed) } catch { return } - logReq(msg) - if (msg.type === 'create') { - counter += 1 - const sessionId = `fake-claude-session-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') - const cliSessionId = msg.boot?.resume || 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' - process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') - process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') - } else if (msg.type === 'shutdown') { - process.exit(0) - } -}) -"#; - -struct FakeSidecarEnv { - dir: std::path::PathBuf, -} - -impl FakeSidecarEnv { - fn install() -> Self { - let dir = std::env::temp_dir().join(format!( - "freshell-cross-kind-liveness-{}", - uuid_like_suffix() - )); - std::fs::create_dir_all(&dir).expect("create fake sidecar temp dir"); - let script = dir.join("fake-claude-sidecar.mjs"); - std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("FAKE_SIDECAR_REQUEST_LOG", dir.join("requests.jsonl")); - Self { dir } - } - - fn create_rows(&self) -> Vec { - let Ok(raw) = std::fs::read_to_string(self.dir.join("requests.jsonl")) else { - return Vec::new(); - }; - raw.lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| serde_json::from_str::(l).expect("request log row parses")) - .filter(|r| r["msg"]["type"] == "create") - .collect() - } -} - -impl Drop for FakeSidecarEnv { - fn drop(&mut self) { - for var in [ - "FRESHELL_CLAUDE_SIDECAR", - "FRESHELL_CLAUDE_NODE", - "FAKE_SIDECAR_REQUEST_LOG", - ] { - std::env::remove_var(var); - } - let _ = std::fs::remove_dir_all(&self.dir); - } -} - fn uuid_like_suffix() -> String { use std::time::{SystemTime, UNIX_EPOCH}; let nanos = SystemTime::now() @@ -483,6 +407,18 @@ async fn build_ws_state_with_probe( WsState, freshell_terminal::TerminalRegistry, freshell_freshagent::FreshAgentState, +) { + build_ws_state_with_probe_and_env(cli_commands, probe, None).await +} + +async fn build_ws_state_with_probe_and_env( + cli_commands: Vec, + probe: std::sync::Arc, + environment: Option<&ClaudeSimEnvironment>, +) -> ( + WsState, + freshell_terminal::TerminalRegistry, + freshell_freshagent::FreshAgentState, ) { let auth_token = Arc::new(AUTH_TOKEN.to_string()); let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(64).0); @@ -515,6 +451,9 @@ async fn build_ws_state_with_probe( }; let mut fresh_claude = freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)); + if let Some(environment) = environment { + fresh_claude = fresh_claude.with_sidecar_environment(environment.environment()); + } fresh_claude.set_terminal_liveness(Arc::clone(&terminal_liveness)); let mut fresh_codex = freshell_freshagent::FreshCodexState::new( Arc::clone(&auth_token), @@ -597,8 +536,18 @@ async fn build_ws_state_with_probe( /// and the `WsState` clone (kata b8ke Task 3: the shared ownership /// coordinator is minted HERE, exactly like `main.rs` injects it). async fn spawn_server() -> (String, freshell_terminal::TerminalRegistry, WsState) { - let (state, registry, _fresh_agent_state) = - build_ws_state(vec![sleeper_cli_spec("claude")]).await; + spawn_server_with_claude(None).await +} + +async fn spawn_server_with_claude( + environment: Option<&ClaudeSimEnvironment>, +) -> (String, freshell_terminal::TerminalRegistry, WsState) { + let (state, registry, _fresh_agent_state) = build_ws_state_with_probe_and_env( + vec![sleeper_cli_spec("claude")], + Arc::new(freshell_ws::existence::NoIndexProbe::default()), + environment, + ) + .await; let router = freshell_ws::router(state.clone()); // Ephemeral loopback port only -- NEVER the self-hosted 3001/3002 ports. @@ -642,10 +591,29 @@ async fn spawn_merged_server() -> MergedHarness { /// minted (the states are cloned into it). async fn spawn_merged_server_with_hooks( hooks: Arc, +) -> MergedHarness { + spawn_merged_server_with_hooks_and_claude(hooks, None).await +} + +async fn spawn_merged_server_with_claude(environment: &ClaudeSimEnvironment) -> MergedHarness { + spawn_merged_server_with_hooks_and_claude( + Arc::new(freshell_freshagent::HandoffTestHooks::default()), + Some(environment), + ) + .await +} + +async fn spawn_merged_server_with_hooks_and_claude( + hooks: Arc, + environment: Option<&ClaudeSimEnvironment>, ) -> MergedHarness { let cli_commands = Arc::new(vec![sleeper_cli_spec("claude"), sleeper_cli_spec("codex")]); - let (state, registry, fresh_agent_state) = - build_ws_state(cli_commands.iter().cloned().collect()).await; + let (state, registry, fresh_agent_state) = build_ws_state_with_probe_and_env( + cli_commands.iter().cloned().collect(), + Arc::new(freshell_ws::existence::NoIndexProbe::default()), + environment, + ) + .await; // The snapshot REST door shares the WS door's state slices (main.rs's // `SnapshotState::new` wiring: same auth token, same codex/claude @@ -715,10 +683,20 @@ async fn spawn_merged_server_with_hooks( /// exists to guard). The terminal lane shares the same real ledger /// through the WsState. async fn spawn_merged_server_with_ledger() -> (MergedHarness, Arc) { + spawn_merged_server_with_ledger_and_claude(None).await +} + +async fn spawn_merged_server_with_ledger_and_claude( + environment: Option<&ClaudeSimEnvironment>, +) -> (MergedHarness, Arc) { let hooks = Arc::new(freshell_freshagent::HandoffTestHooks::default()); let cli_commands = Arc::new(vec![sleeper_cli_spec("claude"), sleeper_cli_spec("codex")]); - let (state, registry, fresh_agent_state) = - build_ws_state(cli_commands.iter().cloned().collect()).await; + let (state, registry, fresh_agent_state) = build_ws_state_with_probe_and_env( + cli_commands.iter().cloned().collect(), + Arc::new(freshell_ws::existence::NoIndexProbe::default()), + environment, + ) + .await; // The REAL ledger (tempdir-rooted) replaces the disabled one BEFORE any // write can flow; the fresh states write through the same ledger via the @@ -1108,9 +1086,9 @@ fn live_pty_count_for_session( #[tokio::test] async fn freshagent_resume_is_refused_while_a_terminal_pty_owns_the_session() { let _guard = ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSimEnvironment::new(); - let (url, _registry, _ws_state) = spawn_server().await; + let (url, _registry, _ws_state) = spawn_server_with_claude(Some(&env)).await; let mut ws = connect(&url).await; // 1. A fresh claude terminal reaches Running, owning preallocated session S. @@ -1170,9 +1148,9 @@ async fn freshagent_resume_is_refused_while_a_terminal_pty_owns_the_session() { // 3. ZERO sidecar spawns. assert!( - env.create_rows().is_empty(), + env.rows("query").is_empty(), "no sidecar may spawn while the terminal owns the session: {:?}", - env.create_rows() + env.rows("query") ); } @@ -1186,9 +1164,9 @@ async fn freshagent_resume_is_refused_while_a_terminal_pty_owns_the_session() { #[tokio::test] async fn freshagent_session_ref_resume_is_refused_while_a_terminal_pty_owns_the_session() { let _guard = ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSimEnvironment::new(); - let (url, _registry, _ws_state) = spawn_server().await; + let (url, _registry, _ws_state) = spawn_server_with_claude(Some(&env)).await; let mut ws = connect(&url).await; // 1. A fresh claude terminal reaches Running, owning preallocated session S. @@ -1245,9 +1223,9 @@ async fn freshagent_session_ref_resume_is_refused_while_a_terminal_pty_owns_the_ // 3. ZERO sidecar spawns. assert!( - env.create_rows().is_empty(), + env.rows("query").is_empty(), "no sidecar may spawn while the terminal owns the session: {:?}", - env.create_rows() + env.rows("query") ); } @@ -1257,13 +1235,14 @@ async fn freshagent_session_ref_resume_is_refused_while_a_terminal_pty_owns_the_ #[tokio::test] async fn terminal_create_is_refused_while_a_live_sidecar_owns_the_session() { let _guard = ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSimEnvironment::new(); let durable = "cdcdcdcd-cdcd-4dcd-8dcd-cdcdcdcdcdcd"; - let (url, registry, _ws_state) = spawn_server().await; + env.seed_transcript_at(durable, std::path::Path::new("/tmp")); + let (url, registry, _ws_state) = spawn_server_with_claude(Some(&env)).await; let mut ws = connect(&url).await; - // 1. A fresh-agent resume of S goes live (the fake sidecar answers `created`). + // 1. A fresh-agent resume of S goes live (the production sidecar boots the saved conversation). // (ejh6 Task 7: sessionRef-only -- a legacy `resumeSessionId` carry is now // rejected at the raw-Value guard, which would break this setup create.) send_json( @@ -1282,7 +1261,7 @@ async fn terminal_create_is_refused_while_a_live_sidecar_owns_the_session() { v["type"] == "freshAgent.created" && v["requestId"] == "req-fa-owner-1" }) .await; - assert_eq!(env.create_rows().len(), 1, "the sidecar owns S now"); + assert_eq!(env.rows("query").len(), 1, "the sidecar owns S now"); // 2. A terminal.create restoring the SAME session id (the D7 direct // wire-sessionRef rung) must be refused -- the sidecar is the one writer. @@ -1334,13 +1313,14 @@ async fn terminal_create_is_refused_while_a_live_sidecar_owns_the_session() { #[tokio::test] async fn d7_cross_kind_refusal_omits_live_terminal_id() { let _guard = ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSimEnvironment::new(); let durable = "abababab-cdcd-4dcd-8dcd-cdcdcdcdcdcd"; - let (url, registry, _ws_state) = spawn_server().await; + env.seed_transcript_at(durable, std::path::Path::new("/tmp")); + let (url, registry, _ws_state) = spawn_server_with_claude(Some(&env)).await; let mut ws = connect(&url).await; - // 1. A fresh-agent resume of S goes live (the fake sidecar answers `created`). + // 1. A fresh-agent resume of S goes live (the production sidecar boots the saved conversation). send_json( &mut ws, &json!({ @@ -1357,7 +1337,7 @@ async fn d7_cross_kind_refusal_omits_live_terminal_id() { v["type"] == "freshAgent.created" && v["requestId"] == "req-fa-owner-d7" }) .await; - assert_eq!(env.create_rows().len(), 1, "the sidecar owns S now"); + assert_eq!(env.rows("query").len(), 1, "the sidecar owns S now"); // 2. The D7 cross-kind refusal: RESTORE_UNAVAILABLE, message names the // session, and NO liveTerminalId (no terminal owns the session). @@ -1612,10 +1592,11 @@ async fn a_successful_terminal_create_does_not_drop_its_ticket_unarmed() { #[tokio::test] async fn fresh_agent_create_and_kill_drive_the_shared_coordinator() { let _guard = ENV_LOCK.lock().await; - let sidecar = FakeSidecarEnv::install(); // SYNC at base — no .await - let (url, _registry, ws_state) = spawn_server().await; + let sidecar = ClaudeSimEnvironment::new(); + let (url, _registry, ws_state) = spawn_server_with_claude(Some(&sidecar)).await; let mut ws = connect(&url).await; - let sid = format!("coord-{}", uuid::Uuid::new_v4()); + let sid = uuid::Uuid::new_v4().to_string(); + sidecar.seed_transcript_at(&sid, std::path::Path::new("/tmp")); send_json( &mut ws, &json!({ @@ -1664,9 +1645,10 @@ async fn fresh_agent_create_and_kill_drive_the_shared_coordinator() { #[tokio::test] async fn concurrent_terminal_and_fresh_agent_start_same_session_ref_yield_one_writer() { let _guard = ENV_LOCK.lock().await; - let sidecar = FakeSidecarEnv::install(); // SYNC at base — no .await - let (url, registry, _ws_state) = spawn_server().await; - let sid = format!("race-{}", uuid::Uuid::new_v4()); + let sidecar = ClaudeSimEnvironment::new(); + let (url, registry, _ws_state) = spawn_server_with_claude(Some(&sidecar)).await; + let sid = uuid::Uuid::new_v4().to_string(); + sidecar.seed_transcript_at(&sid, std::path::Path::new("/tmp")); let mut ws_term = connect(&url).await; let mut ws_fresh = connect(&url).await; @@ -1692,9 +1674,9 @@ async fn concurrent_terminal_and_fresh_agent_start_same_session_ref_yield_one_wr // two separate <=1 assertions (one sidecar + one PTY would pass those). let live_writers = || { let creates = sidecar - .create_rows() + .rows("query") .iter() - .filter(|r| r["msg"]["boot"]["resume"].as_str() == Some(sid.as_str())) + .filter(|r| r["options"]["resume"].as_str() == Some(sid.as_str())) .count(); let ptys = live_pty_count_for_session(®istry, "claude", &sid); creates + ptys @@ -1774,9 +1756,9 @@ async fn concurrent_terminal_and_fresh_agent_start_same_session_ref_yield_one_wr // independent: even a transient second writer that spawned and was torn // down between two sampler iterations leaves its Created event behind. let sidecar_creates = sidecar - .create_rows() + .rows("query") .iter() - .filter(|r| r["msg"]["boot"]["resume"].as_str() == Some(sid.as_str())) + .filter(|r| r["options"]["resume"].as_str() == Some(sid.as_str())) .count(); assert!( pty_spawns.load(std::sync::atomic::Ordering::SeqCst) + sidecar_creates <= 1, @@ -1789,10 +1771,11 @@ async fn concurrent_terminal_and_fresh_agent_start_same_session_ref_yield_one_wr #[tokio::test] async fn terminal_create_refusal_names_the_fresh_agent_owner_kind_and_generation() { let _guard = ENV_LOCK.lock().await; - let _sidecar = FakeSidecarEnv::install(); // SYNC at base — no .await - let (url, _registry, _ws_state) = spawn_server().await; + let sidecar = ClaudeSimEnvironment::new(); + let (url, _registry, _ws_state) = spawn_server_with_claude(Some(&sidecar)).await; let mut ws = connect(&url).await; - let sid = format!("typed-owner-{}", uuid::Uuid::new_v4()); + let sid = uuid::Uuid::new_v4().to_string(); + sidecar.seed_transcript_at(&sid, std::path::Path::new("/tmp")); // Establish a live freshclaude owner first. send_json( &mut ws, @@ -1856,9 +1839,10 @@ async fn terminal_create_refusal_names_the_fresh_agent_owner_kind_and_generation #[tokio::test] async fn non_negotiated_terminal_create_is_coordinator_fenced() { let _guard = ENV_LOCK.lock().await; - let _sidecar = FakeSidecarEnv::install(); // SYNC at base — no .await - let (url, _registry, _ws_state) = spawn_server().await; - let sid = format!("raw-{}", uuid::Uuid::new_v4()); + let sidecar = ClaudeSimEnvironment::new(); + let (url, _registry, _ws_state) = spawn_server_with_claude(Some(&sidecar)).await; + let sid = uuid::Uuid::new_v4().to_string(); + sidecar.seed_transcript_at(&sid, std::path::Path::new("/tmp")); // A negotiated connection establishes the fresh owner. let mut ws_owner = connect(&url).await; send_json( @@ -1985,10 +1969,11 @@ async fn non_negotiated_terminal_create_is_coordinator_fenced() { #[tokio::test] async fn ready_frame_replays_current_runtime_owners() { let _guard = ENV_LOCK.lock().await; - let _sidecar = FakeSidecarEnv::install(); // SYNC at base — no .await - let (url, _registry, _ws_state) = spawn_server().await; + let sidecar = ClaudeSimEnvironment::new(); + let (url, _registry, _ws_state) = spawn_server_with_claude(Some(&sidecar)).await; let mut ws = connect(&url).await; - let sid = format!("replay-{}", uuid::Uuid::new_v4()); + let sid = uuid::Uuid::new_v4().to_string(); + sidecar.seed_transcript_at(&sid, std::path::Path::new("/tmp")); send_json( &mut ws, &json!({ @@ -2829,9 +2814,10 @@ async fn snapshot_get_for_opencode_never_spawns_the_shared_serve() { #[tokio::test] async fn fresh_agent_to_terminal_handoff_is_atomic_and_broadcast() { let _guard = ENV_LOCK.lock().await; - let sidecar = FakeSidecarEnv::install(); // SYNC at base — no .await - let mut h = spawn_merged_server().await; - let sid = format!("ho-e2e-{}", uuid::Uuid::new_v4()); + let sidecar = ClaudeSimEnvironment::new(); + let mut h = spawn_merged_server_with_claude(&sidecar).await; + let sid = uuid::Uuid::new_v4().to_string(); + sidecar.seed_transcript_at(&sid, std::path::Path::new("/tmp")); // Fresh owner. send_json( &mut h.ws, @@ -2997,23 +2983,11 @@ async fn fresh_agent_to_terminal_handoff_is_atomic_and_broadcast() { #[tokio::test] async fn terminal_to_fresh_agent_handoff_lands_over_the_unstamped_terminal_row() { let _guard = ENV_LOCK.lock().await; - let _sidecar = FakeSidecarEnv::install(); // SYNC at base — no .await - // The claude-lane resume gates on transcript presence (the 6b - // pattern): the target's resume of `sid` needs the transcript under - // CLAUDE_CONFIG_DIR. - let store_dir = - std::env::temp_dir().join(format!("freshell-handoff-ep5-r2-{}", uuid_like_suffix())); - let project_dir = store_dir.join("projects").join("slug"); - std::fs::create_dir_all(&project_dir).expect("create transcript project dir"); - let sid = format!("ho-rev-{}", uuid::Uuid::new_v4()); - std::fs::write( - project_dir.join(format!("{sid}.jsonl")), - "{\"cwd\": \"/tmp\"}\n", - ) - .expect("write fake transcript"); - std::env::set_var("CLAUDE_CONFIG_DIR", &store_dir); + let sidecar = ClaudeSimEnvironment::new(); + let sid = uuid::Uuid::new_v4().to_string(); + sidecar.seed_transcript_at(&sid, std::path::Path::new("/tmp")); - let (mut h, pane_ledger) = spawn_merged_server_with_ledger().await; + let (mut h, pane_ledger) = spawn_merged_server_with_ledger_and_claude(Some(&sidecar)).await; // 1. The TERMINAL owner: an ordinary WS terminal binding (claude mode) // over the durable id — the coordinator's Live{Terminal} and the @@ -3096,9 +3070,7 @@ async fn terminal_to_fresh_agent_handoff_lands_over_the_unstamped_terminal_row() // 4. THE REAL LEDGER: the recovery row became the fresh-agent // target's — pane_kind fresh-agent, the terminal's binding // superseded (pre-ep5-r2 the row stayed the dead terminal's). - // The target's session-init adoption write is ASYNC to the - // runner's commit (the sidecar's sdk.session.init lands after the - // handoff answered) — bounded-poll for the flip. + // Observe the target's authoritative binding over the old terminal row. let flipped = tokio::time::timeout(Duration::from_secs(20), async { loop { let done = pane_ledger @@ -3128,12 +3100,9 @@ async fn terminal_to_fresh_agent_handoff_lands_over_the_unstamped_terminal_row() "the terminal's binding is superseded: {row:?}" ); - // Cleanup: the sidecar target dies with the harness (the fake - // sidecar is a short-lived script under the dropped env), and the - // terminal row's PTY is reaped through the registry. + // The fixture guard closes the real SDK query on drop; the terminal + // row's PTY is reaped through the registry. h.ws_state.registry.kill(&terminal_id); - std::env::remove_var("CLAUDE_CONFIG_DIR"); - let _ = std::fs::remove_dir_all(store_dir); } /// b8ke focused ep5 r3 F1 (e2e): a SUPERSEDED handoff's stale @@ -3150,23 +3119,11 @@ async fn terminal_to_fresh_agent_handoff_lands_over_the_unstamped_terminal_row() #[tokio::test] async fn a_superseded_handoffs_stale_authoritative_write_is_refused_end_to_end() { let _guard = ENV_LOCK.lock().await; - let _sidecar = FakeSidecarEnv::install(); // SYNC at base — no .await - // The claude-lane resume gates on transcript presence (the 6b - // pattern): the target's resume of `sid` needs the transcript under - // CLAUDE_CONFIG_DIR. - let store_dir = - std::env::temp_dir().join(format!("freshell-handoff-ep5-r3-{}", uuid_like_suffix())); - let project_dir = store_dir.join("projects").join("slug"); - std::fs::create_dir_all(&project_dir).expect("create transcript project dir"); - let sid = format!("ho-sup-{}", uuid::Uuid::new_v4()); - std::fs::write( - project_dir.join(format!("{sid}.jsonl")), - "{\"cwd\": \"/tmp\"}\n", - ) - .expect("write fake transcript"); - std::env::set_var("CLAUDE_CONFIG_DIR", &store_dir); + let sidecar = ClaudeSimEnvironment::new(); + let sid = uuid::Uuid::new_v4().to_string(); + sidecar.seed_transcript_at(&sid, std::path::Path::new("/tmp")); - let (mut h, pane_ledger) = spawn_merged_server_with_ledger().await; + let (mut h, pane_ledger) = spawn_merged_server_with_ledger_and_claude(Some(&sidecar)).await; // 1. The TERMINAL owner: an ordinary WS terminal binding (claude mode) // over the durable id — the coordinator's Live{Terminal} and the @@ -3338,8 +3295,6 @@ async fn a_superseded_handoffs_stale_authoritative_write_is_refused_end_to_end() // the second terminal's PTY is reaped through the registry. h.ws_state.registry.kill(&second_terminal_id); let _ = first_terminal_id; - std::env::remove_var("CLAUDE_CONFIG_DIR"); - let _ = std::fs::remove_dir_all(store_dir); } /// b8ke focused ep5 r4 F1 (e2e): the FAILED-TRANSITION REPAIR, end to end @@ -3357,25 +3312,11 @@ async fn a_superseded_handoffs_stale_authoritative_write_is_refused_end_to_end() #[tokio::test] async fn the_failed_transition_repair_fences_and_reverts_end_to_end() { let _guard = ENV_LOCK.lock().await; - let _sidecar = FakeSidecarEnv::install(); // SYNC at base — no .await - // The claude-lane resume gates on transcript presence (the 6b - // pattern): the target's resume of `sid` needs the transcript under - // CLAUDE_CONFIG_DIR. - let store_dir = std::env::temp_dir().join(format!( - "freshell-handoff-ep5-r4-repair-{}", - uuid_like_suffix() - )); - let project_dir = store_dir.join("projects").join("slug"); - std::fs::create_dir_all(&project_dir).expect("create transcript project dir"); - let sid = format!("ho-rep-{}", uuid::Uuid::new_v4()); - std::fs::write( - project_dir.join(format!("{sid}.jsonl")), - "{\"cwd\": \"/tmp\"}\n", - ) - .expect("write fake transcript"); - std::env::set_var("CLAUDE_CONFIG_DIR", &store_dir); + let sidecar = ClaudeSimEnvironment::new(); + let sid = uuid::Uuid::new_v4().to_string(); + sidecar.seed_transcript_at(&sid, std::path::Path::new("/tmp")); - let (mut h, pane_ledger) = spawn_merged_server_with_ledger().await; + let (mut h, pane_ledger) = spawn_merged_server_with_ledger_and_claude(Some(&sidecar)).await; // 1. The TERMINAL owner + 2. the FIRST handoff to the FRESH target — // the r2 success flow: the target's authoritative binding lands @@ -3509,8 +3450,6 @@ async fn the_failed_transition_repair_fences_and_reverts_end_to_end() { // Cleanup: the first terminal's row was reaped by the handoff; the // fresh target's sidecar dies with the harness. let _ = terminal_id; - std::env::remove_var("CLAUDE_CONFIG_DIR"); - let _ = std::fs::remove_dir_all(store_dir); } /// b8ke focused ep5 r5 F1 (e2e): an ORDINARY codex cold resume with @@ -3530,10 +3469,9 @@ async fn the_failed_transition_repair_fences_and_reverts_end_to_end() { #[tokio::test] async fn an_ordinary_codex_cold_resume_with_recovered_settings_lands_end_to_end() { let _guard = ENV_LOCK.lock().await; - let _sidecar = FakeSidecarEnv::install(); // SYNC at base — no .await - // The isolated codex home + the fake app-server (the lease-suite - // pattern): the resume spawns a real sidecar subprocess through - // CODEX_CMD. + // The isolated codex home + the fake app-server (the lease-suite + // pattern): the resume spawns a real sidecar subprocess through + // CODEX_CMD. let codex_home = tempfile::tempdir().expect("isolated codex home"); let prev_codex_home = std::env::var_os("CODEX_HOME"); let prev_allow = std::env::var_os("FAKE_CODEX_APP_SERVER_ALLOW_DURABLE_WRITES"); From b867038a303fd55758388b0c353258832fcd7336 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:55:16 -0700 Subject: [PATCH 078/100] fix(storage): retain Claude identity through boot migration --- src/store/storage-migration.ts | 13 +++++ .../client/store/storage-migration.test.ts | 53 +++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/store/storage-migration.ts b/src/store/storage-migration.ts index 65659ddee..b62227a43 100644 --- a/src/store/storage-migration.ts +++ b/src/store/storage-migration.ts @@ -56,6 +56,7 @@ import { } from '@shared/session-contract' import { sanitizeCodexDurabilityRef } from '@shared/codex-durability' import { migrateLegacyFreshAgentContent, migrateLegacyFreshAgentDurableState } from '@shared/fresh-agent' +import { isDurableProviderSessionId } from '@shared/session-flavor' import { normalizeFreshAgentPaneModelSelection } from './paneTypes' const log = createLogger('StorageMigration') @@ -208,6 +209,16 @@ function normalizeLayoutNode(node: unknown): unknown { if (content.kind === 'fresh-agent') { const existingRestoreError = readRestoreError(content.restoreError) if (existingRestoreError) { + const provider = content.provider === 'claude' || content.provider === 'codex' || content.provider === 'opencode' + ? content.provider + : undefined + const candidateSessionRef = sanitizeSessionRef(content.sessionRef) + const retainedSessionRef = existingRestoreError.reason !== 'invalid_legacy_restore_target' + && candidateSessionRef !== undefined + && candidateSessionRef.provider === provider + && isDurableProviderSessionId(provider, candidateSessionRef.sessionId) + ? candidateSessionRef + : undefined const { sessionRef: _legacySessionRef, restoreError: _legacyRestoreError, @@ -234,6 +245,8 @@ function normalizeLayoutNode(node: unknown): unknown { ...candidate, content: { ...rest, + ...(retainedSessionRef ? { sessionRef: retainedSessionRef } : {}), + ...(retainedSessionRef?.provider === 'claude' ? { sessionId: retainedSessionRef.sessionId } : {}), restoreError: existingRestoreError, }, } diff --git a/test/unit/client/store/storage-migration.test.ts b/test/unit/client/store/storage-migration.test.ts index 7e5b68376..8fb13f83c 100644 --- a/test/unit/client/store/storage-migration.test.ts +++ b/test/unit/client/store/storage-migration.test.ts @@ -206,6 +206,59 @@ describe('storage-migration', () => { }) }) + it('preserves a validated Claude sessionRef alongside a recoverable restore error during boot migration', async () => { + localStorage.setItem('freshell_version', '5') + localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ + persistedAt: Date.now(), + version: 3, + tabs: { + activeTabId: 'tab-claude', + tabs: [{ id: 'tab-claude', title: 'Claude', createdAt: 1, mode: 'freshclaude' }], + }, + panes: { + version: 6, + layouts: { + 'tab-claude': { + type: 'leaf', + id: 'pane-claude', + content: { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-restore-error', + status: 'idle', + sessionId: VALID_CLAUDE_SESSION_ID, + sessionRef: { provider: 'claude', sessionId: VALID_CLAUDE_SESSION_ID }, + restoreError: { + code: 'RESTORE_UNAVAILABLE', + reason: 'durable_artifact_missing', + }, + }, + }, + }, + activePane: { 'tab-claude': 'pane-claude' }, + paneTitles: {}, + paneTitleSetByUser: {}, + }, + tombstones: [], + })) + + await importFreshStorageMigration() + + const migratedRaw = localStorage.getItem(LAYOUT_STORAGE_KEY) + expect(migratedRaw).not.toBeNull() + const migrated = JSON.parse(migratedRaw!) + const content = migrated.panes.layouts['tab-claude'].content + expect(content.sessionRef).toEqual({ + provider: 'claude', + sessionId: VALID_CLAUDE_SESSION_ID, + }) + expect(content.restoreError).toEqual({ + code: 'RESTORE_UNAVAILABLE', + reason: 'durable_artifact_missing', + }) + }) + it('migrates durable legacy Codex recovery_failed panes to creating resume panes', async () => { localStorage.setItem('freshell_version', '3') localStorage.setItem(LAYOUT_STORAGE_KEY, JSON.stringify({ From 0ca5466a225b9ab34860a1050bfdf65e958bb887 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:57:27 -0700 Subject: [PATCH 079/100] fix(freshclaude): follow native resets in attach and quiet deadlines A late attach acknowledges the current native UUID and consumes its restart marker. A pending quiet deadline follows native history while retaining its exact process-owner fence. Regression tests fail before these changes and pass afterward: three attach statuses and four quiet deadline cases. --- .../freshell-freshagent/src/claude/attach.rs | 43 +++++++++++-------- .../freshell-freshagent/src/claude/deadman.rs | 7 ++- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/crates/freshell-freshagent/src/claude/attach.rs b/crates/freshell-freshagent/src/claude/attach.rs index 94ef7e018..9cea57b2b 100644 --- a/crates/freshell-freshagent/src/claude/attach.rs +++ b/crates/freshell-freshagent/src/claude/attach.rs @@ -54,37 +54,37 @@ impl FreshClaudeState { msg.resume_session_id = None; match self.reply_live_attach(&msg, &previous, fence, &reply).await { - Ok(true) => { - self.notify_turn_interrupted_after_ack(&msg.session_id, session_type) + Ok(Some(attached_id)) => { + self.notify_turn_interrupted_after_ack(&attached_id, session_type) .await; return; } Err(()) => return, - Ok(false) => {} + Ok(None) => {} } // A competing attach may already be booting this conversation. // Wait for it, then inspect the runtime again before launching. let _flight = self.wait_resume_flight(&durable).await; match self.reply_live_attach(&msg, &previous, fence, &reply).await { - Ok(true) => { - self.notify_turn_interrupted_after_ack(&msg.session_id, session_type) + Ok(Some(attached_id)) => { + self.notify_turn_interrupted_after_ack(&attached_id, session_type) .await; return; } Err(()) => return, - Ok(false) => {} + Ok(None) => {} } match self.resume_untracked(&msg, &durable, fence, true).await { Ok(()) => { // This operation advanced its own claim. The incoming // pre-launch fence no longer names its committed owner. match self.reply_live_attach(&msg, &previous, None, &reply).await { - Ok(true) => { - self.notify_turn_interrupted_after_ack(&msg.session_id, session_type) + Ok(Some(attached_id)) => { + self.notify_turn_interrupted_after_ack(&attached_id, session_type) .await; } Err(()) => {} - Ok(false) => attach_error( + Ok(None) => attach_error( &reply, &msg, &previous, @@ -119,16 +119,20 @@ impl FreshClaudeState { previous: &str, fence: Option, reply: &FrameSink, - ) -> Result { + ) -> Result, ()> { let Some(key) = self.resolve_session_key(&msg.session_id).await else { - return Ok(false); + return Ok(None); + }; + let Some(native_id) = self.sessions.lock().await.get(&key) + .map(|runtime| runtime.identity.current()) else { + return Ok(None); }; let operation = format!("claude-attach-{}", uuid::Uuid::new_v4()); let guard = match fence { Some(fence) => crate::ownership_lane::arm_adopt_guard( &self.ownership, PROVIDER, - &msg.session_id, + &native_id, &operation, &freshell_ownership::OwnerIdentity { kind: freshell_ownership::RuntimeOwnerKind::FreshAgent, @@ -143,7 +147,7 @@ impl FreshClaudeState { None => crate::ownership_lane::arm_attach_guard( &self.ownership, PROVIDER, - &msg.session_id, + &native_id, &operation, None, "freshclaude/attach", @@ -164,28 +168,29 @@ impl FreshClaudeState { .lock() .await .get(&key) + .filter(|runtime| runtime.identity.current() == native_id) .map(ClaudeSession::current_status); let Some(status) = status else { - return Ok(false); + return Ok(None); }; - if previous != msg.session_id { + if previous != native_id { reply( self.materialized_message( previous, - &msg.session_id, + &native_id, session_type_str(msg.session_type), ) .await, ); } reply(status_snapshot_frame( - &msg.session_id, - &msg.session_id, + &native_id, + &native_id, &status, session_type_str(msg.session_type), )); drop(guard); - Ok(true) + Ok(Some(native_id)) } } diff --git a/crates/freshell-freshagent/src/claude/deadman.rs b/crates/freshell-freshagent/src/claude/deadman.rs index 91bc2d2f4..9520aac59 100644 --- a/crates/freshell-freshagent/src/claude/deadman.rs +++ b/crates/freshell-freshagent/src/claude/deadman.rs @@ -184,8 +184,11 @@ impl FreshClaudeState { return; } let sessions = self.sessions.lock().await; + let Some(current_key) = Self::session_key_in(&sessions, &map_key) else { + return; + }; let Some(session) = sessions - .get(&map_key) + .get(¤t_key) .filter(|session| session.ownership_id == ownership_id) else { return; @@ -203,7 +206,7 @@ impl FreshClaudeState { tracing::warn!(provider = PROVIDER, session_id = %stamp, ownership_id = %ownership_id, phase = "fired", generation, "freshagent.claude.quiet_deadman"); self.edges.quiet_deadman(&crate::claude_edges::EdgeRecord { - session_key: &map_key, + session_key: ¤t_key, stamp: &stamp, sidecar_session_id: &session.sidecar_session_id, session_type: &session.session_type, From 8a7f4454a5c38f9150140907ada19201c70083b6 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:59:30 -0700 Subject: [PATCH 080/100] test(e2e): use native compact history and create attention workspace --- .../specs/fresh-agent-control-rust.spec.ts | 32 ++++++++++++------- .../fresh-agent-error-turn-attention.spec.ts | 1 + 2 files changed, 21 insertions(+), 12 deletions(-) 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 dde2375d2..062723ccf 100644 --- a/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-control-rust.spec.ts @@ -901,15 +901,14 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { // The durable transcript accumulates compaction + follow-up entries. const transcriptDir = path.join(lane.info.homeDir, '.claude', 'projects') const transcriptFile = path.join(transcriptDir, projectSlugOf(lane.projectDir), `${durableBefore}.jsonl`) - const transcriptLines = readJsonl(transcriptFile) - const userTexts = transcriptLines - .filter((l) => l.type === 'user') - .map((line) => { + // Native transcript writes may finish just after the result/idle frame. + // Retain the earlier conversation and follow-up, and correlate the saved + // local-command metadata with the actual command submitted to this CLI. + await expect(async () => { + const transcriptLines = readJsonl(transcriptFile) + const userRows = transcriptLines.filter((line) => line.type === 'user') + const textOf = (line: any): string => { const content = line.message?.content - // Ordinary prompts use the simulator's string content shape while - // real CLI message blocks (including tool results) use text blocks. - // The local /compact command is written to native history as a - // string, so inspect both supported Claude transcript forms. if (typeof content === 'string') return content if (Array.isArray(content)) { return content @@ -918,10 +917,19 @@ test.describe('fresh-agent control surfaces — claude lane (rust)', () => { .join('\n') } return '' - }) - expect(userTexts).toEqual( - expect.arrayContaining(['warm-up turn', '/compact focus the diff', 'follow-up after compact']), - ) + } + expect(userRows.map(textOf)).toEqual( + expect.arrayContaining(['warm-up turn', 'follow-up after compact']), + ) + const savedCommands = userRows.filter((line) => line.uuid === compactSend.uuid) + expect(savedCommands, 'exactly one saved row for the submitted compact command').toHaveLength(1) + expect(textOf(savedCommands[0])).toContain('/compact') + expect(textOf(savedCommands[0])).toContain('focus the diff') + expect(transcriptLines).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'system', subtype: 'compact_boundary' }), + expect.objectContaining({ type: 'user', isCompactSummary: true }), + ])) + }).toPass({ timeout: 15_000 }) } finally { await lane.server.stop().catch(() => {}) await fs.rm(lane.sharedRoot, { recursive: true, force: true }).catch(() => {}) diff --git a/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts b/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts index d83aa5d61..96b49d7a1 100644 --- a/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-error-turn-attention.spec.ts @@ -208,6 +208,7 @@ test.describe('fresh-agent error-turn attention (unified needs-attention signal) let server: RustServer | undefined try { const projectDir = path.join(sharedRoot, 'project') + await fs.mkdir(projectDir, { recursive: true }) server = new RustServer({ env: { ...claudeSimulatorServerEnv({ ledgerPath: simLedger, program: SIMULATOR_PROGRAM }), From 34aefc832017171b2009e955867d51a5661b5766 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:02:07 -0700 Subject: [PATCH 081/100] test(freshclaude): replace obsolete adoption fixtures with native runtime behavior --- crates/freshell-freshagent/src/claude.rs | 2894 +++++------------ crates/freshell-freshagent/src/claude/fork.rs | 2 +- .../src/claude/teardown_tests.rs | 6 +- .../src/claude/tests/recovery_tests.rs | 27 +- .../src/session_handoff/tests.rs | 56 +- crates/freshell-freshagent/src/snapshot.rs | 12 +- 6 files changed, 769 insertions(+), 2228 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 121f36152..e92c4387c 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -672,6 +672,18 @@ impl FreshClaudeState { self.resolve_session_key(session_id).await } + #[cfg(test)] + pub(crate) async fn test_rekey_conversation(&self, old: &str, new: &str) { + let (pending, session_type) = { + let sessions = self.sessions.lock().await; + let runtime = sessions.get(old).expect("the native runtime exists"); + (runtime.pending.clone(), runtime.session_type.clone()) + }; + self.rekey_conversation(old, new, &session_type, &pending, "test/native-rekey") + .await + .expect("the native conversation rekeys"); + } + /// b8ke e3r3 F8 test seam installer (the clean-failure pre-restore park). #[cfg(test)] pub fn set_kill_close_failure_test_pause( @@ -3540,7 +3552,7 @@ impl FreshClaudeState { String, Arc>, crate::claude_turns::SharedLedger, - Option, + String, deadman::SharedQuiet, )> { let key = self.resolve_session_key(wire_id).await?; @@ -3550,7 +3562,7 @@ impl FreshClaudeState { key, session.turn_lock.clone(), session.ledger.clone(), - Some(session.identity.current()), + session.identity.current(), session.quiet.state.clone(), )) } @@ -3569,16 +3581,15 @@ impl FreshClaudeState { op: TurnLockedRequest, map_key: &str, wire_id: &str, - durable: Option<&str>, + durable: &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 terminus = 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 { @@ -3676,7 +3687,7 @@ impl FreshClaudeState { TurnLockedRequest::Send, &map_key, &session_id, - durable_before_wait.as_deref(), + &durable_before_wait, &turn_lock, ) .await @@ -4013,7 +4024,7 @@ impl FreshClaudeState { TurnLockedRequest::Configure, &map_key, &session_id, - durable_before_wait.as_deref(), + &durable_before_wait, &turn_lock, ) .await @@ -4339,7 +4350,7 @@ impl FreshClaudeState { TurnLockedRequest::Compact, &map_key, &session_id, - durable_before_wait.as_deref(), + &durable_before_wait, &turn_lock, ) .await @@ -6640,8 +6651,7 @@ pub(crate) mod tests { ownership_id: format!("test-{session_id}"), consumer, sidecar_session_id: session_id.to_string(), - cli_session_id: None, - owning_operation: None, + owning_generation: None, identity: RuntimeIdentity::new(session_id, Vec::new()), pending: Arc::new(std::sync::Mutex::new(ClaudePending::default())), @@ -6750,28 +6760,18 @@ pub(crate) mod tests { let _ = grandchild.wait().await; } - /// Task 3: insert a fake session AND stage its pending set by folding raw `sdk.*` - /// lines through the PRODUCTION fold ([`fold_pending_frame`]) — the snapshot.rs - /// route tests address the overlay this way. `durable` (when `Some`) registers the - /// `cli_index` alias so the route's durable-id resolution (`resolve_session_key`) - /// finds the session, exactly as a live post-init session is reachable. + /// Stage native live pending controls through the production fold. pub(crate) async fn insert_fake_claude_session_with_pending( st: &FreshClaudeState, - map_key: &str, + native_id: &str, durable: Option<&str>, frames: &[Value], ) { - insert_fake_claude_session(st, map_key).await; if let Some(durable) = durable { - st.cli_index - .lock() - .await - .insert(durable.to_string(), map_key.to_string()); + assert_eq!(native_id, durable); } - let pending = { - let sessions = st.sessions.lock().await; - Arc::clone(&sessions[map_key].pending) - }; + insert_fake_claude_session(st, native_id).await; + let pending = st.sessions.lock().await[native_id].pending.clone(); for frame in frames { fold_pending_frame(&pending, frame); } @@ -7015,7 +7015,7 @@ pub(crate) mod tests { // A legacy pane now observes the canonical runtime. assert!(st.sessions.lock().await.contains_key(durable)); assert_eq!( - st.cli_index.lock().await.get(durable), + st.resolve_session_key(durable).await.as_ref(), Some(&durable.to_string()) ); // The fake received the resume boot (spawn log now records the create request). @@ -7120,155 +7120,112 @@ pub(crate) mod tests { ); } - /// The rebind arm's ack must announce the session's REAL tracked status (the - /// stdout consumer's status fold), not a hardcoded `"idle"`: attaching to a - /// live session mid-compaction must keep the pane truthfully busy instead of - /// flipping it to idle, and a completion that landed in the reconnect dead - /// window must be told truthfully by the ack that rescues the pane. The - /// arrangement drives a REAL wire value (`compacting` — one of the two - /// statuses the production sidecar actually announces, index.mjs:151-153) - /// through the REAL consumer fold (the fake sidecar's `__set_status__` - /// hook), and the fold is observed on the WIRE (the `freshAgent.status` - /// broadcast) before the attach, so the assertion races nothing. + /// An attach answers with the current native id and the runtime's actual compacting status. #[tokio::test] async fn attach_rebind_ack_stamps_the_tracked_live_status_not_hardcoded_idle() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - - // A tracked live claude session (fake sidecar standing in for the Node one). - st.handle_create(dedup_create_msg("req-rebind-status"), None) - .await; - let created = await_claude_created(&mut rx, "req-rebind-status").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - - // Alias a durable id at it (live: the consumer's sdk.session.init fold writes - // this row; here the test writes it directly). - let durable = "9c9c9c9c-9c9c-49c9-8c9c-9c9c9c9c9c9c"; - st.cli_index - .lock() - .await - .insert(durable.to_string(), session_id.clone()); + let _env = FakeClaudeSidecarEnv::install(); - // Drive sdk.status { status: "compacting" } through the REAL stdout consumer - // fold, and wait until its broadcast is observable — the fold (which the ack - // reads) lands before that broadcast, so the attach below sees "compacting". - st.handle_send(send_msg(&session_id, "__set_status__:compacting")) - .await; + let (st, mut rx) = state_with_bus(); + st.handle_create( + dedup_create_msg("attach_rebind_ack_stamps_the_tracked_live_status_not_hardcoded_idle"), + None, + ) + .await; + let created = await_claude_created( + &mut rx, + "attach_rebind_ack_stamps_the_tracked_live_status_not_hardcoded_idle", + ) + .await; + let current = created["sessionId"].as_str().unwrap().to_string(); + let prior = uuid::Uuid::new_v4().to_string(); + inherit_native_fixture(&st, ¤t, &prior).await; + st.handle_send(send_msg(¤t, "/compact HANG")).await; await_status_on_wire(&mut rx, "compacting").await; - - // The reconnect rescue: attach addressing the durable id → the REBIND arm. - st.attach_for_test(attach_msg_with_resume("late-attacher", durable)) + st.attach_for_test(attach_msg_with_resume("late-attacher", &prior)) .await; - let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; - assert_eq!(frame["sessionId"], durable); + assert_eq!(frame["sessionId"], current); + assert_eq!(frame["event"]["timelineSessionId"], current); + assert_eq!(frame["event"]["status"], "compacting", "{frame}"); assert_eq!( - frame["event"]["status"], "compacting", - "the rebind ack must speak the session's tracked status, not a hardcoded idle: {frame}" + st.resolve_session_key(&prior).await.as_deref(), + Some(current.as_str()) ); - assert_eq!(frame["event"]["timelineSessionId"], durable); - drop(env); + st.shutdown().await; } - /// A mid-turn `compacting` must NOT wedge the tracked status for the rest of - /// the session's life. The turn's `sdk.result` settles it (C17: the result - /// retires the turn's command and ends the compaction), so once the - /// compacted turn completes, the close-and-reopen - /// rescue ack announces `"idle"` — never a sticky `compacting` that leaves - /// the revived pane blue with user input queueing forever (the client's - /// flush gate runs only when `!isBusy`). Both transitions are driven through - /// the REAL consumer fold (fake-sidecar hooks) in the REAL value space, and - /// each fold is observed on the WIRE before the attach, so the assertion - /// races nothing. + /// An attach answers with the current native id and the runtime's actual idle status. #[tokio::test] async fn attach_rebind_ack_settles_to_idle_once_the_compacted_turn_completes() { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + env.program(json!([{"kind":"text", "data":{"text":"warm history"}}])); let (st, mut rx) = state_with_bus(); - - // A tracked live claude session (fake sidecar standing in for the Node one). - st.handle_create(dedup_create_msg("req-settle-status"), None) - .await; - let created = await_claude_created(&mut rx, "req-settle-status").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - - // Alias a durable id at it (as above: live the init fold writes this row). - let durable = "8b8b8b8b-8b8b-48b8-8b8b-8b8b8b8b8b8b"; - st.cli_index - .lock() - .await - .insert(durable.to_string(), session_id.clone()); - - // The wedge repro: a turn compacts mid-flight → tracked status "compacting". - st.handle_send(send_msg(&session_id, "__set_status__:compacting")) - .await; + st.handle_create( + dedup_create_msg("attach_rebind_ack_settles_to_idle_once_the_compacted_turn_completes"), + None, + ) + .await; + let created = await_claude_created( + &mut rx, + "attach_rebind_ack_settles_to_idle_once_the_compacted_turn_completes", + ) + .await; + let current = created["sessionId"].as_str().unwrap().to_string(); + let prior = uuid::Uuid::new_v4().to_string(); + inherit_native_fixture(&st, ¤t, &prior).await; + st.handle_send(send_msg(¤t, "warm history")).await; + await_frame_of_inner_type(&mut rx, "freshAgent.result").await; + st.handle_send(send_msg(¤t, "/compact")).await; await_status_on_wire(&mut rx, "compacting").await; - - // The compacted turn completes: its sdk.result settles it. Observing - // its broadcast proves the settle fold has landed. inject_raw_send: the - // same turn's result, not a second send (which would still be owed). - inject_raw_send(&st, &session_id, "__emit_result__").await; - let _ = await_frame_of_inner_type(&mut rx, "freshAgent.result").await; - - // The reconnect rescue AFTER the turn settled: the ack must speak "idle", - // never the pre-completion "compacting". - st.attach_for_test(attach_msg_with_resume("late-attacher-settled", durable)) + await_frame_of_inner_type(&mut rx, "freshAgent.result").await; + st.attach_for_test(attach_msg_with_resume("late-attacher", &prior)) .await; - let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; - assert_eq!(frame["sessionId"], durable); + assert_eq!(frame["sessionId"], current); + assert_eq!(frame["event"]["timelineSessionId"], current); + assert_eq!(frame["event"]["status"], "idle", "{frame}"); assert_eq!( - frame["event"]["status"], "idle", - "a completed turn must settle the tracked status (no sticky compacting): {frame}" + st.resolve_session_key(&prior).await.as_deref(), + Some(current.as_str()) ); - assert_eq!(frame["event"]["timelineSessionId"], durable); - drop(env); + st.shutdown().await; } - /// Attaching while a turn is genuinely in flight must ack `"running"` — the - /// reference bridge's per-turn lifecycle edge (sdk-bridge.ts:426 — the real - /// sidecar announces NO `sdk.status: running`, busy is derived from stream - /// deltas, so the turn ledger speaks "running" from turn evidence). - /// Drives a REAL `sdk.assistant` frame through the fold and observes its - /// broadcast before the attach, so the assertion races nothing. + /// An attach answers with the current native id and the runtime's actual running status. #[tokio::test] async fn attach_rebind_ack_speaks_running_while_a_turn_is_in_flight() { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + env.program(json!([{"kind":"text", "data":{"text":"still running"}}, {"kind":"hang"}])); let (st, mut rx) = state_with_bus(); - - // A tracked live claude session (fake sidecar standing in for the Node one). - st.handle_create(dedup_create_msg("req-running-status"), None) - .await; - let created = await_claude_created(&mut rx, "req-running-status").await; - let session_id = created["sessionId"].as_str().unwrap().to_string(); - - // Alias a durable id at it (as above: live the init fold writes this row). - let durable = "7a7a7a7a-7a7a-47a7-8a7a-7a7a7a7a7a7a"; - st.cli_index - .lock() - .await - .insert(durable.to_string(), session_id.clone()); - - // A turn starts: sdk.assistant is the running edge. Observing its - // broadcast proves the running fold has landed. - st.handle_send(send_msg(&session_id, "__emit_assistant__")) - .await; - let _ = await_frame_of_inner_type(&mut rx, "freshAgent.assistant").await; - - // The reconnect rescue mid-turn: the ack must speak "running". - st.attach_for_test(attach_msg_with_resume("late-attacher-midturn", durable)) + st.handle_create( + dedup_create_msg("attach_rebind_ack_speaks_running_while_a_turn_is_in_flight"), + None, + ) + .await; + let created = await_claude_created( + &mut rx, + "attach_rebind_ack_speaks_running_while_a_turn_is_in_flight", + ) + .await; + let current = created["sessionId"].as_str().unwrap().to_string(); + let prior = uuid::Uuid::new_v4().to_string(); + inherit_native_fixture(&st, ¤t, &prior).await; + st.handle_send(send_msg(¤t, "show running")).await; + await_frame_of_inner_type(&mut rx, "freshAgent.assistant").await; + st.attach_for_test(attach_msg_with_resume("late-attacher", &prior)) .await; - let frame = await_frame_of_inner_type(&mut rx, "freshAgent.session.snapshot").await; - assert_eq!(frame["sessionId"], durable); + assert_eq!(frame["sessionId"], current); + assert_eq!(frame["event"]["timelineSessionId"], current); + assert_eq!(frame["event"]["status"], "running", "{frame}"); assert_eq!( - frame["event"]["status"], "running", - "the rebind ack must speak \"running\" while a turn is in flight: {frame}" + st.resolve_session_key(&prior).await.as_deref(), + Some(current.as_str()) ); - assert_eq!(frame["event"]["timelineSessionId"], durable); - drop(env); + st.shutdown().await; } /// Pins the DEFAULT: a freshly resumed session has folded no non-idle status, so @@ -7975,86 +7932,42 @@ pub(crate) mod tests { ); } - /// F4 (PR-A review): before the CLI reports a new pane's session the - /// coordinator holds no record for it, and an unfenced (checkpointed) - /// send runs: refusing it would lose every new pane's first message - /// (827d9395d). A send that DOES carry an observed owner pair is still - /// checked: the pair names an owner the coordinator does not hold, so it - /// is stale and refused typed, never run unguarded. - #[tokio::test(flavor = "multi_thread")] - async fn a_fenced_send_before_the_session_report_is_checked_not_skipped() { + /// Map presence never authorizes an unowned runtime to accept a send. + #[tokio::test] + async fn an_unowned_runtime_refuses_sends_with_or_without_an_observed_fence() { let (mut st, mut rx) = state_with_bus(); - let session_id = "claude-send-before-report"; let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); st.set_ownership(registry.clone()); - insert_fake_claude_session(&st, session_id).await; - let vacant = registry.observe(PROVIDER, session_id); - assert!( - matches!(vacant.state, freshell_ownership::OwnershipState::Vacant), - "fixture: nothing owns an unreported session" - ); - let checkpointed = |request_id: &str| { - let mut send = send_msg(session_id, "first message"); - send.request_id = Some(request_id.to_string()); - send.checkpoint_intent = Some(freshell_protocol::FreshAgentCheckpointIntent { - logical_attempt_id: format!("{request_id}-attempt"), - workspace: None, - }); - send - }; - let drain = |rx: &mut tokio::sync::broadcast::Receiver| { - let mut frames: Vec = Vec::new(); - while let Ok(raw) = rx.try_recv() { - if let Ok(frame) = serde_json::from_str(&raw) { - frames.push(frame); - } + let id = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&st, &id).await; + let vacant = registry.observe(PROVIDER, &id); + for fenced in [true, false] { + let mut send = checkpointed_test_send(&id); + let request_id = format!("unowned-send-{fenced}"); + send.request_id = Some(request_id.clone()); + if fenced { + send.observed_epoch = Some(vacant.epoch); + send.observed_generation = Some(vacant.generation); } - frames - }; - - let mut fenced = checkpointed("fenced-before-report"); - fenced.observed_epoch = Some(vacant.epoch); - fenced.observed_generation = Some(vacant.generation); - st.handle_send(fenced).await; - let frames = drain(&mut rx); - assert!( - frames.iter().any(|frame| { - frame["requestId"] == json!("fenced-before-report") - && frame.to_string().contains("SESSION_RESERVED") - }), - "the supplied fence is honored and refuses typed: {frames:?}" - ); - assert!( - !st.sessions.lock().await[session_id] - .ledger - .lock() - .expect("turn ledger lock") - .busy(), - "the refused send never reaches the sidecar" - ); - - st.handle_send(checkpointed("unfenced-before-report")).await; - let frames = drain(&mut rx); - assert!( - !frames - .iter() - .any(|frame| frame.to_string().contains("SESSION_RESERVED")), - "an unfenced first send of a new pane runs: {frames:?}" - ); - assert!( - st.sessions.lock().await[session_id] - .ledger - .lock() - .expect("turn ledger lock") - .busy(), - "the unfenced first send reaches the sidecar" - ); + assert!(matches!( + st.handle_send(send).await, + ClaudeSendOutcome::Refused { .. } + )); + let frames = drain(&mut rx).await; + assert!( + frames.iter().any(|frame| frame["requestId"] == request_id + && frame["message"] + .as_str() + .is_some_and(|msg| msg.contains("SESSION_RESERVED"))), + "{frames:?}" + ); + assert!(!st.sessions.lock().await[&id].ledger.lock().unwrap().busy()); + } + st.shutdown().await; } - /// The checkpointed send a new pane makes before the CLI reported its - /// session: the browser's send shape (a checkpoint intent, no owner pair), - /// addressed by the lane's placeholder id. - fn checkpointed_send_before_session_report(session_id: &str) -> FreshAgentSend { + /// A browser checkpointed send without an observed owner pair. + fn checkpointed_test_send(session_id: &str) -> FreshAgentSend { let mut send = send_msg(session_id, "first prompt of a new pane"); send.request_id = Some(format!("send-{session_id}")); send.checkpoint_intent = Some(freshell_protocol::FreshAgentCheckpointIntent { @@ -8064,59 +7977,14 @@ pub(crate) mod tests { send } - /// The events named `freshagent.claude.send_before_session_report` that - /// the capture recorded. - fn send_before_session_report_events( - events: &std::sync::Mutex)>>, - ) -> usize { - events - .lock() - .expect("capture lock") - .iter() - .filter(|(_message, fields)| { - fields.get("event").is_some_and(|event| { - event.contains("freshagent.claude.send_before_session_report") - }) - }) - .count() - } - - /// With an ownership coordinator wired, a checkpointed send to a session the - /// CLI has not reported yet (and the coordinator holds no record for) runs - /// unfenced, and says so in `freshagent.claude.send_before_session_report`. - #[tokio::test] - async fn a_send_before_the_session_report_logs_that_it_ran_unfenced() { - let (events, _capture_guard) = info_capture_for_test::capture(); - let (mut st, mut rx) = state_with_bus(); - st.set_ownership(Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new())); - let session_id = "claude-placeholder-wired"; - insert_fake_claude_session(&st, session_id).await; - - st.handle_send(checkpointed_send_before_session_report(session_id)) - .await; - - let frames = drain(&mut rx).await; - assert!( - frames - .iter() - .any(|frame| frame["type"] == "freshAgent.send.accepted"), - "the send runs: {frames:?}" - ); - assert_eq!(send_before_session_report_events(&events), 1); - } - - /// Without an ownership coordinator (the session host's lane) there is no - /// fence to skip: the send runs as before and the before-report event, - /// which says the coordinator holds no record, is not logged. + /// The session host accepts its tracked runtime without a local coordinator. #[tokio::test] - async fn a_send_on_a_lane_without_a_coordinator_does_not_log_a_skipped_fence() { - let (events, _capture_guard) = info_capture_for_test::capture(); + async fn a_send_on_a_lane_without_a_coordinator_runs() { let (st, mut rx) = state_with_bus(); let session_id = "claude-placeholder-unwired"; insert_fake_claude_session(&st, session_id).await; - st.handle_send(checkpointed_send_before_session_report(session_id)) - .await; + st.handle_send(checkpointed_test_send(session_id)).await; let frames = drain(&mut rx).await; assert!( @@ -8125,7 +7993,6 @@ pub(crate) mod tests { .any(|frame| frame["type"] == "freshAgent.send.accepted"), "the send runs: {frames:?}" ); - assert_eq!(send_before_session_report_events(&events), 0); } /// kata z7j7 (claude scope honesty): a settings-bearing send whose payload @@ -8611,41 +8478,14 @@ pub(crate) mod tests { assert_eq!(env.spawn_count(), 2); } - /// Model a session registered by the older host before it knew its - /// native UUID. Only compatibility tests call this; current creates are - /// already durably owned and never take this path. - async fn prepare_legacy_init(st: &FreshClaudeState, map_key: &str) { - let durable = { - let mut sessions = st.sessions.lock().await; - let session = sessions.get_mut(map_key).expect("legacy fixture runtime"); - session.owning_operation = None; - session.owning_generation = None; - session.cli_session_id.take() - }; - if let Some(durable) = durable { - st.cli_index.lock().await.remove(&durable); - crate::ownership_lane::release_retained_stamp( - &st.ownership, - &st.ownership_stamps, - PROVIDER, - &durable, - "test/legacy-init-fixture", - ); - } - } - - async fn adopt_legacy_fixture(st: &FreshClaudeState, map_key: &str, durable: &str) { - prepare_legacy_init(st, map_key).await; - st.adopt_session_init( - durable, - map_key, - "freshclaude", - None, - st.identity_sink(), - None, - ) - .await; - await_alias(st, durable, map_key).await; + async fn inherit_native_fixture(st: &FreshClaudeState, current: &str, prior: &str) { + st.sessions.lock().await[current] + .identity + .inherit([prior.to_string()]); + assert_eq!( + st.resolve_session_key(prior).await.as_deref(), + Some(current) + ); } /// Drain `rx` until the `freshAgent.created` (or `.create.failed`) frame for @@ -9994,10 +9834,9 @@ pub(crate) mod tests { let placeholder = created["sessionId"].as_str().unwrap().to_string(); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); while st - .cli_index - .lock() + .resolve_session_key(fresh_create_durable_id) .await - .get(fresh_create_durable_id) + .as_ref() .is_none() { assert!( @@ -10159,11 +9998,6 @@ pub(crate) mod tests { let old = uuid::Uuid::new_v4().to_string(); let new = uuid::Uuid::new_v4().to_string(); insert_fake_claude_session(&st, &old).await; - { - let mut sessions = st.sessions.lock().await; - sessions.get_mut(&old).unwrap().cli_session_id = Some(old.clone()); - } - st.cli_index.lock().await.insert(old.clone(), old.clone()); let pause = std::sync::Arc::new(AdoptionTestPause { notify: tokio::sync::Notify::new(), @@ -10235,16 +10069,9 @@ pub(crate) mod tests { // Alias the durable id at it (live: the consumer's sdk.session.init fold // writes this mapping; here the test writes it directly, same trick as // attach_rebind_ack_stamps_the_tracked_live_status_not_hardcoded_idle). - st.cli_index - .lock() - .await - .insert(durable.to_string(), placeholder.clone()); - st.sessions - .lock() - .await - .get_mut(&placeholder) - .expect("session tracked under the placeholder") - .cli_session_id = Some(durable.to_string()); + st.sessions.lock().await[&placeholder] + .identity + .inherit([durable.to_string()]); // Kill addressed by the DURABLE id must retire the durable-keyed row // (resolve_session_key walks cli_index to the live map key). @@ -10269,16 +10096,9 @@ pub(crate) mod tests { .await; let created2 = await_claude_created(&mut rx, "req-retire-claude-2").await; let placeholder2 = created2["sessionId"].as_str().unwrap().to_string(); - st.cli_index - .lock() - .await - .insert(durable.to_string(), placeholder2.clone()); - st.sessions - .lock() - .await - .get_mut(&placeholder2) - .expect("session tracked under the placeholder") - .cli_session_id = Some(durable.to_string()); + st.sessions.lock().await[&placeholder2] + .identity + .inherit([durable.to_string()]); st.handle_kill(FreshAgentKill { observed_epoch: None, observed_generation: None, @@ -10520,14 +10340,13 @@ pub(crate) mod tests { let created = await_claude_created(&mut rx, "req-close-before-teardown").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - adopt_legacy_fixture(&st, &placeholder, durable).await; + inherit_native_fixture(&st, &placeholder, durable).await; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let present = st - .cli_index - .lock() + .resolve_session_key(durable) .await - .get(durable) + .as_ref() .is_some_and(|mapped| mapped == &placeholder); if present { break; @@ -10606,14 +10425,13 @@ pub(crate) mod tests { let created = await_claude_created(&mut rx, "req-alias-close-fails").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - adopt_legacy_fixture(&st, &placeholder, durable).await; + inherit_native_fixture(&st, &placeholder, durable).await; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let present = st - .cli_index - .lock() + .resolve_session_key(durable) .await - .get(durable) + .as_ref() .is_some_and(|mapped| mapped == &placeholder); if present { break; @@ -10692,14 +10510,13 @@ pub(crate) mod tests { let created = await_claude_created(&mut rx, "req-one-envelope").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - adopt_legacy_fixture(&st, &placeholder, durable).await; + inherit_native_fixture(&st, &placeholder, durable).await; let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let present = st - .cli_index - .lock() + .resolve_session_key(durable) .await - .get(durable) + .as_ref() .is_some_and(|mapped| mapped == &placeholder); if present { break; @@ -10740,105 +10557,60 @@ pub(crate) mod tests { assert!(pendings.is_empty(), "claude kills carry no marker ids"); } - /// Focused-episode-6 round 4 (Finding F5) — the mint gate: with the - /// kill's close-pending enumeration armed, an adoption ABANDONS: no - /// cli_index insert, no alias-tombstone write, no binding-row write — - /// nothing the kill's ONE envelope could miss. This retires the - /// delta-r6-r2 completion-diff design (a SECOND close for an id - /// discovered only after the consumer abort, whose own clean failure - /// could never roll the teardown back): discovery completes before the - /// envelope now, by construction. - #[tokio::test(flavor = "multi_thread")] - async fn a_mint_adoption_abandons_once_the_kills_close_gate_is_set() { + /// A close in progress refuses a native reset before its identity or binding moves. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_native_rekey_is_refused_once_the_kills_close_gate_is_set() { let _guard = CLAUDE_ENV_LOCK.lock().await; let _env = FakeClaudeSidecarEnv::install(); let (st, mut rx) = state_with_bus(); - let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); st.set_identity_sink(fake.clone()); - - st.handle_create(dedup_create_msg("req-gate-mint"), None) + st.handle_create(dedup_create_msg("close-gates-native-rekey"), None) .await; - let created = await_claude_created(&mut rx, "req-gate-mint").await; - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - - // Park the kill INSIDE its one durable close (mutations recorded; - // the answer stalled): the enumeration + mint gate are provably in - // force — the placeholder rides the envelope. - let stall = fake.arm_retire_stall("claude", &placeholder); - let st2 = st.clone(); - let ph = placeholder.clone(); - let mut kill = tokio::spawn(async move { - st2.handle_kill(FreshAgentKill { - observed_epoch: None, - observed_generation: None, - provider: freshell_protocol::AgentProvider::Claude, - session_id: ph, - session_type: SessionType::Freshclaude, - cwd: None, - }) + let created = await_claude_created(&mut rx, "close-gates-native-rekey").await; + let current = created["sessionId"].as_str().unwrap().to_string(); + let pending = st.sessions.lock().await[¤t].pending.clone(); + let stall = fake.arm_retire_stall(PROVIDER, ¤t); + let kill = { + let st = st.clone(); + let id = current.clone(); + tokio::spawn(async move { st.handle_kill(kill_msg(&id)).await }) + }; + stall.entered.recv_timeout(Duration::from_secs(15)).unwrap(); + let next = uuid::Uuid::new_v4().to_string(); + let refusal = st + .rekey_conversation( + ¤t, + &next, + "freshclaude", + &pending, + "test/close-in-flight", + ) .await; - }); - stall - .entered - .recv_timeout(std::time::Duration::from_secs(15)) - .expect("the durable close is in flight (the gate is armed)"); - - // The mint that could slip the enumeration→teardown window in the - // completion-diff shape: discard it entirely. - let gated = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; - st.adopt_session_init( - gated, - &placeholder, - "freshclaude", - Some(&crate::identity_sink::FreshAgentSettings { - model: Some("gate-model".into()), - ..crate::identity_sink::FreshAgentSettings::default() - }), - Some(fake.clone()), - None, - ) - .await; - assert!( - st.cli_index.lock().await.get(gated).is_none(), - "the gated mint inserts nothing: {:?}", - st.cli_index.lock().await + assert!(refusal.unwrap_err().contains("closing")); + assert_eq!( + st.resolve_session_key(¤t).await.as_deref(), + Some(current.as_str()) ); - - stall.release.send(()).expect("release the stall"); - tokio::time::timeout(std::time::Duration::from_secs(15), &mut kill) + assert!(st.resolve_session_key(&next).await.is_none()); + assert!(!fake + .bindings + .lock() + .unwrap() + .iter() + .any(|row| row.session_id == next)); + stall.release.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(15), kill) .await - .expect("the kill completes") - .expect("kill task completed"); - assert!( - !fake - .retires - .lock() - .unwrap() - .iter() - .any(|(_, id)| id == gated), - "nothing retires an id the envelope never saw: {:?}", - fake.retires.lock().unwrap() - ); - assert!( - !fake - .bindings - .lock() - .unwrap() - .iter() - .any(|b| b.session_id == gated), - "the gated adoption writes no binding row: {:?}", - fake.bindings.lock().unwrap() - ); - assert!( - !fake - .alias_record_writes - .lock() - .unwrap() - .iter() - .any(|(_, _, d)| d == gated), - "the gated adoption writes no alias record: {:?}", - fake.alias_record_writes.lock().unwrap() - ); + .unwrap() + .unwrap(); + assert!(st.sessions.lock().await.is_empty()); + assert!(!fake + .retires + .lock() + .unwrap() + .iter() + .any(|(_, id)| id == &next)); } // ── b8ke focused episode-2 round-1: the adoption's atomicity tests ───── @@ -10912,276 +10684,6 @@ pub(crate) mod tests { } } - /// b8ke focused episode-2 round-1 F2: the pre-registration window must - /// not expose a Live owner that cannot be killed. An adoption for a - /// session that is NOT yet registered defers the WHOLE adoption — no - /// claim, no commit, no alias — until the create registers the - /// runtime. Pre-fix: the consumer's init committed Live{FreshAgent} - /// and minted the alias immediately (a handoff in the window saw a - /// killable-owner lie: kill_for_handoff answered AlreadyGone and the - /// create then inserted its still-live sidecar — two writers). - #[tokio::test(flavor = "multi_thread")] - async fn the_init_adoption_defers_until_the_runtime_is_registered() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let _env = FakeClaudeSidecarEnv::install(); - let (mut st, _rx) = state_with_bus(); - let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); - st.set_ownership(registry.clone()); - - // An adoption for a session that was never registered (the - // create's insert window): nothing commits, nothing publishes. - st.adopt_session_init( - "dur-e2r2-f2", - "placeholder-never-registered", - "freshclaude", - None, - None, - None, - ) - .await; - - // Give the immediate (pre-fix) commit ample time to land, then - // assert the deferral: NO Live owner, NO alias. - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - assert!( - !matches!( - registry.observe("claude", "dur-e2r2-f2").state, - freshell_ownership::OwnershipState::Live { .. } - ), - "the pre-registration window must not expose a Live owner, got {:?}", - registry.observe("claude", "dur-e2r2-f2").state - ); - assert!( - st.cli_index.lock().await.get("dur-e2r2-f2").is_none(), - "the deferred adoption publishes no alias" - ); - } - - /// b8ke focused episode-2 round-1 F3: a REFUSED adoption fails closed. - /// A foreign owner (a live terminal) holds the canonical key — the - /// freshly spawned runtime is TORN DOWN (removed + the standard - /// teardown + the lost-session recovery), the alias is never - /// published, and the foreign owner is never stolen. Pre-fix: the - /// refusal only logged — the sidecar kept running unowned beside the - /// foreign owner while the alias minted. - #[tokio::test(flavor = "multi_thread")] - async fn a_refused_init_adoption_tears_the_fresh_runtime_down() { - 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()); - - let fresh_create_durable_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - // The FOREIGN OWNER: a live terminal under the durable id the - // fresh create's sidecar is about to mint. - let foreign = freshell_ownership::OwnerIdentity { - kind: freshell_ownership::RuntimeOwnerKind::Terminal, - terminal_id: Some("t-foreign".into()), - live_session_key: None, - pid: None, - ownership_id: None, - }; - let freshell_ownership::BeginOutcome::Granted { generation } = registry.begin_start( - "claude", - fresh_create_durable_id, - freshell_ownership::RuntimeOwnerKind::Terminal, - "op-foreign-owner", - None, - "test", - 0, - ) else { - panic!("expected Granted") - }; - assert!(matches!( - registry.commit_live( - "claude", - fresh_create_durable_id, - "op-foreign-owner", - generation, - foreign - ), - freshell_ownership::CommitOutcome::Committed - )); - - // The ordinary fresh create: the consumer's init mints the durable - // id; the adoption claim is REFUSED (OwnedByOtherKind) → teardown. - st.handle_create(dedup_create_msg("req-e2r3-f3"), None) - .await; - let created = await_claude_created(&mut rx, "req-e2r3-f3").await; - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - prepare_legacy_init(&st, &placeholder).await; - st.adopt_session_init( - fresh_create_durable_id, - &placeholder, - "freshclaude", - None, - None, - None, - ) - .await; - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - let removed = !st.sessions.lock().await.contains_key(&placeholder); - if removed { - break; - } - assert!( - tokio::time::Instant::now() < deadline, - "the refused adoption must tear the fresh runtime down — the \ - session stays registered" - ); - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - - // Nothing published: no alias for the minted durable id. - assert!( - st.cli_index - .lock() - .await - .get(fresh_create_durable_id) - .is_none(), - "the refused adoption publishes nothing" - ); - // The foreign owner is NEVER stolen. - assert!(matches!( - registry.observe("claude", fresh_create_durable_id).state, - freshell_ownership::OwnershipState::Live { owner, .. } - if owner.kind == freshell_ownership::RuntimeOwnerKind::Terminal - )); - // The lost-session recovery frame reached the bus (bounded: the - // teardown's reap precedes the broadcast). - let mut saw_lost = false; - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - while !saw_lost && tokio::time::Instant::now() < deadline { - while let Ok(raw) = rx.try_recv() { - if raw.contains("freshAgent.error") && raw.contains(&placeholder) { - saw_lost = true; - } - } - if !saw_lost { - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - } - assert!(saw_lost, "the teardown engages the lost-session recovery"); - } - - /// 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; - 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()); - - // An ordinary fresh create: the runtime registers + the adoption - // commits Live{FreshAgent} under the minted durable id (the OLD id - // of this scenario's rollback). - st.handle_create(dedup_create_msg("req-e2r4-f4"), None) - .await; - let created = await_claude_created(&mut rx, "req-e2r4-f4").await; - let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - if matches!( - registry.observe("claude", fresh_create_durable_id).state, - freshell_ownership::OwnershipState::Live { owner, .. } - if owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent - ) { - break; - } - assert!( - tokio::time::Instant::now() < deadline, - "the ordinary create's adoption must commit under the old id" - ); - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - - // 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, - None, - None, - ) - .await; - - assert!( - !matches!( - registry.observe("claude", "new-fork-id-e2r4").state, - freshell_ownership::OwnershipState::Live { .. } - ), - "the fork ADOPTION must NOT claim under the new id — the ROLLBACK's \ - commit owns the one move (the delta-r2 dual-record shape): {:?}", - registry.observe("claude", "new-fork-id-e2r4").state - ); - // The re-key bookkeeping: the alias maps the fork id to the session. - assert_eq!( - st.cli_index.lock().await.get("new-fork-id-e2r4"), - Some(&placeholder), - "the fork re-key alias still lands" - ); - // b8ke focused episode-2 round-2 F1: after the rollback's COMMIT, the - // canonical ownership MOVES to the client-visible new id - // (`commit_lane_claim_rekey_at` — pinned by the registry's - // commit_live_rekey test), and the OLD id becomes a resolvable - // alias: the lane resolves it for every lifecycle operation, and - // the snapshot reports the CANONICAL record for it — never a false - // Vacant that licenses a second writer beside the live runtime. - // The registry-backed alias: the coordinator record (the e2r3 F3 - // single source of truth) — driven through the registry's own - // rekey_live (the same move the rollback's Adopt-path commit - // performs). - assert!(matches!( - registry.rekey_live( - "claude", - fresh_create_durable_id, - "new-fork-id-e2r4", - &placeholder, - freshell_ownership::OwnerIdentity { - kind: freshell_ownership::RuntimeOwnerKind::FreshAgent, - terminal_id: None, - live_session_key: Some(placeholder.clone()), - pid: Some(4321), - ownership_id: Some("rekey-op-fork-e2r4".into()), - }, - "test-rekey", - "rekey-op-fork-e2r4", - ), - freshell_ownership::CommitOutcome::Committed - )); - assert_eq!( - st.resolve_ownership_key(fresh_create_durable_id), - "new-fork-id-e2r4", - "the superseded old id resolves to the client-visible canonical id" - ); - assert_eq!( - st.resolve_ownership_key("new-fork-id-e2r4"), - "new-fork-id-e2r4", - "the canonical id resolves to itself" - ); - // The snapshot for the OLD id reports the CANONICAL record. - let snap = st.ownership_snapshot("claude", fresh_create_durable_id); - assert_eq!( - snap.state, - registry.observe("claude", "new-fork-id-e2r4").state, - "a snapshot on the superseded id reports the canonical record — \ - never a false Vacant over the live re-keyed runtime" - ); - } - /// b8ke ext r13 F3: the rollback's replace window holds REAL /// authority. The rollback is parked INSIDE its respawn (the fake /// sidecar's deferred create answer) while the Adopt-path attach guard @@ -11992,662 +11494,185 @@ pub(crate) mod tests { .expect("the retained stamp exists under the durable id") } - /// b8ke focused episode-2 post-cap F3: a STALE/FOREIGN ownership - /// commit tears the runtime down AND the adoption answers ABANDONED — - /// the pre-fix fall-through reported Published for a runtime that no - /// longer exists. The deterministic window: the pre-commit park holds - /// the adoption with its claim granted; the test fences the claimed - /// key (the watchdog's own recovery seizure); the released commit - /// fails, the teardown runs, and the adoption answers ABANDONED. - /// b8ke ext r27 F4 reshape: the durable binding now writes BEFORE the - /// commit (inside the coordinator operation, after the publication), - /// so it HAS legitimately landed by the park point — the stale - /// commit's abandonment no longer implies "nothing durable - /// persists"; the row is the same Bound-after-death shape every - /// crashed session's row has (the ledger's load-bearing normal), and - /// the claim settles typed through the teardown (the key never goes - /// Live). - #[tokio::test(flavor = "multi_thread")] - async fn a_stale_commit_tears_down_and_abandons_after_the_in_operation_binding() { + /// b8ke focused episode-2 round-1 F7: the adoption's claim records the + /// PROVENANCE-derived initiator (the device id) — never the fixed lane + /// string. Captured off the coordinator's `ownership.start.begin` event + /// for the adoption's own claim (operation_id `claude-init-adopt-*`). + #[tokio::test] + async fn the_init_adoption_claim_records_the_provenance_initiator() { 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()); - let sink = Arc::new(crate::identity_sink::FakeIdentitySink::default()); - st.set_identity_sink(sink.clone()); - // A live session (the create) whose adoption completes normally. - st.handle_create(dedup_create_msg("req-postcap-f3"), None) + let (sink, _capture_guard) = init_capture::install(); + let provenance = Some(crate::identity_sink::BindProvenance { + client_instance_id: Some("client-e2r7".into()), + device_id: Some("dev-e2r7".into()), + tab_key: None, + asserted_at: 1, + }); + st.handle_create(dedup_create_msg("req-e2r7-f7"), provenance) .await; - let created = await_claude_created(&mut rx, "req-postcap-f3").await; - let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); - let _placeholder = created["sessionId"].as_str().unwrap().to_string(); - // The first create's adoption must COMMIT before the pre-commit - // park arms — otherwise it parks there too, and both the `reached` - // flag and the stale-start recovery below see the WRONG claim. - await_create_ownership_committed(&st, fresh_create_durable_id).await; - // The pre-commit park: a SECOND session (fresh map key — the - // ordinary-create shape, no prior durable id) whose adoption - // defers, then claims the fresh durable key, publishes, and parks - // before the commit. - let stale_dur = "abcdefab-abcd-4abc-8abc-abcdefabcde"; - let stale_map_key = "stale-map-key-postcap-f3"; - let pause = std::sync::Arc::new(AdoptionTestPause { - notify: tokio::sync::Notify::new(), - reached: std::sync::atomic::AtomicBool::new(false), - }); - st.set_adoption_commit_test_pause(Some(std::sync::Arc::clone(&pause))); - let (tx, mut rx_done) = tokio::sync::oneshot::channel::>(); - let st2 = st.clone(); - let sink2: Option = Some(sink.clone()); - // Non-default settings: the binding is RECORDABLE, so the pre-fix - // fall-through would have PERSISTED it for the torn-down runtime — - // the `was_recorded` assertion is not vacuous. - let settings = crate::identity_sink::FreshAgentSettings { - model: Some("opus".into()), - ..Default::default() - }; - tokio::spawn(async move { - let _ = st2 - .adopt_session_init_with_completion( - stale_dur, - stale_map_key, - "freshclaude", - Some(&settings), - sink2, - None, - Some(tx), - ) - .await; - }); - // The registration lands (the deferred adoption proceeds to the - // claim + publication and parks before the commit). - insert_fake_claude_session(&st, stale_map_key).await; + let created = await_claude_created(&mut rx, "req-e2r7-f7").await; + let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); + // The deferred adoption claims under the minted durable id once the + // create registers the runtime. let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - while !pause.reached.load(std::sync::atomic::Ordering::SeqCst) { + loop { + if matches!( + registry.observe("claude", fresh_create_durable_id).state, + freshell_ownership::OwnershipState::Live { owner, .. } + if owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent + ) { + break; + } assert!( tokio::time::Instant::now() < deadline, - "the adoption never reached the pre-commit park" + "the provenance-carrying create's adoption must commit" ); - tokio::time::sleep(std::time::Duration::from_millis(10)).await; + tokio::time::sleep(std::time::Duration::from_millis(25)).await; } - st.set_adoption_commit_test_pause(None); - - // THE STALE SEIZURE: the watchdog's own recovery path fences the - // claimed key while the adoption is parked. - let recs = registry.recover_stale_starts(0, 0); - assert_eq!(recs.len(), 1); - let rec = recs.into_iter().next().unwrap(); - assert_eq!(rec.session_id, stale_dur); - assert!(matches!( - registry.fence_unconfirmed_stop( - "claude", - stale_dur, - &rec.operation_id, - rec.generation, - freshell_ownership::FenceReason::StaleStart, - ), - freshell_ownership::FenceOutcome::Fenced - )); - - // Release: the commit is stale (the key is Fenced) → teardown + - // ABANDONED, and NOTHING persists for the dead runtime. - pause.notify.notify_one(); - let answer = tokio::time::timeout(std::time::Duration::from_secs(15), &mut rx_done) - .await - .expect("the adoption settles") - .expect("the completion channel survived"); + let events = sink.lock().expect("capture lock").clone(); + let claim = events + .iter() + .find(|(event, _initiator, op)| { + event == "ownership.start.begin" && op == "create-resume-req-e2r7-f7" + }) + .expect("the adoption's claim event is captured"); assert_eq!( - answer, None, - "the stale commit answers ABANDONED — never Published for a torn-down runtime" - ); - // r27 F4: the binding wrote INSIDE the operation, BEFORE the commit - // — it legitimately landed for the (now torn-down) runtime, exactly - // like a crashed session's Bound row; the stale commit's - // abandonment is proven by the ABANDONED answer and the claim's - // typed settle, not by row absence. - assert!( - sink.was_recorded("claude", stale_dur), - "the in-operation binding landed before the stale commit was known" + claim.1, "dev-e2r7", + "the adoption claim records the provenance device id, got {:?}", + events ); } - /// 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. - #[tokio::test(flavor = "multi_thread")] - async fn a_failed_fork_rekey_publication_fails_the_adoption_through_the_completion() { + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_kill_whose_envelope_fails_clean_reopens_the_close_gate() { 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-e2r3-f2"), None) + let (st, mut rx) = state_with_bus(); + let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + st.set_identity_sink(fake.clone()); + st.handle_create(dedup_create_msg("req-gate-abort"), None) .await; - let created = await_claude_created(&mut rx, "req-e2r3-f2").await; - let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - // The first adoption publishes. - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - 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 FAILED PUBLICATION: the kill's mint gate armed (the session - // enumerated; the gate check alone cannot see the kill's - // completion), then the fork-rekey adoption for the NEW id. - st.close_pending - .lock() - .expect("close-pending lock") - .insert(placeholder.clone(), 1); - let (tx, mut rx_done) = tokio::sync::oneshot::channel::>(); - let outcome = st - .adopt_session_init_with_completion( - "ffeeeeee-ffff-4fff-8fff-ffffffffffff", - &placeholder, - "freshclaude", - None, - None, - None, - Some(tx), + let created = await_claude_created(&mut rx, "req-gate-abort").await; + let current = created["sessionId"].as_str().unwrap().to_string(); + let (pending, pid) = { + let sessions = st.sessions.lock().await; + ( + sessions[¤t].pending.clone(), + sessions[¤t].child.id(), ) - .await; + }; + fake.fail_retires_after(0); + st.handle_kill(kill_msg(¤t)).await; assert!( - matches!(outcome, SessionInitAdoptionOutcome::Abandoned), - "the gated publication abandons (pre-fix: the adoption returned nothing \ - and the handler continued unaliased)" + !await_specific_frame(&mut rx, "freshAgent.killed", ¤t).await["success"] + .as_bool() + .unwrap() ); - let answer = rx_done - .try_recv() - .expect("the completion hook answered synchronously on Abandoned"); + assert!(!st.sessions.lock().await[¤t].identity.is_closing()); + let next = uuid::Uuid::new_v4().to_string(); + st.rekey_conversation( + ¤t, + &next, + "freshclaude", + &pending, + "test/failed-close", + ) + .await + .unwrap(); assert_eq!( - answer, None, - "the rollback handler receives the FAILED answer" - ); - } - - /// b8ke focused episode-2 round-2 F2: an adoption whose claim is BLOCKED - /// by an UNRELATED operation fails CLOSED. The finding's shape: a fresh - /// create exposes its cliSessionId while the adoption is deferred; - /// another device starts a handoff on the still-vacant canonical key — - /// the deferred adoption then sees Blocked{Handoff} whose op id is NOT - /// this runtime's own owning operation (a plain create's session - /// carries none): the freshly spawned runtime must be TORN DOWN and - /// NOTHING published — never left running unowned while the handoff - /// believes there was no prior owner. The runtime's OWN operation - /// (the session's recorded owning op — the create-resume claim's op id) - /// still proceeds claim-free. - #[tokio::test(flavor = "multi_thread")] - async fn a_foreign_blocked_adoption_tears_down_while_the_own_operation_proceeds() { - 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()); - - // (a) The FOREIGN blocker: an ordinary fresh create's session (no - // owning operation) + an UNRELATED handoff holding the key. - st.handle_create(dedup_create_msg("req-e2r2-f2a"), None) - .await; - let created = await_claude_created(&mut rx, "req-e2r2-f2a").await; - let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); - let placeholder_a = created["sessionId"].as_str().unwrap().to_string(); - // Let the fresh create's OWN adoption commit first (it is the first - // claimant — this part is the pre-existing behavior). - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - if matches!( - registry.observe("claude", fresh_create_durable_id).state, - freshell_ownership::OwnershipState::Live { .. } - ) { - break; - } - assert!( - tokio::time::Instant::now() < deadline, - "the fresh create's own adoption must commit" - ); - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - - // A SECOND session registered with NO owning operation (the plain - // create's shape) whose durable id is already blocked by a FOREIGN - // handoff: enter Handoff on the id directly, then adopt for the - // second session under the same id. - let foreign_dur = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"; - assert!(matches!( - registry.begin_handoff( - "claude", - foreign_dur, - freshell_ownership::RuntimeOwnerKind::Terminal, - "foreign-handoff-op", - None, - "other-device", - 1, - ), - freshell_ownership::BeginOutcome::Granted { .. } - )); - // The second session (a plain create's session: owning op None). - st.sessions - .lock() - .await - .get_mut(&placeholder_a) - .expect("session") - .owning_operation = None; - st.sessions - .lock() - .await - .get_mut(&placeholder_a) - .expect("session") - .cli_session_id = None; - 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. - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - while st.sessions.lock().await.contains_key(&placeholder_a) { - assert!( - tokio::time::Instant::now() < deadline, - "the foreign-blocked adoption must TEAR the runtime down (pre-fix: \ - published over it and left the sidecar running unowned)" - ); - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - assert!( - st.cli_index.lock().await.get(foreign_dur).is_none(), - "the foreign-blocked adoption publishes NOTHING" + st.resolve_session_key(¤t).await.as_deref(), + Some(next.as_str()) ); - assert!( - !matches!( - registry.observe("claude", foreign_dur).state, - freshell_ownership::OwnershipState::Live { - owner, - .. - } if owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent - ), - "the foreign handoff's key never becomes FreshAgent-owned by the adoption" - ); - - // (b) The OWN operation: a session whose recorded owning op IS the - // blocking operation — the under-ticket handoff-target shape. The - // adoption proceeds claim-free (the owning operation performs the - // one commit); the runtime stays. - let own_dur = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; + assert_eq!(st.sessions.lock().await[&next].child.id(), pid); + assert!(fake + .bindings + .lock() + .unwrap() + .iter() + .any(|row| row.session_id == next)); assert!(matches!( - registry.begin_handoff( - "claude", - own_dur, - freshell_ownership::RuntimeOwnerKind::FreshAgent, - "own-runner-op", - None, - "runner", - 2, - ), - freshell_ownership::BeginOutcome::Granted { .. } + st.handle_send(send_msg(&next, "writer remains healthy")) + .await, + ClaudeSendOutcome::Accepted { .. } )); - // A second live session carrying the runner's op id. - st.handle_create(dedup_create_msg("req-e2r2-f2b"), None) - .await; - let created_b = await_claude_created(&mut rx, "req-e2r2-f2b").await; - let placeholder_b = created_b["sessionId"].as_str().unwrap().to_string(); - { - let mut sessions = st.sessions.lock().await; - let session = sessions.get_mut(&placeholder_b).expect("second session"); - 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) - .await; - assert!( - st.sessions.lock().await.contains_key(&placeholder_b), - "the runtime's OWN operation proceeds — no teardown" - ); - assert_eq!( - st.cli_index.lock().await.get(own_dur), - Some(&placeholder_b), - "the own-operation adoption publishes the bookkeeping" - ); - } - - /// b8ke focused episode-2 round-2 F3: the publication revalidates the - /// session's INCARNATION. A kill that COMPLETES between the adoption's - /// read and its publication (the session removed AND its close-pending - /// entry gone — the gate check alone cannot see it) must fail the - /// publication: no dangling alias, no Live commit of the killed - /// runtime's captured pid. Pre-fix the publication returned success and - /// committed the dead runtime as the authoritative owner. - #[tokio::test(flavor = "multi_thread")] - async fn a_completed_kill_before_publication_abandons_the_adoption() { - 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()); - // The deterministic hold: the adoption parks between its phase-1 - // read (session LIVE, pid captured) and the publication. Armed - // BELOW — only AFTER the first create's own adoption has provably - // published, so the spawned adoption is the ONLY parker (the e2r3 - // F10 fix: the reached flag then belongs to the task under test). - let pause = std::sync::Arc::new(AdoptionTestPause { - notify: tokio::sync::Notify::new(), - reached: std::sync::atomic::AtomicBool::new(false), - }); - - st.handle_create(dedup_create_msg("req-e2r2-f3"), None) - .await; - let created = await_claude_created(&mut rx, "req-e2r2-f3").await; - let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - // The durable id the sidecar will mint must differ from the FIRST - // create's own adoption. The FIRST create's adoption must PROVABLY - // complete BEFORE the pause arms — otherwise it parks on the same - // pause and the `reached` flag would prove the WRONG task parked - // (the e2r3 F10 fix: exactly one parker, unambiguously the one - // under test). - let first_dur = fresh_create_durable_id; - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - while st.cli_index.lock().await.get(first_dur).is_none() { - assert!( - tokio::time::Instant::now() < deadline, - "the first create's own adoption never published" - ); - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - st.set_adoption_test_pause(Some(std::sync::Arc::clone(&pause))); - let killed_dur = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; - - 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) - .await; - }); - // b8ke focused episode-2 round-3 F10: the test must PROVE the - // adoption reached the park before the kill — the seam's `reached` - // flag flips exactly at the hold, so a park that never happened - // fails here instead of passing vacuously. - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - while !pause.reached.load(std::sync::atomic::Ordering::SeqCst) { - assert!( - tokio::time::Instant::now() < deadline, - "the adoption never reached the kill-window park — the test's \ - interleaving did not occur" - ); - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - - // THE COMPLETED KILL between the read and the publication: the - // session REMOVED and its close-pending entry GONE (the kill - // finished; nothing re-arms the mint gate — the gate check alone - // cannot see this). - let removed = st.sessions.lock().await.remove(&placeholder); - assert!(removed.is_some(), "the kill removed the session"); - assert!( - st.close_pending - .lock() - .expect("close-pending probe") - .get(&placeholder) - .is_none(), - "the completed kill left no close-pending entry" - ); - - // Release the adoption: the publication must fail closed on the - // session-incarnation revalidation. The join must COMPLETE (a - // timeout-elapsed adoption means the release never landed — a - // hang, not a pass). - // e2 post-cap F7: notify_one RETAINS a permit for a waiter that - // has not registered yet — the reached→notify gap cannot lose - // the wake. - pause.notify.notify_one(); - match tokio::time::timeout(std::time::Duration::from_secs(15), &mut adoption).await { - Ok(joined) => { - assert!(joined.is_ok(), "the adoption task completed without panic"); - } - Err(_) => { - panic!("the released adoption never completed — the park release did not land") - } - } - - assert!( - st.cli_index.lock().await.get(killed_dur).is_none(), - "the adoption over a killed session publishes NO alias (pre-fix: \ - the dangling alias landed)" - ); - assert!( - !matches!( - registry.observe("claude", killed_dur).state, - freshell_ownership::OwnershipState::Live { .. } - ), - "the adoption never commits the KILLED runtime as the owner \ - (pre-fix: snapshots reported the dead runtime as authoritative)" - ); - } - - /// b8ke focused episode-2 round-1 F7: the adoption's claim records the - /// PROVENANCE-derived initiator (the device id) — never the fixed lane - /// string. Captured off the coordinator's `ownership.start.begin` event - /// for the adoption's own claim (operation_id `claude-init-adopt-*`). - #[tokio::test] - async fn the_init_adoption_claim_records_the_provenance_initiator() { - 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()); - - let (sink, _capture_guard) = init_capture::install(); - let provenance = Some(crate::identity_sink::BindProvenance { - client_instance_id: Some("client-e2r7".into()), - device_id: Some("dev-e2r7".into()), - tab_key: None, - asserted_at: 1, - }); - st.handle_create(dedup_create_msg("req-e2r7-f7"), provenance) - .await; - - let created = await_claude_created(&mut rx, "req-e2r7-f7").await; - let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); - // The deferred adoption claims under the minted durable id once the - // create registers the runtime. - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - if matches!( - registry.observe("claude", fresh_create_durable_id).state, - freshell_ownership::OwnershipState::Live { owner, .. } - if owner.kind == freshell_ownership::RuntimeOwnerKind::FreshAgent - ) { - break; - } - assert!( - tokio::time::Instant::now() < deadline, - "the provenance-carrying create's adoption must commit" - ); - tokio::time::sleep(std::time::Duration::from_millis(25)).await; - } - let events = sink.lock().expect("capture lock").clone(); - let claim = events - .iter() - .find(|(event, _initiator, op)| { - event == "ownership.start.begin" && op == "create-resume-req-e2r7-f7" - }) - .expect("the adoption's claim event is captured"); - assert_eq!( - claim.1, "dev-e2r7", - "the adoption claim records the provenance device id, got {:?}", - events - ); - } - - /// F5's abort path: a kill whose ONE envelope fails Clean releases the - /// close gate it armed — the session is genuinely untouched (a later - /// adoption mints normally), exactly like the r2 abort the envelope - /// failure rules promised. - #[tokio::test(flavor = "multi_thread")] - async fn a_kill_whose_envelope_fails_clean_reopens_the_close_gate() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let _env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); - st.set_identity_sink(fake.clone()); - - st.handle_create(dedup_create_msg("req-gate-abort"), None) - .await; - let created = await_claude_created(&mut rx, "req-gate-abort").await; - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - - fake.fail_retires_after(0); // every close call fails Clean - st.handle_kill(FreshAgentKill { - observed_epoch: None, - observed_generation: None, - provider: freshell_protocol::AgentProvider::Claude, - session_id: placeholder.clone(), - session_type: SessionType::Freshclaude, - cwd: None, - }) - .await; - - assert!( - st.sessions.lock().await.contains_key(&placeholder), - "the failed kill leaves the session MAPPED" - ); - assert!( - st.close_pending - .lock() - .expect("close-pending probe") - .get(&placeholder) - .is_none(), - "the aborted kill released the gate it armed" - ); - // The mint channel is open again: an adoption lands exactly like on - // a never-killed session. - st.adopt_session_init( - "cccccccc-cccc-4ccc-8ccc-cccccccccccc", - &placeholder, - "freshclaude", - None, - Some(fake.clone()), - None, - ) - .await; - assert_eq!( - st.cli_index - .lock() - .await - .get("cccccccc-cccc-4ccc-8ccc-cccccccccccc") - .cloned(), - Some(placeholder.clone()), - "the post-abort adoption mints normally" - ); + st.shutdown().await; } - /// kata b8ke Task 3 review I-1: a typed kill REFUSAL (the coordinator - /// blocked the stop — here: a handoff owns the session's transition) - /// kills NOTHING, so it must also roll back the pre-kill mint gate it - /// armed — otherwise the still-live session's adoptions abandon - /// forever (the held gate makes `adopt_session_init` discard every - /// mint). The refused kill must leave the session fully operational - /// for a subsequent adoption. - #[tokio::test(flavor = "multi_thread")] - async fn a_refused_kill_releases_the_close_gate_and_adoptions_proceed() { + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_refused_kill_releases_the_close_gate_and_native_rekey_proceeds() { 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()); - let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); st.set_identity_sink(fake.clone()); - st.handle_create(dedup_create_msg("req-kill-refused"), None) .await; let created = await_claude_created(&mut rx, "req-kill-refused").await; - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - - await_create_ownership_committed(&st, &placeholder).await; - // The refusal precondition: a handoff owns the placeholder's - // transition, so the kill's fenced stop is typed-blocked. - let freshell_ownership::BeginOutcome::Granted { .. } = registry.begin_handoff( + let current = created["sessionId"].as_str().unwrap().to_string(); + let (pending, pid) = { + let sessions = st.sessions.lock().await; + ( + sessions[¤t].pending.clone(), + sessions[¤t].child.id(), + ) + }; + let freshell_ownership::BeginOutcome::Granted { generation, .. } = registry.begin_handoff( PROVIDER, - &placeholder, + ¤t, freshell_ownership::RuntimeOwnerKind::Terminal, "ho-refused-1", None, "test", crate::session_lease::now_epoch_ms() + 1, ) else { - panic!("fixture: the handoff begins from the committed Live state") + panic!("the handoff starts from the committed owner") }; - while rx.try_recv().is_ok() {} - - st.handle_kill(FreshAgentKill { - observed_epoch: None, - observed_generation: None, - provider: freshell_protocol::AgentProvider::Claude, - session_id: placeholder.clone(), - session_type: SessionType::Freshclaude, - cwd: None, - }) - .await; - - // The refusal killed nothing: the session stays mapped and the - // answer reports failure. + st.handle_kill(kill_msg(¤t)).await; assert!( - st.sessions.lock().await.contains_key(&placeholder), - "a refused kill must leave the session mapped" - ); - let mut killed_frame = None; - while let Ok(raw) = rx.try_recv() { - let frame: serde_json::Value = serde_json::from_str(&raw).unwrap(); - if frame["type"] == "freshAgent.killed" { - killed_frame = Some(frame); - } - } - let killed_frame = killed_frame.expect("the kill answers freshAgent.killed"); - assert_eq!( - killed_frame["success"], false, - "the refused kill reports success:false: {killed_frame}" + !await_specific_frame(&mut rx, "freshAgent.killed", ¤t).await["success"] + .as_bool() + .unwrap() ); - // THE regression: the mint gate is released, so an adoption lands - // exactly like on a never-killed session. - let gated = "dddddddd-dddd-4ddd-8ddd-dddddddddddd"; - st.adopt_session_init( - gated, - &placeholder, + assert!(!st.sessions.lock().await[¤t].identity.is_closing()); + assert!(matches!( + registry.fail(PROVIDER, ¤t, "ho-refused-1", generation, true), + freshell_ownership::FailOutcome::RestoredPriorOwner + )); + let next = uuid::Uuid::new_v4().to_string(); + st.rekey_conversation( + ¤t, + &next, "freshclaude", - None, - Some(fake.clone()), - None, + &pending, + "test/refused-close", ) - .await; + .await + .unwrap(); assert_eq!( - st.cli_index.lock().await.get(gated).cloned(), - Some(placeholder.clone()), - "the post-refusal adoption mints normally (the gate was released)" - ); - assert!( - fake.alias_record_writes - .lock() - .unwrap() - .iter() - .any(|(_, p, d)| p == &placeholder && d == gated), - "the post-refusal adoption writes its alias record" + st.resolve_session_key(¤t).await.as_deref(), + Some(next.as_str()) ); + assert_eq!(st.sessions.lock().await[&next].child.id(), pid); + assert!(fake + .bindings + .lock() + .unwrap() + .iter() + .any(|row| row.session_id == next)); + assert!(matches!( + st.handle_send(send_msg(&next, "writer remains healthy")) + .await, + ClaudeSendOutcome::Accepted { .. } + )); + st.shutdown().await; } /// b8ke focused round-2 review R2-2: an explicit kill whose teardown @@ -12820,16 +11845,15 @@ pub(crate) mod tests { // The fake sidecar's default durable cliSessionId (its script emits it // on sdk.session.init when no resume boot was supplied). let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - adopt_legacy_fixture(&st, &placeholder, durable).await; + inherit_native_fixture(&st, &placeholder, durable).await; // The REAL consumer fold adopts the alias (poll, bounded) — the kill // must race NOTHING here: the alias exists before the exit. let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let present = st - .cli_index - .lock() + .resolve_session_key(durable) .await - .get(durable) + .as_ref() .is_some_and(|mapped| mapped == &placeholder); if present { break; @@ -12843,10 +11867,17 @@ pub(crate) mod tests { // Drive the REAL exit-eviction: the sidecar dies ('__exit__'), the // consumer's EOF arm evicts the session and demotes the alias. - st.handle_send(send_msg(&placeholder, "__exit__")).await; + st.sessions + .lock() + .await + .get_mut(&placeholder) + .unwrap() + .child + .start_kill() + .unwrap(); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { - if st.sessions.lock().await.is_empty() && st.cli_index.lock().await.is_empty() { + if st.sessions.lock().await.is_empty() { break; } assert!( @@ -12889,79 +11920,108 @@ pub(crate) mod tests { drop(env); } - /// Focused-ep5-r1 Finding 2 (retire-on-kill round 2), the in-flight - /// adoption race with REAL concurrency (no synchronous-install shortcut): - /// the orphan gate models the production spawn_blocking closure - /// faithfully — the binding write's apply is detached and lands only on - /// release, exactly like a closure that survives the consumer task's - /// abort. The kill lands IN BETWEEN. The durable row must never reappear: - /// the kill's tombstone fences the released write. - #[tokio::test(flavor = "multi_thread")] - async fn handle_kill_while_the_adoption_write_is_in_flight_never_rebounds_the_row() { + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn handle_kill_suppresses_an_orphan_binding_apply_after_its_receipt() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); + let env = FakeClaudeSidecarEnv::install_with_knobs(Some(1_500), false); let (st, mut rx) = state_with_bus(); - let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); - // Arm the gate for the durable the fake sidecar will emit, BEFORE the - // create: the consumer's sdk.session.init adoption write detaches. - let gate = fake.arm_orphan_binding_gate("claude", "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"); + let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); st.set_identity_sink(fake.clone()); + let task = { + let st = st.clone(); + tokio::spawn(async move { + st.handle_create(dedup_create_msg("orphan-create-binding"), None) + .await + }) + }; + let request = env.create_request().await; + let id = request["sessionId"].as_str().unwrap().to_string(); + let gate = fake.arm_orphan_binding_gate(PROVIDER, &id); + gate.entered.recv_timeout(Duration::from_secs(15)).unwrap(); + st.handle_kill(kill_msg(&id)).await; + gate.release.send(()).unwrap(); + gate.applied.recv_timeout(Duration::from_secs(15)).unwrap(); + let outcome = tokio::time::timeout(Duration::from_secs(15), task) + .await + .unwrap() + .unwrap(); + // This fixture's receipt is immediate; a successful create may precede kill. + let created = matches!(outcome, ClaudeCreateOutcome::Created { .. }); + assert!(st.resolve_session_key(&id).await.is_none()); + assert!(fake + .suppressed + .lock() + .unwrap() + .contains(&(PROVIDER.into(), id.clone()))); + assert!(!fake + .bindings + .lock() + .unwrap() + .iter() + .any(|row| row.session_id == id)); + let mut saw_close_ack = false; + let mut saw_created = false; + while let Ok(raw) = rx.try_recv() { + let frame: Value = serde_json::from_str(&raw).unwrap(); + if frame["type"] == "freshAgent.killed" { + saw_close_ack = true; + } + if frame["type"] == "freshAgent.created" { + assert!( + !saw_close_ack, + "created must precede the acknowledged close" + ); + saw_created = true; + } + } + assert_eq!(saw_created, created); + assert!(saw_close_ack); + } - let mut msg = dedup_create_msg("req-orphan-kill"); - msg.cwd = Some("/tmp".to_string()); // settings-bearing => the adoption writes - st.handle_create(msg, None).await; - let created = await_claude_created(&mut rx, "req-orphan-kill").await; - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - adopt_legacy_fixture(&st, &placeholder, "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa").await; - - // The adoption write has been INVOKED (production's spawn-launch - // point) — it is in flight, not yet applied. - gate.entered - .recv_timeout(std::time::Duration::from_secs(15)) - .expect("the sdk.session.init adoption entered the binding write"); - - // The user closes the pane NOW (the bare placeholder — the real wire - // shape) while that write is still in flight. - st.handle_kill(FreshAgentKill { - observed_epoch: None, - observed_generation: None, - provider: freshell_protocol::AgentProvider::Claude, - session_id: placeholder.clone(), - session_type: SessionType::Freshclaude, - cwd: None, - }) - .await; + /// A successful write receipt remains awaited while its binding stall is held. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_close_during_the_awaited_create_binding_refuses_created() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let env = FakeClaudeSidecarEnv::install_with_knobs(Some(1_500), false); + let (st, mut rx) = state_with_bus(); + let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + st.set_identity_sink(fake.clone()); + let task = { + let st = st.clone(); + tokio::spawn(async move { + st.handle_create(dedup_create_msg("awaited-create-close"), None) + .await + }) + }; + let request = env.create_request().await; + let id = request["sessionId"].as_str().unwrap().to_string(); + let stall = fake.arm_binding_stall(PROVIDER, &id); + stall.entered.recv_timeout(Duration::from_secs(15)).unwrap(); assert!( - fake.kill_tombstones.lock().unwrap().contains_key(&( - "claude".to_string(), - "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa".to_string() - )), - "the kill folded the durable identity's kill tombstone" + !task.is_finished(), + "the binding receipt is still outstanding" ); - - // The in-flight write lands NOW, after both the kill and its retire. - gate.release.send(()).expect("release the orphan write"); - gate.applied - .recv_timeout(std::time::Duration::from_secs(15)) - .expect("the detached apply ran"); - + st.handle_kill(kill_msg(&id)).await; + stall.release.send(()).unwrap(); + let outcome = tokio::time::timeout(Duration::from_secs(15), task) + .await + .unwrap() + .unwrap(); assert!( - !fake - .bindings - .lock() - .unwrap() - .iter() - .any(|b| b.session_id == "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"), - "a binding write in flight at kill time must never land the row" + matches!(outcome, ClaudeCreateOutcome::Failed { .. }), + "{outcome:?}" ); - assert!( - fake.suppressed.lock().unwrap().contains(&( - "claude".to_string(), - "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa".to_string() - )), - "the tombstone suppression of the orphan write is positively observable" + assert!(st.resolve_session_key(&id).await.is_none()); + assert_eq!( + fake.states.lock().unwrap().get(&(PROVIDER.into(), id)), + Some(&crate::identity_sink::FakeRowState::Closed) ); - drop(env); + while let Ok(raw) = rx.try_recv() { + assert_ne!( + serde_json::from_str::(&raw).unwrap()["type"], + "freshAgent.created" + ); + } } /// Focused-ep5-r1 Finding 2, the tombstone lifecycle's exit: an EXPLICIT @@ -12976,21 +12036,19 @@ pub(crate) mod tests { let (st, mut rx) = state_with_bus(); let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); st.set_identity_sink(fake.clone()); - let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; // Create, let the alias land, kill (placeholder — the real wire shape). st.handle_create(dedup_create_msg("req-claim-kill"), None) .await; let created = await_claude_created(&mut rx, "req-claim-kill").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); - adopt_legacy_fixture(&st, &placeholder, durable).await; + let durable = created["sessionId"].as_str().unwrap(); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { let present = st - .cli_index - .lock() + .resolve_session_key(durable) .await - .get(durable) + .as_ref() .is_some_and(|mapped| mapped == &placeholder); if present { break; @@ -13123,119 +12181,65 @@ pub(crate) mod tests { } } - /// Bounded-wait until `cli_index[durable] == expected` — the same poll the - /// round-1/2 kill tests use to observe the consumer's adoption fold - /// without racing it. - async fn await_alias(st: &FreshClaudeState, durable: &str, expected: &str) { - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - let present = st - .cli_index - .lock() - .await - .get(durable) - .is_some_and(|mapped| mapped == expected); - if present { - return; - } + async fn await_native_route(st: &FreshClaudeState, id: &str, current: &str) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(15); + while st.resolve_session_key(id).await.as_deref() != Some(current) { assert!( tokio::time::Instant::now() < deadline, - "the alias {durable} -> {expected} never landed" + "native id {id} never routed to {current}" ); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; + tokio::time::sleep(Duration::from_millis(10)).await; } } - /// Focused-ep5-r2 Finding 2 (retire-on-kill round 3), THE pin: reopen the - /// durable session D, then kill via the OLD pane's placeholder P — the - /// live row must SURVIVE. The reopen (the genuine claim) consumes the - /// stale P→D alias tombstone (and clears the ledger fence for every known - /// alias of D), so the late placeholder-addressed kill retires only the - /// placeholder it names. + /// A successful explicit reopen consumes the old persisted placeholder route. #[tokio::test] async fn reopened_session_survives_a_late_kill_naming_the_old_placeholder() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); + let _env = FakeClaudeSidecarEnv::install(); let (st, mut rx) = state_with_bus(); - let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); st.set_identity_sink(fake.clone()); - let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - - // Epoch 1: create + adoption, then the close (bare placeholder — the - // real wire shape), which retires D and demotes P1→D to the alias - // tombstones. - st.handle_create(dedup_create_msg("req-reopen-1"), None) + st.handle_create(dedup_create_msg("legacy-reopen-first"), None) .await; - let created = await_claude_created(&mut rx, "req-reopen-1").await; - let placeholder_old = created["sessionId"].as_str().unwrap().to_string(); - adopt_legacy_fixture(&st, &placeholder_old, durable).await; - await_alias(&st, durable, &placeholder_old).await; - st.handle_kill(kill_msg(&placeholder_old)).await; - assert!( - fake.kill_tombstones - .lock() - .unwrap() - .contains_key(&("claude".to_string(), durable.to_string())), - "epoch 1 close folded D's fence" - ); - - // Epoch 2: the genuine claim — resume the SAME durable id (the fake - // sidecar echoes the resume boot's id as the cliSessionId). - let mut resume = dedup_create_msg("req-reopen-2"); - resume.resume_session_id = Some(durable.to_string()); - resume.cwd = Some("/tmp".to_string()); + let created = await_claude_created(&mut rx, "legacy-reopen-first").await; + let durable = created["sessionId"].as_str().unwrap(); + let placeholder = "placeholder-old-epoch"; + fake.record_alias_tombstone(PROVIDER, placeholder, durable, 1_000) + .await + .unwrap(); + st.handle_kill(kill_msg(durable)).await; + st.handle_kill(kill_msg(placeholder)).await; + assert!(fake.kill_tombstone_at_ms(PROVIDER, placeholder).is_some()); + let mut resume = dedup_create_msg("legacy-reopen-second"); + resume.resume_session_id = Some(durable.into()); st.handle_create(resume, None).await; - let recreated = await_claude_created(&mut rx, "req-reopen-2").await; - assert_eq!(recreated["type"].as_str().unwrap(), "freshAgent.created"); - let placeholder_new = recreated["sessionId"].as_str().unwrap().to_string(); - await_alias(&st, durable, &placeholder_new).await; - - // The late kill naming the OLD placeholder (the finding's shape — a - // stale pane closes against its original session id). - st.handle_kill(kill_msg(&placeholder_old)).await; - - let retires = fake.retires.lock().unwrap().clone(); + let reopened = await_claude_created(&mut rx, "legacy-reopen-second").await; + assert_eq!(reopened["type"], "freshAgent.created", "{reopened}"); + assert_eq!(reopened["sessionId"], durable); + assert!(fake + .alias_tombstone_records(PROVIDER, placeholder) + .is_empty()); + assert!(fake.kill_tombstone_at_ms(PROVIDER, placeholder).is_none()); + let retire_count = fake + .retires + .lock() + .unwrap() + .iter() + .filter(|(_, id)| id == durable) + .count(); + st.handle_kill(kill_msg(placeholder)).await; assert_eq!( - retires - .iter() - .filter(|(p, s)| p == "claude" && s == durable) - .count(), - 1, - "the reopened durable must NOT be retired by the old placeholder's kill: {retires:?}" - ); - assert!( - retires - .iter() - .any(|(p, s)| p == "claude" && s == &placeholder_old), - "the wire id still retires by name: {retires:?}" - ); - // The claim consumed the stale alias record at reopen time. - let now = crate::session_lease::now_epoch_ms() as i64; - assert!( - st.alias_tombstones - .lock() - .unwrap() - .durables_for(&placeholder_old, now, &st.alias_row_bound_probe()) - .is_empty(), - "the genuine claim must consume the old placeholder's alias tombstone" - ); - // ...and the ledger fence of the consumed alias was cleared at the - // same commit (the reopened identity's every known alias reopens). - assert!( - fake.tombstone_clears - .lock() - .unwrap() - .contains(&("claude".to_string(), placeholder_old.clone())), - "the claim also cleared the old placeholder's own ledger fence" - ); - assert!( - fake.claim_commits + fake.retires .lock() .unwrap() - .contains(&("claude".to_string(), durable.to_string())), - "the claim revived the durable row its close had retired" + .iter() + .filter(|(_, id)| id == durable) + .count(), + retire_count ); - drop(env); + assert!(st.has_live_session(durable).await); + st.shutdown().await; } /// Focused-ep5-r2 Finding 2, the defense-in-depth half: even if a demoted @@ -13256,21 +12260,15 @@ pub(crate) mod tests { .await; let created = await_claude_created(&mut rx, "req-live-owner").await; let placeholder_live = created["sessionId"].as_str().unwrap().to_string(); - adopt_legacy_fixture(&st, &placeholder_live, durable).await; - await_alias(&st, durable, &placeholder_live).await; + inherit_native_fixture(&st, &placeholder_live, durable).await; + await_native_route(&st, durable, &placeholder_live).await; // Fabricate the surviving stale record: the OLD epoch's placeholder // demoted to a tombstone that (hypothetically) escaped consumption. let now = crate::session_lease::now_epoch_ms() as i64; - st.alias_tombstones - .lock() - .expect("alias tombstones lock") - .record( - "placeholder-old-epoch", - durable, - now, - &st.alias_row_bound_probe(), - ); + fake.record_alias_tombstone(PROVIDER, "placeholder-old-epoch", durable, now) + .await + .unwrap(); st.handle_kill(kill_msg("placeholder-old-epoch")).await; @@ -13286,247 +12284,86 @@ pub(crate) mod tests { drop(env); } - /// Focused-ep5-r2 Finding 3 (retire-on-kill round 3), the exact - /// interleaving from the report, constructed deterministically: the - /// consumer-exit eviction has removed the session entry but has NOT YET - /// demoted its cli_index alias (and no alias tombstone exists yet) when - /// the kill lands. The kill must still resolve the durable identity via - /// the LIVE alias — consulting only the tombstones misses it (the old - /// no-session branch read tombstones alone). + /// The death record owns native lineage as soon as the live map entry leaves. #[tokio::test] - async fn kill_in_the_exit_eviction_gap_retires_the_durable_via_the_live_alias() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); - st.set_identity_sink(fake.clone()); - let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - - st.handle_create(dedup_create_msg("req-evict-gap"), None) - .await; - let created = await_claude_created(&mut rx, "req-evict-gap").await; - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - adopt_legacy_fixture(&st, &placeholder, durable).await; - await_alias(&st, durable, &placeholder).await; - - // THE WINDOW: the eviction's first half (sessions-map removal) has - // run; its second half (cli_index demotion) has NOT. - assert!( - st.sessions.lock().await.remove(&placeholder).is_some(), - "fixture: remove the session entry exactly like the eviction's first half" - ); - assert!( - st.alias_tombstones - .lock() - .expect("alias tombstones lock") - .durables_for( - &placeholder, - crate::session_lease::now_epoch_ms() as i64, - &st.alias_row_bound_probe() - ) - .is_empty(), - "fixture: no alias tombstone exists yet either" - ); - - // The kill lands NOW — inside the eviction gap. - st.handle_kill(kill_msg(&placeholder)).await; - - let retires = fake.retires.lock().unwrap().clone(); - assert!( - retires.contains(&("claude".to_string(), durable.to_string())), - "a kill inside the exit-eviction gap must still resolve the live alias and \ - retire the durable row: {retires:?}" - ); - drop(env); - } - - /// Focused-ep5-r3 Finding 2 (retire-on-kill round 4), the eviction-gap - /// interleaving from the report — pinned as a STRUCTURAL property (the - /// exact schedule the finding names is otherwise a microseconds-wide - /// race): the eviction's demote+remove must be ONE critical section - /// holding `cli_index` across the tombstone record, matching the kill - /// consult's `cli_index → sessions → alias_tombstones` lock order, so a - /// kill consult can NEVER observe "neither the live alias nor the - /// tombstone". In the round-3 shape (index released before the tombstone - /// lock) the gap was real: the alias was gone from `cli_index` while the - /// eviction parked on a held tombstones lock, with `cli_index` FREE the - /// whole window. - #[tokio::test(flavor = "multi_thread")] - // The test deliberately widens the eviction's window by holding the - // tombstone lock across awaits (the deterministic staging of the - // finding's interleaving) — never a production shape. - #[allow(clippy::await_holding_lock)] - async fn the_eviction_holds_the_index_across_the_demote_and_remove() { + async fn kill_during_exit_reporting_retires_every_retained_native_id() { let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); + let _env = FakeClaudeSidecarEnv::install(); let (st, mut rx) = state_with_bus(); - let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); + let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); st.set_identity_sink(fake.clone()); - let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - - st.handle_create(dedup_create_msg("req-evict-cs"), None) + st.handle_create(dedup_create_msg("kill-during-exit-report"), None) .await; - let created = await_claude_created(&mut rx, "req-evict-cs").await; - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - adopt_legacy_fixture(&st, &placeholder, durable).await; - await_alias(&st, durable, &placeholder).await; - - // The consumer-exit shape per the report: the session entry is GONE - // (removed) while the alias demotion is mid-flight. - assert!( - st.sessions.lock().await.remove(&placeholder).is_some(), - "fixture: the exit removed the session entry" - ); - - // Widen the eviction's window to a deterministic hold: park the - // tombstones lock BEFORE the eviction can take it, then start the - // eviction. A single-critical-section eviction blocks on the - // tombstones lock STILL HOLDING `cli_index`; the two-phase shape - // releases `cli_index` for the whole window. - let tombstones_hold = st.alias_tombstones.lock().expect("alias tombstones lock"); - let st2 = st.clone(); - let placeholder_for_evict = placeholder.clone(); - let evict = - tokio::spawn(async move { st2.evict_cli_index_aliases(&placeholder_for_evict).await }); - - // Wait for engagement: the eviction has TAKEN `cli_index` (try_lock - // fails while it works). Then the pin: for the entire rest of the - // hold window, `cli_index` must NEVER become free — free once means - // the demote+remove is split and a kill in that window sees neither - // map. - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - let mut engaged = false; - while tokio::time::Instant::now() < deadline { - match st.cli_index.try_lock() { - Err(_) => { - engaged = true; - break; - } - Ok(guard) => { - drop(guard); - tokio::time::sleep(std::time::Duration::from_millis(1)).await; - } - } - } - assert!(engaged, "the eviction never engaged cli_index"); - for _ in 0..50 { - assert!( - st.cli_index.try_lock().is_err(), - "the eviction must hold cli_index across the demote+remove: a kill in \ - the gap (index free, tombstones not yet recorded) sees neither map" - ); - tokio::time::sleep(std::time::Duration::from_millis(2)).await; - } - - // Release the hold: the eviction completes as ONE transition, and a - // kill naming the bare placeholder now resolves the durable through - // the freshly-recorded tombstone. - let demoted = { - drop(tombstones_hold); - evict.await.expect("the eviction completed") - }; - assert_eq!(demoted, vec![durable.to_string()]); - st.handle_kill(kill_msg(&placeholder)).await; - let retires = fake.retires.lock().unwrap().clone(); - assert!( - retires.contains(&("claude".to_string(), durable.to_string())), - "post-eviction the bare-placeholder kill retires the durable via the tombstone: {retires:?}" - ); - drop(env); + let created = await_claude_created(&mut rx, "kill-during-exit-report").await; + let current = created["sessionId"].as_str().unwrap(); + let prior = uuid::Uuid::new_v4().to_string(); + inherit_native_fixture(&st, current, &prior).await; + let hold = st.hold_exit_reports(); + st.sessions + .lock() + .await + .get_mut(current) + .unwrap() + .child + .start_kill() + .unwrap(); + tokio::time::timeout(Duration::from_secs(15), hold.parked()) + .await + .unwrap(); + assert!(st.sessions.lock().await.is_empty()); + let dead = st + .dead_sessions + .find(&prior) + .expect("lineage already retained"); + assert!(dead.ids.contains(¤t.to_string())); + st.handle_kill(kill_msg(&prior)).await; + hold.release(); + let retired = fake.retires.lock().unwrap().clone(); + assert!(retired.contains(&(PROVIDER.into(), prior))); + assert!(retired.contains(&(PROVIDER.into(), current.into()))); } - /// Focused-ep5-r2 Finding 5 (retire-on-kill round 3): the durable kill - /// fence is cleared only once the replacement session is ESTABLISHED - /// (sidecar created + answered). A resume whose create fails mid-way must - /// KEEP the fence — and then an older in-flight binding write (the - /// orphan-gate shape, released AFTER the failure) is still fenced instead - /// of resurrecting Bound. - #[tokio::test(flavor = "multi_thread")] - async fn a_resume_create_that_fails_midway_keeps_the_fence_and_no_orphan_rebounds_the_row() { - let _guard = CLAUDE_ENV_LOCK.lock().await; - let env = FakeClaudeSidecarEnv::install(); - let (st, mut rx) = state_with_bus(); - let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); - let durable = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - // Arm the orphan gate for D BEFORE the create: the epoch-1 adoption - // write detaches (in-flight when the kill lands). - let gate = fake.arm_orphan_binding_gate("claude", durable); - st.set_identity_sink(fake.clone()); - - let mut msg = dedup_create_msg("req-fence-orphan"); - msg.cwd = Some("/tmp".to_string()); // settings-bearing => the adoption writes - st.handle_create(msg, None).await; - let created = await_claude_created(&mut rx, "req-fence-orphan").await; - let placeholder = created["sessionId"].as_str().unwrap().to_string(); - adopt_legacy_fixture(&st, &placeholder, durable).await; - gate.entered - .recv_timeout(std::time::Duration::from_secs(15)) - .expect("the adoption write entered (in flight)"); - - st.handle_kill(kill_msg(&placeholder)).await; - assert!( - fake.kill_tombstones - .lock() - .unwrap() - .contains_key(&("claude".to_string(), durable.to_string())), - "the kill folded D's fence" - ); - - // The resume attempt fails MID-WAY: the provider's create dies before - // `created` (the fake sidecar's fail knob — the replacement session - // is never established). - std::env::set_var("FRESHELL_TEST_CLAUDE_FAIL_CREATE", "1"); - let mut resume = dedup_create_msg("req-fence-resume"); - resume.resume_session_id = Some(durable.to_string()); - st.handle_create(resume, None).await; - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - let raw = tokio::time::timeout(std::time::Duration::from_secs(15), rx.recv()) - .await - .expect("a frame arrives") - .expect("bus open"); - let frame: Value = serde_json::from_str(&raw).unwrap(); - if frame["type"] == "freshAgent.create.failed" - && frame["requestId"] == "req-fence-resume" - { - break; - } - assert!( - tokio::time::Instant::now() < deadline, - "the failed resume never answered create.failed: {frame}" - ); - } - - // The orphaned epoch-1 write lands NOW — against whatever fence state - // the failed resume left behind. - gate.release.send(()).expect("release the orphan write"); - gate.applied - .recv_timeout(std::time::Duration::from_secs(15)) - .expect("the detached apply ran"); - + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_resume_create_that_fails_midway_keeps_the_fence_and_no_orphan_rebounds_the_row() { + let _guard = CLAUDE_ENV_LOCK.lock().await; + let _env = FakeClaudeSidecarEnv::install(); + let (st, mut rx) = state_with_bus(); + let fake = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + st.set_identity_sink(fake.clone()); + st.handle_create(dedup_create_msg("resume-failure-first"), None) + .await; + let created = await_claude_created(&mut rx, "resume-failure-first").await; + let id = created["sessionId"].as_str().unwrap().to_string(); + let old_binding = fake.bindings.lock().unwrap().last().unwrap().clone(); + let gate = fake.arm_orphan_binding_gate(PROVIDER, &id); + let orphan = { + let fake = fake.clone(); + tokio::spawn(async move { fake.record_binding(old_binding).await }) + }; + gate.entered.recv_timeout(Duration::from_secs(15)).unwrap(); + st.handle_kill(kill_msg(&id)).await; + let close = fake.kill_tombstone_at_ms(PROVIDER, &id).unwrap(); + std::env::set_var("FRESHELL_TEST_CLAUDE_FAIL_CREATE", "1"); + let mut resume = dedup_create_msg("resume-failure-second"); + resume.resume_session_id = Some(id.clone()); + let outcome = st.handle_create(resume, None).await; assert!( - fake.suppressed - .lock() - .unwrap() - .contains(&("claude".to_string(), durable.to_string())), - "the fence must OUTLIVE the failed resume — the orphan stays fenced; \ - clears={:?}", - fake.tombstone_clears.lock().unwrap() - ); - // The pin is "no Bound resurrection": the gated epoch-1 write is the - // ONLY binding attempt this lane ever ran, so the durable's row never - // reached Bound on the fake's state map at all — the fence held from - // kill through failed resume through orphan release. - assert_ne!( - fake.states - .lock() - .unwrap() - .get(&("claude".to_string(), durable.to_string())) - .copied(), - Some(crate::identity_sink::FakeRowState::Bound), - "no Bound resurrection after a mid-way-failed resume" + matches!(outcome, ClaudeCreateOutcome::Failed { .. }), + "{outcome:?}" + ); + assert_eq!(fake.kill_tombstone_at_ms(PROVIDER, &id), Some(close)); + gate.release.send(()).unwrap(); + gate.applied.recv_timeout(Duration::from_secs(15)).unwrap(); + orphan.await.unwrap().unwrap(); + assert!(fake + .suppressed + .lock() + .unwrap() + .contains(&(PROVIDER.into(), id.clone()))); + assert_eq!( + fake.states.lock().unwrap().get(&(PROVIDER.into(), id)), + Some(&crate::identity_sink::FakeRowState::Closed) ); - drop(env); } /// Focused-ep5-r2 Finding 4 (retire-on-kill round 3), the claude attach @@ -13729,7 +12566,7 @@ pub(crate) mod tests { "the orphan session was torn down, never registered" ); assert!( - state.cli_index.lock().await.get(DURABLE).is_none(), + state.resolve_session_key(DURABLE).await.as_ref().is_none(), "the durable never aliased into a live session" ); drop(env); @@ -13760,10 +12597,9 @@ pub(crate) mod tests { ); st.handle_kill(kill_msg(durable)).await; let now = crate::session_lease::now_epoch_ms() as i64; - st.alias_tombstones - .lock() - .expect("alias tombstones lock") - .record("placeholder-old", durable, now, &st.alias_row_bound_probe()); + fake.record_alias_tombstone(PROVIDER, "placeholder-old", durable, now) + .await + .unwrap(); assert_eq!( fake.states .lock() @@ -13798,10 +12634,7 @@ pub(crate) mod tests { "the consumed alias's ledger fence is cleared too" ); assert!( - st.alias_tombstones - .lock() - .expect("alias tombstones lock") - .durables_for("placeholder-old", now, &st.alias_row_bound_probe()) + fake.alias_tombstone_records(PROVIDER, "placeholder-old") .is_empty(), "the alias tombstone is consumed" ); @@ -13861,10 +12694,9 @@ pub(crate) mod tests { st.handle_kill(kill_msg(durable)).await; st.handle_kill(kill_msg("placeholder-old")).await; // the alias's own fence let now = crate::session_lease::now_epoch_ms() as i64; - st.alias_tombstones - .lock() - .expect("alias tombstones lock") - .record("placeholder-old", durable, now, &st.alias_row_bound_probe()); + fake.record_alias_tombstone(PROVIDER, "placeholder-old", durable, now) + .await + .unwrap(); let stale_snapshot = fake.kill_tombstone_at_ms("claude", durable); // THE KILL DURING THE CLAIM: a newer close advances the durable @@ -13898,10 +12730,8 @@ pub(crate) mod tests { "the row is never revived" ); assert!( - !st.alias_tombstones - .lock() - .expect("alias tombstones lock") - .durables_for("placeholder-old", now, &st.alias_row_bound_probe()) + !fake + .alias_tombstone_records(PROVIDER, "placeholder-old") .is_empty(), "the alias tombstone is never consumed" ); @@ -13928,73 +12758,13 @@ pub(crate) mod tests { // ── retire-on-kill round 5 (focused-ep5-r4 Findings 1+2+5) ───────────────── - /// Finding 2, the RETENTION rule at the store level: an alias record's - /// lifetime is the ROW lifetime its durable resolves to — TTL expiry and - /// capacity eviction may only discard a record whose row is already - /// Retired-or-GC'd. With a still-Bound row, the record survives ANY age; - /// with the row gone, the 6h TTL applies as before. (The probe parameter - /// is the identity sink's raw `row_is_bound` answer; `|_| true` / - /// `|_| false` stand in for the two row states here.) - #[test] - fn the_alias_record_lives_as_long_as_the_row_it_resolves_to() { - let bound = |_: &str| true; - let gone = |_: &str| false; - let over_ttl = ALIAS_TOMBSTONE_TTL_MS + 60_000; - - let mut kept = AliasTombstones::default(); - kept.record("p", "d", 0, &bound); - assert_eq!( - kept.durables_for("p", over_ttl, &bound), - vec!["d".to_string()], - "a still-Bound row retains its alias mapping past the TTL — the pane's close \ - (days later, by the bare placeholder) must still reach the durable row" - ); - - let mut aged = AliasTombstones::default(); - aged.record("p", "d", 0, &gone); - assert!( - aged.durables_for("p", over_ttl, &gone).is_empty(), - "a Retired-or-GC'd row's alias record ages out on schedule" - ); - } - - /// Finding 2's cap half: capacity eviction consults ROW STATE, not age - /// alone — the oldest record is NOT evicted while its durable row is - /// Bound (a long-lived pane is ordinary); the eviction proceeds against - /// the oldest UNBOUND placeholder, and a wholly bound-set may exceed the - /// cap (correctness over the bound, loudly). - #[test] - fn the_alias_cap_never_evicts_a_record_whose_row_is_still_bound() { - // "d-p0" is Bound; every other durable's row is gone. - let probe = |durable: &str| durable == "d-p0"; - let mut t = AliasTombstones::default(); - for i in 0..(ALIAS_TOMBSTONE_CAP + 4) { - t.record(&format!("p{i}"), &format!("d-p{i}"), i as i64, &probe); - } - // Cap+4 records: exactly 4 placeholders evicted, and the eviction - // order is oldest-UNBOUND-first — p0 (the oldest, but bound) survives - // while p1..p4 (older unbound) go first. - assert_eq!( - t.durables_for("p0", ALIAS_TOMBSTONE_CAP as i64 + 4, &probe), - vec!["d-p0".to_string()], - "the bound-row record survives the cap — age alone never evicts it" - ); - for gone in ["p1", "p2", "p3", "p4"] { - assert!( - t.durables_for(gone, ALIAS_TOMBSTONE_CAP as i64 + 4, &probe) - .is_empty(), - "oldest unbound placeholders are the eviction candidates ({gone} must go)" - ); - } - } - /// Finding 2 at the kill boundary (the report's verbatim failure): the /// pane lived past the alias TTL (long-lived panes are ordinary), the /// sidecar exit-demoted the alias days later, and the pane's close names /// the bare placeholder. The mapping must still resolve to the durable /// row — the kill retires it. #[tokio::test] - async fn handle_kill_after_the_alias_ttl_with_a_still_bound_row_still_retires_the_durable() { + async fn handle_kill_resolves_an_old_persisted_alias_while_its_row_stays_bound() { let (st, _rx) = state_with_bus(); let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); st.set_identity_sink(fake.clone()); @@ -14009,16 +12779,10 @@ pub(crate) mod tests { ); // The demoted alias, stamped 7h ago (past the 6h TTL). let now = crate::session_lease::now_epoch_ms() as i64; - let stale_at = now - (ALIAS_TOMBSTONE_TTL_MS + 60 * 60 * 1000); - st.alias_tombstones - .lock() - .expect("alias tombstones lock") - .record( - "placeholder-old", - durable, - stale_at, - &st.alias_row_bound_probe(), - ); + let stale_at = now - 7 * 60 * 60 * 1000; + fake.record_alias_tombstone(PROVIDER, "placeholder-old", durable, stale_at) + .await + .unwrap(); st.handle_kill(kill_msg("placeholder-old")).await; @@ -14123,7 +12887,7 @@ pub(crate) mod tests { "the late orphan session was torn down, never kept" ); assert!( - state.cli_index.lock().await.get(DURABLE).is_none(), + state.resolve_session_key(DURABLE).await.as_ref().is_none(), "the durable's registration alias is gone too" ); assert_eq!( @@ -14150,9 +12914,12 @@ pub(crate) mod tests { ) { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + let home = tempfile::tempdir().unwrap(); let (state, mut rx) = state_with_bus(); let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); let durable = "cdcdcdcd-cdcd-4cdc-8dcd-cdcdcdcdcdcd"; + write_fake_transcript(home.path(), durable); + std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); fake.seed( "claude", durable, @@ -14211,7 +12978,7 @@ pub(crate) mod tests { "nothing registers behind a kill the commit pre-dated" ); assert!( - state.cli_index.lock().await.get(durable).is_none(), + state.resolve_session_key(durable).await.as_ref().is_none(), "no registration alias survives" ); assert_eq!( @@ -14250,16 +13017,9 @@ pub(crate) mod tests { let created = await_claude_created(&mut rx, "req-sweep-1").await; assert_eq!(created["type"].as_str().unwrap(), "freshAgent.created"); let placeholder = created["sessionId"].as_str().unwrap().to_string(); - st.cli_index - .lock() - .await - .insert(durable.to_string(), placeholder.clone()); - st.sessions - .lock() - .await - .get_mut(&placeholder) - .expect("session tracked under the placeholder") - .cli_session_id = Some(durable.to_string()); + st.sessions.lock().await[&placeholder] + .identity + .inherit([durable.to_string()]); // The close the sweep answers for: the fence stands, the row retired. fake.retire_closed("claude", durable) @@ -14296,16 +13056,9 @@ pub(crate) mod tests { let created = await_claude_created(&mut rx, "req-sweep-2").await; assert_eq!(created["type"].as_str().unwrap(), "freshAgent.created"); let placeholder = created["sessionId"].as_str().unwrap().to_string(); - st.cli_index - .lock() - .await - .insert(durable.to_string(), placeholder.clone()); - st.sessions - .lock() - .await - .get_mut(&placeholder) - .expect("session tracked under the placeholder") - .cli_session_id = Some(durable.to_string()); + st.sessions.lock().await[&placeholder] + .identity + .inherit([durable.to_string()]); // A kill stamped the fence, then a NEWER claim committed against that // stamp and cleared it — the live session is the claim's, not an @@ -14364,10 +13117,9 @@ pub(crate) mod tests { .kill_tombstone_at_ms("claude", durable) .expect("fixture: the fence stands"); insert_fake_claude_session(&st, "placeholder-late").await; - st.cli_index - .lock() - .await - .insert(durable.to_string(), "placeholder-late".to_string()); + st.sessions.lock().await["placeholder-late"] + .identity + .inherit([durable.to_string()]); // Hold the sessions map: the sweep passes its fence check (the fence // still stands) and parks AT its removal — so the commit below @@ -14455,10 +13207,9 @@ pub(crate) mod tests { crate::identity_sink::FakeRowState::Bound, ); insert_fake_claude_session(&st, "placeholder-orphan").await; - st.cli_index - .lock() - .await - .insert(durable.to_string(), "placeholder-orphan".to_string()); + st.sessions.lock().await["placeholder-orphan"] + .identity + .inherit([durable.to_string()]); st.sweep_late_claim_orphans(&[durable.to_string()]).await; @@ -14510,7 +13261,7 @@ pub(crate) mod tests { .await .expect("the persisted record"); assert!( - st.cli_index.lock().await.is_empty() && st.sessions.lock().await.is_empty(), + st.sessions.lock().await.is_empty(), "fixture: no process-local mapping anywhere (the restart)" ); @@ -14683,7 +13434,7 @@ pub(crate) mod tests { ); assert!(state.sessions.lock().await.is_empty(), "nothing registers"); assert!( - state.cli_index.lock().await.get(DURABLE).is_none(), + state.resolve_session_key(DURABLE).await.as_ref().is_none(), "no registration alias survives" ); assert!( @@ -14716,11 +13467,15 @@ pub(crate) mod tests { async fn a_create_resume_uses_its_durable_id_without_writing_a_self_alias() { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + env.program(json!([{"kind":"text", "data":{"text":"resumed"}}])); + let home = tempfile::tempdir().unwrap(); let (st, mut rx) = state_with_bus(); let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); st.set_identity_sink(fake.clone()); let durable = "e4e4e4e4-e4e4-4e48-8e48-e4e4e4e4e4e4"; + write_fake_transcript(home.path(), durable); + std::env::set_var("CLAUDE_CONFIG_DIR", home.path()); let mut msg = dedup_create_msg("req-alias-persist"); msg.session_ref = Some(freshell_protocol::SessionLocator { provider: "claude".to_string(), @@ -14731,7 +13486,7 @@ pub(crate) mod tests { let placeholder = created["sessionId"].as_str().unwrap().to_string(); assert_eq!(placeholder, durable); - st.handle_send(send_msg(durable, "__emit_assistant__")) + st.handle_send(send_msg(durable, "first resumed turn")) .await; await_frame_of_inner_type(&mut rx, "freshAgent.assistant").await; assert!(fake.alias_tombstone_records("claude", durable).is_empty()); @@ -14867,7 +13622,7 @@ pub(crate) mod tests { "nothing registers when the commit could not run" ); assert!( - state.cli_index.lock().await.get(DURABLE).is_none(), + state.resolve_session_key(DURABLE).await.as_ref().is_none(), "no registration alias survives the failed commit" ); assert_eq!( @@ -14937,7 +13692,7 @@ pub(crate) mod tests { "nothing registers when the commit could not run" ); assert!( - state.cli_index.lock().await.get(durable).is_none(), + state.resolve_session_key(durable).await.as_ref().is_none(), "no registration alias survives the failed commit" ); assert_eq!( @@ -15108,8 +13863,7 @@ pub(crate) mod tests { ownership_id: format!("test-{session_id}"), consumer, sidecar_session_id: session_id.to_string(), - cli_session_id: None, - owning_operation: None, + owning_generation: None, identity: RuntimeIdentity::new(session_id, Vec::new()), pending: Arc::new(std::sync::Mutex::new(pending)), @@ -15796,7 +14550,7 @@ pub(crate) mod tests { "test-fold-session".to_string(), "freshclaude".to_string(), "fold-session".to_string(), - Arc::new(std::sync::Mutex::new("fold-session".to_string())), + RuntimeIdentity::new("fold-session", Vec::new()), Arc::clone(&pending), crate::claude_turns::SharedLedger::default(), quiet, @@ -15944,60 +14698,27 @@ pub(crate) mod tests { // ── cliSessionId recording (restart-parity plan §2.8 item 2) ────────────────── - /// The stdout consumer must record `sdk.session.init`'s durable `cliSessionId` in - /// [`FreshClaudeState::cli_index`] (durable id → sessions-map key), and an explicit - /// kill must evict the index entry along with the session. + /// The first native init confirms the create UUID; close removes its live route. #[tokio::test] - async fn session_init_records_cli_session_id_in_the_index() { + async fn session_init_preserves_the_native_create_identity_until_close() { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); let (st, mut rx) = state_with_bus(); - st.handle_create(dedup_create_msg("req-cli-idx-1"), None) - .await; - let created_frame = await_claude_created(&mut rx, "req-cli-idx-1").await; - let created = created_frame["sessionId"].as_str().unwrap().to_string(); - - st.handle_send(send_msg(&created, "__emit_assistant__")) + st.handle_create(dedup_create_msg("native-init-route"), None) .await; - // The fake emits sdk.session.init with the durable uuid; poll until the - // consumer has recorded it (bounded). - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - { - let idx = st.cli_index.lock().await; - if idx.get(created.as_str()) == Some(&created) { - break; - } - } - assert!( - tokio::time::Instant::now() < deadline, - "cli_index never recorded the durable id" - ); - tokio::time::sleep(std::time::Duration::from_millis(50)).await; - } - // The session carries the best-effort copy of the durable id. - assert_eq!( - st.sessions - .lock() - .await - .get(&created) - .unwrap() - .cli_session_id - .as_deref(), - Some(created.as_str()) - ); - // Kill evicts the index entry. - st.handle_kill(FreshAgentKill { - observed_epoch: None, - observed_generation: None, - provider: freshell_protocol::AgentProvider::Claude, - session_id: created.clone(), - session_type: SessionType::Freshclaude, - cwd: None, - }) - .await; - assert!(st.cli_index.lock().await.is_empty()); - drop(env); + let frame = await_claude_created(&mut rx, "native-init-route").await; + let id = frame["sessionId"].as_str().unwrap(); + assert!(matches!( + st.handle_send(send_msg(id, "first turn")).await, + ClaudeSendOutcome::Accepted { .. } + )); + env.simulator_row("prompt").await; + let init = await_frame_of_inner_type(&mut rx, "freshAgent.session.init").await; + assert_eq!(init["event"]["cliSessionId"], id); + assert_eq!(st.sessions.lock().await[id].identity.current(), id); + assert_eq!(st.resolve_session_key(id).await.as_deref(), Some(id)); + st.handle_kill(kill_msg(id)).await; + assert!(st.resolve_session_key(id).await.is_none()); } /// P1.13 (Task 9): the `sdk.session.init` arm must record ONE fresh-agent binding @@ -16060,10 +14781,7 @@ pub(crate) mod tests { msg.effort = Some("high".to_string()); msg.cwd = Some(env.dir.to_string_lossy().to_string()); state.handle_create(msg, None).await; - // The frame broadcasts unconditionally after `adopt_session_init` - // returns (including its `Deferred` arm, claude.rs:8046-8062), so - // the frame orders nothing about the row; the row BARRIER below is - // what proves the row landed. + // Create publishes only after the native binding is durable. let created = await_claude_created(&mut rx, "req-binding-init").await; let b = await_claude_session_init_binding_row(&fake, created["sessionId"].as_str().unwrap()) @@ -16147,10 +14865,7 @@ pub(crate) mod tests { state .handle_create(dedup_create_msg("req-binding-blank"), None) .await; - // The frame broadcasts unconditionally after `adopt_session_init` - // returns (including its `Deferred` arm, claude.rs:8046-8062), so - // the frame orders nothing about the row; the row BARRIER below is - // what proves the row landed. + // Create publishes only after the native binding is durable. let created = await_claude_created(&mut rx, "req-binding-blank").await; let fresh_create_durable_id = created["sessionId"].as_str().unwrap(); let b = @@ -16179,184 +14894,6 @@ pub(crate) mod tests { drop(env); } - /// b8ke ext r27 F4: the session-init adoption's durable binding write - /// runs BEFORE the commit-Live (after the alias publication, which a - /// concurrent kill's tombstone fold resolves through) — while the - /// write is parked (the sink's binding stall), the coordinator - /// operation still COVERS the session: the key holds the adoption's - /// `Starting` claim, so a competing handoff BEGIN is refused typed - /// (Blocked). Pre-r27 the commit had already released the operation at - /// this point — the key read Live and a handoff could begin while the - /// binding was blocked. - #[tokio::test(flavor = "multi_thread")] - async fn the_session_init_binding_write_precedes_the_commit_and_holds_the_operation() { - 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(Arc::clone(®istry)); - let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); - st.set_identity_sink(fake.clone()); - - let mut msg = dedup_create_msg("req-r27-f4-order"); - msg.model = Some("opus-x".to_string()); - st.handle_create(msg, None).await; - let created = await_claude_created(&mut rx, "req-r27-f4-order").await; - let map_key = created["sessionId"].as_str().unwrap().to_string(); - prepare_legacy_init(&st, &map_key).await; - let fresh_create_durable_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - let stall = fake.arm_binding_stall("claude", fresh_create_durable_id); - let st2 = st.clone(); - let identity = st.identity_sink(); - let create_task = tokio::spawn(async move { - st2.adopt_session_init( - fresh_create_durable_id, - &map_key, - "freshclaude", - None, - identity, - None, - ) - .await; - }); - stall - .entered - .recv_timeout(std::time::Duration::from_secs(15)) - .expect("the adoption reached its binding write"); - - // THE r27 F4 CONTRACT: the write is parked BEFORE the commit — the - // coordinator operation still covers the session. Pre-r27 the key - // read Live here (the commit preceded the write) and a handoff - // could BEGIN while the binding was blocked. - assert!( - matches!( - registry.observe("claude", fresh_create_durable_id).state, - freshell_ownership::OwnershipState::Starting { .. } - ), - "the binding write runs INSIDE the coordinator operation (the \ - pre-commit Starting claim) — got {:?}", - registry.observe("claude", fresh_create_durable_id).state - ); - assert!( - matches!( - registry.begin_handoff( - "claude", - fresh_create_durable_id, - freshell_ownership::RuntimeOwnerKind::Terminal, - "competing-mid-write", - None, - "test", - crate::session_lease::now_epoch_ms(), - ), - freshell_ownership::BeginOutcome::Blocked { .. } - ), - "a competing handoff cannot begin while the binding write is \ - still inside the coordinator operation" - ); - // Restore the key for the release (the probe handoff never entered). - let _ = registry.fail( - "claude", - fresh_create_durable_id, - "competing-mid-write", - u64::MAX, - false, - ); - - // Release: the publish + commit complete, the create answers green. - let _ = stall.release.send(()); - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - while !matches!( - registry.observe("claude", fresh_create_durable_id).state, - freshell_ownership::OwnershipState::Live { .. } - ) { - assert!( - tokio::time::Instant::now() < deadline, - "the adoption never committed after the release" - ); - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - let _ = create_task.await; - drop(env); - } - - /// b8ke ext r27 F4: a session-init adoption whose binding write FAILS - /// tears the freshly adopted runtime down and lands the TYPED - /// recoverable state — BEFORE the commit-Live (no owner broadcast, no - /// Live; the ticket's RAII drop settles the claim typed). Pre-r27 the - /// commit had already made the owner authoritative (and broadcast it) - /// before the failed write tore the runtime down underneath it. - #[tokio::test(flavor = "multi_thread")] - async fn a_failed_session_init_binding_write_abandons_before_the_commit() { - 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(Arc::clone(®istry)); - let fake = std::sync::Arc::new(crate::identity_sink::FakeIdentitySink::default()); - st.set_identity_sink(fake.clone()); - - // The ledger refuses every write (disk-full/permission shape). - let mut msg = dedup_create_msg("req-r27-f4-fail"); - msg.model = Some("opus-x".to_string()); - st.handle_create(msg, None).await; - let created = await_claude_created(&mut rx, "req-r27-f4-fail").await; - let fresh_create_durable_id = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; - assert_eq!(created["type"], "freshAgent.created", "{created}"); - - prepare_legacy_init(&st, created["sessionId"].as_str().unwrap()).await; - while rx.try_recv().is_ok() {} - fake.set_fail_writes(true); - st.adopt_session_init( - fresh_create_durable_id, - created["sessionId"].as_str().unwrap(), - "freshclaude", - None, - st.identity_sink(), - None, - ) - .await; - - // The adoption runs on the sidecar's init event: the binding write - // fails → the runtime is torn down and the adoption ABANDONS - // BEFORE the publish/commit. Bounded-poll for the settled state. - let created_session_id = created["sessionId"] - .as_str() - .unwrap_or_default() - .to_string(); - let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); - loop { - let settled = matches!( - registry.observe("claude", fresh_create_durable_id).state, - freshell_ownership::OwnershipState::Vacant - ); - let session_gone = !st.has_live_session(&created_session_id).await; - if settled && session_gone { - break; - } - assert!( - tokio::time::Instant::now() < deadline, - "the failed binding write never settled (state {:?})", - registry.observe("claude", fresh_create_durable_id).state - ); - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - // NO owner broadcast was ever made (the commit never ran). - let mut saw_owner = false; - while let Ok(raw) = rx.try_recv() { - let frame: Value = serde_json::from_str(&raw).unwrap(); - if frame["type"] == "session.runtimeOwner" - && frame["sessionId"] == json!(fresh_create_durable_id) - { - saw_owner = true; - } - } - assert!( - !saw_owner, - "the failed binding write never broadcast a committed owner" - ); - drop(env); - } - /// D8 lane-reach (restore-open-sessions-only, review round 3): the /// WS-dispatched connection provenance threaded into `handle_create` must /// reach the identity-sink write at `sdk.session.init`. The ledger schema @@ -16382,10 +14919,7 @@ pub(crate) mod tests { 7_777, ); state.handle_create(msg, Some(provenance)).await; - // The frame broadcasts unconditionally after `adopt_session_init` - // returns (including its `Deferred` arm, claude.rs:8046-8062), so - // the frame orders nothing about the row; the row BARRIER below is - // what proves the row landed. + // Create publishes only after the native binding is durable. let created = await_claude_created(&mut rx, "req-binding-prov").await; let b = await_claude_session_init_binding_row(&fake, created["sessionId"].as_str().unwrap()) @@ -16734,10 +15268,17 @@ pub(crate) mod tests { let created = created_frame["sessionId"].as_str().unwrap().to_string(); // Kill the sidecar through the public API (fake exits on text "__exit__"), // then poll (bounded) until the consumer-exit eviction clears BOTH maps. - st.handle_send(send_msg(&created, "__exit__")).await; + st.sessions + .lock() + .await + .get_mut(&created) + .unwrap() + .child + .start_kill() + .unwrap(); let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); loop { - if st.sessions.lock().await.is_empty() && st.cli_index.lock().await.is_empty() { + if st.sessions.lock().await.is_empty() { break; } assert!( @@ -17248,10 +15789,10 @@ pub(crate) mod tests { /// u1/a1/u2/a2 chain (uuid + parentUuid linked): the rollback fixture corpus. fn two_turn_transcript() -> String { [ - 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"}]}}), + json!({"type":"user","uuid":"11111111-1111-4111-8111-111111111111","parentUuid":null,"timestamp":"t1","message":{"role":"user","content":[{"type":"text","text":"prompt one"}]}}), + json!({"type":"assistant","uuid":"aaaaaaaa-1111-4111-8111-111111111111","parentUuid":"11111111-1111-4111-8111-111111111111","timestamp":"t2","message":{"role":"assistant","content":[{"type":"text","text":"answer one"}]}}), + json!({"type":"user","uuid":"22222222-2222-4222-8222-222222222222","parentUuid":"aaaaaaaa-1111-4111-8111-111111111111","timestamp":"t3","message":{"role":"user","content":[{"type":"text","text":"prompt two"}]}}), + json!({"type":"assistant","uuid":"aaaaaaaa-2222-4222-8222-222222222222","parentUuid":"22222222-2222-4222-8222-222222222222","timestamp":"t4","message":{"role":"assistant","content":[{"type":"text","text":"answer two"}]}}), ] .iter() .map(|v| v.to_string()) @@ -17339,13 +15880,13 @@ rl.on('line', (line) => { let clock = crate::attention::AttentionClock::default(); let turn_lock = Arc::new(TokioMutex::new(())); let quiet = deadman::QuietWatch::default(); - let identity = RuntimeIdentity::new(map_key, Vec::new()); + let identity = RuntimeIdentity::new(durable, [map_key.to_string()]); let consumer = st.spawn_consumer( reader, - map_key.to_string(), + durable.to_string(), format!("test-{map_key}"), "freshclaude".to_string(), - map_key.to_string(), + durable.to_string(), identity.clone(), Arc::clone(&pending), Arc::clone(&ledger), @@ -17353,7 +15894,7 @@ rl.on('line', (line) => { clock.clone(), ); st.sessions.lock().await.insert( - map_key.to_string(), + durable.to_string(), ClaudeSession { session_type: "freshclaude".into(), quiet, @@ -17363,9 +15904,8 @@ rl.on('line', (line) => { tree_guard: crate::claude_spawn::SidecarTreeGuard::unguarded(), ownership_id: format!("test-{map_key}"), consumer, - sidecar_session_id: map_key.to_string(), - cli_session_id: Some(durable.to_string()), - owning_operation: None, + sidecar_session_id: durable.to_string(), + owning_generation: None, identity, pending, @@ -17375,10 +15915,6 @@ rl.on('line', (line) => { launch_genesis: None, }, ); - st.cli_index - .lock() - .await - .insert(durable.to_string(), map_key.to_string()); stdin_log } @@ -17396,7 +15932,7 @@ rl.on('line', (line) => { .lock() .await .get(&key) - .and_then(|s| s.cli_session_id.clone()), + .map(|s| s.identity.current()), None => None, }; if let Some(current) = current { @@ -17413,10 +15949,14 @@ rl.on('line', (line) => { } async fn ledger_busy_of(st: &FreshClaudeState, map_key: &str) -> bool { + let key = st + .resolve_session_key(map_key) + .await + .unwrap_or_else(|| map_key.to_string()); st.sessions .lock() .await - .get(map_key) + .get(&key) .map(|s| s.ledger.lock().expect("turn ledger lock").busy()) .unwrap_or(false) } @@ -17436,27 +15976,17 @@ rl.on('line', (line) => { } } - /// Focused ep1-r1 F1 test rig: write a `send` frame to the session's - /// sidecar stdin DIRECTLY (bypassing `handle_send`) — the fake sidecar's - /// magic texts then emit their canned sdk.* frame IN-STREAM with NO - /// freshCode-side set edge, emulating the provider's asynchronous - /// turn-terminal frames (the busy flag was set when the turn was - /// SUBMITTED, not when its result arrives). - async fn inject_raw_send(st: &FreshClaudeState, map_key: &str, text: &str) { - let mut guard = st.sessions.lock().await; - let session = guard.get_mut(map_key).expect("tracked session"); - let frame = - json!({ "type": "send", "sessionId": session.sidecar_session_id, "text": text }); - session.stdin.write(&frame).await.expect("raw stdin write"); - } - /// Mark a fixture session mid-turn: a send the CLI started, armed in the /// session's turn ledger the way `handle_send` and the stdout consumer /// would have left it. async fn prime_fixture_running_turn(st: &FreshClaudeState, map_key: &str) { + let key = st + .resolve_session_key(map_key) + .await + .expect("the native runtime exists"); let guard = st.sessions.lock().await; let mut ledger = guard - .get(map_key) + .get(&key) .expect("tracked session") .ledger .lock() @@ -17511,7 +16041,7 @@ rl.on('line', (line) => { "no sidecar was spawned/killed for a refused attempt" ); assert!( - st.sessions.lock().await.contains_key("rb-busy"), + st.resolve_session_key("rb-busy").await.is_some(), "the session was never torn down" ); drop(env); @@ -17526,13 +16056,15 @@ rl.on('line', (line) => { // BUSY_TURN with zero teardown/spawn traffic; AFTER it, the gate reopens. let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + std::env::set_var("FRESHELL_CLAUDE_SIM_TIME_SCALE", "10"); let (st, mut rx) = state_with_bus(); st.handle_create(dedup_create_msg("req-compact-rb"), None) .await; let created = await_claude_created(&mut rx, "req-compact-rb").await; let session_id = created["sessionId"].as_str().unwrap().to_string(); - st.handle_compact(compact_msg(&session_id, None)).await; + st.handle_compact(compact_msg(&session_id, Some("APIERR:500"))) + .await; assert!( ledger_busy_of(&st, &session_id).await, "a compact arms the ledger BEFORE the sidecar write" @@ -17559,14 +16091,8 @@ rl.on('line', (line) => { "no teardown" ); - // The compact turn ends at its result frame (any subtype — here an - // ERRORED one), and the busy gate reopens (a follow-up rollback leaves - // the window for its true verdict — NOTHING_TO_UNDO on this - // transcript-less session). The fake reports no command lifecycle, so - // the result settles the oldest command (the compact) by the head - // fallback. inject_raw_send: the compact's own result, not a second - // command. - inject_raw_send(&st, &session_id, "__emit_result_error__").await; + // Even failed compaction settles its own command before rollback reopens. + await_frame_of_inner_type(&mut rx, "freshAgent.result").await; await_ledger_busy(&st, &session_id, false).await; let (sink2, captured2) = capturing_sink(); st.handle_rollback( @@ -17576,8 +16102,13 @@ rl.on('line', (line) => { .await; let frames2 = captured_json(&captured2); assert_eq!( - frames2[0]["event"]["code"], "NOTHING_TO_UNDO", - "the busy gate reopened once the compact turn's sdk.result landed: {frames2:?}" + frames2[0]["event"]["type"], "freshAgent.rolledBack", + "{frames2:?}" + ); + let replacement = frames2[0]["event"]["newSessionId"].as_str().unwrap(); + assert_eq!( + st.resolve_session_key(&session_id).await.as_deref(), + Some(replacement) ); drop(env); } @@ -17600,7 +16131,7 @@ rl.on('line', (line) => { assert_eq!(frames[0]["event"]["rollback"], json!(true)); assert_eq!(frames[0]["event"]["requestId"], "rb-r-1"); assert_eq!(std::fs::read_to_string(&stdin_log).unwrap_or_default(), ""); - assert!(st.sessions.lock().await.contains_key("rb-redo-none")); + assert!(st.resolve_session_key("rb-redo-none").await.is_some()); } #[tokio::test] @@ -17632,7 +16163,7 @@ rl.on('line', (line) => { // is NEVER mutated: no stdin bytes, no sidecar spawn/kill, session intact. assert_eq!(std::fs::read_to_string(&stdin_log).unwrap_or_default(), ""); assert_eq!(env.spawn_count(), 0); - assert!(st.sessions.lock().await.contains_key("rb-nowrite")); + assert!(st.resolve_session_key("rb-nowrite").await.is_some()); assert!( sink_impl.rollbacks.lock().unwrap().is_empty(), "no durable row landed" @@ -17650,7 +16181,7 @@ rl.on('line', (line) => { let moved = format!( "{}\n{}", two_turn_transcript(), - json!({"type":"user","uuid":"u3","parentUuid":"a2","timestamp":"t5","message":{"role":"user","content":[{"type":"text","text":"prompt three"}]}}) + json!({"type":"user","uuid":"33333333-3333-4333-8333-333333333333","parentUuid":"aaaaaaaa-2222-4222-8222-222222222222","timestamp":"t5","message":{"role":"user","content":[{"type":"text","text":"prompt three"}]}}) ); write_rollback_transcript(home.path(), "orig-moved", &moved); // The current (post-undo) transcript is the prefix only. @@ -17669,7 +16200,7 @@ rl.on('line', (line) => { .record_rollback("claude", "dur-moved-tip", { let mut r = RollbackRecord::empty(100); r.original_session_id = Some("orig-moved".to_string()); - r.original_tip_uuid = Some("a2".to_string()); // the tip observed at undo time + r.original_tip_uuid = Some("aaaaaaaa-2222-4222-8222-222222222222".to_string()); // the tip observed at undo time r.set_can_redo(true, 100); r }) @@ -17703,7 +16234,7 @@ rl.on('line', (line) => { let (st, mut rx) = state_with_bus(); insert_fake_claude_session_with_pending( &st, - "rb-pending", + "dur-pending", Some("dur-pending"), &[ json!({"type":"sdk.permission.request","sessionId":"rb-pending","requestId":"req-1","tool":{"name":"Bash","input":{"command":"ls"}},"toolUseID":"toolu_1"}), @@ -17711,13 +16242,13 @@ rl.on('line', (line) => { ], ) .await; - st.emit_pending_cancellations("rb-pending", "dur-pending", "freshclaude") + st.emit_pending_cancellations("dur-pending", "dur-pending", "freshclaude") .await; let perm = await_frame_of_inner_type(&mut rx, "freshAgent.permission.cancelled").await; assert_eq!(perm["event"]["requestId"], json!("req-1"), "{perm}"); let question = await_frame_of_inner_type(&mut rx, "freshAgent.question.cancelled").await; assert_eq!(question["event"]["requestId"], json!("q-1"), "{question}"); - let (permissions, questions) = pending_request_ids(&st, "rb-pending").await; + let (permissions, questions) = pending_request_ids(&st, "dur-pending").await; assert!( permissions.is_empty() && questions.is_empty(), "decision 6: cancelled means cancelled — pending cards are never silently resolved" @@ -17738,10 +16269,10 @@ rl.on('line', (line) => { // S' (the CURRENT live session): a fork of O (u1/a1 prefix) whose user // RESENT an edited prompt — its own u-prime/a-prime turn. let s_prime_transcript = [ - 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":"uq","parentUuid":"a1","timestamp":"t5","message":{"role":"user","content":[{"type":"text","text":"prompt two edited"}]}}), - json!({"type":"assistant","uuid":"aq","parentUuid":"uq","timestamp":"t6","message":{"role":"assistant","content":[{"type":"text","text":"answer two edited"}]}}), + json!({"type":"user","uuid":"11111111-1111-4111-8111-111111111111","parentUuid":null,"timestamp":"t1","message":{"role":"user","content":[{"type":"text","text":"prompt one"}]}}), + json!({"type":"assistant","uuid":"aaaaaaaa-1111-4111-8111-111111111111","parentUuid":"11111111-1111-4111-8111-111111111111","timestamp":"t2","message":{"role":"assistant","content":[{"type":"text","text":"answer one"}]}}), + json!({"type":"user","uuid":"44444444-4444-4444-8444-444444444444","parentUuid":"aaaaaaaa-1111-4111-8111-111111111111","timestamp":"t5","message":{"role":"user","content":[{"type":"text","text":"prompt two edited"}]}}), + json!({"type":"assistant","uuid":"aaaaaaaa-4444-4444-8444-444444444444","parentUuid":"44444444-4444-4444-8444-444444444444","timestamp":"t6","message":{"role":"assistant","content":[{"type":"text","text":"answer two edited"}]}}), ] .iter() .map(|v| v.to_string()) @@ -17756,12 +16287,12 @@ rl.on('line', (line) => { .record_rollback("claude", "s-prime", { let mut r = RollbackRecord::empty(100); r.original_session_id = Some("orig-epoch".to_string()); - r.original_tip_uuid = Some("a2".to_string()); + r.original_tip_uuid = Some("aaaaaaaa-2222-4222-8222-222222222222".to_string()); r.push_entry( RollbackEntry { removed_turns: vec![ - json!({"id":"u2","turnId":"u2","role":"user","summary":"prompt two","items":[]}), - json!({"id":"a2","turnId":"a2","role":"assistant","summary":"answer two","items":[]}), + json!({"id":"22222222-2222-4222-8222-222222222222","turnId":"22222222-2222-4222-8222-222222222222","role":"user","summary":"prompt two","items":[]}), + json!({"id":"aaaaaaaa-2222-4222-8222-222222222222","turnId":"aaaaaaaa-2222-4222-8222-222222222222","role":"assistant","summary":"answer two","items":[]}), ], prompt_text: "prompt two".to_string(), at_ms: 100, @@ -17811,12 +16342,15 @@ rl.on('line', (line) => { 1, "exactly the rollback's fork create: {creates:?}" ); - assert_eq!(creates[0]["boot"]["resume"], json!("s-prime")); - assert_eq!(creates[0]["boot"]["resumeSessionAt"], json!("a1")); - assert_eq!(creates[0]["boot"]["kind"], json!("fork")); + assert_eq!(creates[0]["options"]["resume"], json!("s-prime")); + assert_eq!( + creates[0]["options"]["resumeSessionAt"], + json!("aaaaaaaa-1111-4111-8111-111111111111") + ); + assert_eq!(creates[0]["options"]["forkSession"], json!(true)); assert_eq!( - creates[0]["boot"]["dropsTurn"], - json!("uq"), + creates[0]["options"]["resumeDropsTurn"], + json!("44444444-4444-4444-8444-444444444444"), "the guard is the first-to-discard turn's prompt uuid (brief/SDK semantics)" ); @@ -17831,7 +16365,7 @@ rl.on('line', (line) => { ); assert_eq!( record.original_tip_uuid.as_deref(), - Some("aq"), + Some("aaaaaaaa-4444-4444-8444-444444444444"), "S' chain tip" ); assert!( @@ -17854,7 +16388,10 @@ rl.on('line', (line) => { .collect(); assert_eq!( first_entry_ids, - vec!["u2", "a2"], + vec![ + "22222222-2222-4222-8222-222222222222", + "aaaaaaaa-2222-4222-8222-222222222222" + ], "the frozen prior-epoch markers stay first (decision 6; r3)" ); let second_entry_ids: Vec<&str> = record.entries[1] @@ -17864,7 +16401,10 @@ rl.on('line', (line) => { .collect(); assert_eq!( second_entry_ids, - vec!["uq", "aq"], + vec![ + "44444444-4444-4444-8444-444444444444", + "aaaaaaaa-4444-4444-8444-444444444444" + ], "the newest epoch's removed slice" ); // Delta-r1 F8 case (a): the destroy bit at load opened a NEW epoch — the @@ -17889,25 +16429,37 @@ rl.on('line', (line) => { .expect("snapshot builds over the staged s-prime transcript"); let bucket = snap["rolledBackTurns"].as_array().expect("bucket"); assert_eq!(bucket.len(), 4); - assert_eq!(bucket[0]["turnId"], json!("u2")); + assert_eq!( + bucket[0]["turnId"], + json!("22222222-2222-4222-8222-222222222222") + ); assert_eq!( bucket[0]["restorable"], json!(false), "frozen prior-epoch USER marker" ); - assert_eq!(bucket[1]["turnId"], json!("a2")); + assert_eq!( + bucket[1]["turnId"], + json!("aaaaaaaa-2222-4222-8222-222222222222") + ); assert_eq!( bucket[1]["restorable"], json!(false), "frozen prior-epoch ASSISTANT marker — ALL roles are stamped" ); - assert_eq!(bucket[2]["turnId"], json!("uq")); + assert_eq!( + bucket[2]["turnId"], + json!("44444444-4444-4444-8444-444444444444") + ); assert_eq!( bucket[2]["restorable"], json!(true), "current-epoch USER marker" ); - assert_eq!(bucket[3]["turnId"], json!("aq")); + assert_eq!( + bucket[3]["turnId"], + json!("aaaaaaaa-4444-4444-8444-444444444444") + ); assert_eq!( bucket[3]["restorable"], json!(true), @@ -17939,7 +16491,7 @@ rl.on('line', (line) => { assert_eq!(frames2[0]["event"]["type"], "freshAgent.redone"); assert_eq!( frames2[0]["event"]["restoredThroughTurnId"], - json!("aq"), + json!("aaaaaaaa-4444-4444-8444-444444444444"), "redone restores through the restored step's OWN last uuid (r3 boundary rule)" ); assert_eq!( @@ -17955,14 +16507,17 @@ rl.on('line', (line) => { .collect(); assert_eq!(creates.len(), 2, "{creates:?}"); assert_eq!( - creates[1]["boot"]["resume"], + creates[1]["options"]["resume"], json!("s-prime"), "redo re-forks the re-rooted chain root" ); - assert_eq!(creates[1]["boot"]["resumeSessionAt"], json!("aq")); - assert_eq!(creates[1]["boot"]["kind"], json!("fork")); + assert_eq!( + creates[1]["options"]["resumeSessionAt"], + json!("aaaaaaaa-4444-4444-8444-444444444444") + ); + assert_eq!(creates[1]["options"]["forkSession"], json!(true)); assert!( - creates[1]["boot"].get("dropsTurn").is_none(), + creates[1]["options"].get("resumeDropsTurn").is_none(), "the guard is omitted when the discard range is empty (redo to the tip) — never fabricated" ); let record2 = sink_impl @@ -17980,7 +16535,10 @@ rl.on('line', (line) => { .collect(); assert_eq!( remaining_ids, - vec!["u2", "a2"], + vec![ + "22222222-2222-4222-8222-222222222222", + "aaaaaaaa-2222-4222-8222-222222222222" + ], "only the frozen prior-epoch markers remain" ); assert!(!record2.can_redo()); @@ -18144,7 +16702,7 @@ rl.on('line', (line) => { .record_rollback("claude", "dur-rf", { let mut r = RollbackRecord::empty(100); r.original_session_id = Some("dur-rf-orig".to_string()); - r.original_tip_uuid = Some("a2".to_string()); + r.original_tip_uuid = Some("aaaaaaaa-2222-4222-8222-222222222222".to_string()); r.set_can_redo(true, 100); r }) @@ -18176,7 +16734,7 @@ rl.on('line', (line) => { assert_eq!(env.spawn_count(), 0, "a read failure forks nothing"); assert_eq!(std::fs::read_to_string(&stdin_log).unwrap_or_default(), ""); assert!( - st.sessions.lock().await.contains_key("rb-rf"), + st.resolve_session_key("rb-rf").await.is_some(), "the session was never torn down" ); drop(env); @@ -18222,7 +16780,7 @@ rl.on('line', (line) => { let stops: Vec<_> = super::teardown_tests::capture::teardown_events(&events) .into_iter() .filter(|(_, fields)| { - fields.get("session_id").map(String::as_str) == Some("rb-graceful") + fields.get("session_id").map(String::as_str) == Some("dur-graceful") }) .collect(); assert_eq!( diff --git a/crates/freshell-freshagent/src/claude/fork.rs b/crates/freshell-freshagent/src/claude/fork.rs index e0701f12d..f02ebc24d 100644 --- a/crates/freshell-freshagent/src/claude/fork.rs +++ b/crates/freshell-freshagent/src/claude/fork.rs @@ -507,7 +507,7 @@ impl FreshClaudeState { TurnLockedRequest::Fork, map_key, wire_id, - Some(durable), + durable, turn_lock, ) .await?; diff --git a/crates/freshell-freshagent/src/claude/teardown_tests.rs b/crates/freshell-freshagent/src/claude/teardown_tests.rs index 5569b8d43..7d6135ab3 100644 --- a/crates/freshell-freshagent/src/claude/teardown_tests.rs +++ b/crates/freshell-freshagent/src/claude/teardown_tests.rs @@ -108,8 +108,6 @@ async fn fake_session( ownership_id: format!("test-teardown-{session_id}"), consumer, sidecar_session_id: session_id.to_string(), - cli_session_id: None, - owning_operation: None, owning_generation: None, identity: RuntimeIdentity::new(session_id, Vec::new()), pending: Arc::new(std::sync::Mutex::new(ClaudePending::default())), @@ -181,7 +179,9 @@ pub(super) mod capture { ) { let events = Arc::new(StdMutex::new(Vec::new())); let subscriber = tracing_subscriber::registry().with(CaptureLayer(Arc::clone(&events))); - (events, tracing::subscriber::set_default(subscriber)) + let guard = tracing::subscriber::set_default(subscriber); + tracing::callsite::rebuild_interest_cache(); + (events, guard) } } diff --git a/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs b/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs index 6ea169fa4..dcec8c677 100644 --- a/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs +++ b/crates/freshell-freshagent/src/claude/tests/recovery_tests.rs @@ -127,6 +127,7 @@ async fn a_configure_receipt_from_a_replaced_runtime_cannot_change_its_replaceme async fn an_observed_hosted_transcript_cannot_fall_back_to_fresh_after_it_is_removed() { let _guard = CLAUDE_ENV_LOCK.lock().await; let env = FakeClaudeSidecarEnv::install(); + env.program(json!([{"kind":"text", "data":{"text":"transcript observed"}}])); let home = tempfile::tempdir().unwrap(); let (st, mut rx) = state_with_bus(); let mut environment: Vec<(String, String)> = std::env::vars().collect(); @@ -142,7 +143,8 @@ async fn an_observed_hosted_transcript_cannot_fall_back_to_fresh_after_it_is_rem let id = created["sessionId"].as_str().unwrap().to_string(); assert!(st.sessions.lock().await[&id].launch_genesis.is_some()); write_fake_transcript(home.path(), &id); - inject_raw_send(&st, &id, "__emit_result__").await; + st.handle_send(send_msg(&id, "observe the saved transcript")) + .await; await_frame_of_inner_type(&mut rx, "freshAgent.result").await; let deadline = tokio::time::Instant::now() + Duration::from_secs(5); while st.sessions.lock().await[&id].launch_genesis.is_some() { @@ -152,12 +154,15 @@ async fn an_observed_hosted_transcript_cannot_fall_back_to_fresh_after_it_is_rem ); tokio::time::sleep(Duration::from_millis(10)).await; } - let transcript = home.path().join("projects/-t").join(format!("{id}.jsonl")); - std::fs::rename(&transcript, transcript.with_extension("saved")).unwrap(); let pid = st.sessions.lock().await[&id].child.id().unwrap(); // SAFETY: this process belongs to this test's isolated fake sidecar. assert_eq!(unsafe { libc::kill(pid as i32, libc::SIGKILL) }, 0); - await_frame_of_inner_type(&mut rx, "freshAgent.status").await; + loop { + let status = await_frame_of_inner_type(&mut rx, "freshAgent.status").await; + if status["event"]["status"] == "exited" { + break; + } + } let deadline = tokio::time::Instant::now() + Duration::from_secs(5); while st.resolve_session_key(&id).await.is_some() { assert!( @@ -166,6 +171,20 @@ async fn an_observed_hosted_transcript_cannot_fall_back_to_fresh_after_it_is_rem ); tokio::task::yield_now().await; } + // The real CLI wrote its own project directory in addition to the seeded + // file. Once its writer is confirmed stopped, remove every copy of this id. + let mut moved = 0; + for project in std::fs::read_dir(home.path().join("projects")).unwrap() { + let transcript = project.unwrap().path().join(format!("{id}.jsonl")); + if transcript.is_file() { + std::fs::rename(&transcript, transcript.with_extension("saved")).unwrap(); + moved += 1; + } + } + assert!( + moved >= 1, + "the observed transcript was present before removal" + ); assert!(matches!( st.ensure_runtime(&id, SessionType::Freshclaude, None, None) .await, diff --git a/crates/freshell-freshagent/src/session_handoff/tests.rs b/crates/freshell-freshagent/src/session_handoff/tests.rs index 3357ee833..fb1eb2db8 100644 --- a/crates/freshell-freshagent/src/session_handoff/tests.rs +++ b/crates/freshell-freshagent/src/session_handoff/tests.rs @@ -7003,54 +7003,18 @@ 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"); - // 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 re-key's publication — the REAL lane-level bookkeeping, - // driven through the adoption itself. - { - let map_key = rig - .fresh_claude + // Use the consumer's actual native reset path: runtime identity, lease, + // coordinator owner and retained stamp move together. + rig.fresh_claude + .test_rekey_conversation(&superseded, &canonical) + .await; + assert_eq!( + rig.fresh_claude .test_resolve_session_key(&superseded) .await - .expect("the live session's map key"); - rig.fresh_claude - .test_adopt_session_init(&canonical, &map_key, "freshclaude") - .await; - } - // (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). - let rekey_map_key = rig - .fresh_claude - .test_resolve_session_key(&superseded) - .await - .expect("the live session's map key"); - assert!(matches!( - rig.ownership.rekey_live( - "claude", - &superseded, - &canonical, - &rekey_map_key, - freshell_ownership::OwnerIdentity { - kind: freshell_ownership::RuntimeOwnerKind::FreshAgent, - terminal_id: None, - live_session_key: Some(rekey_map_key.clone()), - pid: sidecar_pid, - ownership_id: Some("test-rekey-op".into()), - }, - "test-rekey", - "test-rekey-op", - ), - freshell_ownership::CommitOutcome::Committed - )); + .as_deref(), + Some(canonical.as_str()) + ); // THE e2r2 F1 red/green: the handoff on the SUPERSEDED id resolves the // canonical key and stops the prior owner. diff --git a/crates/freshell-freshagent/src/snapshot.rs b/crates/freshell-freshagent/src/snapshot.rs index 0beb6bb44..721a3aba1 100644 --- a/crates/freshell-freshagent/src/snapshot.rs +++ b/crates/freshell-freshagent/src/snapshot.rs @@ -857,7 +857,7 @@ mod tests { /// `pendingQuestions` populated with the exact `.strict()` contract entry keys /// (object equality pins the KEY SET: no extras, no missing) and the /// presence-of-pending gates flipped (`normalize.ts:186-204, 226-232`). Addressed by - /// the DURABLE UUID (the `cli_index` alias), the id a live pane's GET carries. + /// the native UUID, the id a live pane's GET carries. #[tokio::test] async fn claude_locator_overlays_live_pending_and_flips_capability_gates() { let _guard = CLAUDE_ENV_LOCK.lock().await; @@ -866,7 +866,7 @@ mod tests { let claude = claude_state(); crate::claude::tests::insert_fake_claude_session_with_pending( &claude, - "client-nanoid-9", + durable, Some(durable), &[ json!({ "type": "sdk.permission.request", "sessionId": "s", "requestId": "req-1", @@ -923,7 +923,7 @@ mod tests { let claude = claude_state(); crate::claude::tests::insert_fake_claude_session_with_pending( &claude, - "client-nanoid-suggest", + durable, Some(durable), &[ json!({ "type": "sdk.permission.request", "sessionId": "s", "requestId": "req-npm", @@ -990,7 +990,7 @@ mod tests { let claude = claude_state(); crate::claude::tests::insert_fake_claude_session_with_pending( &claude, - "client-nanoid-12", + durable, Some(durable), &[ json!({ "type": "sdk.question.request", "sessionId": "s", "requestId": "q-prev", @@ -1076,7 +1076,7 @@ mod tests { let claude = claude_state(); crate::claude::tests::insert_fake_claude_session_with_pending( &claude, - "client-nanoid-10", + durable, Some(durable), &[ json!({ "type": "sdk.permission.request", "sessionId": "s", "requestId": "req-7", @@ -1207,7 +1207,7 @@ mod tests { let claude = claude_state(); crate::claude::tests::insert_fake_claude_session_with_pending( &claude, - "client-nanoid-11", + durable, Some(durable), &[], ) From 94b4db00bf20e331448d8cd818adcfb8aa532754 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:02:33 -0700 Subject: [PATCH 082/100] test(e2e): isolate Claude reveal and restore liveness --- ...fresh-agent-hidden-approval-reveal.spec.ts | 64 ++++++++++++++++--- .../specs/restore-contract-wall-rust.spec.ts | 45 ++++++------- 2 files changed, 75 insertions(+), 34 deletions(-) diff --git a/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts b/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts index 39f82dc8a..9f9b5479a 100644 --- a/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts +++ b/test/e2e-browser/specs/fresh-agent-hidden-approval-reveal.spec.ts @@ -10,7 +10,7 @@ * and then fail. G26 keeps this refresh non-blocking (a hidden pane owes one * 'reveal-surface' refresh instead of marking its transcript dirty). * - * 1. an approval raised while tab A is hidden shows its card after the + * 1. an approval delivered while tab A is hidden shows its card after the * reveal, and the overlay is never drawn; * 2. the same approval cancelled while tab A is hidden is gone after the * reveal, and the overlay is never drawn. @@ -24,11 +24,12 @@ * itself. What this spec does catch is a regression to the blocking reveal * path (the overlay recorder) and a broken end-to-end journey. * - * Trigger: a visible composer starts the turn, then the user switches tabs - * before the simulator causes the production sidecar's canUseTool callback - * to raise one Bash approval. The send itself must not be hidden: a hidden - * send acceptance legitimately dirties the transcript and tests the blocking - * transcript-refresh path instead of the approval-only reveal path. + * Trigger: a visible composer starts the turn. The production sidecar streams + * the Bash tool-use transcript while tab A is visible; the test holds only + * the resulting canUseTool approval frame at the WebSocket boundary, switches + * tabs, then delivers that frame while A is hidden. The send and tool-use + * transcript must not be hidden: their transcript changes legitimately test + * the blocking transcript-refresh path instead of the approval-only reveal. * Then the client's own `freshAgent.interrupt` frame for the session — the * exact frame the Stream Deck "stop" sends to a pane in a background tab — * cancels it (the sidecar's interrupt-time pending cancellation, mirroring @@ -151,7 +152,14 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without let pauseAfterCancellation = false let deliveryPaused = false let heldResults = 0 + let heldPermissionRequest = false + let releasePermissionRequest: (() => void) | null = null const heldDeliveries: Array<() => void> = [] + const releaseApprovalRequest = () => { + const deliver = releasePermissionRequest + releasePermissionRequest = null + deliver?.() + } const releaseDeliveries = () => { deliveryPaused = false for (const deliver of heldDeliveries.splice(0)) deliver() @@ -167,6 +175,18 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without heldDeliveries.push(() => route.send(message)) return } + // Deliver the streamed tool-use transcript while the pane is + // visible, then hold only the approval surface event until its tab + // is hidden. This isolates the non-blocking approval reveal from + // the separate transcript-dirty reveal path. + if (!heldPermissionRequest + && frame?.type === 'freshAgent.event' + && frame.event?.type === 'freshAgent.permission.request' + && frame.provider === 'claude') { + heldPermissionRequest = true + releasePermissionRequest = () => route.send(message) + return + } route.send(message) if (pauseAfterCancellation && frame?.type === 'freshAgent.event' && frame.event?.type === 'freshAgent.permission.cancelled') { @@ -267,7 +287,7 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without }) }, bridgeSessionId) - // ── 1. An approval RAISED while hidden appears on reveal ───────────── + // ── 1. An approval delivered while hidden appears on reveal ───────── const composer = paneA.getByRole('textbox', { name: 'Chat message input' }) await composer.fill(RAISE_TEXT) await paneA.getByRole('button', { name: 'Send' }).click() @@ -288,15 +308,38 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without timeout: 10_000, message: 'the visible pane observed the turn start before it is hidden', }).toBe('running') - // The delayed canUseTool request now arrives after A is hidden. - await tabBLocator.click() - await expect.poll(() => harness.getActiveTabId(), { timeout: 10_000 }).toBe(tabB) + // The tool-use transcript and assistant line arrive while A remains + // visible. The matching canUseTool request is held at the WS boundary. + await expect.poll(async () => { + const received = ((await harness.getReceivedWsMessages()) as any[]).filter((message) => ( + message?.type === 'freshAgent.event' && message.sessionId === bridgeSessionId + )) + const assistant = received.find((message) => message.event?.type === 'freshAgent.assistant' + && message.event?.content?.some((block: any) => block?.type === 'tool_use' && block?.name === 'Bash')) + return typeof assistant?.event?.uuid === 'string' + && received.some((message) => message.event?.type === 'freshAgent.session.changed' + && message.event?.reason === 'transcript' + && message.event?.uuid === assistant.event.uuid) + }, { + timeout: 15_000, + message: 'the prompt and Bash tool-use transcript land while the pane is visible', + }).toBe(true) await expect .poll(() => readClaudeSimLedger(simLedger).filter((row) => row.kind === 'approval').length, { timeout: 15_000, message: 'the simulated CLI invokes the production canUseTool approval path', }) .toBe(1) + await expect.poll(() => releasePermissionRequest !== null, { + timeout: 15_000, + message: 'the pending approval event is held at the WS boundary', + }).toBe(true) + expect(await harness.getActiveTabId(), 'the transcript changed while tab A was visible').toBe(tabA) + // The approval event arrives after A is hidden; no transcript-changing + // event is introduced in the hidden interval before this surface update. + await tabBLocator.click() + await expect.poll(() => harness.getActiveTabId(), { timeout: 10_000 }).toBe(tabB) + releaseApprovalRequest() try { await expect .poll(async () => (await receivedFreshAgentEvents(harness, 'freshAgent.permission.request')).length, { @@ -369,6 +412,7 @@ test.describe('hidden fresh-agent panes show current approvals on reveal without message: 'the stopped turn result is delivered after the approval-only reveal', }).toBe(1) } finally { + try { releaseApprovalRequest() } catch { /* The page can already be closed after a failed test. */ } try { releaseDeliveries() } catch { /* The page can already be closed after a failed test. */ } await server?.stop().catch(() => {}) await fs.rm(sharedRoot, { recursive: true, force: true }).catch(() => {}) diff --git a/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts b/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts index cc8af4400..5e18064b4 100644 --- a/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts +++ b/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts @@ -1334,6 +1334,11 @@ test.describe('Restore Contract Wall (P0.1)', () => { match: { text: 'wall freshclaude turn' }, emit: [{ kind: 'text', data: { text: 'RESTORE_WALL_REPLY' } }], }, + { + on: 'msg:prompt', + match: { text: 'wall freshclaude liveness turn' }, + emit: [{ kind: 'text', data: { text: 'RESTORE_WALL_LIVENESS_REPLY' } }], + }, ], }, }), @@ -1421,30 +1426,15 @@ test.describe('Restore Contract Wall (P0.1)', () => { expect(create.resumeSessionId ?? create.sessionRef?.sessionId, JSON.stringify(create)).toBe(originalSessionId) } - // LIVENESS (council follow-up, PR #562/#563 close-out): everything - // above proves the durable IDENTITY survived, but a mutation where the - // attach arm sets pane status and never wires the event stream (the - // literal historical P0.2 bug shape) would leave every assertion above - // green over a dead socket. Mirror lane test 1's post-restart turn on - // THIS ordering (SIGKILL restart THEN reload, attach-resolved): send a - // second turn and require the assistant reply to ROUND-TRIP through - // the live event stream into the freshAgent slice (turns[] is fed by - // wire events -- freshAgent.assistant folds, fresh-agent-ws.ts -- so a - // dead socket can never grow it), plus the fixture transcript on disk - // gaining the second user line (the server->sidecar leg). - const countFoldedAssistantTurns = async (): Promise => { - const sessions = (await harness.getState())?.freshAgent?.sessions ?? {} - return Object.values(sessions).reduce( - (n: number, s: any) => - n + (s?.turns ?? []).filter((t: any) => t?.role === 'assistant').length, - 0, - ) - } - const assistantTurnsBefore = await countFoldedAssistantTurns() + // LIVENESS: after this SIGKILL restart and reload, send another turn + // through the same pane and require its unique answer in the rendered + // conversation. The normalized session cache's `turns` array is + // snapshot-only; assistant frames are folded into the transcript view, + // so the pane and durable JSONL are the behavior-level checks here. await sendFreshAgentTurn(page, harness, rehydratedTabId, 'wall freshclaude liveness turn') - await expect - .poll(countFoldedAssistantTurns, { timeout: 30_000 }) - .toBeGreaterThan(assistantTurnsBefore) + await expect( + page.locator('[data-context="fresh-agent"]').last().getByText('RESTORE_WALL_LIVENESS_REPLY', { exact: true }), + ).toBeVisible({ timeout: 30_000 }) const transcriptFile = path.join( info.homeDir, '.claude', @@ -1463,7 +1453,14 @@ test.describe('Restore Contract Wall (P0.1)', () => { expect(userTexts).toEqual( expect.arrayContaining(['wall freshclaude turn', 'wall freshclaude liveness turn']), ) - expect(transcriptLines.filter((l: any) => l.type === 'assistant').length).toBeGreaterThanOrEqual(2) + const assistantTexts = transcriptLines + .filter((line: any) => line.type === 'assistant') + .flatMap((line: any) => line.message?.content ?? []) + .filter((block: any) => block?.type === 'text') + .map((block: any) => block.text) + expect(assistantTexts).toEqual( + expect.arrayContaining(['RESTORE_WALL_REPLY', 'RESTORE_WALL_LIVENESS_REPLY']), + ) } finally { await context.close().catch(() => {}) await server.stop() From ee60620c54818b935c1eae7dd2ae78653f795acf Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:05:46 -0700 Subject: [PATCH 083/100] test(freshclaude): exercise hosted MCP and settings through the real sidecar Replace hosted protocol emitters with the production constructor, index.mjs, SDK recorder, and contract simulator. Observe the actual fixed MCP recipe and configure requests; inject refusal only at SDK setModel. Preserve fresh/resume boot, actor journal, default-effort and plugin behavior. All 24 Claude-filtered session-host tests and all-target Clippy pass. --- .../src/providers/fresh_agent.rs | 221 ++++++++---------- 1 file changed, 94 insertions(+), 127 deletions(-) diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index c66821333..342cc1e98 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -163,13 +163,15 @@ impl HostedTransport { async fn with_claude_sidecar_environment( provider: FreshProvider, environment: Vec<(String, String)>, + context: Option<&ProviderLaunchContext>, ) -> Arc { - let (broadcast_tx, broadcast_rx) = broadcast::channel(1024); - let broadcast_tx = Arc::new(broadcast_tx); - let state = ProviderState::Claude( - FreshClaudeState::new(Arc::clone(&broadcast_tx)).with_sidecar_environment(environment), - ); - Self::from_parts(provider, state, broadcast_tx, broadcast_rx) + let mut transport = Self::new_with_context(provider, context).await; + let hosted = Arc::get_mut(&mut transport).expect("new transport has one owner"); + let ProviderState::Claude(state) = &mut hosted.state else { + panic!("the fixture requires a Claude runtime"); + }; + *state = state.clone().with_sidecar_environment(environment); + transport } async fn new_with_context( @@ -1537,36 +1539,6 @@ mod tests { #[tokio::test] async fn managed_provider_fresh_claude_sends_only_fixed_hosted_mcp_selection() { let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; - let dir = tempfile::tempdir().unwrap(); - let script = dir.path().join("sidecar.mjs"); - let receipt = dir.path().join("create.json"); - std::fs::write(&script, r#" -import {createInterface} from 'node:readline'; -import {writeFileSync} from 'node:fs'; -const lines=createInterface({input:process.stdin}); -lines.on('line', line=>{ - const req=JSON.parse(line); - if(req.type==='create'){ - writeFileSync(process.env.FRESHELL_TEST_MCP_RECEIPT,JSON.stringify(req)); - process.stdout.write(JSON.stringify({type:'created',requestId:req.requestId,sessionId:'owned-mcp-fixture',protocol:2})+'\n'); - if(req.model!=='deferred-identity')process.stdout.write(JSON.stringify({type:'sdk.session.init',sessionId:'owned-mcp-fixture',cliSessionId:req.boot?.resume??'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',model:'fixture',cwd:req.cwd,tools:[],slashCommands:[]})+'\n'); - } - if(req.type==='shutdown')process.exit(0); -}); -"#).unwrap(); - let keys = [ - "FRESHELL_CLAUDE_SIDECAR", - "FRESHELL_CLAUDE_NODE", - "FRESHELL_TEST_MCP_RECEIPT", - ]; - let _env = SnapshotProviderEnv( - keys.iter() - .map(|key| (*key, std::env::var_os(key))) - .collect(), - ); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("FRESHELL_TEST_MCP_RECEIPT", &receipt); let context = ProviderLaunchContext { preparation: ProviderPreparation::Claude { mcp_args: vec!["--mcp-config".into(), ".claude/freshell-mcp.json".into()], @@ -1581,60 +1553,54 @@ lines.on('line', line=>{ (true, true, None), (true, false, Some("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")), ] { - let transport = HostedTransport::new_with_context( - FreshProvider::Claude, + let conversations: Vec<&str> = expected_identity.into_iter().collect(); + let claude = SimulatedClaude::start_with( + &conversations, selected.then_some(&context), + vec![("FRESHELL_HOSTED_FRESH_AGENT".into(), "claude".into())], ) .await; - let profile = FreshAgentProfile { - provider: FreshProvider::Claude, - runtime_variant: "freshclaude".into(), - cwd: dir.path().to_string_lossy().into(), - model: deferred_identity.then(|| "deferred-identity".into()), - effort: None, - permission_mode: None, - sandbox: None, - provider_store_id: "owned-store".into(), - native_session_id: expected_identity.map(str::to_owned), - plugins: None, - model_selection: None, - session_ref: None, - provider_launch_context: selected.then_some(context.clone()), - provider_secret_references: vec![], - }; + let mut profile = claude.profile(expected_identity); + profile.model = deferred_identity.then(|| "deferred-identity".into()); + profile.provider_launch_context = selected.then_some(context.clone()); let started = - tokio::time::timeout(Duration::from_secs(1), transport.start(&profile)).await; - let observed: Value = - serde_json::from_slice(&std::fs::read(&receipt).unwrap()).unwrap(); - transport.stop().await.unwrap(); - let started = started - .expect("Claude create cannot wait for the first model turn") - .unwrap(); + tokio::time::timeout(Duration::from_secs(20), claude.transport.start(&profile)) + .await + .expect("Claude create cannot wait for the first model turn") + .unwrap(); + let queries = claude.ledger_rows("query"); + assert_eq!(queries.len(), 1, "one actual SDK query boots the runtime"); + let observed = &queries[0]["options"]; if let Some(expected) = expected_identity { - // A resume is named by `created` (the sidecar answers it once the - // CLI booted that conversation), not by a later turn's init. assert_eq!(started.native_session_id.as_deref(), Some(expected)); - assert_eq!( - observed["boot"], - json!({ "kind": "resume", "resume": expected }), - "the create boots the resumed conversation" - ); + assert_eq!(observed["resume"], expected); } if deferred_identity { assert_eq!( started.native_session_id.as_deref(), - observed["sessionId"].as_str(), - "fresh native identity is known before any prompt or init" + observed["sessionId"].as_str() ); } - assert_eq!( - observed["hostedMcp"], selected, - "only accepted hosted selection enables the SDK recipe" - ); assert!( - observed.get("mcpServers").is_none(), - "arbitrary MCP maps are never sent over private create IPC" + claude.ledger_rows("prompt").is_empty(), + "identity never needs a prompt" ); + if selected { + assert_eq!( + observed["mcpServers"], + json!({ + "freshell": {"command":"node", "args":["/opt/freshell-mcp/server.js"]} + }), + "accepted hosted selection reaches the real SDK as the fixed recipe" + ); + assert!(observed["mcpServers"]["freshell"].get("env").is_none()); + } else { + assert!( + observed.get("mcpServers").is_none(), + "unselected context enables no recipe" + ); + } + Arc::clone(&claude.transport).stop().await.unwrap(); } } @@ -1649,6 +1615,14 @@ lines.on('line', line=>{ impl SimulatedClaude { async fn start(conversations: &[&str]) -> Self { + Self::start_with(conversations, None, vec![]).await + } + + async fn start_with( + conversations: &[&str], + context: Option<&ProviderLaunchContext>, + extra_environment: Vec<(String, String)>, + ) -> Self { let root = tempfile::tempdir().unwrap(); let repo = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); let home = root.path().join("home"); @@ -1664,8 +1638,20 @@ lines.on('line', line=>{ } let ledger = root.path().join("sim-ledger.jsonl"); let text = |path: &std::path::Path| path.to_string_lossy().into_owned(); - let environment = vec![ + let tmp = root.path().join("tmp"); + std::fs::create_dir_all(&tmp).unwrap(); + std::fs::write(root.path().join("active"), "").unwrap(); + let mut environment = vec![ ("HOME".to_string(), text(&home)), + ("TMPDIR".into(), text(&tmp)), + ( + "FRESHELL_TEST_CLAUDE_RUNTIME_GUARD".into(), + text(&root.path().join("active")), + ), + ( + "FRESHELL_TEST_CLAUDE_RESPOND_LOG".into(), + text(&root.path().join("requests.jsonl")), + ), ( "PATH".to_string(), std::env::var("PATH").unwrap_or_default(), @@ -1674,13 +1660,17 @@ lines.on('line', line=>{ ("CLAUDE_CONFIG_DIR".to_string(), text(&config)), ( "FRESHELL_CLAUDE_SDK_QUERY_MODULE".to_string(), - text(&repo.join("test/fixtures/claude-cli-sim/query.mjs")), + freshell_platform::opencode_plugin::plugin_file_spec( + &repo.join("test/integration/providers/claude-sdk-for-rust-runtime.mjs"), + ), ), ("FRESHELL_CLAUDE_SIM_LEDGER".to_string(), text(&ledger)), ]; + environment.extend(extra_environment); let transport = HostedTransport::with_claude_sidecar_environment( FreshProvider::Claude, environment, + context, ) .await; Self { @@ -2015,7 +2005,7 @@ lines.on('line', line=>{ } /// What one hosted Claude configure produced: the actor's answer, the - /// durable profile afterwards, every configure line the scripted sidecar + /// durable profile afterwards, every configure line the production sidecar /// received, the provider event named `settled_on` once it reached the /// host journal, and the whole journal at that point. struct HostedClaudeConfigure { @@ -2030,56 +2020,35 @@ lines.on('line', line=>{ } /// Runs one configure through the real `FreshClaudeState` behind a hosted - /// actor whose profile starts on sonnet/low. The scripted sidecar answers - /// the configure line with `receipt`, a JavaScript expression over `req`. + /// actor whose profile starts on sonnet/low. The real sidecar applies SDK + /// settings; the refusal case injects an error at the SDK setModel boundary. async fn hosted_claude_configure( settings: freshell_runtime_protocol::FreshAgentTurnSettings, - receipt: &str, + reject_model: bool, settled_on: &str, ) -> HostedClaudeConfigure { let _lock = OPENCODE_SNAPSHOT_ENV_LOCK.lock().await; let dir = tempfile::tempdir().unwrap(); - let script = dir.path().join("sidecar.mjs"); - let receipt_log = dir.path().join("configure.jsonl"); - std::fs::write( - &script, - format!( - r#" -import {{createInterface}} from 'node:readline'; -import {{appendFileSync}} from 'node:fs'; -const lines=createInterface({{input:process.stdin}}); -const emit=frame=>process.stdout.write(JSON.stringify(frame)+'\n'); -lines.on('line', line=>{{ - const req=JSON.parse(line); - if(req.type==='create')emit({{type:'created',requestId:req.requestId,sessionId:req.sessionId??'owned-configure',protocol:2}}); - if(req.type==='configure'){{ - appendFileSync(process.env.FRESHELL_TEST_CONFIGURE_RECEIPT,JSON.stringify(req)+'\n'); - emit({receipt}); - }} - if(req.type==='shutdown')process.exit(0); -}}); -"# - ), + let claude = SimulatedClaude::start_with( + &[], + None, + if reject_model { + vec![("FRESHELL_TEST_CLAUDE_REJECT_MODEL".into(), "opus".into())] + } else { + vec![] + }, ) - .unwrap(); - let keys = [ - "FRESHELL_CLAUDE_SIDECAR", - "FRESHELL_CLAUDE_NODE", - "FRESHELL_TEST_CONFIGURE_RECEIPT", - ]; - let _env = SnapshotProviderEnv( - keys.iter() - .map(|key| (*key, std::env::var_os(key))) - .collect(), - ); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("FRESHELL_TEST_CONFIGURE_RECEIPT", &receipt_log); - let transport = HostedTransport::new(FreshProvider::Claude).await; + .await; + let transport = claude.transport.clone(); let profile = FreshAgentProfile { provider: FreshProvider::Claude, runtime_variant: "freshclaude".into(), - cwd: dir.path().to_string_lossy().into(), + cwd: claude + .root + .path() + .join("workspace") + .to_string_lossy() + .into(), model: Some("sonnet".into()), effort: Some("low".into()), permission_mode: Some("default".into()), @@ -2123,10 +2092,11 @@ lines.on('line', line=>{{ .into_iter() .map(|entry| entry.event) .collect(); - let applied = std::fs::read_to_string(&receipt_log) + let applied = std::fs::read_to_string(claude.root.path().join("requests.jsonl")) .unwrap_or_default() .lines() - .map(|line| serde_json::from_str(line).unwrap()) + .map(|line| serde_json::from_str::(line).unwrap()) + .filter(|request| request["type"] == "configure") .collect(); Arc::clone(&transport).stop().await.unwrap(); HostedClaudeConfigure { @@ -2138,9 +2108,6 @@ lines.on('line', line=>{{ } } - const CLAUDE_CONFIGURED_OK: &str = - "{type:'sdk.configured',sessionId:req.sessionId,requestId:req.requestId,ok:true,settings:req.settings}"; - #[tokio::test] async fn hosted_claude_configure_applies_live_and_relays_the_session_metadata_frame() { let run = hosted_claude_configure( @@ -2149,7 +2116,7 @@ lines.on('line', line=>{{ effort: Some("high".into()), ..Default::default() }, - CLAUDE_CONFIGURED_OK, + false, "freshAgent.session.metadata", ) .await; @@ -2188,7 +2155,7 @@ lines.on('line', line=>{{ model: Some("opus".into()), ..Default::default() }, - CLAUDE_CONFIGURED_OK, + false, "freshAgent.session.metadata", ) .await; @@ -2225,7 +2192,7 @@ lines.on('line', line=>{{ effort: Some("high".into()), ..Default::default() }, - "{type:'sdk.configured',sessionId:req.sessionId,requestId:req.requestId,ok:false,message:'Model or effort is unavailable'}", + true, "freshAgent.error", ) .await; From 633ef6fd738213e6c7859c6b31db67a520e57101 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:14:02 -0700 Subject: [PATCH 084/100] test(freshclaude): run live-frame and handoff checks through the real sidecar --- crates/freshell-freshagent/src/claude.rs | 3 +- .../src/claude/live_frames_tests.rs | 297 +++++++++--------- .../src/claude/tests/test_sidecar_env.rs | 28 +- .../src/session_handoff/tests.rs | 282 ++++++++--------- .../providers/claude-sdk-for-rust-runtime.mjs | 19 ++ 5 files changed, 319 insertions(+), 310 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index e92c4387c..faaaedcca 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -7784,7 +7784,8 @@ pub(crate) mod tests { pub(crate) static CLAUDE_ENV_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); mod test_sidecar_env; - use test_sidecar_env::ClaudeTestRuntime as FakeClaudeSidecarEnv; + pub(crate) use test_sidecar_env::ClaudeTestRuntime; + use ClaudeTestRuntime as FakeClaudeSidecarEnv; fn send_msg(session_id: &str, text: &str) -> FreshAgentSend { FreshAgentSend { diff --git a/crates/freshell-freshagent/src/claude/live_frames_tests.rs b/crates/freshell-freshagent/src/claude/live_frames_tests.rs index 7452a2e5e..8d759281d 100644 --- a/crates/freshell-freshagent/src/claude/live_frames_tests.rs +++ b/crates/freshell-freshagent/src/claude/live_frames_tests.rs @@ -1,83 +1,43 @@ -//! The live frames a claude sidecar sends mid-turn (transcript-live plan §4.2, -//! Phase C): API retries, notices, per-message transcript announcements and the -//! CLI's own permission-mode changes, as every client receives them. -//! -//! The sidecar is a small Node fake started through the production create -//! (`handle_create`), so the stdout consumer, its folds and the broadcast are -//! the real ones. A send's text is the list of `sdk.*` frames the fake writes -//! back (as a JSON array), each stamped with the sidecar's session id; a -//! configure is logged and answered the way index.mjs answers it. - -use super::*; +//! Live retry, notice, transcript and permission-mode behavior through the +//! production sidecar, SDK adapter and contract-tested CLI simulator. use super::teardown_tests::capture; +use super::tests::ClaudeTestRuntime; +use super::*; -/// The durable id the fake's session reports. -const DURABLE: &str = "cccccccc-cccc-4ccc-8ccc-cccccccccccc"; - -/// The fake sidecar; `CONFIGURE_LOG` and `DURABLE` are prepended. -const ECHO_SIDECAR: &str = r#" -const fs = require('node:fs') -const out = (frame) => process.stdout.write(JSON.stringify(frame) + '\n') -require('node:readline').createInterface({ input: process.stdin }).on('line', (line) => { - let msg - try { msg = JSON.parse(line) } catch { return } - if (msg.type === 'create') { - const sessionId = 'live-frames-session' - out({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) - out({ type: 'sdk.session.init', sessionId, cliSessionId: msg.sessionId || DURABLE, model: 'fake-model', cwd: '/tmp', tools: [] }) - } else if (msg.type === 'send') { - for (const frame of JSON.parse(msg.text)) out({ ...frame, sessionId: msg.sessionId }) - } else if (msg.type === 'configure') { - fs.appendFileSync(CONFIGURE_LOG, JSON.stringify(msg) + '\n') - out({ type: 'sdk.configured', sessionId: msg.sessionId, requestId: msg.requestId, ok: true, settings: msg.settings }) - } else if (msg.type === 'shutdown') { - process.exit(0) - } -}) -"#; - -/// One session of the echo fake, created through the production create. -struct EchoSession { +struct LiveSession { st: FreshClaudeState, rx: tokio::sync::broadcast::Receiver, - /// The pane's id for the session (its map key and envelope id). pane: String, - configure_log: std::path::PathBuf, - /// Every frame read off the bus so far, in order. frames: Vec, - _dir: tempfile::TempDir, + runtime: ClaudeTestRuntime, _env: tokio::sync::MutexGuard<'static, ()>, } -impl EchoSession { +impl LiveSession { async fn start(permission_mode: Option<&str>) -> Self { + Self::start_program(permission_mode, json!([{"kind":"hang"}]), None).await + } + + async fn start_program( + permission_mode: Option<&str>, + steps: Value, + sdk_messages: Option, + ) -> Self { let env = super::tests::CLAUDE_ENV_LOCK.lock().await; - let dir = tempfile::tempdir().expect("fake sidecar directory"); - let script = dir.path().join("echo-sidecar.cjs"); - let configure_log = dir.path().join("configure.log"); - std::fs::write(&configure_log, "").expect("init the configure log"); - let quoted = |text: &str| serde_json::to_string(text).expect("a JS string"); - std::fs::write( - &script, - format!( - "const CONFIGURE_LOG = {}\nconst DURABLE = {}\n{ECHO_SIDECAR}", - quoted(&configure_log.to_string_lossy()), - quoted(DURABLE), - ), - ) - .expect("write the fake sidecar"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("CLAUDE_CONFIG_DIR", dir.path().join("claude")); - let (tx, rx) = tokio::sync::broadcast::channel::(256); + let runtime = ClaudeTestRuntime::install(); + runtime.program(steps); + if let Some(messages) = sdk_messages { + std::env::set_var("FRESHELL_TEST_CLAUDE_SDK_MESSAGES", messages.to_string()); + } + let (tx, rx) = tokio::sync::broadcast::channel::(512); let st = FreshClaudeState::new(Arc::new(tx)); let mut session = Self { st, rx, pane: String::new(), - configure_log, frames: Vec::new(), - _dir: dir, + runtime, _env: env, }; let mut create = create_msg("live-frames"); @@ -89,21 +49,17 @@ impl EchoSession { }) .await; assert_eq!(created["type"], "freshAgent.created", "{created}"); - session.pane = created["sessionId"] - .as_str() - .expect("the created session id") - .to_string(); + session.pane = created["sessionId"].as_str().unwrap().to_string(); session } - /// The fake writes `frames` back as the CLI side of a send. - async fn sidecar_writes(&mut self, frames: Value) { - self.st - .handle_send(send_msg(&self.pane, &frames.to_string())) - .await; + async fn send(&self, text: &str) { + assert!(matches!( + self.st.handle_send(send_msg(&self.pane, text)).await, + ClaudeSendOutcome::Accepted { .. } + )); } - /// Read frames until one matches `want` (returned) or 20 s pass. async fn await_frame(&mut self, what: &str, want: impl Fn(&Value) -> bool) -> Value { if let Some(hit) = self.frames.iter().find(|frame| want(frame)) { return hit.clone(); @@ -125,26 +81,29 @@ impl EchoSession { found.unwrap_or_else(|_| panic!("no {what}; frames: {:?}", self.frames)) } - /// The first `freshAgent.event` whose inner event has this type. async fn await_event(&mut self, event_type: &str) -> Value { - let what = format!("a {event_type} event"); - self.await_frame(&what, |frame| { + self.await_frame(event_type, |frame| { frame["type"] == "freshAgent.event" && frame["event"]["type"] == event_type }) .await } - /// The configure lines the fake received. - fn configures(&self) -> Vec { - std::fs::read_to_string(&self.configure_log) + fn rows(&self, file: &str) -> Vec { + std::fs::read_to_string(self.runtime.dir.join(file)) .unwrap_or_default() .lines() .filter(|line| !line.trim().is_empty()) - .map(|line| serde_json::from_str(line).expect("a configure line")) + .map(|line| serde_json::from_str(line).unwrap()) + .collect() + } + + fn configures(&self) -> Vec { + self.rows("requests.jsonl") + .into_iter() + .filter(|frame| frame["type"] == "configure") .collect() } - /// The status the snapshot overlay states for the session. async fn overlay_status(&self) -> Value { let mut snapshot = json!({}); self.st @@ -154,26 +113,14 @@ impl EchoSession { } } -impl Drop for EchoSession { - fn drop(&mut self) { - // The script and store die with `_dir`: no later test in this binary - // may inherit paths to them (the env lock is still held here). - remove_claude_env(); - } -} - -/// Removes the process-wide variables these tests point at their tempdirs. -fn remove_claude_env() { - std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); - std::env::remove_var("CLAUDE_CONFIG_DIR"); -} - -/// Calls [`remove_claude_env`] when dropped, so a failed assertion cannot leak them. -struct ClaudeEnvReset; - +/// Restores the process store in the snapshot-only environment test. +struct ClaudeEnvReset(Option); impl Drop for ClaudeEnvReset { fn drop(&mut self) { - remove_claude_env(); + match &self.0 { + Some(previous) => std::env::set_var("CLAUDE_CONFIG_DIR", previous), + None => std::env::remove_var("CLAUDE_CONFIG_DIR"), + } } } @@ -253,13 +200,17 @@ fn records( #[tokio::test] async fn an_api_retry_reaches_every_client_as_freshagent_retry_and_keeps_the_turn_running() { let (events, _capture) = capture::install(); - let mut session = EchoSession::start(None).await; - session - .sidecar_writes(json!([ - { "type": "sdk.assistant", "content": [{ "type": "text", "text": "working" }], "model": "fake-model" }, - { "type": "sdk.retry", "attempt": 1, "maxRetries": 10, "delayMs": 512, "errorStatus": 529, "error": "overloaded" }, - ])) - .await; + let mut session = LiveSession::start_program( + None, + json!([ + {"kind":"text", "data":{"text":"working"}}, + {"kind":"api_retry", "data":{"status":529}}, + {"kind":"hang"}, + ]), + None, + ) + .await; + session.send("retry this model request").await; let retry = session.await_event("freshAgent.retry").await; assert_eq!(retry["sessionId"], json!(session.pane), "{retry}"); @@ -267,7 +218,7 @@ async fn an_api_retry_reaches_every_client_as_freshagent_retry_and_keeps_the_tur assert_eq!(retry["sessionType"], "freshclaude"); assert_eq!(retry["event"]["attempt"], 1); assert_eq!(retry["event"]["maxRetries"], 10); - assert_eq!(retry["event"]["delayMs"], 512); + assert_eq!(retry["event"]["delayMs"], 500); assert_eq!(retry["event"]["errorStatus"], 529); assert_eq!(retry["event"]["error"], "overloaded"); @@ -289,16 +240,15 @@ async fn an_api_retry_reaches_every_client_as_freshagent_retry_and_keeps_the_tur #[tokio::test] async fn a_notice_reaches_every_client_as_freshagent_notice_and_keeps_the_turn_running() { let (events, _capture) = capture::install(); - let mut session = EchoSession::start(None).await; let rate_limit = - json!({ "status": "rejected", "resetsAt": 1_792_656_000, "rateLimitType": "five_hour" }); - session - .sidecar_writes(json!([ - { "type": "sdk.assistant", "content": [{ "type": "text", "text": "working" }], "model": "fake-model" }, - { "type": "sdk.notice", "level": "error", "code": "RATE_LIMITED", "message": "You have reached your Claude usage limit.", "rateLimit": rate_limit }, - { "type": "sdk.notice", "level": "info", "code": "INFORMATIONAL", "message": "a private hook said hello" }, - ])) - .await; + json!({"status":"rejected", "resetsAt":1_792_656_000, "rateLimitType":"five_hour"}); + let mut session = LiveSession::start_program(None, json!([ + {"kind":"text", "data":{"text":"working"}}, {"kind":"hang"}, + ]), Some(json!([ + {"type":"rate_limit_event", "uuid":"11111111-1111-4111-8111-111111111111", "rate_limit_info":rate_limit}, + {"type":"system", "subtype":"informational", "uuid":"22222222-2222-4222-8222-222222222222", "level":"suggestion", "content":"a private hook said hello"}, + ]))).await; + session.send("show provider notices").await; let notice = session.await_event("freshAgent.notice").await; assert_eq!(notice["sessionId"], json!(session.pane), "{notice}"); @@ -352,40 +302,46 @@ async fn a_notice_reaches_every_client_as_freshagent_notice_and_keeps_the_turn_r /// unchanged (only the `session-commands` reason is folded server-side). #[tokio::test] async fn a_transcript_announcement_reaches_every_client_as_session_changed() { - let mut session = EchoSession::start(None).await; + let mut session = LiveSession::start_program( + None, + json!([ + {"kind":"replay", "data":{"recording":"tools-sync", "pace":0}}, + ]), + None, + ) + .await; session - .sidecar_writes(json!([ - { "type": "sdk.session.changed", "reason": "transcript", "uuid": "11111111-1111-4111-8111-111111111111", "parentToolUseId": null }, - { "type": "sdk.session.changed", "reason": "transcript", "uuid": "22222222-2222-4222-8222-222222222222", "parentToolUseId": "toolu_agent1" }, - ])) + .send("replay the recorded CLI tool and subagent turn") .await; - - let announced = session - .await_frame("the subagent's announcement", |frame| { - frame["event"]["type"] == "freshAgent.session.changed" - && frame["event"]["parentToolUseId"] == "toolu_agent1" - }) - .await; - assert_eq!(announced["sessionId"], json!(session.pane)); - assert_eq!(announced["event"]["reason"], "transcript"); - assert_eq!( - announced["event"]["uuid"], - "22222222-2222-4222-8222-222222222222" - ); - let main_thread: Vec<&Value> = session - .frames - .iter() - .filter(|frame| { - frame["event"]["type"] == "freshAgent.session.changed" - && frame["event"]["reason"] == "transcript" + session.await_event("freshAgent.result").await; + let expected: Vec = session + .rows("sdk.jsonl") + .into_iter() + .filter(|msg| { + (msg["type"] == "assistant" || msg["type"] == "user") && msg["uuid"].is_string() }) + .map(|msg| json!({"uuid":msg["uuid"], "parentToolUseId":msg["parent_tool_use_id"]})) .collect(); - assert_eq!(main_thread.len(), 2, "{main_thread:?}"); + assert!( + expected + .iter() + .any(|row| row["parentToolUseId"].is_string()), + "the real recording includes a subagent" + ); + assert!( + expected.iter().any(|row| row["parentToolUseId"].is_null()), + "the main conversation also writes messages" + ); + let actual: Vec = session.frames.iter() + .filter(|frame| frame["event"]["type"] == "freshAgent.session.changed" && frame["event"]["reason"] == "transcript") + .map(|frame| { + assert_eq!(frame["sessionId"], session.pane); + json!({"uuid":frame["event"]["uuid"], "parentToolUseId":frame["event"]["parentToolUseId"]}) + }).collect(); assert_eq!( - main_thread[0]["event"]["uuid"], - "11111111-1111-4111-8111-111111111111" + actual, expected, + "every actual SDK transcript message is announced once in order" ); - assert_eq!(main_thread[0]["event"]["parentToolUseId"], Value::Null); } /// The CLI changed its own permission mode (an approved ExitPlanMode): the @@ -396,12 +352,29 @@ async fn a_transcript_announcement_reaches_every_client_as_session_changed() { #[tokio::test] async fn the_clis_own_permission_mode_change_becomes_the_sessions_setting() { let (events, _capture) = capture::install(); - let mut session = EchoSession::start(Some("plan")).await; + let mut session = LiveSession::start_program( + Some("plan"), + json!([ + {"kind":"exit_plan", "data":{"plan":"# Plan\n\nContinue the work"}}, + {"kind":"text", "data":{"text":"plan approved"}}, + ]), + None, + ) + .await; + session.send("request approval to exit plan mode").await; + let permission = session.await_event("freshAgent.permission.request").await; session - .sidecar_writes(json!([ - { "type": "sdk.session.metadata", "permissionMode": "default" }, - { "type": "sdk.result", "result": "success", "durationMs": 1, "costUsd": 0, "usage": {} }, - ])) + .st + .handle_approval_respond(FreshAgentApprovalRespond { + provider: freshell_protocol::AgentProvider::Claude, + session_id: session.pane.clone(), + session_type: SessionType::Freshclaude, + request_id: freshell_protocol::StringOrNumber::Str( + permission["event"]["requestId"].as_str().unwrap().into(), + ), + decision: json!({"behavior":"allow"}), + cwd: None, + }) .await; let metadata = session.await_event("freshAgent.session.metadata").await; assert_eq!(metadata["sessionId"], json!(session.pane), "{metadata}"); @@ -447,7 +420,7 @@ async fn the_clis_own_permission_mode_change_becomes_the_sessions_setting() { /// model and effort pair, as every metadata frame does). #[tokio::test] async fn a_configure_that_changes_the_permission_mode_broadcasts_metadata_stating_it() { - let mut session = EchoSession::start(None).await; + let mut session = LiveSession::start(None).await; session .st .handle_configure(configure_msg(&session.pane, "plan")) @@ -465,7 +438,7 @@ async fn a_configure_that_changes_the_permission_mode_broadcasts_metadata_statin /// that missed the configure still lands here). #[tokio::test] async fn a_send_that_changes_the_permission_mode_broadcasts_metadata_stating_it() { - let mut session = EchoSession::start(Some("plan")).await; + let mut session = LiveSession::start(Some("plan")).await; let mut send = send_msg(&session.pane, "[]"); send.settings = Some(freshell_protocol::FreshAgentSendSettings { cwd: None, @@ -488,7 +461,7 @@ async fn a_send_that_changes_the_permission_mode_broadcasts_metadata_stating_it( #[tokio::test] async fn snapshots_read_the_store_the_lanes_sidecars_write() { let _env = super::tests::CLAUDE_ENV_LOCK.lock().await; - let _reset = ClaudeEnvReset; + let _reset = ClaudeEnvReset(std::env::var_os("CLAUDE_CONFIG_DIR")); let process_store = tempfile::tempdir().expect("this process's store"); std::env::set_var("CLAUDE_CONFIG_DIR", process_store.path()); let sidecar_store = tempfile::tempdir().expect("the sidecars' store"); @@ -526,11 +499,21 @@ async fn snapshots_read_the_store_the_lanes_sidecars_write() { } #[tokio::test] -async fn an_echo_session_leaves_the_process_environment_as_it_found_it() { - // The fake's script and store live in a tempdir that dies with the session: - // a later test in this binary must not inherit paths to them. - drop(EchoSession::start(None).await); +async fn a_live_session_restores_the_process_environment() { + let before = { + let _env = super::tests::CLAUDE_ENV_LOCK.lock().await; + ( + std::env::var_os("FRESHELL_CLAUDE_SIDECAR"), + std::env::var_os("CLAUDE_CONFIG_DIR"), + ) + }; + drop(LiveSession::start(None).await); let _env = super::tests::CLAUDE_ENV_LOCK.lock().await; - assert_eq!(std::env::var_os("FRESHELL_CLAUDE_SIDECAR"), None); - assert_eq!(std::env::var_os("CLAUDE_CONFIG_DIR"), None); + assert_eq!( + ( + std::env::var_os("FRESHELL_CLAUDE_SIDECAR"), + std::env::var_os("CLAUDE_CONFIG_DIR") + ), + before + ); } diff --git a/crates/freshell-freshagent/src/claude/tests/test_sidecar_env.rs b/crates/freshell-freshagent/src/claude/tests/test_sidecar_env.rs index 0069d0d09..a51788e55 100644 --- a/crates/freshell-freshagent/src/claude/tests/test_sidecar_env.rs +++ b/crates/freshell-freshagent/src/claude/tests/test_sidecar_env.rs @@ -12,6 +12,8 @@ const KEYS: &[&str] = &[ "FRESHELL_TEST_CLAUDE_SPAWN_LOG", "FRESHELL_TEST_CLAUDE_INTERRUPT_LOG", "FRESHELL_TEST_CLAUDE_RESPOND_LOG", + "FRESHELL_TEST_CLAUDE_SDK_LOG", + "FRESHELL_TEST_CLAUDE_SDK_MESSAGES", "FRESHELL_TEST_CLAUDE_DEFER_CREATE_MS", "FRESHELL_TEST_CLAUDE_FAIL_CREATE", "FRESHELL_TEST_CLAUDE_RUNTIME_GUARD", @@ -23,8 +25,8 @@ const KEYS: &[&str] = &[ "CLAUDE_CONFIG_DIR", ]; -pub(super) struct ClaudeTestRuntime { - pub(super) dir: PathBuf, +pub(crate) struct ClaudeTestRuntime { + pub(crate) dir: PathBuf, spawn_log: PathBuf, interrupt_log: PathBuf, respond_log: PathBuf, @@ -32,11 +34,11 @@ pub(super) struct ClaudeTestRuntime { } impl ClaudeTestRuntime { - pub(super) fn install() -> Self { + pub(crate) fn install() -> Self { Self::install_with_knobs(None, false) } - pub(super) fn install_with_knobs(defer_create_ms: Option, fail_create: bool) -> Self { + pub(crate) fn install_with_knobs(defer_create_ms: Option, fail_create: bool) -> Self { let previous = KEYS .iter() .map(|&key| (key, std::env::var_os(key))) @@ -73,6 +75,8 @@ impl ClaudeTestRuntime { std::env::set_var("FRESHELL_TEST_CLAUDE_SPAWN_LOG", &spawn_log); std::env::set_var("FRESHELL_TEST_CLAUDE_INTERRUPT_LOG", &interrupt_log); std::env::set_var("FRESHELL_TEST_CLAUDE_RESPOND_LOG", &respond_log); + std::env::set_var("FRESHELL_TEST_CLAUDE_SDK_LOG", dir.join("sdk.jsonl")); + std::env::remove_var("FRESHELL_TEST_CLAUDE_SDK_MESSAGES"); std::env::set_var("FRESHELL_TEST_CLAUDE_RUNTIME_GUARD", &guard); std::env::set_var("FRESHELL_CLAUDE_SIM_LEDGER", dir.join("sim-ledger.jsonl")); std::env::set_var("FRESHELL_CLAUDE_SIM_TIME_SCALE", "0.01"); @@ -106,11 +110,11 @@ impl ClaudeTestRuntime { } } - pub(super) fn spawn_log_path(&self) -> &Path { + pub(crate) fn spawn_log_path(&self) -> &Path { &self.spawn_log } - pub(super) fn program(&self, steps: Value) { + pub(crate) fn program(&self, steps: Value) { std::env::set_var( "FRESHELL_CLAUDE_SIM_PROGRAM", json!({ @@ -120,7 +124,7 @@ impl ClaudeTestRuntime { ); } - pub(super) async fn simulator_row(&self, kind: &str) -> Value { + pub(crate) async fn simulator_row(&self, kind: &str) -> Value { let deadline = tokio::time::Instant::now() + Duration::from_secs(15); loop { if let Some(row) = rows(&self.dir.join("sim-ledger.jsonl")) @@ -137,7 +141,7 @@ impl ClaudeTestRuntime { } } - pub(super) async fn create_request(&self) -> Value { + pub(crate) async fn create_request(&self) -> Value { let deadline = tokio::time::Instant::now() + Duration::from_secs(15); loop { if let Some(row) = self.query_rows().first() { @@ -155,19 +159,19 @@ impl ClaudeTestRuntime { rows(&self.spawn_log) } - pub(super) fn spawn_count(&self) -> usize { + pub(crate) fn spawn_count(&self) -> usize { self.query_rows().len() } - pub(super) fn interrupt_log_contents(&self) -> String { + pub(crate) fn interrupt_log_contents(&self) -> String { std::fs::read_to_string(&self.interrupt_log).unwrap_or_default() } - pub(super) async fn respond_log_frames(&self, min: usize) -> Vec { + pub(crate) async fn respond_log_frames(&self, min: usize) -> Vec { self.wait_request_frames(None, min).await } - pub(super) async fn request_frames_of_type(&self, kind: &str, min: usize) -> Vec { + pub(crate) async fn request_frames_of_type(&self, kind: &str, min: usize) -> Vec { self.wait_request_frames(Some(kind), min).await } diff --git a/crates/freshell-freshagent/src/session_handoff/tests.rs b/crates/freshell-freshagent/src/session_handoff/tests.rs index fb1eb2db8..98460787a 100644 --- a/crates/freshell-freshagent/src/session_handoff/tests.rs +++ b/crates/freshell-freshagent/src/session_handoff/tests.rs @@ -5,8 +5,7 @@ //! late exit still releases), detached completion, abort leaves zero-or-one //! owner, the OpenCode invariant, and the kilroy flavor. //! -//! Harness: the inline fake claude sidecar env pattern (copied verbatim from -//! `cross_kind_liveness.rs::FakeSidecarEnv` — the request-log knob), the +//! Harness: the production Claude sidecar over the CLI simulator, the //! fake `opencode serve` (the `freshagent_session_lease.rs` shape), and the //! common sleeper CLI spec so terminal targets genuinely spawn Running PTYs. //! `ENV_LOCK` serializes this file's tests (process-global env knobs). @@ -33,94 +32,35 @@ use super::{ /// Serializes the tests in this file: they mutate process-global env vars /// (`FRESHELL_CLAUDE_SIDECAR` / `FRESHELL_CLAUDE_NODE` / -/// `FAKE_SIDECAR_REQUEST_LOG` / `OPENCODE_CMD` / ...). It is the crate-wide +/// `OPENCODE_CMD` / ...). It is the crate-wide /// [`crate::OPENCODE_ENV_LOCK`] under its historical name — the snapshot /// cold-GET tests mutate `OPENCODE_CMD` too and must exclude these. use crate::OPENCODE_ENV_LOCK as ENV_LOCK; -// ── fake claude sidecar (request-log knob only; copied from -// cross_kind_liveness.rs per the plan's instruction) ────────────────────── +// ── production Claude sidecar over the contract-tested CLI simulator ───── -const FAKE_CLAUDE_SIDECAR_SOURCE: &str = r#" -import readline from 'node:readline' -import fs from 'node:fs' +struct ClaudeSidecarEnv(crate::claude::tests::ClaudeTestRuntime); -const logPath = process.env.FAKE_SIDECAR_REQUEST_LOG || '' -function logReq(msg) { - if (!logPath) return - try { fs.appendFileSync(logPath, JSON.stringify({ pid: process.pid, msg }) + '\n') } catch {} -} - -let counter = 0 -const rl = readline.createInterface({ input: process.stdin, terminal: false }) -rl.on('line', (line) => { - const trimmed = line.trim() - if (!trimmed) return - let msg - try { msg = JSON.parse(trimmed) } catch { return } - logReq(msg) - if (msg.type === 'create') { - counter += 1 - const sessionId = msg.sessionId || `fake-claude-session-${process.pid}-${counter}` - process.stdout.write(JSON.stringify({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) + '\n') - const cliSessionId = msg.boot?.resume || sessionId - process.stdout.write(JSON.stringify({ type: 'sdk.session.init', sessionId, cliSessionId, model: 'fake-model', cwd: '/tmp', tools: [] }) + '\n') - process.stdout.write(JSON.stringify({ type: 'sdk.status', sessionId, status: 'idle' }) + '\n') - } else if (msg.type === 'shutdown') { - process.exit(0) - } -}) -"#; - -struct FakeSidecarEnv { - dir: std::path::PathBuf, -} - -impl FakeSidecarEnv { +impl ClaudeSidecarEnv { fn install() -> Self { - let dir = - std::env::temp_dir().join(format!("freshell-session-handoff-{}", uuid_like_suffix())); - std::fs::create_dir_all(&dir).expect("create fake sidecar temp dir"); - let script = dir.join("fake-claude-sidecar.mjs"); - std::fs::write(&script, FAKE_CLAUDE_SIDECAR_SOURCE).expect("write fake sidecar"); - std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - std::env::set_var("FRESHELL_CLAUDE_NODE", "node"); - std::env::set_var("FAKE_SIDECAR_REQUEST_LOG", dir.join("requests.jsonl")); - Self { dir } + Self(crate::claude::tests::ClaudeTestRuntime::install()) } fn create_rows(&self) -> Vec { - let Ok(raw) = std::fs::read_to_string(self.dir.join("requests.jsonl")) else { - return Vec::new(); - }; - raw.lines() - .filter(|l| !l.trim().is_empty()) - .map(|l| serde_json::from_str::(l).expect("request log row parses")) - .filter(|r| r["msg"]["type"] == "create") + std::fs::read_to_string(self.0.spawn_log_path()) + .unwrap_or_default() + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| serde_json::from_str(line).expect("actual SDK query log row")) .collect() } - /// The sidecar pid that served THIS session's create (the process the - /// handoff must reap). fn sidecar_pid_for(&self, sid: &str) -> Option { self.create_rows() .into_iter() - .find(|r| r["msg"]["boot"]["resume"] == sid) - .and_then(|r| r["pid"].as_u64()) - .map(|p| p as u32) - } -} - -impl Drop for FakeSidecarEnv { - fn drop(&mut self) { - for var in [ - "FRESHELL_CLAUDE_SIDECAR", - "FRESHELL_CLAUDE_NODE", - "FAKE_SIDECAR_REQUEST_LOG", - ] { - std::env::remove_var(var); - } - let _ = std::fs::remove_dir_all(&self.dir); + .find(|row| row["options"]["resume"] == sid || row["options"]["sessionId"] == sid) + .and_then(|row| row["pid"].as_u64()) + .map(|pid| pid as u32) } } @@ -712,7 +652,7 @@ fn handoff_action_contract_defaults_legacy_ack_only_to_clear() { async fn unsupported_atomic_handoff_refuses_before_mutation_and_clear_bypasses_preflight() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let hooks = Arc::new(HandoffTestHooks::default()); hooks .force_unsupported_preflight @@ -927,7 +867,29 @@ async fn await_live_session(state: &crate::FreshClaudeState, sid: &str, want: bo /// Establish a live freshclaude owner for `sid` (the fake sidecar path) and /// return once the coordinator records it Live. async fn establish_fresh_claude_owner(rig: &mut Rig, sid: &str) { - rig.fresh_claude.handle_create(create_msg(sid), None).await; + // The actual CLI resumes a saved conversation; an arbitrary id is not + // a substitute for its transcript. Preserve any scenario's existing file. + let store = std::path::PathBuf::from( + std::env::var_os("CLAUDE_CONFIG_DIR").expect("isolated Claude store"), + ); + let project = store.join("projects").join("-handoff"); + std::fs::create_dir_all(&project).unwrap(); + let file = project.join(format!("{sid}.jsonl")); + let already_saved = std::fs::read_dir(store.join("projects")) + .unwrap() + .flatten() + .any(|entry| entry.path().join(format!("{sid}.jsonl")).is_file()); + if !already_saved { + std::fs::write(file, format!("{}\n", json!({ + "type":"user", "uuid":uuid::Uuid::new_v4().to_string(), "sessionId":sid, + "cwd":"/tmp", "promptSource":"sdk", "message":{"role":"user", "content":"saved handoff conversation"}, + }))).unwrap(); + } + let outcome = rig.fresh_claude.handle_create(create_msg(sid), None).await; + assert!( + matches!(outcome, crate::ClaudeCreateOutcome::Created { .. }), + "{outcome:?}" + ); let deadline = tokio::time::Instant::now() + Duration::from_secs(10); loop { match rig.ownership.observe("claude", sid).state { @@ -1078,7 +1040,7 @@ async fn handoff_to_terminal_reaps_sidecar_before_target_start_and_commits_owner // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let (events, _capture_guard) = tracing_capture::capture(); // M-1 (round-3 review): hooks installed so the runner's step order is @@ -1253,7 +1215,7 @@ async fn handoff_target_spawn_failure_leaves_vacant_with_typed_error() { // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks::default()); hooks.fail_target_spawn_once.store(true, Ordering::SeqCst); @@ -1347,7 +1309,7 @@ async fn handoff_reap_timeout_reprobes_the_prior_and_restores_only_a_live_one() // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks::default()); let mut rig = build_rig(Some(Arc::clone(&hooks))); @@ -1437,7 +1399,7 @@ async fn handoff_reap_timeout_with_unconfirmable_prior_ends_vacant_typed() { // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks { pause_after_enter: Some(tokio::sync::Notify::new()), @@ -1558,7 +1520,7 @@ async fn handoff_reap_timeout_fences_the_key_until_the_detached_reap_confirms_de // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let kill_pause = Arc::new(tokio::sync::Notify::new()); let mut rig = build_rig_with_claude_pauses_and_reap_timeout_ms( @@ -1715,7 +1677,7 @@ async fn a_watcher_join_error_fences_the_key_until_the_replacement_probe_confirm // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let kill_pause = Arc::new(tokio::sync::Notify::new()); let hooks = Arc::new(HandoffTestHooks::default()); @@ -1877,7 +1839,7 @@ async fn a_watcher_join_error_fences_the_key_until_the_replacement_probe_confirm async fn handoff_with_a_platform_limited_prior_stop_fences_the_key_typed() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig_with_options(None, None, None, 8_000, None, true); establish_fresh_claude_owner(&mut rig, &sid).await; @@ -1962,7 +1924,7 @@ async fn handoff_with_a_platform_limited_prior_stop_fences_the_key_typed() { async fn a_stale_stop_fence_recovers_through_the_handoff_runner() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig(None); @@ -2211,7 +2173,7 @@ impl crate::session_handoff::FlavorWrite for FixedFlavorWriter { async fn a_kilroy_to_claude_cli_handoff_preserves_the_kilroy_flavor() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let log: FlavorWriteLog = Arc::new(std::sync::Mutex::new(Vec::new())); // The session's current durable flavor is KILROY (the hidden type). @@ -2250,7 +2212,7 @@ async fn a_kilroy_to_claude_cli_handoff_preserves_the_kilroy_flavor() { async fn an_unconfirmed_target_reap_on_the_flavor_failure_fences_then_the_watcher_resolves() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks::default()); hooks @@ -2329,7 +2291,7 @@ async fn an_unconfirmed_target_reap_on_the_flavor_failure_fences_then_the_watche async fn a_platform_limited_target_reap_on_the_flavor_failure_fences_typed() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks::default()); hooks @@ -2401,7 +2363,7 @@ fn last_handoff_failed_frame(frames: &[Value]) -> &Value { async fn the_unconfirmed_reap_flavor_failure_frame_stays_fenced_never_vacant() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let (sink, _capture_guard) = tracing_capture::capture(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks::default()); @@ -2483,7 +2445,7 @@ async fn the_unconfirmed_reap_flavor_failure_frame_stays_fenced_never_vacant() { async fn the_platform_limited_reap_failure_frame_stays_fenced_never_vacant() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let (sink, _capture_guard) = tracing_capture::capture(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks::default()); @@ -2605,7 +2567,7 @@ async fn the_platform_limited_reap_failure_frame_stays_fenced_never_vacant() { async fn the_stale_commit_unconfirmed_reap_frame_stays_fenced_never_vacant() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let (sink, _capture_guard) = tracing_capture::capture(); let sid = uuid::Uuid::new_v4().to_string(); let pause = Arc::new(tokio::sync::Notify::new()); @@ -2761,7 +2723,7 @@ async fn the_stale_commit_unconfirmed_reap_frame_stays_fenced_never_vacant() { async fn a_watcher_release_mid_failure_window_leaves_the_final_frame_resolved() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let pause = Arc::new(tokio::sync::Notify::new()); let failure_park = Arc::new(tokio::sync::Notify::new()); @@ -2913,7 +2875,7 @@ async fn a_watcher_release_mid_failure_window_leaves_the_final_frame_resolved() async fn a_foreign_commit_during_the_abort_broadcast_window_is_never_overwritten() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let abort_broadcast_pause = Arc::new(tokio::sync::Notify::new()); let hooks = Arc::new(HandoffTestHooks { @@ -3034,7 +2996,7 @@ async fn a_foreign_commit_during_the_abort_broadcast_window_is_never_overwritten async fn the_uncommitted_target_reap_never_confirms_on_a_missing_row() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); // A SHORT reap budget: the pid-alive window deterministically fences // within the test instead of waiting the production budget. @@ -3125,7 +3087,7 @@ async fn the_uncommitted_target_reap_never_confirms_on_a_missing_row() { async fn the_confirmed_reap_flavor_failure_broadcasts_the_truthful_vacancy() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let (sink, _capture_guard) = tracing_capture::capture(); let sid = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig_with_flavor_writer(Arc::new(FailingFlavorWriter)); @@ -3260,7 +3222,7 @@ impl crate::session_handoff::FlavorWrite for TwoPhaseProbeFlavorWriter { async fn an_abort_after_the_staged_flavor_precursor_never_persists_the_target_flavor() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let store_dir = std::env::temp_dir().join(format!("freshell-r28-f4-store-{}", uuid_like_suffix())); @@ -3374,7 +3336,7 @@ async fn an_abort_after_the_staged_flavor_precursor_never_persists_the_target_fl async fn an_abort_in_the_flavor_window_with_an_unconfirmed_target_teardown_fences_not_vacates() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); // The claude-lane resume gates on transcript presence (the 6b pattern) // — the fresh TARGET's resume needs the fake transcript. @@ -3566,7 +3528,7 @@ impl crate::session_handoff::FlavorWrite for BlockingFlavorWriter { async fn a_flavor_write_failure_surfaces_as_the_typed_handoff_failure() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig_with_flavor_writer(Arc::new(FailingFlavorWriter)); establish_fresh_claude_owner(&mut rig, &sid).await; @@ -3596,7 +3558,7 @@ async fn a_flavor_write_failure_surfaces_as_the_typed_handoff_failure() { async fn the_flavor_write_serializes_consecutive_handoffs_in_generation_order() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let release = Arc::new(tokio::sync::Notify::new()); let reached = Arc::new(std::sync::atomic::AtomicBool::new(false)); @@ -3675,7 +3637,7 @@ async fn the_flavor_write_serializes_consecutive_handoffs_in_generation_order() async fn the_handoff_commit_writes_the_durable_flavor_server_side() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig(None); establish_fresh_claude_owner(&mut rig, &sid).await; @@ -3722,7 +3684,7 @@ async fn the_handoff_commit_writes_the_durable_flavor_server_side() { async fn a_stale_start_fence_recovers_through_the_acknowledged_force_clear() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig_with_options(None, None, None, 8_000, None, true); @@ -3919,7 +3881,7 @@ async fn a_stale_start_fence_recovers_through_the_acknowledged_force_clear() { async fn a_platform_limited_fence_recovers_only_through_the_acknowledged_force_clear() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig_with_options(None, None, None, 8_000, None, true); establish_fresh_claude_owner(&mut rig, &sid).await; @@ -4081,7 +4043,7 @@ async fn a_platform_limited_fence_recovers_only_through_the_acknowledged_force_c async fn the_cleared_unverified_state_requires_the_acknowledged_start() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig_with_options(None, None, None, 8_000, None, true); establish_fresh_claude_owner(&mut rig, &sid).await; @@ -4190,7 +4152,7 @@ async fn the_cleared_unverified_state_requires_the_acknowledged_start() { async fn the_acknowledged_start_reaps_the_live_prior_before_the_new_writer() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig(None); establish_fresh_claude_owner(&mut rig, &sid).await; @@ -4298,7 +4260,7 @@ async fn the_acknowledged_start_reaps_the_live_prior_before_the_new_writer() { async fn the_acknowledged_start_over_an_already_dead_prior_proceeds() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig(None); establish_fresh_claude_owner(&mut rig, &sid).await; @@ -4752,7 +4714,7 @@ async fn the_row_removed_but_pid_alive_window_reports_unconfirmed() { async fn a_delayed_platform_limited_reap_fences_the_key_and_never_releases() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let kill_pause = Arc::new(tokio::sync::Notify::new()); // The platform-limited seam + a SHORT reap budget: the parked lane kill @@ -4834,7 +4796,7 @@ async fn a_delayed_platform_limited_reap_fences_the_key_and_never_releases() { async fn handoff_with_an_unconfirmable_claude_tree_fences_the_key_until_the_escalation_lands() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); // The one-round confirmation window: a TERM-immune descendant // deterministically outlives it (the SIGKILL escalation is rounds away). @@ -4973,7 +4935,7 @@ async fn a_prior_restored_after_reap_timeout_still_releases_when_it_later_exits( // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks::default()); let mut rig = build_rig(Some(Arc::clone(&hooks))); @@ -5023,7 +4985,7 @@ async fn a_wire_fenced_kill_at_the_handoff_generation_converges_after_a_restore( // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks::default()); let mut rig = build_rig(Some(Arc::clone(&hooks))); @@ -5193,7 +5155,7 @@ async fn handoff_continues_to_consistency_when_client_disconnects() { // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks { pause_after_enter: Some(tokio::sync::Notify::new()), @@ -5258,7 +5220,7 @@ async fn handoff_task_abort_leaves_zero_or_one_owner() { // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks { pause_after_enter: Some(tokio::sync::Notify::new()), @@ -5370,7 +5332,7 @@ async fn handoff_abort_during_prior_kill_reprobes_and_repairs_the_stamp() { // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let kill_pause = Arc::new(tokio::sync::Notify::new()); let mut rig = build_rig_with_claude_pauses(None, Some(Arc::clone(&kill_pause)), None); @@ -5527,7 +5489,7 @@ async fn handoff_abort_during_target_spawn_reaps_the_uncommitted_terminal() { // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let pause = Arc::new(tokio::sync::Notify::new()); let hooks = Arc::new(HandoffTestHooks { @@ -5635,7 +5597,7 @@ async fn handoff_abort_during_target_spawn_reaps_the_uncommitted_terminal() { async fn a_handoff_target_exiting_before_the_commit_never_publishes_live() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); // The claude-lane resume gates on transcript presence (the 6b // pattern). @@ -5707,7 +5669,7 @@ async fn a_handoff_target_exiting_before_the_commit_never_publishes_live() { let target_pid = rows .iter() .rev() - .find(|r| r["msg"]["boot"]["resume"] == sid) + .find(|r| r["options"]["resume"] == sid || r["options"]["sessionId"] == sid) .and_then(|r| r["pid"].as_u64()) .expect("the target sidecar's create row") as u32; let _ = std::process::Command::new("kill") @@ -5769,7 +5731,7 @@ async fn a_handoff_target_exiting_before_the_commit_never_publishes_live() { async fn handoff_abort_of_a_fresh_target_fires_the_failed_transition_repair() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); // The claude-lane resume gates on transcript presence (the 6b pattern): // the target's resume of `sid` needs the transcript under @@ -5879,7 +5841,7 @@ async fn handoff_abort_of_a_fresh_target_fires_the_failed_transition_repair() { async fn handoff_abort_during_pre_publication_spawn_fences_until_the_spawn_settles() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let pre_publish_park = Arc::new(tokio::sync::Notify::new()); let hooks = Arc::new(HandoffTestHooks { @@ -6007,7 +5969,7 @@ async fn handoff_abort_cleanup_spares_a_same_id_different_provider_terminal() { // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let pause = Arc::new(tokio::sync::Notify::new()); let hooks = Arc::new(HandoffTestHooks { @@ -6132,7 +6094,7 @@ async fn handoff_abort_during_fresh_target_resume_reaps_the_registered_session_a // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); // The claude-lane resume gates on transcript presence (the 6b pattern). let store_dir = std::env::temp_dir().join(format!( "freshell-handoff-abort-fresh-store-{}", @@ -6185,7 +6147,7 @@ async fn handoff_abort_during_fresh_target_resume_reaps_the_registered_session_a let target_pid = env .create_rows() .into_iter() - .filter(|r| r["msg"]["boot"]["resume"] == sid) + .filter(|r| r["options"]["resume"] == sid || r["options"]["sessionId"] == sid) .filter_map(|r| r["pid"].as_u64()) .find(|pid| *pid != prior_pid as u64) .expect("the target sidecar's create row") as u32; @@ -6241,7 +6203,7 @@ async fn the_claude_handoff_target_binding_carries_the_handoff_generation() { let _guard = ENV_LOCK.lock().await; // The claude-lane env surface is process-global (see the pattern above). let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); // The claude-lane resume gates on transcript presence (the 6b pattern). let store_dir = std::env::temp_dir().join(format!("freshell-handoff-r27-f2-{}", uuid_like_suffix())); @@ -6380,7 +6342,7 @@ async fn a_terminal_target_binding_failure_fails_the_handoff_typed() { let _guard = ENV_LOCK.lock().await; // The claude-lane env surface is process-global (see the pattern above). let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); // The claude-lane resume gates on transcript presence (the 6b pattern). let store_dir = std::env::temp_dir().join(format!("freshell-handoff-r27-f3-{}", uuid_like_suffix())); @@ -6482,7 +6444,7 @@ async fn handoff_abort_holds_the_fence_until_the_uncommitted_target_continuation // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); // The claude-lane resume gates on transcript presence (the 6b pattern). let store_dir = std::env::temp_dir().join(format!("freshell-handoff-r36-store-{}", uuid_like_suffix())); @@ -6524,7 +6486,7 @@ async fn handoff_abort_holds_the_fence_until_the_uncommitted_target_continuation let target_pid = env .create_rows() .into_iter() - .filter(|r| r["msg"]["boot"]["resume"] == sid) + .filter(|r| r["options"]["resume"] == sid || r["options"]["sessionId"] == sid) .filter_map(|r| r["pid"].as_u64()) .find(|pid| *pid != prior_pid as u64) .expect("the target sidecar's create row") as u32; @@ -6636,7 +6598,7 @@ async fn handoff_abort_holds_the_fence_until_the_uncommitted_target_continuation async fn an_aborted_from_vacant_platform_limited_target_fences_typed_and_recovers() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); // The claude-lane resume gates on transcript presence (the 6b pattern). let store_dir = std::env::temp_dir().join(format!( "freshell-handoff-r54a-store-{}", @@ -6769,7 +6731,7 @@ async fn an_aborted_from_vacant_platform_limited_target_fences_typed_and_recover async fn an_aborted_from_vacant_unconfirmed_target_fences_with_a_replacement_watcher() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); // The claude-lane resume gates on transcript presence (the 6b pattern). let store_dir = std::env::temp_dir().join(format!( "freshell-handoff-r54b-store-{}", @@ -6820,7 +6782,7 @@ async fn an_aborted_from_vacant_unconfirmed_target_fences_with_a_replacement_wat let target_pid = env .create_rows() .into_iter() - .filter(|r| r["msg"]["boot"]["resume"] == sid) + .filter(|r| r["options"]["resume"] == sid || r["options"]["sessionId"] == sid) .filter_map(|r| r["pid"].as_u64()) .find(|pid| *pid != 0) .expect("the target sidecar's create row") as u32; @@ -6912,7 +6874,7 @@ async fn an_aborted_from_vacant_unconfirmed_target_fences_with_a_replacement_wat async fn an_abort_cleanup_broadcasts_the_restored_prior_owner() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); let hooks = Arc::new(HandoffTestHooks { pause_after_enter: Some(tokio::sync::Notify::new()), @@ -6992,7 +6954,7 @@ fn fresh_create_msg(session_type: SessionType) -> FreshAgentCreate { async fn a_handoff_on_a_superseded_rekeyed_id_resolves_the_canonical_owner() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let canonical = uuid::Uuid::new_v4().to_string(); let superseded = uuid::Uuid::new_v4().to_string(); let mut rig = build_rig(None); @@ -7061,7 +7023,7 @@ async fn a_handoff_on_a_superseded_rekeyed_id_resolves_the_canonical_owner() { async fn a_fresh_created_claude_session_owns_its_uuid_and_hands_off_atomically() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let mut rig = build_rig(None); let _ = drain_runtime_owner_frames(&mut rig.rx); @@ -7150,7 +7112,7 @@ async fn a_fresh_created_claude_session_owns_its_uuid_and_hands_off_atomically() async fn a_fresh_created_kilroy_session_owns_its_acknowledged_uuid() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let mut rig = build_rig(None); let _ = drain_runtime_owner_frames(&mut rig.rx); @@ -8379,7 +8341,7 @@ async fn handoff_to_a_kilroy_target_resumes_as_kilroy_on_the_claude_lane() { // snapshot.rs's tests through THEIR lock — hold it too: our own // ENV_LOCK only serializes this file against itself. let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); // The claude-lane resume path gates on transcript presence — install a // fake transcript store root for the session. let store_dir = std::env::temp_dir().join(format!( @@ -8435,20 +8397,61 @@ async fn handoff_to_a_kilroy_target_resumes_as_kilroy_on_the_claude_lane() { // The prior terminal is dead. assert!(rig.registry.terminal_is_dead(&prior_terminal)); - // The created/resumed session's frames carry sessionType "kilroy". + // The real CLI emits its stream init when a turn starts. Prove the + // resumed consumer keeps Kilroy's flavor using that actual first turn. + let sent = rig + .fresh_claude + .handle_send(freshell_protocol::FreshAgentSend { + provider: AgentProvider::Claude, + session_id: sid.clone(), + session_type: SessionType::Kilroy, + text: "prove the Kilroy handoff consumer".into(), + cwd: Some("/tmp".into()), + images: None, + request_id: None, + settings: None, + observed_epoch: None, + observed_generation: None, + checkpoint_intent: None, + }) + .await; + assert!( + matches!(sent, crate::ClaudeSendOutcome::Accepted { .. }), + "{sent:?}" + ); + + // Retain both facts regardless of their relative delivery order. The + // consumer can publish its first Kilroy frame before or after the runner's + // owner commit; waiting for one must never discard the other. let deadline = tokio::time::Instant::now() + Duration::from_secs(10); let mut saw_kilroy_frame = false; + let mut frames = Vec::new(); while tokio::time::Instant::now() < deadline { - match tokio::time::timeout(Duration::from_millis(250), rig.rx.recv()).await { + match tokio::time::timeout_at(deadline, rig.rx.recv()).await { Ok(Ok(raw)) => { - if raw.contains("\"sessionType\":\"kilroy\"") && raw.contains(&sid) { + let frame: Value = serde_json::from_str(&raw).expect("JSON bus frame"); + if frame["type"] == "session.runtimeOwner" + && frame["sessionId"] == sid + && frame["ownerKind"] == "fresh-agent" + { + frames.push(frame.clone()); + } + if frame["type"] == "freshAgent.event" + && frame["sessionType"] == "kilroy" + && frame["sessionId"] == sid + { saw_kilroy_frame = true; + } + if saw_kilroy_frame + && frames + .iter() + .any(|frame| frame["transition"] == "handoff-committed") + { break; } } Ok(Err(tokio::sync::broadcast::error::RecvError::Lagged(_))) => continue, - Ok(Err(_)) => break, - Err(_) => {} + Ok(Err(_)) | Err(_) => break, } } assert!( @@ -8465,7 +8468,6 @@ async fn handoff_to_a_kilroy_target_resumes_as_kilroy_on_the_claude_lane() { } // The broadcast frames carry epoch + ownerKind fresh-agent. - let frames = drain_runtime_owner_frames(&mut rig.rx); let committed = runtime_owner_frame(&frames, "handoff-committed"); assert!(committed["epoch"].as_u64().unwrap_or(0) >= 1); assert_eq!(committed["ownerKind"], json!("fresh-agent")); @@ -8624,7 +8626,7 @@ async fn handoff_route_refuses_provider_mismatched_targets_typed() { // The fake serve: wrong-lane dispatches that reach the opencode lane // resume a session the fake 404s (the "missing" id rule). let _serve_env = FakeOpencodeServeEnv::install(); - let env = FakeSidecarEnv::install(); + let env = ClaudeSidecarEnv::install(); let mut rig = build_rig(None); // A LIVE prior for the wrong-mode case: the refusal must leave this @@ -8843,7 +8845,7 @@ async fn handoff_route_resolves_absent_session_type_when_unambiguous() { async fn the_prior_stop_never_confirms_on_a_missing_row() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let sid = uuid::Uuid::new_v4().to_string(); // A SHORT reap budget: the pid-alive window deterministically fences // within the test. @@ -8936,7 +8938,7 @@ async fn the_prior_stop_never_confirms_on_a_missing_row() { async fn the_replacement_probe_never_confirms_a_terminal_prior_on_a_missing_row() { let _guard = ENV_LOCK.lock().await; let _claude_env = crate::claude::tests::CLAUDE_ENV_LOCK.lock().await; - let _env = FakeSidecarEnv::install(); + let _env = ClaudeSidecarEnv::install(); let rig = build_rig_inner(None, None, None, 10_000, None, false, None, None); // The prior's stand-in process: ALIVE, its registry row ABSENT — the // exact row-removed-but-not-yet-reaped shape a concurrent kill diff --git a/test/integration/providers/claude-sdk-for-rust-runtime.mjs b/test/integration/providers/claude-sdk-for-rust-runtime.mjs index 992138d53..3de757795 100644 --- a/test/integration/providers/claude-sdk-for-rust-runtime.mjs +++ b/test/integration/providers/claude-sdk-for-rust-runtime.mjs @@ -68,6 +68,25 @@ export function query(params) { }) recordInput() const runtime = simulateQuery(params) + // Rare SDK notices have no local-provider CLI recording. These explicit + // per-test SDK messages still pass through production index.mjs mapping. + const injected = JSON.parse(process.env.FRESHELL_TEST_CLAUDE_SDK_MESSAGES ?? '[]') + const messages = runtime[Symbol.asyncIterator].bind(runtime) + runtime[Symbol.asyncIterator] = async function* () { + let emitted = false + for await (const message of { [Symbol.asyncIterator]: messages }) { + append(process.env.FRESHELL_TEST_CLAUDE_SDK_LOG, message) + yield message + if (!emitted && message.type === 'assistant') { + emitted = true + for (const notice of injected) { + const injectedMessage = { session_id: message.session_id, ...notice } + append(process.env.FRESHELL_TEST_CLAUDE_SDK_LOG, injectedMessage) + yield injectedMessage + } + } + } + } closeWhenGuardEnds(runtime) const initialize = runtime.initializationResult.bind(runtime) runtime.initializationResult = async () => { From a2cef8120a84fd67f37764739ec4250b3e6cc06f Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:16:31 -0700 Subject: [PATCH 085/100] docs(freshclaude): describe native create and naming establishment --- crates/freshell-freshagent/src/claude.rs | 34 +++++++----------------- 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index faaaedcca..e83e875a1 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -12,7 +12,7 @@ //! //! | Client→server | Behaviour | //! |---|---| -//! | `freshAgent.create {sessionType:'freshclaude'\|'kilroy',…}` | allocate a native Claude UUID, establish ownership and recovery metadata, then acknowledge the sidecar and broadcast `freshAgent.created` with its durable ref | +//! | `freshAgent.create {sessionType:'freshclaude'\|'kilroy',…}` | allocate a native Claude UUID, boot its owned sidecar, establish ownership and recovery metadata, then publish `freshAgent.created` with its durable ref | //! | `freshAgent.send {sessionId,text}` | recover an ended runtime when needed, apply settings, then push the user turn into the SDK input stream and broadcast its correlated acceptance | //! //! The live map uses the current native UUID. A conversation reset moves the @@ -48,9 +48,8 @@ //! //! ## Safety //! -//! The Node sidecar (and the `claude` CLI grandchild the SDK spawns) inherit the server's -//! isolated HOME (so they authenticate from + write ALL transcript data under -//! `/.claude`, never the user's real store) and carry a +//! The Node sidecar and SDK-spawned Claude CLI use the configured provider +//! environment. Test rigs supply an isolated HOME and transcript store. Both carry a //! `FRESHELL_CLAUDE_SIDECAR_ID` ownership tag. Every teardown, and //! [`FreshClaudeState::shutdown`] for all sessions concurrently, first asks the sidecar to //! shut down (it aborts its SDK query, which stops the claude CLI), escalates to @@ -1486,19 +1485,10 @@ impl FreshClaudeState { self.naming.get() } - /// Unified agent names (Task 2): admit a freshclaude create's pre-durable - /// naming handle and stash it against the placeholder session id, so the - /// `sdk.session.init` adoption binds it to the durable UUID. Kilroy is - /// out of scope (its `naming_handle` is ignored); a create WITHOUT a - /// handle simply proceeds unnamed. Returns the frame projection when a - /// handle was admitted. - /// Unified agent names (Task 8 acceptance): the pending RECORD for the - /// create frame's handle, admitted at REQUEST RECEIPT — BEFORE the - /// sidecar spawn/`created` handshake (seconds) during which the pane - /// content and the client's rename capture already carry the handle. - /// A rename committed inside that window must resolve the same record - /// the server accepted on the wire (the pre-fix shape answered 404 - /// NAME_NOT_FOUND and silently lost the manual pre-durable name). + /// Admit the pending name at request receipt, before the CLI boots, so + /// a rename during startup reaches the same record. Establishment binds + /// it to the native UUID after saving recovery metadata. Kilroy ignores + /// naming handles; a create without a handle proceeds unnamed. async fn admit_create_handle_early(&self, msg: &FreshAgentCreate, session_type: &str) { if session_type == "kilroy" { return; @@ -2080,10 +2070,8 @@ impl FreshClaudeState { // codex/opencode: a duplicate `create` arriving right after this point must see // the cache populated, never race past this guard's release and spawn a second // sidecar). - // A resume create names the conversation the CLI booted: `created` comes - // only after that boot, so the caller learns the native identity here - // instead of waiting for a turn's init (design §6, §7.3). A fresh create - // has no durable id until its first turn, so it carries none. + // Every successful create names its boot-verified native conversation, + // including a fresh conversation that has not received a prompt. let session_ref = Some(claude_session_ref(&launch_id)); self.create_dedup .record_success( @@ -2097,9 +2085,7 @@ impl FreshClaudeState { ) .await; - // Broadcast freshAgent.created (ws-handler.ts:3378); placeholder == the bare - // nanoid. Unified agent names: the created frame carries the pane's naming - // projection (the pending handle's record) when one was admitted. + // Publish the native identity and its current durable naming projection. let name_ref = naming_projection .as_ref() .map(|(reference, _)| reference.clone()); From 4b29eff8458f05d60a64ed6957f028a0d2ca666f Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:19:57 -0700 Subject: [PATCH 086/100] test(e2e): run Kilroy harness through the production sidecar --- .../fixtures/fake-kilroy-runtime.mjs | 222 ---------- .../helpers/harness-06/kilroy-runtime.test.ts | 410 ++++++++---------- .../helpers/harness-06/kilroy-runtime.ts | 186 ++++---- .../specs/harness-06-misc-fixtures.spec.ts | 53 ++- 4 files changed, 330 insertions(+), 541 deletions(-) delete mode 100644 test/e2e-browser/fixtures/fake-kilroy-runtime.mjs diff --git a/test/e2e-browser/fixtures/fake-kilroy-runtime.mjs b/test/e2e-browser/fixtures/fake-kilroy-runtime.mjs deleted file mode 100644 index 31901f473..000000000 --- a/test/e2e-browser/fixtures/fake-kilroy-runtime.mjs +++ /dev/null @@ -1,222 +0,0 @@ -#!/usr/bin/env node -// HARNESS-06 fake Kilroy runtime — the harness-level "full Kilroy runtime" -// fixture (NOT the production Kilroy). Speaks the REAL claude-sidecar -// newline-JSON protocol verbatim (crates/freshell-claude-sidecar/index.mjs, -// doc comment lines 9-29) with kilroy flavour, so any harness that can drive a -// fresh-agent claude/kilroy sidecar can drive this one deterministically: -// -// in : {"type":"create",protocol:2,requestId,cwd,model,permissionMode,effort, -// boot:{kind:'fresh'}|{kind:'resume',resume}} (protocol v2, crates/freshell-claude-sidecar/boot.mjs) -// {"type":"send",sessionId,uuid,text} {"type":"interrupt",sessionId,stopId} {"type":"shutdown"} -// out: {"type":"created","requestId","sessionId",protocol:2} FIRST (bare nanoid placeholder, -// read_created discards any earlier sdk.* line), then: -// sdk.session.init {sessionId,cliSessionId,model,cwd,tools:[]} -// sdk.session.snapshot {sessionId,messages} (resume only) -// sdk.status {sessionId,status} (running at a send; idle only after -// create and an interrupt with nothing -// in flight: a turn ends with its result) -// sdk.permission.request {sessionId,requestId,subtype,tool} (approval knob; before -// the assistant, then cancelled when the -// fake allows it on its own) -// sdk.assistant {sessionId,content:[],model} -// sdk.result {sessionId,result,subtype,isError,terminalReason?,numTurns,…} -// the facts the server's verdict reads -// sdk.interrupt_settled {sessionId,stopId,ok} (interrupt; the interrupted turn's -// own aborted sdk.result follows) -// -// Like the real sidecar (C18) it mints NO attention edge: the server rings -// every turn end itself from the results, minus the one the user's own Stop -// landed on, and mints the waiting edge from the pending approval. -// -// Every inbound request is appended to FAKE_KILROY_LOG as JSONL -// ({pid,t,msg}) BEFORE handling — "records Kilroy invocations". -// -// Knobs (env): -// FAKE_KILROY_LOG JSONL request ledger path -// FAKE_KILROY_CLI_SESSION_ID fixed durable UUID (default: per-process random) -// FAKE_KILROY_HOLD_TURN=1 send starts running and never completes on its -// own (interrupt is the only turn exit) -// FAKE_KILROY_APPROVAL=1 send surfaces an approval request first, then -// allows it on its own after -// FAKE_KILROY_APPROVAL_DELAY_MS (default 250) -// FAKE_KILROY_FAIL_RESULT=1 the turn ends error_during_execution (a failed turn) -// FAKE_KILROY_CRASH_ON_SEND=1 process.exit(3) mid-turn — no completion ever -// (a real crash screams no protocol frame; the -// server rings the death of an owed turn) -// -// Interrupt ends the TURN, never the session: NO sdk.exit, the session stays -// alive, and the NEXT turn runs as usual. - -import readline from 'node:readline' -import fs from 'node:fs' -import path from 'node:path' -import { randomBytes, randomUUID } from 'node:crypto' - -const LOG = process.env.FAKE_KILROY_LOG -const HOLD_TURN = process.env.FAKE_KILROY_HOLD_TURN === '1' -const APPROVAL = process.env.FAKE_KILROY_APPROVAL === '1' -const APPROVAL_DELAY_MS = Number(process.env.FAKE_KILROY_APPROVAL_DELAY_MS ?? 250) -const FAIL_RESULT = process.env.FAKE_KILROY_FAIL_RESULT === '1' -const CRASH_ON_SEND = process.env.FAKE_KILROY_CRASH_ON_SEND === '1' -const CLI_SESSION_ID = process.env.FAKE_KILROY_CLI_SESSION_ID ?? randomUUID() -/** The result facts the real sidecar forwards for a turn a Stop landed on (T11). */ -const STOPPED_RESULT = Object.freeze({ subtype: 'error_during_execution', isError: true, terminalReason: 'aborted_streaming', numTurns: 1 }) -/** The result facts of a turn that ran to its end. */ -const ENDED_RESULT = Object.freeze({ subtype: 'success', isError: false, terminalReason: 'completed', numTurns: 1 }) -/** The result facts of a failed turn (FAKE_KILROY_FAIL_RESULT). */ -const FAILED_RESULT = Object.freeze({ subtype: 'error_during_execution', isError: true, numTurns: 1, errorText: 'Fixture turn failed' }) - -/** One `sdk.result` frame with `facts`, in the real sidecar's shape. */ -function resultFrame(sessionId, facts) { - return { type: 'sdk.result', sessionId, result: facts.subtype, ...facts, durationMs: 1, costUsd: 0, usage: { input_tokens: 1, output_tokens: 1 } } -} - -const NANOID_ALPHABET = 'useandom-26T198340PX75pxJACKVERYMINDBUSHWOLF_GQZbfghjklqvwyzrict' -function nanoid(size = 21) { - const bytes = randomBytes(size) - let id = '' - for (let i = 0; i < size; i++) id += NANOID_ALPHABET[bytes[i] & 63] - return id -} - -function emit(obj) { - process.stdout.write(`${JSON.stringify(obj)}\n`) -} - -function logRequest(msg) { - if (!LOG) return - fs.mkdirSync(path.dirname(LOG), { recursive: true }) - fs.appendFileSync(LOG, `${JSON.stringify({ pid: process.pid, t: Date.now(), msg })}\n`) -} - -// sessionId -> { cliSessionId, cwd, pendingResults, sendInFlight, interrupted } -const sessions = new Map() - -/** Per-session turn bookkeeping: the turns owed a result, and the in-flight - * and interrupt render flags. */ -function sessionState(cliSessionId, cwd) { - return { cliSessionId, cwd, pendingResults: 0, sendInFlight: false, interrupted: false } -} - -const rl = readline.createInterface({ input: process.stdin }) -rl.on('line', (line) => { - let msg - try { - msg = JSON.parse(line) - } catch { - return - } - logRequest(msg) - void handle(msg) -}) - -async function handle(msg) { - if (msg.type === 'create') { - const sessionId = msg.sessionId ?? nanoid() - const resumeSessionId = msg.boot?.resume - const cliSessionId = resumeSessionId ?? msg.sessionId ?? CLI_SESSION_ID - const cwd = msg.cwd ?? process.cwd() - sessions.set(sessionId, sessionState(cliSessionId, cwd)) - // `created` MUST be the first line written for this request. - emit({ type: 'created', requestId: msg.requestId, sessionId, protocol: 2 }) - emit({ - type: 'sdk.session.init', - sessionId, - cliSessionId, - model: msg.model ?? 'claude-opus-4-6', - cwd, - tools: [], - }) - if (resumeSessionId) { - emit({ type: 'sdk.session.snapshot', sessionId, messages: [] }) - } - emit({ type: 'sdk.status', sessionId, status: 'idle' }) - return - } - - if (msg.type === 'send') { - const st = sessions.get(msg.sessionId) ?? sessionState(CLI_SESSION_ID, process.cwd()) - sessions.set(msg.sessionId, st) - emit({ type: 'sdk.status', sessionId: msg.sessionId, status: 'running' }) - if (CRASH_ON_SEND) { - process.stderr.write('[fake-kilroy] FAKE_KILROY_CRASH_ON_SEND: exiting 3 mid-turn\n') - process.exit(3) - } - // A turn is owed until its result, so an interrupt arriving mid-render - // ends it. - st.pendingResults += 1 - if (HOLD_TURN) return // wedged mid-turn; interrupt is the only turn exit - - st.sendInFlight = true - try { - if (APPROVAL) { - // The real sidecar's approval shape: the request (the server mints the - // waiting edge from it), then the fake allows it on its own. - const requestId = nanoid() - emit({ type: 'sdk.permission.request', sessionId: msg.sessionId, requestId, subtype: 'can_use_tool', tool: { name: 'Bash', input: { command: 'ls' } } }) - await new Promise((r) => setTimeout(r, APPROVAL_DELAY_MS)) - emit({ type: 'sdk.permission.cancelled', sessionId: msg.sessionId, requestId }) - } - - emit({ - type: 'sdk.assistant', - sessionId: msg.sessionId, - content: [{ type: 'text', text: `kilroy fixture reply: ${msg.text}` }], - model: 'claude-opus-4-6', - }) - // An in-flight interrupt deferred to this scripted completion: the - // interrupted turn's own result is the CLI's aborted shape (T11 — an - // interrupted turn never ends 'success'). - const facts = st.interrupted ? STOPPED_RESULT : FAIL_RESULT ? FAILED_RESULT : ENDED_RESULT - st.interrupted = false - st.pendingResults = Math.max(0, st.pendingResults - 1) - // The result ends the turn: no idle follows (the real sidecar sends none). - emit(resultFrame(msg.sessionId, facts)) - } finally { - st.sendInFlight = false - } - return - } - - if (msg.type === 'interrupt') { - const st = sessions.get(msg.sessionId) - const settled = (fields) => emit({ type: 'sdk.interrupt_settled', sessionId: msg.sessionId, stopId: msg.stopId ?? null, ...fields }) - if (!st) { - // Real-sidecar parity (index.mjs handleInterrupt): a session this fake - // does not host settles ok:false. - settled({ ok: false, message: 'Claude session is no longer available.' }) - return - } - if (st.pendingResults > 0) { - // A turn is owed: settle ok:true (the receipt comes before the stopped - // turn's result, sdk.d.ts:3765), then the stopped turn's own aborted - // result. NEVER an sdk.exit: interrupt ends the TURN, never the session. - st.pendingResults = 0 - st.interrupted = true - settled({ ok: true, stillQueued: [] }) - if (st.sendInFlight) { - // The scripted turn is still mid-render (the approval lane's await): - // its completion lands next with the aborted result. - return - } - // Held turn: nothing else will end it, so the stopped turn's own result - // follows here. The result ends the turn (no idle follows). - st.interrupted = false - emit(resultFrame(msg.sessionId, STOPPED_RESULT)) - return - } - // Nothing owed: the SDK resolves an idle interrupt (sdk.d.ts:2384-2394). - settled({ ok: true, stillQueued: [] }) - emit({ type: 'sdk.status', sessionId: msg.sessionId, status: 'idle' }) - return - } - - if (msg.type === 'shutdown') { - process.exit(0) - } -} - -process.on('uncaughtException', (err) => { - process.stderr.write(`[fake-kilroy] uncaught: ${err}\n`) - process.exit(4) -}) diff --git a/test/e2e-browser/helpers/harness-06/kilroy-runtime.test.ts b/test/e2e-browser/helpers/harness-06/kilroy-runtime.test.ts index 82ad61a54..e9f82c95a 100644 --- a/test/e2e-browser/helpers/harness-06/kilroy-runtime.test.ts +++ b/test/e2e-browser/helpers/harness-06/kilroy-runtime.test.ts @@ -1,261 +1,233 @@ import { describe, it, expect, afterEach } from 'vitest' -import fsp from 'node:fs/promises' -import os from 'node:os' +import fs from 'node:fs/promises' import path from 'node:path' import { randomUUID } from 'node:crypto' -import { spawnFakeKilroy, type KilroyRuntime } from './kilroy-runtime.js' - -/** - * HARNESS-06 kilroy-runtime coverage: the harness-level "full Kilroy runtime" - * fake. It must speak the real claude-sidecar newline-JSON protocol - * (crates/freshell-claude-sidecar/index.mjs doc comment) with kilroy flavour, - * record every request to a JSONL ledger ("records Kilroy invocations"), and - * expose controllable approval / failure / crash / resume edges. - */ - +import { + spawnKilroyRuntime, + readKilroyLedger, + type KilroyRuntime, + type KilroyRuntimeOptions, +} from './kilroy-runtime.js' + +// Drive the production sidecar, not a second implementation of its protocol. +// The simulator supplies model turns and records the real SDK's inputs. const runtimes: KilroyRuntime[] = [] -const tmpDirs: string[] = [] - -async function make(env: NodeJS.ProcessEnv = {}): Promise<{ rt: KilroyRuntime; logPath: string }> { - const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'freshell-h06-kilroy-')) - tmpDirs.push(dir) - const logPath = path.join(dir, 'requests.jsonl') - const rt = await spawnFakeKilroy({ FAKE_KILROY_LOG: logPath, ...env }) - runtimes.push(rt) - return { rt, logPath } +async function make(options: KilroyRuntimeOptions = {}): Promise { + const runtime = await spawnKilroyRuntime({ timeScale: 0.02, ...options }) + runtimes.push(runtime) + return runtime } afterEach(async () => { while (runtimes.length) await runtimes.pop()!.kill() - while (tmpDirs.length) await fsp.rm(tmpDirs.pop()!, { recursive: true, force: true }) }) -async function readLedger(logPath: string): Promise>> { - try { - const text = await fsp.readFile(logPath, 'utf8') - return text.split('\n').filter(Boolean).map((l) => JSON.parse(l)) - } catch (err) { - if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [] - throw err - } +async function create(rt: KilroyRuntime, extra: Record = {}): Promise { + const sessionId = randomUUID() + rt.send({ type: 'create', protocol: 2, sessionType: 'kilroy', sessionId, requestId: 'create', cwd: rt.cwd, boot: { kind: 'fresh' }, ...extra }) + const answer = await rt.nextEvent('created') + return answer.sessionId as string } -/** Whether the fake wrote an idle status between event indexes `from` and `to`. */ -function idleBetween(rt: KilroyRuntime, from: number, to: number): boolean { - return rt.events().slice(from, to).some((e) => e.type === 'sdk.status' && e.status === 'idle') +function send(rt: KilroyRuntime, sessionId: string, text: string): string { + const uuid = randomUUID() + rt.send({ type: 'send', sessionId, uuid, text }) + return uuid } -/** - * Every frame the fake wrote before this call has been read: an interrupt with - * nothing in flight is settled after all of them (it arms nothing). Returns the - * index of its settle. - */ -async function readEverythingSoFar(rt: KilroyRuntime, sessionId: string): Promise { - rt.send({ type: 'interrupt', sessionId }) - const settle = await rt.nextEvent('sdk.interrupt_settled') - return rt.events().indexOf(settle) +function serverOnlyFrames(rt: KilroyRuntime) { + return rt.events().filter((event) => ['sdk.turn.complete', 'sdk.turn.waiting', 'sdk.exit'].includes(event.type)) } -describe('harness-06 fake kilroy runtime', () => { - it('answers create with created-first, init, idle — and records the invocation', async () => { - const { rt, logPath } = await make() - const sessionId = '11111111-1111-4111-8111-111111111111' - rt.send({ type: 'create', sessionId, requestId: 'r-1', cwd: '/tmp/fixture-cwd', model: 'claude-opus-4-6' }) - - const created = (await rt.nextEvent('created')) as { requestId: string; sessionId: string } - expect(created.requestId).toBe('r-1') - expect(created.sessionId).toBe(sessionId) - - const init = (await rt.nextEvent('sdk.session.init', undefined, 5000)) as { - sessionId: string; cliSessionId: string; model: string; cwd: string - } - expect(init.sessionId).toBe(created.sessionId) - expect(init.cliSessionId).toBe(sessionId) - expect(init.cwd).toBe('/tmp/fixture-cwd') - - const idle = (await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle', 5000)) - expect((idle as { sessionId: string }).sessionId).toBe(created.sessionId) +async function barrier(rt: KilroyRuntime, sessionId: string) { + const requestId = randomUUID() + rt.send({ type: 'configure', sessionId, requestId, settings: {} }) + await rt.nextEvent('sdk.configured', (event) => event.requestId === requestId) +} - // The FIRST stdout line must be `created` (read_created discards earlier sdk.*). - expect((rt.events()[0] as { type: string }).type).toBe('created') +const APPROVAL_PROGRAM = { + rules: [{ + on: 'msg:prompt', match: { text: 'needs approval' }, + emit: [ + { kind: 'approval', data: { tool: 'Bash', input: { command: 'ls' } } }, + { kind: 'text', data: { text: 'approved reply' } }, + ], + }], +} - const ledger = await readLedger(logPath) - expect(ledger).toHaveLength(1) - expect((ledger[0].msg as { type: string }).type).toBe('create') - expect((ledger[0].msg as { cwd?: string }).cwd).toBe('/tmp/fixture-cwd') +describe('harness-06 production Kilroy runtime', () => { + it('answers create first with the native UUID and defers session init until a real prompt', async () => { + const rt = await make() + const sessionId = await create(rt, { model: 'claude-opus-4-6' }) + expect(rt.events()[0]).toMatchObject({ type: 'created', requestId: 'create', sessionId, protocol: 2 }) + rt.send({ type: 'interrupt', sessionId, stopId: 'idle-before-prompt' }) + await rt.nextEvent('sdk.interrupt_settled') + expect(rt.events().filter((event) => event.type === 'sdk.session.init')).toEqual([]) + const query = (await readKilroyLedger(rt.ledgerPath)).find((row) => row.kind === 'query')! + expect(query.options).toMatchObject({ sessionId, cwd: rt.cwd, model: 'claude-opus-4-6' }) + + send(rt, sessionId, 'first prompt') + expect(await rt.nextEvent('sdk.session.init')).toMatchObject({ sessionId, cliSessionId: sessionId, cwd: rt.cwd, model: 'claude-opus-4-6' }) + await rt.nextEvent('sdk.result') }) - it('runs a full send turn: running -> assistant -> result(success, with its facts), with no edge and no idle after it', async () => { - const { rt, logPath } = await make() - rt.send({ type: 'create', requestId: 'r-1', cwd: '/tmp' }) - const created = (await rt.nextEvent('created')) as { sessionId: string } - await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle') - - rt.send({ type: 'send', sessionId: created.sessionId, text: 'hello kilroy' }) - - const running = (await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'running')) - expect((running as { sessionId: string }).sessionId).toBe(created.sessionId) - - const assistant = (await rt.nextEvent('sdk.assistant')) as { content: Array<{ type: string; text?: string }> } - expect(Array.isArray(assistant.content)).toBe(true) - expect(assistant.content[0].type).toBe('text') - expect(assistant.content[0].text).toContain('hello kilroy') - - const result = (await rt.nextEvent('sdk.result')) as Record - // The facts the server's verdict reads (it mints the edge, C18). - expect(result).toMatchObject({ result: 'success', subtype: 'success', isError: false, terminalReason: 'completed' }) - - rt.send({ type: 'send', sessionId: created.sessionId, text: 'again' }) + it('runs successive real turns with their submitted UUIDs and result facts, and mints no edge or synthetic idle', async () => { + const rt = await make({ program: { rules: [{ on: 'msg:prompt', emit: [{ kind: 'text', data: { text: 'kilroy reply' } }] }] } }) + const sessionId = await create(rt) + const first = send(rt, sessionId, 'hello kilroy') + const assistant = await rt.nextEvent('sdk.assistant') + expect(assistant.content).toEqual([{ type: 'text', text: 'kilroy reply' }]) + const result = await rt.nextEvent('sdk.result') + expect(result).toMatchObject({ sessionId, result: 'success', subtype: 'success', isError: false, terminalReason: 'completed' }) + const second = send(rt, sessionId, 'again') await rt.nextEvent('sdk.result') - expect(rt.events().filter((e) => e.type === 'sdk.turn.complete'), 'the fake mints no edge').toEqual([]) - // The result ends the turn, as with the real sidecar (which sends no idle): - // nothing between the first turn's result and the second turn is an idle. - const secondRunning = rt.events().findIndex( - (e, i) => i > rt.events().indexOf(result) && e.type === 'sdk.status' && e.status === 'running', - ) - expect(secondRunning).toBeGreaterThan(0) - expect(idleBetween(rt, rt.events().indexOf(result), secondRunning), 'no per-turn idle').toBe(false) - - const ledger = await readLedger(logPath) - expect(ledger.map((row) => (row.msg as { type: string }).type)).toEqual(['create', 'send', 'send']) + const prompts = (await readKilroyLedger(rt.ledgerPath)).filter((row) => row.kind === 'prompt') + expect(prompts.map((row) => [row.sessionId, row.uuid, row.text])).toEqual([ + [sessionId, first, 'hello kilroy'], [sessionId, second, 'again'], + ]) + expect(serverOnlyFrames(rt)).toEqual([]) + expect(rt.events().slice(rt.events().indexOf(result)).filter((event) => event.type === 'sdk.status' && event.status === 'idle')).toEqual([]) }) - it('approval knob surfaces an approval request before completing (the server mints the waiting edge)', async () => { - const { rt } = await make({ FAKE_KILROY_APPROVAL: '1', FAKE_KILROY_APPROVAL_DELAY_MS: '150' }) - rt.send({ type: 'create', requestId: 'r-1', cwd: '/tmp' }) - const created = (await rt.nextEvent('created')) as { sessionId: string } - await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle') - - rt.send({ type: 'send', sessionId: created.sessionId, text: 'needs approval' }) - - const request = (await rt.nextEvent('sdk.permission.request')) as { requestId: string; sessionId: string } - expect(request.sessionId).toBe(created.sessionId) - - // Consume through the completion, THEN assert ordering in the full stream. - await rt.nextEvent('sdk.assistant') - await rt.nextEvent('sdk.result') // allowed by the fake after the delay - const types = rt.events().map((e) => (e as { type: string }).type) - expect(types.indexOf('sdk.permission.request')).toBeLessThan(types.indexOf('sdk.permission.cancelled')) - expect(types.indexOf('sdk.permission.cancelled')).toBeLessThan(types.indexOf('sdk.assistant')) - expect(types.indexOf('sdk.assistant')).toBeLessThan(types.indexOf('sdk.result')) - expect(types.filter((type) => type === 'sdk.turn.waiting' || type === 'sdk.turn.complete'), 'the fake mints no edge').toEqual([]) + it('waits for the actual approval decision before completing and records what canUseTool received', async () => { + const rt = await make({ program: APPROVAL_PROGRAM }) + const sessionId = await create(rt) + send(rt, sessionId, 'needs approval') + const request = await rt.nextEvent('sdk.permission.request') + expect(request).toMatchObject({ sessionId, tool: { name: 'Bash', input: { command: 'ls' } } }) + await barrier(rt, sessionId) + expect(rt.events().some((event) => event.type === 'sdk.result')).toBe(false) + expect((await readKilroyLedger(rt.ledgerPath)).filter((row) => row.kind === 'can_use_tool')).toEqual([]) + rt.send({ type: 'permission.respond', sessionId, requestId: request.requestId, decision: { behavior: 'allow', updatedInput: { command: 'ls' } } }) + // A user answer resolves canUseTool; cancellation frames belong to aborts. + const assistant = await rt.nextEvent('sdk.assistant', (event) => event.content?.some((block: any) => block.text === 'approved reply')) + const result = await rt.nextEvent('sdk.result') + expect(rt.events().indexOf(request)).toBeLessThan(rt.events().indexOf(assistant)) + expect(rt.events().indexOf(assistant)).toBeLessThan(rt.events().indexOf(result)) + const decision = (await readKilroyLedger(rt.ledgerPath)).find((row) => row.kind === 'can_use_tool')! + expect(decision.decision).toMatchObject({ behavior: 'allow', updatedInput: { command: 'ls' } }) + expect(serverOnlyFrames(rt)).toEqual([]) }) - it('failure knob yields a failed result with its error text, and no edge (the server rings it)', async () => { - const { rt } = await make({ FAKE_KILROY_FAIL_RESULT: '1' }) - rt.send({ type: 'create', requestId: 'r-1', cwd: '/tmp' }) - const created = (await rt.nextEvent('created')) as { sessionId: string } - await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle') - - rt.send({ type: 'send', sessionId: created.sessionId, text: 'fail me' }) - const result = (await rt.nextEvent('sdk.result')) as Record - expect(result).toMatchObject({ subtype: 'error_during_execution', isError: true, errorText: 'Fixture turn failed', sessionId: created.sessionId }) - expect(result).not.toHaveProperty('terminalReason') - await readEverythingSoFar(rt, created.sessionId) - expect(rt.events().filter((e) => e.type === 'sdk.turn.complete' || e.type === 'sdk.error'), 'no edge, no banner').toEqual([]) + it('forwards a failed result with its error text while leaving attention accounting to Rust', async () => { + const rt = await make({ program: { rules: [{ on: 'msg:prompt', emit: [{ kind: 'completion', data: { subtype: 'error_max_turns', errors: ['Fixture turn failed'] } }] }] } }) + const sessionId = await create(rt) + send(rt, sessionId, 'fail me') + const result = await rt.nextEvent('sdk.result') + expect(result).toMatchObject({ sessionId, subtype: 'error_max_turns', isError: true, errorText: 'Fixture turn failed' }) + await barrier(rt, sessionId) + expect(serverOnlyFrames(rt)).toEqual([]) + expect(rt.events().filter((event) => event.type === 'sdk.error')).toEqual([]) }) - it('interrupt on a HELD in-flight turn settles ok:true with its stopId, then the aborted result — never an edge or an sdk.exit', async () => { - const { rt } = await make({ FAKE_KILROY_HOLD_TURN: '1' }) - rt.send({ type: 'create', requestId: 'r-1', cwd: '/tmp' }) - const created = (await rt.nextEvent('created')) as { sessionId: string } - await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle') - - rt.send({ type: 'send', sessionId: created.sessionId, text: 'hold' }) - await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'running') - - rt.send({ type: 'interrupt', sessionId: created.sessionId, stopId: 'stop-1' }) - const settle = (await rt.nextEvent('sdk.interrupt_settled')) as { ok: boolean; stopId: string } - expect(settle).toMatchObject({ ok: true, stopId: 'stop-1' }) - const result = (await rt.nextEvent('sdk.result')) as Record - expect(result).toMatchObject({ result: 'error_during_execution', terminalReason: 'aborted_streaming' }) - const settled = await readEverythingSoFar(rt, created.sessionId) - // The stopped turn's result ends it: no idle follows (the real sidecar sends none). - expect(idleBetween(rt, rt.events().indexOf(result), settled), 'no idle after the stopped result').toBe(false) - - // No attention edge on the wire (the server silences the user's own - // Stop) and NEVER an sdk.exit (interrupt ends the TURN, not the session). - expect( - rt.events().some((e) => (e as { type: string }).type === 'sdk.turn.complete'), - 'a user-initiated interrupt rings no attention edge', - ).toBe(false) - expect( - rt.events().some((e) => (e as { type: string }).type === 'sdk.exit'), - 'interrupt must never end the session', - ).toBe(false) + it('interrupts an actual held turn with the Stop receipt and aborted result, then runs the next turn', async () => { + const rt = await make() + const sessionId = await create(rt) + send(rt, sessionId, 'HANG') + await rt.nextEvent('sdk.status', (event) => event.status === 'requesting') + rt.send({ type: 'interrupt', sessionId, stopId: 'stop-1' }) + expect(await rt.nextEvent('sdk.interrupt_settled')).toMatchObject({ sessionId, stopId: 'stop-1', ok: true, stillQueued: [] }) + const result = await rt.nextEvent('sdk.result') + expect(result).toMatchObject({ sessionId, subtype: 'error_during_execution', isError: true }) + expect(result.terminalReason).toMatch(/^aborted_/) + send(rt, sessionId, 'next unrelated turn') + expect(await rt.nextEvent('sdk.result')).toMatchObject({ subtype: 'success' }) + expect(serverOnlyFrames(rt)).toEqual([]) }) - it('interrupt during a scripted approval defers to the turn: its aborted result, no edge, and the NEXT turn runs', async () => { - const { rt } = await make({ FAKE_KILROY_APPROVAL: '1', FAKE_KILROY_APPROVAL_DELAY_MS: '1000' }) - rt.send({ type: 'create', requestId: 'r-1', cwd: '/tmp' }) - const created = (await rt.nextEvent('created')) as { sessionId: string } - await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle') - - rt.send({ type: 'send', sessionId: created.sessionId, text: 'interrupt me mid-approval' }) - await rt.nextEvent('sdk.permission.request') - // The turn is provably in flight (inside the approval await) — interrupt. - rt.send({ type: 'interrupt', sessionId: created.sessionId }) - const settle = (await rt.nextEvent('sdk.interrupt_settled')) as { ok: boolean } - expect(settle.ok).toBe(true) - // The in-flight scripted completion lands with the aborted result. - const result = (await rt.nextEvent('sdk.result')) as Record - expect(result).toMatchObject({ result: 'error_during_execution', terminalReason: 'aborted_streaming' }) - - // The NEXT turn on the same live session runs to success. - rt.send({ type: 'send', sessionId: created.sessionId, text: 'next unrelated turn' }) - const next = (await rt.nextEvent('sdk.result')) as Record - expect(next).toMatchObject({ subtype: 'success' }) - expect(rt.events().filter((e) => e.type === 'sdk.turn.complete'), 'the fake mints no edge').toEqual([]) + it('interrupts a pending approval through the SDK and keeps the session usable', async () => { + const rt = await make({ program: APPROVAL_PROGRAM }) + const sessionId = await create(rt) + send(rt, sessionId, 'needs approval') + const request = await rt.nextEvent('sdk.permission.request') + rt.send({ type: 'interrupt', sessionId, stopId: 'stop-approval' }) + expect(await rt.nextEvent('sdk.interrupt_settled')).toMatchObject({ sessionId, stopId: 'stop-approval', ok: true }) + expect(await rt.nextEvent('sdk.result')).toMatchObject({ sessionId, subtype: 'error_during_execution', terminalReason: 'aborted_streaming' }) + expect(rt.events().some((event) => event.type === 'sdk.permission.cancelled' && event.requestId === request.requestId)).toBe(true) + send(rt, sessionId, 'next unrelated turn') + expect(await rt.nextEvent('sdk.result')).toMatchObject({ subtype: 'success' }) + expect(serverOnlyFrames(rt)).toEqual([]) }) - it('an interrupt with NOTHING in flight settles ok:true and leaves the session as it was: the next turn runs', async () => { - const { rt } = await make() - rt.send({ type: 'create', requestId: 'r-1', cwd: '/tmp' }) - const created = (await rt.nextEvent('created')) as { sessionId: string } - await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle') - - rt.send({ type: 'interrupt', sessionId: created.sessionId }) - const settle = (await rt.nextEvent('sdk.interrupt_settled')) as { ok: boolean } - expect(settle.ok).toBe(true) - await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle') - - rt.send({ type: 'send', sessionId: created.sessionId, text: 'next unrelated turn' }) + it('settles an idle interrupt without a synthetic status or latch that affects the next turn', async () => { + const rt = await make() + const sessionId = await create(rt) + rt.send({ type: 'interrupt', sessionId, stopId: 'idle-stop' }) + expect(await rt.nextEvent('sdk.interrupt_settled')).toMatchObject({ sessionId, stopId: 'idle-stop', ok: true }) + send(rt, sessionId, 'next unrelated turn') expect(await rt.nextEvent('sdk.result')).toMatchObject({ subtype: 'success' }) + expect(rt.events().filter((event) => event.type === 'sdk.status' && event.status === 'idle')).toEqual([]) + expect(serverOnlyFrames(rt)).toEqual([]) }) - it('crash knob kills the process mid-turn with NO completion edge (the Rust death synthesis rings, not the fake)', async () => { - const { rt } = await make({ FAKE_KILROY_CRASH_ON_SEND: '1' }) - rt.send({ type: 'create', requestId: 'r-1', cwd: '/tmp' }) - const created = (await rt.nextEvent('created')) as { sessionId: string } - await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle') - - rt.send({ type: 'send', sessionId: created.sessionId, text: 'boom' }) - const code = await new Promise((resolve) => { - rt.proc.once('exit', (c) => resolve(c)) - }) - expect(code).toBe(3) - // A real crash screams no protocol frame — the fake must NOT mint an - // edge here or a real crash would double-ring (sidecar edge + the Rust - // unrequested-death synthesis). The RUST side rings for process death. - expect(rt.events().some((e) => (e as { type: string }).type === 'sdk.turn.complete')).toBe(false) + it('reports a mid-turn CLI crash as the real stream end and process exit, without a result or edge', async () => { + const rt = await make({ program: { rules: [{ on: 'msg:prompt', emit: [{ kind: 'crash' }] }] } }) + const sessionId = await create(rt) + send(rt, sessionId, 'crash now') + expect(await rt.nextEvent('sdk.stream.ended')).toMatchObject({ sessionId, reason: 'cli_exited' }) + expect(await rt.waitExit()).toMatchObject({ code: 3, signal: null }) + expect(rt.events().filter((event) => event.type === 'sdk.result')).toEqual([]) + expect(serverOnlyFrames(rt)).toEqual([]) }) - it('resume keeps the durable cliSessionId and replays a session snapshot', async () => { + it('resumes the saved native conversation and initializes it only after the next prompt', async () => { + const rt = await make() const durable = randomUUID() - const { rt } = await make() - rt.send({ type: 'create', protocol: 2, requestId: 'r-1', cwd: '/tmp', boot: { kind: 'resume', resume: durable } }) - const init = (await rt.nextEvent('sdk.session.init')) as { cliSessionId: string } - expect(init.cliSessionId).toBe(durable) - const snapshot = (await rt.nextEvent('sdk.session.snapshot')) as { messages: unknown[] } - expect(Array.isArray(snapshot.messages)).toBe(true) - await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle') + const project = path.join(rt.claudeConfigDir, 'projects', 'saved') + await fs.mkdir(project, { recursive: true }) + const saved = path.join(project, `${durable}.jsonl`) + await fs.writeFile(saved, `${JSON.stringify({ type: 'user', uuid: randomUUID(), parentUuid: null, sessionId: durable, cwd: rt.cwd, message: { role: 'user', content: 'saved history' } })}\n`) + expect(await create(rt, { sessionId: durable, boot: { kind: 'resume', resume: durable } })).toBe(durable) + expect(rt.events().some((event) => event.type === 'sdk.session.init')).toBe(false) + const query = (await readKilroyLedger(rt.ledgerPath)).find((row) => row.kind === 'query')! + expect(query.options.resume).toBe(durable) + const uuid = send(rt, durable, 'continue the conversation') + expect(await rt.nextEvent('sdk.session.init')).toMatchObject({ sessionId: durable, cliSessionId: durable }) + await rt.nextEvent('sdk.result') + const prompt = (await readKilroyLedger(rt.ledgerPath)).find((row) => row.kind === 'prompt')! + expect(prompt).toMatchObject({ sessionId: durable, uuid, text: 'continue the conversation' }) + await expect.poll(() => fs.readFile(saved, 'utf8')).toContain('continue the conversation') + const lines = (await fs.readFile(saved, 'utf8')).trim().split('\n').map((line) => JSON.parse(line)) + expect(lines).toEqual(expect.arrayContaining([ + expect.objectContaining({ message: { role: 'user', content: 'saved history' } }), + expect.objectContaining({ sessionId: durable, uuid, type: 'user' }), + ])) }) - it('shutdown exits 0', async () => { - const { rt } = await make() + it('shutdown exits zero even before a session is created', async () => { + const rt = await make() rt.send({ type: 'shutdown' }) - const code = await new Promise((resolve) => rt.proc.once('exit', (c) => resolve(c))) - expect(code).toBe(0) - runtimes.pop() // already exited + expect(await rt.waitExit()).toEqual({ code: 0, signal: null }) + }) + + it('cleanup confirms the sidecar exit and removes the owned workspace and ledger', async () => { + const rt = await make() + const sessionId = await create(rt) + const boot = (await readKilroyLedger(rt.ledgerPath)).find((row) => row.kind === 'boot')! + const cliPid = boot.cliPid as number + let cliStart: string | undefined + const running = async () => { + if (process.platform === 'linux') { + try { + const stat = await fs.readFile(`/proc/${cliPid}/stat`, 'utf8') + const fields = stat.slice(stat.lastIndexOf(')') + 1).trim().split(/\s+/) + if (cliStart !== undefined && fields[19] !== cliStart) return false + cliStart ??= fields[19] + return fields[0] !== 'Z' && fields[0] !== 'X' + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false + throw error + } + } + try { process.kill(cliPid, 0); return true } catch { return false } + } + expect(sessionId).toBe(boot.sessionId) + expect(await running(), 'the actual simulated CLI is alive before cleanup').toBe(true) + await rt.kill() + expect(rt.proc.exitCode).toBe(0) + expect(rt.proc.signalCode).toBeNull() + await expect.poll(running).toBe(false) + await expect(fs.stat(rt.cwd)).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(fs.stat(rt.ledgerPath)).rejects.toMatchObject({ code: 'ENOENT' }) }) }) diff --git a/test/e2e-browser/helpers/harness-06/kilroy-runtime.ts b/test/e2e-browser/helpers/harness-06/kilroy-runtime.ts index d18d3fdbd..d766ad964 100644 --- a/test/e2e-browser/helpers/harness-06/kilroy-runtime.ts +++ b/test/e2e-browser/helpers/harness-06/kilroy-runtime.ts @@ -1,103 +1,129 @@ import { spawn, type ChildProcess } from 'node:child_process' import readline from 'node:readline' import fs from 'node:fs/promises' +import os from 'node:os' import path from 'node:path' -import { fileURLToPath } from 'node:url' +import { fileURLToPath, pathToFileURL } from 'node:url' -/** - * HARNESS-06 kilroy-runtime driver: spawns `fixtures/fake-kilroy-runtime.mjs` - * and exposes a typed stdio protocol client over it ("the harness-level full - * Kilroy runtime"). The same process can also be used as a REAL server's - * sidecar via the production seam `FRESHELL_CLAUDE_SIDECAR=` (the - * protocol is verbatim); this driver is for fixture-direct smoke/contract - * assertions. - */ - -export interface KilroyRequestLogEntry { +/** A Kilroy-flavoured production sidecar over the contract-tested CLI simulator. */ +export interface KilroyLedgerEntry { + kind: string pid: number - t: number - msg: Record + [key: string]: any +} + +export interface KilroyRuntimeOptions { + program?: { rules: unknown[] } + timeScale?: number } export interface KilroyRuntime { proc: ChildProcess - /** Write one protocol message (a single JSON line) to the sidecar's stdin. */ + cwd: string + claudeConfigDir: string + ledgerPath: string + /** Write a real sidecar request. The caller supplies native create/send UUIDs. */ send: (msg: Record) => void - /** - * Wait for the NEXT event matching `type` (+ optional predicate) after the - * runtime's read cursor. Sequential calls consume successive events, so - * `await nextEvent('sdk.status', s==='idle')` twice yields distinct idles. - */ + /** Consume successive matching frames after this runtime's read cursor. */ nextEvent: ( type: string, - pred?: (event: Record) => boolean, + pred?: (event: Record) => boolean, timeoutMs?: number, - ) => Promise> - /** All events parsed from stdout so far (in arrival order). */ - events: () => readonly Record[] + ) => Promise> + events: () => readonly Record[] + waitExit: (timeoutMs?: number) => Promise<{ code: number | null; signal: NodeJS.Signals | null }> + /** Gracefully close the owned sidecar/CLI, then remove their isolated files. */ kill: () => Promise } -const FIXTURE = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '../../fixtures/fake-kilroy-runtime.mjs', -) +const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../..') +const SIDECAR = path.join(REPO_ROOT, 'crates/freshell-claude-sidecar/index.mjs') +const SIMULATOR = path.join(REPO_ROOT, 'test/fixtures/claude-cli-sim/query.mjs') -export async function spawnFakeKilroy(env: NodeJS.ProcessEnv = {}): Promise { - const proc = spawn(process.execPath, [FIXTURE], { - env: { ...process.env, ...env }, +export async function spawnKilroyRuntime(options: KilroyRuntimeOptions = {}): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'freshell-h06-kilroy-')) + const home = path.join(root, 'home') + const cwd = path.join(root, 'workspace') + const tmp = path.join(root, 'tmp') + for (const directory of [home, cwd, tmp]) await fs.mkdir(directory, { recursive: true }) + const claudeConfigDir = path.join(home, '.claude') + const ledgerPath = path.join(root, 'sim-ledger.jsonl') + const proc = spawn(process.execPath, [SIDECAR], { + cwd, + env: { + HOME: home, + PATH: process.env.PATH ?? '', + TMPDIR: tmp, + LANG: 'C.UTF-8', + CLAUDE_CONFIG_DIR: claudeConfigDir, + FRESHELL_CLAUDE_SDK_QUERY_MODULE: pathToFileURL(SIMULATOR).href, + FRESHELL_CLAUDE_SIM_LEDGER: ledgerPath, + FRESHELL_CLAUDE_SIM_TIME_SCALE: String(options.timeScale ?? 1), + FRESHELL_CLAUDE_SIM_PROGRAM: JSON.stringify(options.program ?? { rules: [] }), + }, stdio: ['pipe', 'pipe', 'pipe'], }) + const exited = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + proc.once('exit', (code, signal) => resolve({ code, signal })) + }) + proc.stdin!.on('error', () => { /* an already-ended sidecar cannot accept another request */ }) - const events: Record[] = [] + const events: Record[] = [] let cursor = 0 const waiters: Array<{ type: string - pred?: (event: Record) => boolean - resolve: (event: Record) => void + pred?: (event: Record) => boolean + resolve: (event: Record) => void timer: NodeJS.Timeout }> = [] - const rl = readline.createInterface({ input: proc.stdout! }) rl.on('line', (line) => { - let event: Record - try { - event = JSON.parse(line) as Record - } catch { - return - } + let event: Record + try { event = JSON.parse(line) } catch { return } events.push(event) for (let i = waiters.length - 1; i >= 0; i--) { - const w = waiters[i] - const idx = events.findIndex( - (e, j) => j >= cursor && e.type === w.type && (!w.pred || w.pred(e)), + const waiter = waiters[i] + const index = events.findIndex( + (candidate, j) => j >= cursor && candidate.type === waiter.type && (!waiter.pred || waiter.pred(candidate)), ) - if (idx >= 0) { - cursor = idx + 1 + if (index >= 0) { + cursor = index + 1 waiters.splice(i, 1) - clearTimeout(w.timer) - w.resolve(events[idx]) + clearTimeout(waiter.timer) + waiter.resolve(events[index]) } } }) - - const stderrChunks: Buffer[] = [] - proc.stderr!.on('data', (c: Buffer) => stderrChunks.push(c)) - - // Ensure the child has booted its readline loop before callers write — - // the fixture processes lines strictly in order, so stdin writes are safe - // immediately after spawn (pipe buffering). We return right away. - + let stderr = '' + proc.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk }) + const waitExit = async (timeoutMs = 10_000) => { + let timer: NodeJS.Timeout | undefined + try { + return await Promise.race([ + exited, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Kilroy sidecar did not exit: ${stderr.slice(-1000)}`)), timeoutMs) + timer.unref() + }), + ]) + } finally { + clearTimeout(timer) + } + } + let cleanup: Promise | undefined return { proc, + cwd, + claudeConfigDir, + ledgerPath, send: (msg) => { - if (!proc.stdin || proc.killed) throw new Error('fake-kilroy sidecar stdin unavailable') + if (!proc.stdin || proc.exitCode !== null || proc.signalCode !== null) throw new Error('Kilroy sidecar stdin unavailable') proc.stdin.write(`${JSON.stringify(msg)}\n`) }, nextEvent: (type, pred, timeoutMs = 15_000) => new Promise((resolve, reject) => { const existing = events.findIndex( - (e, j) => j >= cursor && e.type === type && (!pred || pred(e)), + (event, j) => j >= cursor && event.type === type && (!pred || pred(event)), ) if (existing >= 0) { cursor = existing + 1 @@ -105,30 +131,38 @@ export async function spawnFakeKilroy(env: NodeJS.ProcessEnv = {}): Promise { - const i = waiters.findIndex((w) => w.timer === timer) - if (i >= 0) waiters.splice(i, 1) - reject( - new Error( - `timed out (${timeoutMs}ms) waiting for fake-kilroy event ${type}; ` + - `seen so far: ${events.map((e) => e.type).join(', ')}; ` + - `stderr: ${Buffer.concat(stderrChunks).toString('utf8').slice(-500)}`, - ), - ) + const index = waiters.findIndex((waiter) => waiter.timer === timer) + if (index >= 0) waiters.splice(index, 1) + reject(new Error(`timed out waiting for Kilroy ${type}; frames=${JSON.stringify(events).slice(-3000)}; stderr=${stderr.slice(-1000)}`)) }, timeoutMs) waiters.push({ type, pred, resolve, timer }) }), events: () => events, - kill: async () => { - if (proc.exitCode !== null || proc.killed) return - const exited = new Promise((resolve) => proc.once('exit', () => resolve())) - try { proc.kill('SIGKILL') } catch { /* already dead */ } - await exited - rl.close() - }, + waitExit, + kill: () => cleanup ??= (async () => { + try { + if (proc.exitCode === null && proc.signalCode === null) { + proc.stdin!.write(`${JSON.stringify({ type: 'shutdown' })}\n`) + try { await waitExit(5_000) } catch { + proc.kill('SIGKILL') + await waitExit(5_000) + } + } + } finally { + rl.close() + for (const waiter of waiters) clearTimeout(waiter.timer) + await fs.rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }) + } + })(), } } -export async function readKilroyLedger(logPath: string): Promise { - const text = await fs.readFile(logPath, 'utf8') - return text.split('\n').filter(Boolean).map((l) => JSON.parse(l) as KilroyRequestLogEntry) +/** What the actual SDK query and simulated CLI received, rather than synthetic wire requests. */ +export async function readKilroyLedger(logPath: string): Promise { + let text: string + try { text = await fs.readFile(logPath, 'utf8') } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [] + throw error + } + return text.slice(0, text.lastIndexOf('\n') + 1).split('\n').filter(Boolean).map((line) => JSON.parse(line)) } diff --git a/test/e2e-browser/specs/harness-06-misc-fixtures.spec.ts b/test/e2e-browser/specs/harness-06-misc-fixtures.spec.ts index 3d35893ea..a71af4fdc 100644 --- a/test/e2e-browser/specs/harness-06-misc-fixtures.spec.ts +++ b/test/e2e-browser/specs/harness-06-misc-fixtures.spec.ts @@ -22,7 +22,7 @@ import { FAKE_GEMINI_DEFAULT_TEXT, } from '../helpers/harness-06/fake-ai.js' import { - spawnFakeKilroy, + spawnKilroyRuntime, readKilroyLedger, } from '../helpers/harness-06/kilroy-runtime.js' import { @@ -283,32 +283,37 @@ test.describe('harness-06 misc fixtures smoke', () => { } }) - test('fake Kilroy runtime: create handshake + full success turn + request ledger', async () => { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'freshell-h06-smoke-kilroy-')) - const logPath = path.join(dir, 'requests.jsonl') - const rt = await spawnFakeKilroy({ FAKE_KILROY_LOG: logPath }) + test('Kilroy production sidecar: native create + full success turn + SDK ledger', async () => { + const rt = await spawnKilroyRuntime({ + timeScale: 0.02, + program: { rules: [{ on: 'msg:prompt', emit: [{ kind: 'text', data: { text: 'kilroy smoke reply' } }] }] }, + }) try { - rt.send({ type: 'create', requestId: 'smoke-1', cwd: '/tmp/smoke', model: 'claude-opus-4-6' }) - const created = (await rt.nextEvent('created')) as { requestId: string; sessionId: string } - expect(created.requestId).toBe('smoke-1') - const init = (await rt.nextEvent('sdk.session.init')) as { cliSessionId: string } - expect(init.cliSessionId).toMatch(/^[0-9a-f-]{36}$/) - await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'idle') - - rt.send({ type: 'send', sessionId: created.sessionId, text: 'kilroy smoke turn' }) - await rt.nextEvent('sdk.status', (e) => (e as { status?: string }).status === 'running') - const assistant = (await rt.nextEvent('sdk.assistant')) as { content: Array<{ text?: string }> } - expect(assistant.content[0].text).toContain('kilroy smoke turn') - // The result ends the turn; the server mints its edge (C18), the fake none. - await rt.nextEvent('sdk.result', (e) => (e as { result?: string }).result === 'success') - expect(rt.events().some((e) => (e as { type?: string }).type === 'sdk.turn.complete')).toBe(false) - - // "Records Kilroy invocations": the JSONL ledger carries both requests. - const ledger = await readKilroyLedger(logPath) - expect(ledger.map((row) => (row.msg as { type: string }).type)).toEqual(['create', 'send']) + const sessionId = crypto.randomUUID() + rt.send({ type: 'create', protocol: 2, sessionType: 'kilroy', sessionId, requestId: 'smoke-1', cwd: rt.cwd, model: 'claude-opus-4-6', boot: { kind: 'fresh' } }) + expect(await rt.nextEvent('created')).toMatchObject({ requestId: 'smoke-1', sessionId, protocol: 2 }) + expect(rt.events().filter((event) => event.type === 'sdk.session.init')).toEqual([]) + + const uuid = crypto.randomUUID() + rt.send({ type: 'send', sessionId, uuid, text: 'kilroy smoke turn' }) + expect(await rt.nextEvent('sdk.session.init')).toMatchObject({ cliSessionId: sessionId, sessionId }) + const assistant = await rt.nextEvent('sdk.assistant') + expect(assistant.content).toEqual([{ type: 'text', text: 'kilroy smoke reply' }]) + expect(await rt.nextEvent('sdk.result')).toMatchObject({ sessionId, subtype: 'success', isError: false }) + expect(rt.events().filter((event) => event.type === 'sdk.turn.complete' || event.type === 'sdk.turn.waiting')).toEqual([]) + + const ledger = await readKilroyLedger(rt.ledgerPath) + const queries = ledger.filter((row) => row.kind === 'query') + expect(queries).toHaveLength(1) + expect(queries[0].options).toMatchObject({ sessionId, model: 'claude-opus-4-6', cwd: rt.cwd }) + expect(ledger.filter((row) => row.kind === 'prompt')).toEqual([ + expect.objectContaining({ sessionId, uuid, text: 'kilroy smoke turn' }), + ]) } finally { await rt.kill() - fs.rmSync(dir, { recursive: true, force: true }) + expect(rt.proc.exitCode).toBe(0) + expect(rt.proc.signalCode).toBeNull() + expect(fs.existsSync(rt.cwd)).toBe(false) } }) From bb81f94fb673f8b61634d40f66bb17a0a544ebde Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:21:21 -0700 Subject: [PATCH 087/100] test(e2e): locate Claude restore transcript by ID --- .../specs/restore-contract-wall-rust.spec.ts | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts b/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts index 5e18064b4..3bfbb627a 100644 --- a/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts +++ b/test/e2e-browser/specs/restore-contract-wall-rust.spec.ts @@ -1435,18 +1435,36 @@ test.describe('Restore Contract Wall (P0.1)', () => { await expect( page.locator('[data-context="fresh-agent"]').last().getByText('RESTORE_WALL_LIVENESS_REPLY', { exact: true }), ).toBeVisible({ timeout: 30_000 }) + const claudeProjectSlug = projectDir.replace(/[^A-Za-z0-9]/g, '-') const transcriptFile = path.join( info.homeDir, '.claude', 'projects', - '-fixture', + claudeProjectSlug, `${originalSessionId}.jsonl`, ) - const transcriptLines = (await fs.readFile(transcriptFile, 'utf8')) - .trim() - .split('\n') - .filter(Boolean) - .map((line) => JSON.parse(line)) + let transcriptLines: any[] = [] + await expect.poll(async () => { + const raw = await fs.readFile(transcriptFile, 'utf8').catch(() => '') + transcriptLines = raw.trim().split('\n').filter(Boolean).flatMap((line) => { + try { return [JSON.parse(line)] } catch { return [] } + }) + const currentUserTexts = transcriptLines + .filter((line: any) => line.type === 'user') + .map((line: any) => line.message?.content?.[0]?.text) + const currentAssistantTexts = transcriptLines + .filter((line: any) => line.type === 'assistant') + .flatMap((line: any) => line.message?.content ?? []) + .filter((block: any) => block?.type === 'text') + .map((block: any) => block.text) + return currentUserTexts.includes('wall freshclaude turn') + && currentUserTexts.includes('wall freshclaude liveness turn') + && currentAssistantTexts.includes('RESTORE_WALL_REPLY') + && currentAssistantTexts.includes('RESTORE_WALL_LIVENESS_REPLY') + }, { + timeout: 20_000, + message: 'the same durable Claude transcript contains both turns and replies', + }).toBe(true) const userTexts = transcriptLines .filter((l: any) => l.type === 'user') .map((l: any) => l.message?.content?.[0]?.text) From 2e6bb9d20d9c4d0792b13beadc7a0239507d42de Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:26:18 -0700 Subject: [PATCH 088/100] test(e2e): wait for Claude naming exchange before switch --- .../helpers/unified-agent-names-modes.ts | 111 +++++++++++++++++- 1 file changed, 108 insertions(+), 3 deletions(-) diff --git a/test/e2e-browser/helpers/unified-agent-names-modes.ts b/test/e2e-browser/helpers/unified-agent-names-modes.ts index b3bc94253..2338a7928 100644 --- a/test/e2e-browser/helpers/unified-agent-names-modes.ts +++ b/test/e2e-browser/helpers/unified-agent-names-modes.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs/promises' +import path from 'node:path' import { expect, type Browser } from './fixtures.js' import type { SessionNameRef } from '../../shared/session-names.js' import { @@ -515,15 +517,88 @@ export async function twoBrowsersAndAnloadedHistoryConverge(mode: UnifiedAgentMo } } +function countClaudeTurnCompleteEvents(messages: unknown[], identities: Array): number { + const ids = new Set(identities.filter((identity): identity is string => Boolean(identity))) + return messages.filter((raw) => { + if (!raw || typeof raw !== 'object') return false + const message = raw as { type?: unknown; sessionId?: unknown; event?: { type?: unknown; sessionId?: unknown } } + return message.type === 'freshAgent.event' + && message.event?.type === 'freshAgent.turn.complete' + && (ids.has(String(message.sessionId ?? '')) || ids.has(String(message.event?.sessionId ?? ''))) + }).length +} + +async function hasSavedClaudeExchanges( + homeDir: string, + projectDir: string, + sessionId: string, + exchanges: Array<{ prompt: string; reply: string }>, +): Promise { + const projectSlug = projectDir.replace(/[^A-Za-z0-9]/g, '-') + const transcriptPath = path.join(homeDir, '.claude', 'projects', projectSlug, `${sessionId}.jsonl`) + const contents = await fs.readFile(transcriptPath, 'utf8').catch(() => '') + const rows = contents.split(/\r?\n/).flatMap((line) => { + if (!line.trim()) return [] + try { + return [JSON.parse(line) as { type?: string; message?: { content?: unknown } }] + } catch { + // The simulator appends JSONL asynchronously; ignore an incomplete final line + // while polling for the flushed record. + return [] + } + }) + const messageText = (row: { message?: { content?: unknown } }): string => { + const content = row.message?.content + if (typeof content === 'string') return content + if (!Array.isArray(content)) return '' + return content.flatMap((block: unknown) => { + if (!block || typeof block !== 'object') return [] + const item = block as { type?: unknown; text?: unknown } + return item.type === 'text' && typeof item.text === 'string' ? [item.text] : [] + }).join('') + } + + let cursor = 0 + for (const exchange of exchanges) { + const userIndex = rows.findIndex((row, index) => index >= cursor && row.type === 'user' && messageText(row) === exchange.prompt) + if (userIndex < 0) return false + const nextUserIndex = rows.findIndex((row, index) => index > userIndex && row.type === 'user') + const end = nextUserIndex < 0 ? rows.length : nextUserIndex + const hasReply = rows.slice(userIndex + 1, end) + .some((row) => row.type === 'assistant' && messageText(row).includes(exchange.reply)) + if (!hasReply) return false + cursor = userIndex + 1 + } + return true +} + export async function manualNameSurvivesLateGenerationAndSwitch(mode: UnifiedAgentMode, browser: Browser): Promise { { const gemini = await startFakeGemini(AI_NAME, { hold: true }) - const journey = await bootJourney(mode, { gemini }) + const firstOldPrompt = 'Hold the generation request' + const firstOldReply = 'First old conversation reply is saved' + const secondOldPrompt = 'A second turn keeps the old session listed' + const secondOldReply = 'Second old conversation reply is saved' + const freshClaudeProgram = mode === 'freshclaude' + ? JSON.stringify({ + rules: [ + { on: 'msg:prompt', match: { text: firstOldPrompt }, once: true, emit: [{ kind: 'text', data: { text: firstOldReply } }] }, + { on: 'msg:prompt', match: { text: secondOldPrompt }, once: true, emit: [{ kind: 'text', data: { text: secondOldReply } }] }, + ], + }) + : undefined + const journey = await bootJourney(mode, { + gemini, + ...(freshClaudeProgram ? { env: { FRESHELL_CLAUDE_SIM_PROGRAM: freshClaudeProgram } } : {}), + }) try { const { page, harness, context } = await connect(journey, browser) const created = await createNamedAgent(harness, mode, { entry: 'ui', page, server: journey.server }) const handle = await refreshHandle(harness, created, mode) - await sendFirstMessage(page, harness, handle, mode, 'Hold the generation request') + const firstCompletionCount = mode === 'freshclaude' + ? countClaudeTurnCompleteEvents(await harness.getReceivedWsMessages(), [handle.sessionId]) + : 0 + await sendFirstMessage(page, harness, handle, mode, firstOldPrompt) // The generation request is provably in flight. await expect.poll(() => gemini.requests.length, { timeout: 30_000 }).toBeGreaterThan(0) // The manual rename commits mid-flight. @@ -537,6 +612,19 @@ export async function manualNameSurvivesLateGenerationAndSwitch(mode: UnifiedAge await page.waitForTimeout(3_000) const sessionId = await resolveSessionId(journey, handle) const sessionRef: SessionNameRef = { kind: 'session', provider: (await modeFixture(mode)).provider, sessionId } + if (mode === 'freshclaude') { + const identities = [handle.sessionId, sessionId] + await expect.poll( + async () => countClaudeTurnCompleteEvents(await harness.getReceivedWsMessages(), identities), + { timeout: 30_000, message: 'the first old-session Claude turn completes' }, + ).toBeGreaterThan(firstCompletionCount) + await expect.poll( + () => hasSavedClaudeExchanges(journey.server.info.homeDir, journey.server.projectDir, sessionId, [ + { prompt: firstOldPrompt, reply: firstOldReply }, + ]), + { timeout: 30_000, message: 'the first old-session exchange is flushed to its native transcript' }, + ).toBe(true) + } let record = await readRecord(journey, sessionRef) expect(record.name).toBe(manualName) expect(record.source).toBe('manual') @@ -549,7 +637,24 @@ export async function manualNameSurvivesLateGenerationAndSwitch(mode: UnifiedAge // transcript the real-exchange shape (the directory listing's // parity filter hides a single-turn session, and after the switch // no live pane carries the old row). - await sendFirstMessage(page, harness, handle, mode, 'A second turn keeps the old session listed') + const oldSessionIdentities = [handle.sessionId, sessionId] + const secondCompletionCount = mode === 'freshclaude' + ? countClaudeTurnCompleteEvents(await harness.getReceivedWsMessages(), oldSessionIdentities) + : 0 + await sendFirstMessage(page, harness, handle, mode, secondOldPrompt) + if (mode === 'freshclaude') { + await expect.poll( + async () => countClaudeTurnCompleteEvents(await harness.getReceivedWsMessages(), oldSessionIdentities), + { timeout: 30_000, message: 'the second old-session Claude turn completes before /new' }, + ).toBeGreaterThan(secondCompletionCount) + await expect.poll( + () => hasSavedClaudeExchanges(journey.server.info.homeDir, journey.server.projectDir, sessionId, [ + { prompt: firstOldPrompt, reply: firstOldReply }, + { prompt: secondOldPrompt, reply: secondOldReply }, + ]), + { timeout: 30_000, message: 'both completed old-session exchanges are flushed before /new' }, + ).toBe(true) + } const fixture = await modeFixture(mode) if (fixture.fresh) { // The pane's REAL conversation-switch control: the `/new` slash From d8fc1227291e42a73f6f2a039afb9b97594a1506 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:44:04 -0700 Subject: [PATCH 089/100] test(e2e): let scenario env override provider fixture --- test/e2e-browser/helpers/unified-agent-names.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/e2e-browser/helpers/unified-agent-names.ts b/test/e2e-browser/helpers/unified-agent-names.ts index 7b60b2bb3..5f14363c7 100644 --- a/test/e2e-browser/helpers/unified-agent-names.ts +++ b/test/e2e-browser/helpers/unified-agent-names.ts @@ -329,7 +329,6 @@ export async function bootUnifiedNamesServer(opts: BootUnifiedNamesOptions = {}) const env: Record = { GOOGLE_GENERATIVE_AI_API_KEY: '', ...(opts.gemini ? { FRESHELL_GEMINI_BASE_URL: opts.gemini.baseUrl } : {}), - ...(opts.env ?? {}), } let enabledProviders: string[] = [] if (opts.mode) { @@ -337,6 +336,7 @@ export async function bootUnifiedNamesServer(opts: BootUnifiedNamesOptions = {}) enabledProviders = [fixture.provider] Object.assign(env, await fixture.env(root)) } + Object.assign(env, opts.env ?? {}) const server = new RustServer({ port: opts.port, homeDir: opts.homeDir ?? debugHome, From 0bcefa19d028328f524c0ad6e294907840a26751 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:56:06 -0700 Subject: [PATCH 090/100] fix(freshclaude): close persisted native successors after restart --- crates/freshell-freshagent/src/claude.rs | 260 ++++++++++++++++-- ...reshagent_claude_persisted_native_close.rs | 163 +++++++++++ 2 files changed, 403 insertions(+), 20 deletions(-) create mode 100644 crates/freshell-ws/tests/freshagent_claude_persisted_native_close.rs diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index e83e875a1..db7673116 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -710,18 +710,29 @@ impl FreshClaudeState { self.kill_after_resolve_pause = pause; } - /// b8ke focused episode-2 round-3 F3: resolve a wire session id to - /// its canonical coordinator key. The COORDINATOR is the single - /// source of truth: a re-keyed session's old key holds an - /// `Aliased{to: new}` record there (written atomically by the rekey - /// commit), and this resolves through the registry's own fixpoint - /// walk — never a lane-local map, never a bounded link cap. Unwired - /// (no coordinator): the identity (legacy behavior). + /// Active coordinator records take precedence over a staged durable + /// successor. After restart, persisted supersession supplies the chain + /// that the new coordinator has not observed yet. pub fn resolve_ownership_key(&self, session_id: &str) -> String { - match self.ownership.as_ref() { - Some(registry) => registry.resolve_canonical(PROVIDER, session_id), - None => session_id.to_string(), + if let Some(registry) = &self.ownership { + let current = registry.resolve_canonical(PROVIDER, session_id); + if current != session_id + || !matches!( + registry.observe(PROVIDER, ¤t).state, + freshell_ownership::OwnershipState::Vacant + ) + { + return current; + } } + let durable = self + .identity_sink() + .and_then(|sink| sink.chain_terminus(PROVIDER, session_id)) + .unwrap_or_else(|| session_id.to_string()); + self.ownership.as_ref().map_or_else( + || durable.clone(), + |registry| registry.resolve_canonical(PROVIDER, &durable), + ) } /// Test seam (Task 6 round-3 review I-1): arm the deterministic holds @@ -914,7 +925,8 @@ impl FreshClaudeState { } /// Every registered runtime has a native UUID before create returns. - /// Unknown IDs still consult the coordinator's persisted rekey chain. + /// A live runtime remains authoritative while a replacement binding is + /// staged; unknown IDs consult the durable supersession chain. async fn claude_owner_key(&self, map_key: Option<&str>, wire_id: &str) -> String { let native = match map_key { Some(key) => self @@ -925,7 +937,13 @@ impl FreshClaudeState { .map(|runtime| runtime.identity.current()), None => None, }; - self.resolve_ownership_key(native.as_deref().unwrap_or(wire_id)) + match native { + Some(native) => self.ownership.as_ref().map_or_else( + || native.clone(), + |registry| registry.resolve_canonical(PROVIDER, &native), + ), + None => self.resolve_ownership_key(wire_id), + } } /// Side-effect-free coordinator read for `(provider, session_id)` @@ -2291,7 +2309,7 @@ impl FreshClaudeState { let mut map_key = self .resolve_session_key(&session_id) .await - .unwrap_or_else(|| session_id.clone()); + .unwrap_or_else(|| self.resolve_ownership_key(&session_id)); #[cfg(test)] if let Some(pause) = &self.kill_after_resolve_pause { pause @@ -2306,7 +2324,9 @@ impl FreshClaudeState { let _close_guard; { let sessions = self.sessions.lock().await; - if let Some(current_key) = Self::session_key_in(&sessions, &session_id) { + if let Some(current_key) = Self::session_key_in(&sessions, &session_id) + .or_else(|| Self::session_key_in(&sessions, &map_key)) + { map_key = current_key; } if let Some(expected_owner) = expected_owner { @@ -3157,14 +3177,16 @@ impl FreshClaudeState { if sink.kill_tombstone_at_ms(PROVIDER, id).is_none() { continue; // a claim re-opened the identity after the close — not ours to kill } - let map_key = self - .resolve_session_key(id) - .await - .unwrap_or_else(|| id.to_string()); // The ONE critical section the decision rule above lives in: // remove + re-verify, no await between the two. let removed = { let mut sessions = self.sessions.lock().await; + // Only this runtime's retained lineage belongs to the close. + // A persisted successor may have reopened independently and + // cleared its own fence while this older ID remains fenced. + let Some(map_key) = Self::session_key_in(&sessions, id) else { + continue; + }; match sessions.remove(&map_key) { None => None, Some(session) => { @@ -3181,12 +3203,12 @@ impl FreshClaudeState { ); None } else { - Some(session) + Some((map_key, session)) } } } }; - if let Some(session) = removed { + if let Some((map_key, session)) = removed { tracing::info!(target: "freshell_freshagent::claude", durable = %id, map_key = %map_key, @@ -3282,6 +3304,11 @@ impl FreshClaudeState { .map(|dead| dead.ids) }) .unwrap_or_default(); + if !sessions.contains_key(map_key) { + // A persisted current UUID can have a Bound row without a live + // runtime in this server. It must close with the addressed ID. + candidates.push(map_key.to_string()); + } if let Some(sink) = self.identity_sink() { for (durable, _) in sink.alias_tombstone_records(PROVIDER, wire_session_id) { // An old placeholder cannot close a genuinely reopened writer. @@ -4674,8 +4701,18 @@ impl FreshClaudeState { } async fn resolve_session_key(&self, session_id: &str) -> Option { + { + let sessions = self.sessions.lock().await; + if let Some(key) = Self::session_key_in(&sessions, session_id) { + return Some(key); + } + } + let durable = self + .identity_sink() + .and_then(|sink| sink.chain_terminus(PROVIDER, session_id))?; let sessions = self.sessions.lock().await; Self::session_key_in(&sessions, session_id) + .or_else(|| Self::session_key_in(&sessions, &durable)) } /// The not-tracked resume (codex `ensure_session_resumable` analog, file-store @@ -10032,6 +10069,189 @@ pub(crate) mod tests { ); } + async fn link_native_fixture(st: &FreshClaudeState, prior: &str, current: &str) { + st.identity_sink() + .unwrap() + .record_binding(crate::identity_sink::FreshAgentBindingUpsert { + provider: PROVIDER.into(), + session_id: current.into(), + mode: "freshclaude".into(), + create_request_id: None, + resolves_pending: None, + supersedes: Some(prior.into()), + name_transition: None, + provenance: crate::identity_sink::ProvenanceUpdate::Inherit, + observed_epoch: None, + observed_generation: None, + authoritative: false, + settings: Default::default(), + genesis: crate::identity_sink::GenesisUpdate::Keep, + }) + .await + .unwrap(); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_persisted_native_close_follows_its_captured_runtime_through_a_concurrent_rekey() { + let (mut st, mut rx) = state_with_bus(); + let sink = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + st.set_identity_sink(sink.clone()); + let prior = uuid::Uuid::new_v4().to_string(); + let current = uuid::Uuid::new_v4().to_string(); + let next = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&st, ¤t).await; + link_native_fixture(&st, &prior, ¤t).await; + assert!(!st.sessions.lock().await[¤t].identity.contains(&prior)); + let pause = Arc::new(AdoptionTestPause { + notify: tokio::sync::Notify::new(), + reached: std::sync::atomic::AtomicBool::new(false), + }); + st.set_kill_after_resolve_test_pause(Some(pause.clone())); + let kill = { + let st = st.clone(); + let id = prior.clone(); + tokio::spawn(async move { st.handle_kill(kill_msg(&id)).await }) + }; + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + while !pause.reached.load(std::sync::atomic::Ordering::SeqCst) { + assert!( + tokio::time::Instant::now() < deadline, + "kill never resolved its id" + ); + tokio::task::yield_now().await; + } + let pending = st.sessions.lock().await[¤t].pending.clone(); + st.rekey_conversation( + ¤t, + &next, + "freshclaude", + &pending, + "test/persisted-close-rekey", + ) + .await + .unwrap(); + pause.notify.notify_one(); + tokio::time::timeout(Duration::from_secs(15), kill) + .await + .unwrap() + .unwrap(); + let answer = await_specific_frame(&mut rx, "freshAgent.killed", &prior).await; + assert_eq!(answer["success"], true, "{answer}"); + assert!( + !st.sessions.lock().await.contains_key(&next), + "the captured runtime survives neither a stale map key nor missing wire history" + ); + let retires = sink.retires.lock().unwrap(); + for id in [&prior, ¤t, &next] { + assert!( + retires.contains(&(PROVIDER.into(), id.clone())), + "missing native close for {id}" + ); + } + } + + #[tokio::test] + async fn a_direct_live_native_id_precedes_a_conflicting_persisted_successor() { + let (st, mut rx) = state_with_bus(); + let sink = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + st.set_identity_sink(sink.clone()); + let direct = uuid::Uuid::new_v4().to_string(); + let other = uuid::Uuid::new_v4().to_string(); + insert_fake_claude_session(&st, &direct).await; + insert_fake_claude_session(&st, &other).await; + link_native_fixture(&st, &direct, &other).await; + assert_eq!( + st.resolve_session_key(&direct).await.as_deref(), + Some(direct.as_str()) + ); + assert_eq!(st.claude_owner_key(Some(&direct), &direct).await, direct); + st.handle_kill(kill_msg(&direct)).await; + let answer = await_specific_frame(&mut rx, "freshAgent.killed", &direct).await; + assert_eq!(answer["success"], true); + assert!(st.sessions.lock().await.contains_key(&other)); + assert!(!sink + .retires + .lock() + .unwrap() + .contains(&(PROVIDER.into(), other))); + st.shutdown().await; + } + + #[tokio::test] + async fn an_active_coordinator_owner_precedes_a_staged_persisted_successor() { + use freshell_ownership::{BeginOutcome, RuntimeOwnerKind}; + + let (mut st, _rx) = state_with_bus(); + st.set_identity_sink(Arc::new(crate::identity_sink::FakeIdentitySink::default())); + let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); + st.set_ownership(registry.clone()); + let original = uuid::Uuid::new_v4().to_string(); + let staged = uuid::Uuid::new_v4().to_string(); + assert!(matches!( + registry.begin_start( + PROVIDER, + &original, + RuntimeOwnerKind::FreshAgent, + "pending-original", + None, + "test", + 1 + ), + BeginOutcome::Granted { .. } + )); + link_native_fixture(&st, &original, &staged).await; + assert_eq!(st.resolve_ownership_key(&original), original); + assert!(matches!( + st.ownership_snapshot(PROVIDER, &original).state, + freshell_ownership::OwnershipState::Starting { .. } + )); + } + + #[tokio::test] + async fn the_kill_sweep_spares_a_reopened_successor_whose_prior_native_id_remains_fenced() { + let (st, _rx) = state_with_bus(); + let sink = Arc::new(crate::identity_sink::FakeIdentitySink::default()); + st.set_identity_sink(sink.clone()); + let prior = uuid::Uuid::new_v4().to_string(); + let current = uuid::Uuid::new_v4().to_string(); + link_native_fixture(&st, &prior, ¤t).await; + sink.retire_closed(PROVIDER, &prior).await.unwrap(); + sink.retire_closed(PROVIDER, ¤t).await.unwrap(); + let fence = sink.kill_tombstone_at_ms(PROVIDER, ¤t); + assert!(fence.is_some()); + assert!(st.commit_session_claim(¤t, fence, &[]).await); + insert_fake_claude_session(&st, ¤t).await; + let pid = st.sessions.lock().await[¤t].child.id(); + assert!(!st.sessions.lock().await[¤t].identity.contains(&prior)); + assert!(sink.kill_tombstone_at_ms(PROVIDER, &prior).is_some()); + assert!(sink.kill_tombstone_at_ms(PROVIDER, ¤t).is_none()); + assert_eq!( + st.resolve_session_key(&prior).await.as_deref(), + Some(current.as_str()) + ); + + st.sweep_late_claim_orphans(&[prior.clone(), current.clone()]) + .await; + + { + let mut sessions = st.sessions.lock().await; + let session = sessions + .get_mut(¤t) + .expect("a genuine reopen survives the old close's sweep"); + assert_eq!(session.child.id(), pid); + assert!(session.child.try_wait().unwrap().is_none()); + } + assert_eq!( + sink.states + .lock() + .unwrap() + .get(&(PROVIDER.into(), current)) + .copied(), + Some(crate::identity_sink::FakeRowState::Bound) + ); + st.shutdown().await; + } + /// Retire-on-kill (delta-review round 5, restore-open-sessions-only): an /// explicit kill is an intentional session END — the pane-ledger row retires /// `Closed` through the identity sink, so the recovery inventory (Bound-only diff --git a/crates/freshell-ws/tests/freshagent_claude_persisted_native_close.rs b/crates/freshell-ws/tests/freshagent_claude_persisted_native_close.rs new file mode 100644 index 000000000..5245ed482 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_persisted_native_close.rs @@ -0,0 +1,163 @@ +//! Every incoming native id follows durable supersession after a server restart. + +mod common; + +use common::claude_rig::{ClaudeRig, RigKind, RigOptions}; +use freshell_ownership::{BeginOutcome, CommitOutcome, OwnerIdentity, RuntimeOwnerKind}; +use freshell_ws::pane_ledger::{RetiredReason, RowState}; +use serde_json::json; +use std::process::Stdio; + +async fn persisted_reset_after_restart() -> (ClaudeRig, String, String) { + let mut rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + ..RigOptions::default() + }, + ) + .await; + let mut conn = rig.connect().await; + let old = conn.create("persisted-native-close-create").await; + rig.converse(&mut conn, &old, &["saved before the native reset"]) + .await; + let start = conn.frames.len(); + conn.send_text(&old, "persisted-native-clear", "/clear") + .await; + let materialized = conn + .wait_for_since(start, "the native reset", |frame| { + frame["type"] == "freshAgent.session.materialized" && frame["previousSessionId"] == old + }) + .await; + let current = materialized["sessionId"].as_str().unwrap().to_string(); + assert_ne!(old, current); + conn.wait_for_since(start, "the cleared command settling", |frame| { + frame["sessionId"] == current + && frame["event"]["type"] == "freshAgent.status" + && frame["event"]["status"] == "idle" + }) + .await; + rig.converse(&mut conn, ¤t, &["saved in the current conversation"]) + .await; + rig.restart().await; + let resolution = rig + .pane_ledger + .as_ref() + .unwrap() + .lookup_by_session("claude", &old) + .unwrap(); + assert!(resolution.corrected); + assert_eq!(resolution.row.session_id, current); + assert!(!rig.fresh_claude.has_live_session(&old).await); + assert!(!rig.fresh_claude.has_live_session(¤t).await); + (rig, old, current) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn closing_a_prior_native_id_after_restart_closes_its_reattached_current_runtime() { + let (rig, old, current) = persisted_reset_after_restart().await; + let mut conn = rig.connect().await; + conn.attach(¤t).await; + assert!(rig.fresh_claude.has_live_session(¤t).await); + let processes = rig.runtime_processes(¤t); + conn.kill(&old).await; + let ledger = rig.pane_ledger.as_ref().unwrap(); + let row = ledger.load_binding("claude", ¤t).unwrap(); + let live = rig.fresh_claude.has_live_session(¤t).await; + assert!( + !live, + "late kill({old}) left current {current} running; row={row:?}, current fence={:?}", + ledger.kill_tombstone_at("claude", ¤t) + ); + assert_eq!(row.state, RowState::Retired); + assert_eq!(row.retired_reason, Some(RetiredReason::Closed)); + assert!(ledger.kill_tombstone_at("claude", ¤t).is_some()); + processes + .wait_until_exited("closing a superseded native UUID") + .await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_prior_native_close_after_restart_refuses_the_current_terminal_owner() { + let (rig, old, current) = persisted_reset_after_restart().await; + let mut terminal = tokio::process::Command::new("node") + .args(["-e", "setInterval(() => {}, 1000)"]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .kill_on_drop(true) + .spawn() + .unwrap(); + let op = "terminal-after-native-reset"; + let BeginOutcome::Granted { generation } = rig.ownership.begin_start( + "claude", + ¤t, + RuntimeOwnerKind::Terminal, + op, + None, + "test", + freshell_ownership::now_epoch_ms(), + ) else { + panic!("the restarted coordinator has no owner") + }; + let owner = OwnerIdentity { + kind: RuntimeOwnerKind::Terminal, + terminal_id: Some(op.into()), + live_session_key: None, + pid: terminal.id(), + ownership_id: Some(op.into()), + }; + assert!(matches!( + rig.ownership + .commit_live("claude", ¤t, op, generation, owner), + CommitOutcome::Committed + )); + let before = rig.ownership.observe("claude", ¤t); + let mut conn = rig.connect().await; + let start = conn.frames.len(); + conn.send(json!({"type":"freshAgent.kill", "provider":"claude", "sessionType":"freshclaude", "sessionId":old})).await; + let answer = conn + .wait_for_since(start, "the late native close answer", |frame| { + frame["type"] == "freshAgent.killed" && frame["sessionId"] == old + }) + .await; + assert_eq!(answer["success"], false, "{answer}"); + assert_eq!(answer["code"], "FOREIGN_OWNER", "{answer}"); + assert_eq!( + rig.ownership.observe("claude", ¤t).state, + before.state + ); + assert!( + terminal.try_wait().unwrap().is_none(), + "the terminal writer survives" + ); + let ledger = rig.pane_ledger.as_ref().unwrap(); + assert_eq!( + ledger.load_binding("claude", ¤t).unwrap().state, + RowState::Bound + ); + for id in [&old, ¤t] { + assert!(ledger.kill_tombstone_at("claude", id).is_none()); + } + terminal.start_kill().unwrap(); + terminal.wait().await.unwrap(); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn closing_a_prior_native_id_after_restart_closes_the_untracked_bound_terminus() { + let (rig, old, current) = persisted_reset_after_restart().await; + let mut conn = rig.connect().await; + conn.kill(&old).await; + assert!(!rig.fresh_claude.has_live_session(¤t).await); + let ledger = rig.pane_ledger.as_ref().unwrap(); + let row = ledger.load_binding("claude", ¤t).unwrap(); + assert_eq!( + row.state, + RowState::Retired, + "a closed conversation is not a recovery offer: {row:?}" + ); + assert_eq!(row.retired_reason, Some(RetiredReason::Closed)); + for id in [&old, ¤t] { + assert!(ledger.kill_tombstone_at("claude", id).is_some()); + } +} From 65192f10495f467513795bcf0e5f3f9043516af3 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:07:01 -0700 Subject: [PATCH 091/100] test: use native Claude identity in cross-tab state fixture --- test/unit/client/store/crossTabSync.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/unit/client/store/crossTabSync.test.ts b/test/unit/client/store/crossTabSync.test.ts index df822aee4..c432126e0 100644 --- a/test/unit/client/store/crossTabSync.test.ts +++ b/test/unit/client/store/crossTabSync.test.ts @@ -1472,7 +1472,7 @@ describe('crossTabSync', () => { kind: 'fresh-agent', sessionType: 'freshclaude', provider: 'claude', - sessionId: 'sdk-local-current', + sessionId: canonicalSessionId, createRequestId: 'req-local-current', status: 'running', resumeSessionId: canonicalSessionId, @@ -1513,7 +1513,7 @@ describe('crossTabSync', () => { kind: 'fresh-agent', sessionType: 'freshclaude', provider: 'claude', - sessionId: 'sdk-local-current', + sessionId: canonicalSessionId, createRequestId: 'req-local-current', status: 'running', resumeSessionId: canonicalSessionId, @@ -1590,7 +1590,7 @@ describe('crossTabSync', () => { provider: 'claude', sessionId: canonicalSessionId, }) - expect(paneContent.sessionId).toBe('sdk-local-current') + expect(paneContent.sessionId).toBe(canonicalSessionId) expect(paneContent.createRequestId).toBe('req-local-current') expect(paneContent.status).toBe('running') }) From e90e56c9883fcc88c4f7202c85b04ba17960b1aa Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:07:32 -0700 Subject: [PATCH 092/100] fix: stream cloud verification output without buffering --- scripts/run-standard-tests.ts | 15 +-- .../run-standard-tests-cloud-output.test.ts | 91 +++++++++++++++++++ 2 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 test/unit/tooling/run-standard-tests-cloud-output.test.ts diff --git a/scripts/run-standard-tests.ts b/scripts/run-standard-tests.ts index 49778baba..88a3b3002 100644 --- a/scripts/run-standard-tests.ts +++ b/scripts/run-standard-tests.ts @@ -1,6 +1,6 @@ #!/usr/bin/env tsx -import { execFile, spawn, type ChildProcess } from 'node:child_process' +import { spawn, type ChildProcess } from 'node:child_process' import { availableParallelism, constants as osConstants, setPriority } from 'node:os' import { existsSync, readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' @@ -418,15 +418,16 @@ function resolveExistingLocalOnlyDirs(): string[] { function dispatchCloudVitest(cloudScript: string, forwardedArgs: string[]): Promise { return new Promise((resolveDispatch, rejectDispatch) => { - execFile( + const child = spawn( cloudScript, ['run', '--cloud', '--config=default', ...forwardedArgs], - { stdio: 'inherit', cwd: PROJECT_ROOT, env: process.env }, - (error) => { - if (error) rejectDispatch(error) - else resolveDispatch() - }, + { stdio: 'inherit', cwd: PROJECT_ROOT, env: process.env, windowsHide: true }, ) + child.once('error', rejectDispatch) + child.once('exit', (code, signal) => { + if (code === 0) resolveDispatch() + else rejectDispatch(new Error(`Cloud Vitest phase exited with code ${code ?? 1}${signal ? ` (${signal})` : ''}`)) + }) }) } diff --git a/test/unit/tooling/run-standard-tests-cloud-output.test.ts b/test/unit/tooling/run-standard-tests-cloud-output.test.ts new file mode 100644 index 000000000..cdd35b59a --- /dev/null +++ b/test/unit/tooling/run-standard-tests-cloud-output.test.ts @@ -0,0 +1,91 @@ +// @vitest-environment node + +import { spawn } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +const root = fileURLToPath(new URL('../../../', import.meta.url)) + +async function runCloudFixture(mode: 'progress' | 'volume' | 'failure') { + const fixture = mkdtempSync(path.join(os.tmpdir(), 'freshell-cloud-output-')) + const script = path.join(fixture, 'cloud.mjs') + const release = path.join(fixture, 'release') + writeFileSync(script, `#!${process.execPath} +import { existsSync } from 'node:fs'; +const mode = ${JSON.stringify(mode)}; +process.stderr.write('cloud diagnostic\\n'); +if (mode === 'progress') { + process.stdout.write('cloud progress\\n'); + const deadline = Date.now() + 4000; + const timer = setInterval(() => { + if (existsSync(${JSON.stringify(release)})) { + clearInterval(timer); + process.stdout.write('cloud complete\\n'); + } else if (Date.now() > deadline) { + clearInterval(timer); + process.exitCode = 17; + } + }, 10); +} else if (mode === 'volume') { + process.stdout.write('x'.repeat(2 * 1024 * 1024)); + process.stdout.write('\\ncloud complete\\n'); +} else { + process.exitCode = 7; +} +`, { mode: 0o755 }) + const child = spawn(process.execPath, [ + '--import', 'tsx', 'scripts/run-standard-tests.ts', + 'test/unit/cloud-output-fixture.test.ts', + ], { + cwd: root, + env: { + ...process.env, + FRESHELL_VITEST_BACKEND: 'cloud', + FRESHELL_VITEST_CLOUD_SCRIPT: script, + }, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10_000, + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk + if (stdout.includes('cloud progress')) writeFileSync(release, '') + }) + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk }) + try { + const code = await new Promise((resolve, reject) => { + child.once('error', reject) + child.once('close', resolve) + }) + return { code, stdout, stderr } + } finally { + rmSync(fixture, { recursive: true, force: true }) + } +} + +describe('standard runner cloud output', () => { + it('streams progress while the cloud process is still running', async () => { + const result = await runCloudFixture('progress') + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toContain('cloud complete') + expect(result.stderr).toContain('cloud diagnostic') + }, 15_000) + + it('accepts a successful cloud run with more than the execFile buffer limit of output', async () => { + const result = await runCloudFixture('volume') + expect(result.code, result.stderr).toBe(0) + expect(result.stdout).toContain('x'.repeat(2 * 1024 * 1024)) + expect(result.stdout).toContain('cloud complete') + }, 15_000) + + it('preserves the cloud diagnostic and reports its failing exit code', async () => { + const result = await runCloudFixture('failure') + expect(result.code).toBe(1) + expect(result.stderr).toContain('cloud diagnostic') + expect(result.stderr).toContain('Cloud Vitest phase exited with code 7') + }, 15_000) +}) From e754ba575e784a98fac90b211e8ba5831629242c Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:09:36 -0700 Subject: [PATCH 093/100] docs(freshclaude): describe native runtime cleanup in retained tests --- crates/freshell-freshagent/src/claude.rs | 50 +++++++++++------------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index db7673116..d7d7e7d0d 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -5267,7 +5267,7 @@ impl FreshClaudeState { // Consumer exit == this sidecar's stdout closed == the sidecar ended // (ledger A9): it exits after reporting its SDK stream's end // (`sdk.stream.ended`, G02), or the Node process died. Evict the dead - // session and its cli_index entries, identity-guarded: a newer session + // session, identity-guarded: a newer session // re-registered under the same map key has a different pending-set // allocation, even when it reuses a durable sidecar session id, and // must not be evicted by this stale consumer. @@ -9023,21 +9023,19 @@ pub(crate) mod tests { } } - /// Wait until the fresh create's adoption has COMMITTED ownership under - /// `durable_id`. The adoption publishes the `cli_index` alias and then - /// writes the identity binding BEFORE its commit (ext r27 F4: a failed - /// binding abandons it pre-commit), and the commit-to-Live plus the - /// retained release stamp are one critical section under the stamps - /// lock (ext r30 F1) — so neither the alias nor `was_recorded` proves - /// the commit: both can be observed while the claim is still Starting - /// and the stamp is absent. The stamp is the real condition. + /// Wait until the fresh create has committed ownership under `durable_id`. + /// Establishment writes the identity binding before committing ownership; + /// a failed binding abandons the claim. The commit to Live and retained + /// release stamp share the stamps lock, so a recorded binding alone does + /// not prove the commit: it can be observed while the claim is Starting. + /// The retained stamp is the condition this helper waits for. async fn await_create_ownership_committed(st: &FreshClaudeState, durable_id: &str) { let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(15); while crate::ownership_lane::peek_retained_stamp(&st.ownership_stamps, durable_id).is_none() { assert!( tokio::time::Instant::now() < deadline, - "the create's adoption never committed ownership (no retained stamp)" + "the create never committed ownership (no retained stamp)" ); tokio::time::sleep(std::time::Duration::from_millis(10)).await; } @@ -9134,8 +9132,7 @@ pub(crate) mod tests { "the durable ledger row was NOT closed on refusal — the retire \ log must not name it" ); - // And the runtime is still live in the map (has_live_session keys - // through cli_index — the DURABLE id is the index's entry). + // And the runtime is still live under its native identity. assert!( st.has_live_session(fresh_create_durable_id).await, "the refused kill left the session live" @@ -10273,15 +10270,14 @@ pub(crate) mod tests { let created = await_claude_created(&mut rx, "req-retire-claude").await; let placeholder = created["sessionId"].as_str().unwrap().to_string(); let durable = "3d3d3d3d-3d3d-4d3d-8d3d-3d3d3d3d3d3d"; - // Alias the durable id at it (live: the consumer's sdk.session.init fold - // writes this mapping; here the test writes it directly, same trick as - // attach_rebind_ack_stamps_the_tracked_live_status_not_hardcoded_idle). + // Retain a prior native id in the runtime's history, as a conversation + // reset does. The close must include this historical durable row. st.sessions.lock().await[&placeholder] .identity .inherit([durable.to_string()]); - // Kill addressed by the DURABLE id must retire the durable-keyed row - // (resolve_session_key walks cli_index to the live map key). + // A kill addressed by the historical durable id resolves the same + // runtime and retires that durable row. st.handle_kill(FreshAgentKill { observed_epoch: None, observed_generation: None, @@ -12089,7 +12085,7 @@ pub(crate) mod tests { } assert!( tokio::time::Instant::now() < deadline, - "consumer exit must still evict the dead session and its live cli_index entries" + "consumer exit must still evict the dead native runtime" ); tokio::time::sleep(std::time::Duration::from_millis(50)).await; } @@ -13114,7 +13110,7 @@ pub(crate) mod tests { } /// The CREATE-resume lane twin: the post-commit kill lands while the lane - /// is between commit and the sessions/cli_index inserts — the create + /// is between commit and registration in the sessions map — the create /// answers failed and nothing registers. #[tokio::test(flavor = "multi_thread")] async fn a_kill_landing_between_the_commit_and_the_registration_still_closes_the_resumed_create( @@ -13443,7 +13439,7 @@ pub(crate) mod tests { /// Finding 2's headline: the kill's alias consult reaches the DURABLE /// record — the post-restart shape directly: this process's in-memory - /// store, `cli_index`, and sessions map are ALL empty (a restarted server + /// sessions map is empty (a restarted server /// is exactly that), yet the placeholder→durable mapping survives in the /// persisted alias tombstones, so the close names the bare placeholder /// and still fences + retires the durable row. @@ -15461,8 +15457,8 @@ pub(crate) mod tests { drop(env); } - /// Ledger A9: consumer exit (== sidecar death) must evict the dead session AND its - /// `cli_index` entries — kill/shutdown are NOT the only eviction paths. Without this, + /// Ledger A9: consumer exit (sidecar death) must evict the dead runtime; + /// kill and shutdown are not the only eviction paths. Without this, /// a dead-but-tracked session makes the tracked⇒no-op attach row strand panes forever. #[tokio::test] async fn consumer_exit_evicts_dead_session_and_index() { @@ -15473,8 +15469,8 @@ pub(crate) mod tests { .await; let created_frame = await_claude_created(&mut rx, "req-evict-1").await; let created = created_frame["sessionId"].as_str().unwrap().to_string(); - // Kill the sidecar through the public API (fake exits on text "__exit__"), - // then poll (bounded) until the consumer-exit eviction clears BOTH maps. + // Kill this fixture's sidecar child, then wait for consumer-exit + // eviction to remove the native runtime. st.sessions .lock() .await @@ -15490,7 +15486,7 @@ pub(crate) mod tests { } assert!( tokio::time::Instant::now() < deadline, - "consumer exit must evict the dead session and its cli_index entries" + "consumer exit must evict the dead native runtime" ); tokio::time::sleep(std::time::Duration::from_millis(50)).await; } @@ -16036,8 +16032,8 @@ pub(crate) mod tests { /// A rollback-fixture live session: a `tee ` child stands in for the /// sidecar — every byte the handler writes to its stdin lands in stdin_log (the /// "no sidecar churn" oracle for the refusal legs) — registered with - /// `cli_session_id` = durable and the cli_index alias, exactly like a - /// fully-initialized live session. + /// its current native identity set to `durable`, retaining `map_key` in + /// its history like a runtime that has changed conversations. async fn insert_rollback_fixture_session( st: &FreshClaudeState, map_key: &str, From 5ddfeed3cbf65370987475f76224bd02aaa573a8 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:10:12 -0700 Subject: [PATCH 094/100] style(freshclaude): format combined native attach handling --- crates/freshell-freshagent/src/claude.rs | 5 ++--- crates/freshell-freshagent/src/claude/attach.rs | 17 +++++++++-------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index d7d7e7d0d..f9dd787b3 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -13438,9 +13438,8 @@ pub(crate) mod tests { // ── retire-on-kill round 6 (focused-ep5-r5 Finding 2): durable alias tombstones ── /// Finding 2's headline: the kill's alias consult reaches the DURABLE - /// record — the post-restart shape directly: this process's in-memory - /// sessions map is empty (a restarted server - /// is exactly that), yet the placeholder→durable mapping survives in the + /// record after restart: this process's in-memory sessions map is empty, + /// yet the placeholder→durable mapping survives in the /// persisted alias tombstones, so the close names the bare placeholder /// and still fences + retires the durable row. #[tokio::test] diff --git a/crates/freshell-freshagent/src/claude/attach.rs b/crates/freshell-freshagent/src/claude/attach.rs index 9cea57b2b..64b3950f0 100644 --- a/crates/freshell-freshagent/src/claude/attach.rs +++ b/crates/freshell-freshagent/src/claude/attach.rs @@ -123,8 +123,13 @@ impl FreshClaudeState { let Some(key) = self.resolve_session_key(&msg.session_id).await else { return Ok(None); }; - let Some(native_id) = self.sessions.lock().await.get(&key) - .map(|runtime| runtime.identity.current()) else { + let Some(native_id) = self + .sessions + .lock() + .await + .get(&key) + .map(|runtime| runtime.identity.current()) + else { return Ok(None); }; let operation = format!("claude-attach-{}", uuid::Uuid::new_v4()); @@ -175,12 +180,8 @@ impl FreshClaudeState { }; if previous != native_id { reply( - self.materialized_message( - previous, - &native_id, - session_type_str(msg.session_type), - ) - .await, + self.materialized_message(previous, &native_id, session_type_str(msg.session_type)) + .await, ); } reply(status_snapshot_frame( From b9f771e16c5eb658b7bcfc8f3a3f539413dbe482 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:28:25 -0700 Subject: [PATCH 095/100] fix(test): prepare managed runtime binaries before startup hooks --- config/vitest/vitest.config.ts | 1 + scripts/testing/runtime-build-preflight.ts | 133 +++++++++++ scripts/testing/runtime-sandbox.ts | 19 +- test/e2e-browser/helpers/managed-runtime.ts | 19 +- test/setup/runtime-build-preflight.ts | 39 ++++ .../testing/runtime-build-preflight.test.ts | 220 ++++++++++++++++++ 6 files changed, 420 insertions(+), 11 deletions(-) create mode 100644 scripts/testing/runtime-build-preflight.ts create mode 100644 test/setup/runtime-build-preflight.ts create mode 100644 test/unit/tooling/testing/runtime-build-preflight.test.ts diff --git a/config/vitest/vitest.config.ts b/config/vitest/vitest.config.ts index b14b4f65f..bddfd40f7 100644 --- a/config/vitest/vitest.config.ts +++ b/config/vitest/vitest.config.ts @@ -30,6 +30,7 @@ export default defineConfig({ test: { environment: 'jsdom', setupFiles: ['./test/setup/dom.ts'], + globalSetup: ['./test/setup/runtime-build-preflight.ts'], exclude: [ 'test/runtime/**', '**/node_modules/**', diff --git a/scripts/testing/runtime-build-preflight.ts b/scripts/testing/runtime-build-preflight.ts new file mode 100644 index 000000000..1fccc66c5 --- /dev/null +++ b/scripts/testing/runtime-build-preflight.ts @@ -0,0 +1,133 @@ +import { execFileSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +export const RUNTIME_BUILD_MANIFEST_ENV = 'FRESHELL_RUNTIME_TEST_BUILD_MANIFEST' +export type RuntimeBinary = 'supervisor-test' | 'host-test' | 'supervisor-release' | 'host-release' + | 'supervisor-qualification' | 'server-managed-provider-qualification' | 'server-managed-fresh-agent-fixtures' +export type RuntimeBuildRecipe = 'test-hosts' | 'release-hosts' | 'qualification-supervisor' | 'qualification-server' | 'fresh-fixture-server' +export type RuntimeBuildCommand = { program: string; args: string[] } + +type BuildManifest = { + version: 1 + repoRoot: string + revision: string + binaries: Partial> +} + +const recipes: Record> }> = { + 'test-hosts': { + args: ['-p', 'freshell-supervisor', '-p', 'freshell-session-host', '--features', 'freshell-supervisor/runtime-test-faults,freshell-session-host/fresh-agent-fixtures'], + profile: 'debug', outputs: { 'supervisor-test': 'freshell-supervisor', 'host-test': 'freshell-session-host' }, + }, + 'release-hosts': { + args: ['--release', '-p', 'freshell-supervisor', '-p', 'freshell-session-host'], + profile: 'release', outputs: { 'supervisor-release': 'freshell-supervisor', 'host-release': 'freshell-session-host' }, + }, + 'qualification-supervisor': { + args: ['--release', '-p', 'freshell-supervisor', '--features', 'provider-qualification'], + profile: 'release', outputs: { 'supervisor-qualification': 'freshell-supervisor' }, + }, + 'qualification-server': { + args: ['--release', '-p', 'freshell-server', '--features', 'managed-provider-qualification'], + profile: 'release', outputs: { 'server-managed-provider-qualification': 'freshell-server' }, + }, + 'fresh-fixture-server': { + args: ['--release', '-p', 'freshell-server', '--features', 'managed-fresh-agent-fixtures'], + profile: 'release', outputs: { 'server-managed-fresh-agent-fixtures': 'freshell-server' }, + }, +} + +/** Match discovered files, so focused unit runs and cloud images prepare nothing. */ +export function runtimeBuildRecipesForFiles(repoRoot: string, files: string[], env: NodeJS.ProcessEnv): RuntimeBuildRecipe[] { + const selected = new Set(files.map(file => path.relative(repoRoot, file).split(path.sep).join('/'))) + const contract = selected.has('test/integration/server/provider-parity-contract.test.ts') + const terminal = contract || selected.has('test/integration/server/managed-provider-parity.test.ts') + const fresh = contract || selected.has('test/integration/server/fresh-agent-parity.test.ts') + const crash = env.FRESHELL_RUNTIME_PHASE3_STARTUP_CRASH_SMOKE === '1' + && selected.has('test/integration/server/testing/runtime-phase3-startup-crash.real.test.ts') + if (!terminal && !fresh && !crash) return [] + return [ + 'test-hosts', 'release-hosts', + ...(terminal ? ['qualification-supervisor', 'qualification-server'] as const : []), + ...(fresh ? ['fresh-fixture-server'] as const : []), + ] +} + +function revision(repoRoot: string): string { + return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }).trim() +} + +function readManifest(repoRoot: string, env: NodeJS.ProcessEnv): BuildManifest | undefined { + const file = env[RUNTIME_BUILD_MANIFEST_ENV] + if (!file) return undefined + const manifest = JSON.parse(fs.readFileSync(file, 'utf8')) as BuildManifest + if (manifest.version !== 1 || manifest.repoRoot !== fs.realpathSync(repoRoot) || manifest.revision !== revision(repoRoot)) { + throw new Error('prepared runtime build manifest belongs to another checkout or revision') + } + return manifest +} + +export function preparedRuntimeBinary(repoRoot: string, binary: RuntimeBinary, env: NodeJS.ProcessEnv = process.env): string | undefined { + const manifest = readManifest(repoRoot, env) + if (!manifest) return undefined + const file = manifest.binaries[binary] + if (!file) throw new Error(`runtime binary variant was not prepared: ${binary}`) + if (!path.isAbsolute(file) || !fs.statSync(file).isFile()) throw new Error(`prepared runtime binary is not a file: ${binary}`) + fs.accessSync(file, fs.constants.X_OK) + return file +} + +export function hasPreparedRuntimeBuilds(repoRoot: string, selected: RuntimeBuildRecipe[], env: NodeJS.ProcessEnv): boolean { + if (!readManifest(repoRoot, env)) return false + for (const recipe of selected) for (const binary of Object.keys(recipes[recipe].outputs) as RuntimeBinary[]) { + preparedRuntimeBinary(repoRoot, binary, env) + } + return true +} + +/** Build before suite hooks and freeze each variant before Cargo overwrites it. */ +export function prepareRuntimeBuilds( + repoRoot: string, + selected: RuntimeBuildRecipe[], + { env = process.env, command = { program: path.join(os.homedir(), '.local/bin/mise'), args: ['exec', 'rust@1.96', '--', 'cargo'] } }: { + env?: NodeJS.ProcessEnv + command?: RuntimeBuildCommand + } = {}, +): { manifestPath: string; cleanup: () => void } { + const root = fs.realpathSync(repoRoot) + const directory = path.join(root, '.runtime-build', `preflight-${randomUUID()}`) + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }) + const manifest: BuildManifest = { version: 1, repoRoot: root, revision: revision(root), binaries: {} } + const target = path.resolve(root, env.CARGO_TARGET_DIR || 'target') + const jobs = Math.max(1, Math.min(3, Number.parseInt(env.CARGO_BUILD_JOBS || '3', 10) || 3)) + const cleanup = () => fs.rmSync(directory, { recursive: true, force: true }) + let activeRecipe: RuntimeBuildRecipe | undefined + try { + for (const name of selected) { + activeRecipe = name + const recipe = recipes[name] + const started = Date.now() + process.stderr.write(`${JSON.stringify({ severity: 'info', event: 'runtime_build.preflight.started', at: new Date().toISOString(), recipe: name })}\n`) + execFileSync(command.program, [...command.args, 'build', '--locked', ...recipe.args], { + cwd: root, env: { ...env, CARGO_BUILD_JOBS: String(jobs) }, stdio: 'inherit', timeout: 900_000, + }) + for (const [binary, crate] of Object.entries(recipe.outputs)) { + const destination = path.join(directory, `${binary}${process.platform === 'win32' ? '.exe' : ''}`) + fs.copyFileSync(path.join(target, recipe.profile, `${crate}${process.platform === 'win32' ? '.exe' : ''}`), destination) + fs.chmodSync(destination, 0o755) + manifest.binaries[binary as RuntimeBinary] = destination + } + process.stderr.write(`${JSON.stringify({ severity: 'info', event: 'runtime_build.preflight.completed', at: new Date().toISOString(), recipe: name, durationMs: Date.now() - started })}\n`) + } + const manifestPath = path.join(directory, 'manifest.json') + fs.writeFileSync(manifestPath, JSON.stringify(manifest), { mode: 0o600 }) + return { manifestPath, cleanup } + } catch (error) { + process.stderr.write(`${JSON.stringify({ severity: 'error', event: 'runtime_build.preflight.failed', at: new Date().toISOString(), recipe: activeRecipe, error: String(error) })}\n`) + cleanup() + throw error + } +} diff --git a/scripts/testing/runtime-sandbox.ts b/scripts/testing/runtime-sandbox.ts index 306bc0e68..d5d7eebe3 100644 --- a/scripts/testing/runtime-sandbox.ts +++ b/scripts/testing/runtime-sandbox.ts @@ -7,6 +7,7 @@ import os from 'node:os' import path from 'node:path' import { configuredAmplifierOnecliGrantFiles } from './runtime-amplifier-onecli.js' +import { preparedRuntimeBinary } from './runtime-build-preflight.js' import { parseHelperCreateDelay, RestrictedDockerBroker, type BrokerEvent, type BrokerReceipt } from './runtime-test-broker.js' export const PHASE1_RUNTIME_IMAGE_TAG = 'ubuntu:24.04' @@ -1278,18 +1279,28 @@ export class RuntimeHarness { private buildBinaries(): void { fs.mkdirSync(this.buildDir, { recursive: true }) - const mise = path.join(os.homedir(), '.local', 'bin', 'mise') - execFileSync(mise, ['exec', 'rust@1.96', '--', 'cargo', 'build', '-p', 'freshell-supervisor', '-p', 'freshell-session-host', '--features', 'freshell-supervisor/runtime-test-faults,freshell-session-host/fresh-agent-fixtures'], { cwd: this.repoRoot, stdio: 'inherit' }) this.testSupervisorBinary = path.join(this.buildDir, 'freshell-supervisor-test') this.testHostBinary = path.join(this.buildDir, 'freshell-session-host-test') + this.releaseSupervisorBinary = path.join(this.buildDir, 'freshell-supervisor-release') + this.releaseHostBinary = path.join(this.buildDir, 'freshell-session-host-release') + const prepared = preparedRuntimeBinary(this.repoRoot, 'supervisor-test') + if (prepared) { + for (const [source, destination] of [ + [prepared, this.testSupervisorBinary], + [preparedRuntimeBinary(this.repoRoot, 'host-test')!, this.testHostBinary], + [preparedRuntimeBinary(this.repoRoot, 'supervisor-release')!, this.releaseSupervisorBinary], + [preparedRuntimeBinary(this.repoRoot, 'host-release')!, this.releaseHostBinary], + ]) fs.copyFileSync(source, destination) + return + } + const mise = path.join(os.homedir(), '.local', 'bin', 'mise') + execFileSync(mise, ['exec', 'rust@1.96', '--', 'cargo', 'build', '-p', 'freshell-supervisor', '-p', 'freshell-session-host', '--features', 'freshell-supervisor/runtime-test-faults,freshell-session-host/fresh-agent-fixtures'], { cwd: this.repoRoot, stdio: 'inherit' }) fs.copyFileSync(path.join(this.repoRoot, 'target/debug/freshell-supervisor'), this.testSupervisorBinary) fs.copyFileSync(path.join(this.repoRoot, 'target/debug/freshell-session-host'), this.testHostBinary) fs.chmodSync(this.testSupervisorBinary, 0o755) fs.chmodSync(this.testHostBinary, 0o755) execFileSync(mise, ['exec', 'rust@1.96', '--', 'cargo', 'build', '--release', '-p', 'freshell-supervisor', '-p', 'freshell-session-host'], { cwd: this.repoRoot, stdio: 'inherit' }) - this.releaseSupervisorBinary = path.join(this.buildDir, 'freshell-supervisor-release') - this.releaseHostBinary = path.join(this.buildDir, 'freshell-session-host-release') fs.copyFileSync(path.join(this.repoRoot, 'target/release/freshell-supervisor'), this.releaseSupervisorBinary) fs.copyFileSync(path.join(this.repoRoot, 'target/release/freshell-session-host'), this.releaseHostBinary) fs.chmodSync(this.releaseSupervisorBinary, 0o755) diff --git a/test/e2e-browser/helpers/managed-runtime.ts b/test/e2e-browser/helpers/managed-runtime.ts index 47907196a..144585397 100644 --- a/test/e2e-browser/helpers/managed-runtime.ts +++ b/test/e2e-browser/helpers/managed-runtime.ts @@ -11,6 +11,7 @@ import { type ProviderQualificationRow, } from '../../../scripts/testing/provider-qualification-receipt.js' import { RuntimeHarness, type RuntimeFixtureInputs, type SupervisorInstance } from '../../../scripts/testing/runtime-sandbox.js' +import { preparedRuntimeBinary } from '../../../scripts/testing/runtime-build-preflight.js' import { RustServer } from './rust-server.js' import type { E2eServerInfo as TestServerInfo } from './server-fixture-support.js' import type { QualifiableTerminalProvider } from '../../../scripts/testing/provider-qualification-selection.js' @@ -518,13 +519,16 @@ export class ManagedRuntimeBrowserRig { private buildManagedServer(): string { const mise = path.join(os.homedir(), '.local', 'bin', 'mise') const qualification = this.pendingQualificationProviders() - execFileSync(mise, [ + const features = this.fixtureFreshAgentModes.length > 0 + ? 'managed-fresh-agent-fixtures' + : qualification.length ? 'managed-provider-qualification' : 'managed-runtime-v1' + const prepared = features === 'managed-runtime-v1' ? undefined + : preparedRuntimeBinary(this.repoRoot, `server-${features}`) + if (!prepared) execFileSync(mise, [ 'exec', 'rust@1.96', '--', 'cargo', 'build', '--release', '-p', 'freshell-server', - '--features', this.fixtureFreshAgentModes.length > 0 - ? 'managed-fresh-agent-fixtures' - : qualification.length ? 'managed-provider-qualification' : 'managed-runtime-v1', + '--features', features, ], { cwd: this.repoRoot, stdio: 'inherit' }) - const source = path.join(this.repoRoot, 'target', 'release', 'freshell-server') + const source = prepared ?? path.join(this.repoRoot, 'target', 'release', 'freshell-server') const target = path.join(this.runtime.buildDir, 'freshell-server-managed') fs.copyFileSync(source, target) fs.chmodSync(target, 0o755) @@ -534,12 +538,13 @@ export class ManagedRuntimeBrowserRig { private buildQualificationSupervisor(): string | undefined { if (this.pendingQualificationProviders().length === 0) return undefined const mise = path.join(os.homedir(), '.local', 'bin', 'mise') - execFileSync(mise, [ + const prepared = preparedRuntimeBinary(this.repoRoot, 'supervisor-qualification') + if (!prepared) execFileSync(mise, [ 'exec', 'rust@1.96', '--', 'cargo', 'build', '--release', '-p', 'freshell-supervisor', '--features', 'provider-qualification', ], { cwd: this.repoRoot, stdio: 'inherit' }) const target = path.join(this.runtime.buildDir, 'freshell-supervisor-qualification') - fs.copyFileSync(path.join(this.repoRoot, 'target', 'release', 'freshell-supervisor'), target) + fs.copyFileSync(prepared ?? path.join(this.repoRoot, 'target', 'release', 'freshell-supervisor'), target) fs.chmodSync(target, 0o755) return target } diff --git a/test/setup/runtime-build-preflight.ts b/test/setup/runtime-build-preflight.ts new file mode 100644 index 000000000..6d6eb67b5 --- /dev/null +++ b/test/setup/runtime-build-preflight.ts @@ -0,0 +1,39 @@ +import type { TestProject } from 'vitest/node' +import { + RUNTIME_BUILD_MANIFEST_ENV, + hasPreparedRuntimeBuilds, + prepareRuntimeBuilds, + runtimeBuildRecipesForFiles, + type RuntimeBuildCommand, +} from '../../scripts/testing/runtime-build-preflight.js' + +type Project = { + config: { root: string; env?: NodeJS.ProcessEnv } + vitest: { state: { getPaths(): string[] } } + onTestsRerun(callback: () => void): void +} + +export async function setupRuntimeBuildPreflight(project: Project, options: { command?: RuntimeBuildCommand } = {}): Promise<(() => void) | undefined> { + const env = { ...process.env, ...project.config.env } + const selected = runtimeBuildRecipesForFiles(project.config.root, project.vitest.state.getPaths(), env) + if (selected.length === 0 || hasPreparedRuntimeBuilds(project.config.root, selected, env)) return + let prepared = prepareRuntimeBuilds(project.config.root, selected, { env, ...options }) + const projectEnv = project.config.env ??= {} + const inherited = projectEnv[RUNTIME_BUILD_MANIFEST_ENV] + projectEnv[RUNTIME_BUILD_MANIFEST_ENV] = prepared.manifestPath + project.onTestsRerun(() => { + const next = prepareRuntimeBuilds(project.config.root, selected, { env, ...options }) + prepared.cleanup() + prepared = next + projectEnv[RUNTIME_BUILD_MANIFEST_ENV] = prepared.manifestPath + }) + return () => { + prepared.cleanup() + if (inherited === undefined) delete projectEnv[RUNTIME_BUILD_MANIFEST_ENV] + else projectEnv[RUNTIME_BUILD_MANIFEST_ENV] = inherited + } +} + +export default function setup(project: TestProject): Promise<(() => void) | undefined> { + return setupRuntimeBuildPreflight(project) +} diff --git a/test/unit/tooling/testing/runtime-build-preflight.test.ts b/test/unit/tooling/testing/runtime-build-preflight.test.ts new file mode 100644 index 000000000..ebca142d6 --- /dev/null +++ b/test/unit/tooling/testing/runtime-build-preflight.test.ts @@ -0,0 +1,220 @@ +import { execFileSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { setupRuntimeBuildPreflight } from '../../../setup/runtime-build-preflight.js' +import { RUNTIME_BUILD_MANIFEST_ENV, preparedRuntimeBinary } from '../../../../scripts/testing/runtime-build-preflight.js' +import { RuntimeHarness } from '../../../../scripts/testing/runtime-sandbox.js' +import { ManagedRuntimeBrowserRig } from '../../../e2e-browser/helpers/managed-runtime.js' + +const owned: string[] = [] +afterEach(() => { + vi.unstubAllEnvs() + for (const directory of owned.splice(0)) fs.rmSync(directory, { recursive: true, force: true }) +}) + +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'freshell-build-preflight-')) + owned.push(root) + execFileSync('git', ['init', '--quiet', root]) + execFileSync('git', ['-C', root, '-c', 'user.name=Dan Shapiro', '-c', 'user.email=3732858+danshapiro@users.noreply.github.com', 'commit', '--allow-empty', '--quiet', '-m', 'fixture']) + const cargo = path.join(root, 'cargo.cjs') + fs.writeFileSync(cargo, `const fs = require('node:fs'), path = require('node:path'); +const args = process.argv.slice(2); +fs.appendFileSync(process.env.BUILD_LOG, JSON.stringify(args) + '\\n'); +if (process.env.FAIL_BUILD && args.includes(process.env.FAIL_BUILD)) process.exit(17); +if (process.env.BUILD_DELAY_MS) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Number(process.env.BUILD_DELAY_MS)); +const profile = args.includes('--release') ? 'release' : 'debug'; +const features = args.includes('--features') ? args[args.indexOf('--features') + 1] : 'default'; +const stamp = process.env.BUILD_STAMP ? fs.readFileSync(process.env.BUILD_STAMP, 'utf8') : ''; +const target = path.resolve(process.cwd(), process.env.CARGO_TARGET_DIR || 'target', profile); +fs.mkdirSync(target, {recursive:true}); +for (let i=0; i [path.join(root, 'test/integration/server/managed-provider-parity.test.ts')] } }, + onTestsRerun: () => {}, + } + const command = { program: process.execPath, args: [cargo] } + const rows = () => fs.existsSync(env.BUILD_LOG) ? fs.readFileSync(env.BUILD_LOG, 'utf8').trim().split('\n').map(row => JSON.parse(row) as string[]) : [] + return { root, project, command, rows } +} + +describe('managed runtime build preparation before suite startup', () => { + it('does no builds or artifact work for an unrelated selected unit file', async () => { + const f = fixture() + f.project.vitest.state.getPaths = () => [path.join(f.root, 'test/unit/ordinary.test.ts')] + const teardown = await setupRuntimeBuildPreflight(f.project, { command: f.command }) + expect(f.rows()).toEqual([]) + expect(preparedRuntimeBinary(f.root, 'supervisor-test', f.project.config.env)).toBeUndefined() + await teardown?.() + }) + + it('builds selected variants once and preserves their exact executable copies across later Cargo builds', async () => { + vi.stubEnv(RUNTIME_BUILD_MANIFEST_ENV, path.join(os.tmpdir(), 'unrelated-parent-build-manifest')) + const f = fixture() + f.project.vitest.state.getPaths = () => [ + path.join(f.root, 'test/integration/server/managed-provider-parity.test.ts'), + path.join(f.root, 'test/integration/server/fresh-agent-parity.test.ts'), + path.join(f.root, 'test/integration/server/provider-parity-contract.test.ts'), + ] + const teardown = await setupRuntimeBuildPreflight(f.project, { command: f.command }) + expect(f.rows()).toHaveLength(5) + const terminal = preparedRuntimeBinary(f.root, 'server-managed-provider-qualification', f.project.config.env)! + const fresh = preparedRuntimeBinary(f.root, 'server-managed-fresh-agent-fixtures', f.project.config.env)! + expect(fs.readFileSync(terminal, 'utf8')).toContain('release:managed-provider-qualification') + expect(fs.readFileSync(fresh, 'utf8')).toContain('release:managed-fresh-agent-fixtures') + expect(fs.readFileSync(preparedRuntimeBinary(f.root, 'supervisor-test', f.project.config.env)!, 'utf8')).toContain('debug:freshell-supervisor/runtime-test-faults') + expect(fs.statSync(terminal).mode & 0o111).not.toBe(0) + await teardown?.() + expect(fs.existsSync(terminal)).toBe(false) + expect(fs.existsSync(fresh)).toBe(false) + }) + + it('reuses the parent invocation artifacts for nested parity runners without compiling or owning their cleanup', async () => { + const f = fixture() + f.project.vitest.state.getPaths = () => [path.join(f.root, 'test/integration/server/provider-parity-contract.test.ts')] + const teardown = await setupRuntimeBuildPreflight(f.project, { command: f.command }) + const binary = preparedRuntimeBinary(f.root, 'server-managed-provider-qualification', f.project.config.env)! + const nested = { ...f.project, config: { ...f.project.config, env: { ...f.project.config.env } } } + const childTeardown = await setupRuntimeBuildPreflight(nested, { command: f.command }) + expect(f.rows()).toHaveLength(5) + await childTeardown?.() + expect(fs.existsSync(binary)).toBe(true) + await teardown?.() + expect(fs.existsSync(binary)).toBe(false) + }) + + it('fails preparation before publishing a partial bundle when a prerequisite build fails', async () => { + const f = fixture() + Object.assign(f.project.config.env, { FAIL_BUILD: 'provider-qualification' }) + await expect(setupRuntimeBuildPreflight(f.project, { command: f.command })).rejects.toThrow(/17|build/) + expect(f.rows()).toHaveLength(3) + expect(preparedRuntimeBinary(f.root, 'supervisor-test', f.project.config.env)).toBeUndefined() + expect(fs.existsSync(path.join(f.root, '.runtime-build')) ? fs.readdirSync(path.join(f.root, '.runtime-build')) : []).toEqual([]) + }) + + it('refuses missing prepared variants and artifacts from another checkout instead of silently building', async () => { + const f = fixture() + const teardown = await setupRuntimeBuildPreflight(f.project, { command: f.command }) + expect(() => preparedRuntimeBinary(f.root, 'server-managed-fresh-agent-fixtures', f.project.config.env)).toThrow(/prepared.*variant|not prepared/i) + const other = fixture() + expect(() => preparedRuntimeBinary(other.root, 'supervisor-test', f.project.config.env)).toThrow(/checkout|root/) + expect(other.rows()).toEqual([]) + await teardown?.() + }) + + it('uses the prepared executables in the actual runtime and browser rig build methods without invoking Cargo again', async () => { + const f = fixture() + const teardown = await setupRuntimeBuildPreflight(f.project, { command: f.command }) + vi.stubEnv(RUNTIME_BUILD_MANIFEST_ENV, f.project.config.env[RUNTIME_BUILD_MANIFEST_ENV]) + const runtime = new RuntimeHarness(f.root, undefined, 2) + const rig = new ManagedRuntimeBrowserRig(f.root, 2, {}, {}, 'test', { enabledProviders: ['claude'] }) + fs.mkdirSync(rig.runtime.buildDir, { recursive: true }) + ;(runtime as unknown as { buildBinaries(): void }).buildBinaries() + const methods = rig as unknown as { buildManagedServer(): string; buildQualificationSupervisor(): string } + const server = methods.buildManagedServer() + const supervisor = methods.buildQualificationSupervisor() + expect(fs.readFileSync(runtime.testHostBinary, 'utf8')).toContain('debug:freshell-supervisor/runtime-test-faults') + expect(fs.readFileSync(runtime.releaseHostBinary, 'utf8')).toContain('release:default') + expect(fs.readFileSync(server, 'utf8')).toContain('release:managed-provider-qualification') + expect(fs.readFileSync(supervisor, 'utf8')).toContain('release:provider-qualification') + expect(f.rows()).toHaveLength(4) + await teardown?.() + expect(fs.readFileSync(server, 'utf8')).toContain('release:managed-provider-qualification') + }) + + it('runs preflight before a real Vitest startup hook, selects only requested files, and never starts after a build failure', () => { + const f = fixture() + const sourceRoot = path.resolve(import.meta.dirname, '../../../..') + const url = (file: string) => JSON.stringify(pathToFileURL(path.join(sourceRoot, file)).href) + const marker = path.join(f.root, 'started') + fs.writeFileSync(path.join(f.root, 'package.json'), JSON.stringify({ type: 'module' })) + const setup = path.join(f.root, 'setup.ts') + fs.writeFileSync(setup, `import { setupRuntimeBuildPreflight } from ${url('test/setup/runtime-build-preflight.ts')}; +export default project => setupRuntimeBuildPreflight(project, ${JSON.stringify({ command: f.command })});`) + const config = path.join(f.root, 'vitest.config.ts') + fs.writeFileSync(config, `export default ${JSON.stringify({ root: f.root, test: { environment: 'node', globalSetup: [setup], maxWorkers: 1 } })}`) + const ordinary = 'test/unit/ordinary.test.ts' + const managed = 'test/integration/server/managed-provider-parity.test.ts' + for (const file of [ordinary, managed]) { + fs.mkdirSync(path.dirname(path.join(f.root, file)), { recursive: true }) + fs.writeFileSync(path.join(f.root, file), `import fs from 'node:fs'; +import { it, beforeAll, expect } from ${url('node_modules/vitest/dist/index.js')}; +import { preparedRuntimeBinary } from ${url('scripts/testing/runtime-build-preflight.ts')}; +let binary; +beforeAll(() => { binary = preparedRuntimeBinary(${JSON.stringify(f.root)}, 'server-managed-provider-qualification'); fs.writeFileSync(${JSON.stringify(marker)}, binary || 'unit'); }, 250); +it('starts with the exact selected artifacts', () => expect(${JSON.stringify(file)}.includes('/server/')).toBe(Boolean(binary)));`) + } + const run = (file: string, extra: NodeJS.ProcessEnv = {}) => execFileSync(process.execPath, [ + path.join(sourceRoot, 'node_modules/vitest/vitest.mjs'), 'run', file, '--config', config, + ], { cwd: f.root, env: { ...f.project.config.env, BUILD_DELAY_MS: '100', ...extra }, encoding: 'utf8', timeout: 30_000, stdio: 'pipe' }) + run(ordinary) + expect(f.rows()).toEqual([]) + expect(fs.readFileSync(marker, 'utf8')).toBe('unit') + fs.rmSync(marker) + run(managed) + expect(f.rows()).toHaveLength(4) + expect(fs.readFileSync(marker, 'utf8')).toContain('server-managed-provider-qualification') + fs.rmSync(marker) + expect(() => run(managed, { FAIL_BUILD: 'provider-qualification' })).toThrow() + expect(fs.existsSync(marker)).toBe(false) + }, 30_000) + + it('consumes the latest prepared artifacts on real Vitest reruns and cleans every observed bundle', () => { + const f = fixture() + const sourceRoot = path.resolve(import.meta.dirname, '../../../..') + const url = (file: string) => JSON.stringify(pathToFileURL(path.join(sourceRoot, file)).href) + const stamp = path.join(f.root, 'stamp') + const observations = path.join(f.root, 'observations.jsonl') + fs.writeFileSync(stamp, 'initial') + fs.writeFileSync(path.join(f.root, 'package.json'), JSON.stringify({ type: 'module' })) + const setup = path.join(f.root, 'setup.ts') + fs.writeFileSync(setup, `import { setupRuntimeBuildPreflight } from ${url('test/setup/runtime-build-preflight.ts')}; +export default project => setupRuntimeBuildPreflight(project, ${JSON.stringify({ command: f.command })});`) + const managed = 'test/integration/server/managed-provider-parity.test.ts' + fs.mkdirSync(path.dirname(path.join(f.root, managed)), { recursive: true }) + fs.writeFileSync(path.join(f.root, managed), `import fs from 'node:fs'; +import { it, expect } from ${url('node_modules/vitest/dist/index.js')}; +import { preparedRuntimeBinary, RUNTIME_BUILD_MANIFEST_ENV } from ${url('scripts/testing/runtime-build-preflight.ts')}; +it('observes the exact rebuilt binary', () => { + const binary = preparedRuntimeBinary(${JSON.stringify(f.root)}, 'server-managed-provider-qualification'); + const bytes = fs.readFileSync(binary, 'utf8'); + expect(bytes).toContain(fs.readFileSync(${JSON.stringify(stamp)}, 'utf8')); + fs.appendFileSync(${JSON.stringify(observations)}, JSON.stringify({ manifest: process.env[RUNTIME_BUILD_MANIFEST_ENV], binary, bytes }) + '\\n'); +});`) + const config = path.join(f.root, 'vitest.config.ts') + fs.writeFileSync(config, `export default ${JSON.stringify({ root: f.root, test: { environment: 'node', globalSetup: [setup], maxWorkers: 1 } })}`) + const driver = path.join(f.root, 'rerun.mjs') + fs.writeFileSync(driver, `import fs from 'node:fs'; +import { startVitest } from ${url('node_modules/vitest/dist/node.js')}; +const ctx = await startVitest('test', [${JSON.stringify(managed)}], { root: ${JSON.stringify(f.root)}, config: ${JSON.stringify(config)}, watch: true }); +if (!ctx) throw new Error('Vitest did not start'); +try { + const specs = await ctx.globTestSpecifications([${JSON.stringify(managed)}]); + for (const next of ['rerun-one', 'rerun-two']) { + fs.writeFileSync(${JSON.stringify(stamp)}, next); + await ctx.rerunTestSpecifications(specs); + } +} finally { await ctx.close(); } +`) + execFileSync(process.execPath, [driver], { cwd: f.root, env: { ...f.project.config.env, BUILD_STAMP: stamp }, timeout: 30_000, stdio: 'pipe' }) + const rows = fs.readFileSync(observations, 'utf8').trim().split('\n').map(row => JSON.parse(row)) + expect(rows).toHaveLength(3) + expect(rows.map(row => row.bytes)).toEqual([ + expect.stringContaining(':initial'), expect.stringContaining(':rerun-one'), expect.stringContaining(':rerun-two'), + ]) + for (const row of rows) { + expect(fs.existsSync(row.binary)).toBe(false) + expect(fs.existsSync(row.manifest)).toBe(false) + } + }, 30_000) +}) From 846ad12ba021956b55925fd1aa9b862bfe1d0810 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:28:40 -0700 Subject: [PATCH 096/100] test(freshclaude): align provider contracts with native IPC --- .../providers/claude-cli-sim.test.ts | 7 +++--- .../providers/claude-freshell-mcp.mjs | 8 +++++-- .../providers/claude-freshell-mcp.test.ts | 1 + .../providers/claude-live-frames.mjs | 6 +++-- .../providers/claude-live-frames.test.ts | 23 ++++++++++++++----- .../providers/claude-permission-contract.mjs | 6 +++-- .../claude-permission-contract.test.ts | 1 + 7 files changed, 37 insertions(+), 15 deletions(-) diff --git a/test/integration/providers/claude-cli-sim.test.ts b/test/integration/providers/claude-cli-sim.test.ts index 3740a1328..029a92238 100644 --- a/test/integration/providers/claude-cli-sim.test.ts +++ b/test/integration/providers/claude-cli-sim.test.ts @@ -729,12 +729,13 @@ describe('the real sidecar over the simulator (FRESHELL_CLAUDE_SDK_QUERY_MODULE) it('creates a session, runs a turn and reports its result through the sidecar protocol', async () => { const { home, frames, until, send } = await startSidecar() - send({ type: 'create', protocol: 2, boot: { kind: 'fresh' }, requestId: 'r1', cwd: home.cwd, permissionMode: 'bypassPermissions' }) + const sessionId = randomUUID() + send({ type: 'create', protocol: 2, boot: { kind: 'fresh' }, requestId: 'r1', sessionId, cwd: home.cwd, permissionMode: 'bypassPermissions' }) expect(await until(() => frames.some((f) => f.type === 'created'))).toBe(true) - const sessionId = frames.find((f) => f.type === 'created').sessionId + expect(frames.find((f) => f.type === 'created')).toMatchObject({ sessionId }) send({ type: 'send', sessionId, uuid: randomUUID(), text: 'hello' }) expect(await until(() => frames.some((f) => f.type === 'sdk.result' && f.subtype === 'success'))).toBe(true) - expect(frames.find((f) => f.type === 'sdk.session.init')).toMatchObject({ sessionId, cliSessionId: expect.stringMatching(/^[0-9a-f-]{36}$/) }) + expect(frames.find((f) => f.type === 'sdk.session.init')).toMatchObject({ sessionId, cliSessionId: sessionId }) expect(frames.find((f) => f.type === 'sdk.result')).toMatchObject({ sessionId, result: 'success' }) expect(frames.find((f) => f.type === 'sdk.assistant')?.content).toEqual([{ type: 'text', text: 'ok' }]) }) diff --git a/test/integration/providers/claude-freshell-mcp.mjs b/test/integration/providers/claude-freshell-mcp.mjs index 2ed16a743..dad524c41 100644 --- a/test/integration/providers/claude-freshell-mcp.mjs +++ b/test/integration/providers/claude-freshell-mcp.mjs @@ -44,6 +44,7 @@ const receipt = { verdict: 'FAIL', sdkVersion: pinned.sdkVersion, bundledCliVersion: pinned.cliVersion, + sendRefusals: [], cliArgvHasMcpConfig: false, cliArgvHasToken: null, probe: null, @@ -87,6 +88,7 @@ try { let frame try { frame = JSON.parse(line) } catch { return } frames.push(frame) + if (frame.type === 'sdk.send.rejected') receipt.sendRefusals.push(frame) for (const waiter of [...waiters]) if (waiter.match(frame)) { waiters.splice(waiters.indexOf(waiter), 1); waiter.resolve(frame) } }) const waitFor = (match, ms, what) => new Promise((resolve, reject) => { @@ -99,14 +101,16 @@ try { failureStage = 'create' const requestId = randomUUID() + const nativeSessionId = randomUUID() write({ - type: 'create', protocol: 2, boot: { kind: 'fresh' }, requestId, cwd: fixture.cwd, model: FAKE_MODEL, + type: 'create', protocol: 2, boot: { kind: 'fresh' }, requestId, sessionId: nativeSessionId, cwd: fixture.cwd, model: FAKE_MODEL, permissionMode: 'bypassPermissions', hostedMcp: false, freshellMcp: { command: process.execPath, args: [PROBE_SERVER, reportFile] }, freshellContext: context, }) const created = await waitFor(frame => frame.requestId === requestId && (frame.type === 'created' || frame.type === 'create.failed'), 45_000, 'created') if (created.type !== 'created') throw new Error(`the sidecar refused the create: ${created.message}`) + if (created.sessionId !== nativeSessionId) throw new Error('the sidecar changed the requested native session identity') failureStage = 'cli argv' const argv = (await fs.readFile(`/proc/${created.cliPid}/cmdline`, 'utf8')).split('\0') @@ -115,7 +119,7 @@ try { receipt.cliArgvHasToken = argv.some(arg => arg.includes(token)) failureStage = 'turn' - write({ type: 'send', sessionId: created.sessionId, text: PROMPT }) + write({ type: 'send', sessionId: created.sessionId, uuid: randomUUID(), text: PROMPT }) const init = await waitFor(frame => frame.type === 'sdk.session.init', 45_000, 'the turn init') // The sidecar forwards the CLI's tool list as `{ name }` objects. receipt.toolNames = (init.tools ?? []).map(tool => (typeof tool === 'string' ? tool : tool?.name)).filter(name => String(name).startsWith('mcp__')) diff --git a/test/integration/providers/claude-freshell-mcp.test.ts b/test/integration/providers/claude-freshell-mcp.test.ts index 617899eb9..915f6ee14 100644 --- a/test/integration/providers/claude-freshell-mcp.test.ts +++ b/test/integration/providers/claude-freshell-mcp.test.ts @@ -52,6 +52,7 @@ describe('Freshell MCP in a Claude sidecar against the pinned CLI and a local pr expect(receipt, `${lines[0]}\n${stderr}`).toMatchObject({ sdkVersion: PINNED_SDK_VERSION, bundledCliVersion: PINNED_CLI_VERSION, + sendRefusals: [], cliArgvHasMcpConfig: true, cliArgvHasToken: false, probeToolListed: true, diff --git a/test/integration/providers/claude-live-frames.mjs b/test/integration/providers/claude-live-frames.mjs index 289417bff..f9a8bc802 100644 --- a/test/integration/providers/claude-live-frames.mjs +++ b/test/integration/providers/claude-live-frames.mjs @@ -160,9 +160,11 @@ try { const resultCount = () => frames.filter((frame) => frame.type === 'sdk.result').length const requestId = randomUUID() - write({ type: 'create', protocol: 2, boot: { kind: 'fresh' }, requestId, cwd, model: FAKE_MODEL, permissionMode: 'bypassPermissions' }) + const nativeSessionId = randomUUID() + write({ type: 'create', protocol: 2, boot: { kind: 'fresh' }, requestId, sessionId: nativeSessionId, cwd, model: FAKE_MODEL, permissionMode: 'bypassPermissions' }) const created = await waitFor((frame) => frame.requestId === requestId && (frame.type === 'created' || frame.type === 'create.failed'), 'created') if (created.type !== 'created') throw new Error(`the sidecar refused the create: ${created.message}`) + if (created.sessionId !== nativeSessionId) throw new Error('the sidecar changed the requested native session identity') const sessionId = created.sessionId poller = pollTranscripts() @@ -177,7 +179,7 @@ try { await waitFor(() => resultCount() > results, `the ${name} result`) scenarios[name].end = frames.length } - const send = (text) => write({ type: 'send', sessionId, text }) + const send = (text) => write({ type: 'send', sessionId, uuid: randomUUID(), text }) const configure = async (requestId, settings) => { write({ type: 'configure', sessionId, requestId, settings }) const answer = await waitFor((frame) => frame.type === 'sdk.configured' && frame.requestId === requestId, `configure ${requestId}`) diff --git a/test/integration/providers/claude-live-frames.test.ts b/test/integration/providers/claude-live-frames.test.ts index c557430eb..3dccfbf2d 100644 --- a/test/integration/providers/claude-live-frames.test.ts +++ b/test/integration/providers/claude-live-frames.test.ts @@ -80,6 +80,7 @@ const isAnnouncement = (frame: Frame) => frame.type === 'sdk.session.changed' && describe(`the Claude sidecar's live frames over the pinned CLI ${PINNED_CLI_VERSION} and a local provider`, () => { it('runs every scenario on the pinned SDK and CLI, and the sidecar then exits cleanly', () => { + expect(ofType(all(), 'sdk.send.rejected'), 'native command acceptance').toEqual([]) expect(receipt.failure, stderr.slice(-2000)).toBeUndefined() expect(receipt).toMatchObject({ sdkVersion: PINNED_SDK_VERSION, bundledCliVersion: PINNED_CLI_VERSION, fixtureRemoved: true, sidecarExit: { code: 0, signal: null } }) expect(Object.keys(receipt.scenarios)).toEqual(['text', 'edit', 'agent', 'retry', 'compact-ok', 'more', 'compact-fail', 'plan', 'configure-stated']) @@ -160,18 +161,28 @@ describe(`the Claude sidecar's live frames over the pinned CLI ${PINNED_CLI_VERS .toEqual([['warn', 1, 529], ['warn', 2, 529]]) }) - it('/compact: compacting, running again, the manual boundary and its transcript lines, then the result, with no notice', () => { + it('/compact: compacting, its successful null status, the manual boundary and its transcript lines, then the result, with no notice', () => { const list = frames('compact-ok') - const order = list.filter((frame) => ['sdk.status', 'sdk.compact_boundary', 'sdk.result', 'sdk.notice'].includes(frame.type)).map((frame) => frame.status ?? frame.trigger ?? frame.type) - expect(order).toEqual(['compacting', 'running', 'manual', 'sdk.result']) + const order = list.filter((frame) => ['sdk.status', 'sdk.compact_boundary', 'sdk.result', 'sdk.notice'].includes(frame.type)) + expect(order).toEqual([ + expect.objectContaining({ type: 'sdk.status', status: 'compacting' }), + expect.objectContaining({ type: 'sdk.status', status: null, compactResult: 'success' }), + expect.objectContaining({ type: 'sdk.compact_boundary', trigger: 'manual' }), + expect.objectContaining({ type: 'sdk.result', result: 'success' }), + ]) const boundary = list.findIndex((frame) => frame.type === 'sdk.compact_boundary') expect(list[boundary + 1]).toMatchObject({ type: 'sdk.session.changed', reason: 'transcript' }) }) - it('a /compact whose summary request fails: a COMPACTION_FAILED notice with the CLI\'s reason, running again, the result, no error frame', () => { + it('a /compact whose summary request fails: its failed null status, a COMPACTION_FAILED notice with the CLI\'s reason, then the result, no error frame', () => { const list = frames('compact-fail') - const order = list.filter((frame) => ['sdk.status', 'sdk.notice', 'sdk.result', 'sdk.error'].includes(frame.type)).map((frame) => frame.status ?? frame.code ?? frame.type) - expect(order).toEqual(['compacting', 'COMPACTION_FAILED', 'running', 'sdk.result']) + const order = list.filter((frame) => ['sdk.status', 'sdk.notice', 'sdk.result', 'sdk.error'].includes(frame.type)) + expect(order).toEqual([ + expect.objectContaining({ type: 'sdk.status', status: 'compacting' }), + expect.objectContaining({ type: 'sdk.status', status: null, compactResult: 'failed', compactError: expect.stringContaining('API Error: 400') }), + expect.objectContaining({ type: 'sdk.notice', code: 'COMPACTION_FAILED' }), + expect.objectContaining({ type: 'sdk.result', result: 'success' }), + ]) expect(ofType(list, 'sdk.notice')[0]).toMatchObject({ level: 'error', message: expect.stringMatching(/^Error during compaction: API Error: 400/) }) expect(receipt.logRecords.find((record) => record.code === 'COMPACTION_FAILED')).toMatchObject({ severity: 'error', error: expect.stringMatching(/^Error during compaction/) }) }) diff --git a/test/integration/providers/claude-permission-contract.mjs b/test/integration/providers/claude-permission-contract.mjs index 6348fcca1..2203bbb10 100644 --- a/test/integration/providers/claude-permission-contract.mjs +++ b/test/integration/providers/claude-permission-contract.mjs @@ -191,9 +191,11 @@ async function runScenario(name, spec) { }) const requestId = randomUUID() - send({ type: 'create', protocol: 2, boot: { kind: 'fresh' }, requestId, cwd: fixture.cwd, model: FAKE_MODEL, permissionMode: 'default' }) + const nativeSessionId = randomUUID() + send({ type: 'create', protocol: 2, boot: { kind: 'fresh' }, requestId, sessionId: nativeSessionId, cwd: fixture.cwd, model: FAKE_MODEL, permissionMode: 'default' }) const created = await waitFor((frame) => frame.requestId === requestId && (frame.type === 'created' || frame.type === 'create.failed'), 'created') if (created.type !== 'created') throw new Error(`the sidecar refused the create: ${created.message}`) + if (created.sessionId !== nativeSessionId) throw new Error('the sidecar changed the requested native session identity') sessionId = created.sessionId if (spec.plan) { send({ type: 'configure', sessionId, requestId: 'to-plan', settings: { permissionMode: 'plan' } }) @@ -205,7 +207,7 @@ async function runScenario(name, spec) { state.step = 0 const results = frames.filter((frame) => frame.type === 'sdk.result').length log('info', 'turn.start', { scenario: name, turn }) - send({ type: 'send', sessionId, text: `contract turn ${turn + 1}` }) + send({ type: 'send', sessionId, uuid: randomUUID(), text: `contract turn ${turn + 1}` }) await waitFor(() => frames.filter((frame) => frame.type === 'sdk.result').length > results, `the result of turn ${turn + 1}`) } // A status the CLI reports after a result still reaches the sidecar. diff --git a/test/integration/providers/claude-permission-contract.test.ts b/test/integration/providers/claude-permission-contract.test.ts index 2ef6fedf7..663ba4017 100644 --- a/test/integration/providers/claude-permission-contract.test.ts +++ b/test/integration/providers/claude-permission-contract.test.ts @@ -93,6 +93,7 @@ describe(`Claude permission suggestions and plan approvals over the pinned CLI $ 'remember-local', 'remember-session', 'shapes', 'plan-default', 'plan-accept-edits', 'plan-bypass', 'plan-file', 'plan-empty', ]) for (const name of Object.keys(receipt.scenarios)) { + expect(ofType(receipt.scenarios[name]!.frames, 'sdk.send.rejected'), `${name}: the native send was accepted`).toEqual([]) expect(scenario(name), name).toMatchObject({ fixtureRemoved: true, sidecarExit: { code: 0, signal: null } }) expect(ofType(scenario(name).frames, 'sdk.error'), name).toEqual([]) } From 2a469dcbb3ce06972b19cd76e8815a212571aaa5 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:53:47 -0700 Subject: [PATCH 097/100] fix(opencode): release revival ownership before session waiters --- crates/freshell-freshagent/src/opencode_ws.rs | 252 ++++++++++++++---- 1 file changed, 205 insertions(+), 47 deletions(-) diff --git a/crates/freshell-freshagent/src/opencode_ws.rs b/crates/freshell-freshagent/src/opencode_ws.rs index 0de554c5e..c2c2f06b3 100644 --- a/crates/freshell-freshagent/src/opencode_ws.rs +++ b/crates/freshell-freshagent/src/opencode_ws.rs @@ -271,6 +271,11 @@ pub struct FreshOpencodeState { /// `Started`), mirroring the freshcodex onExit self-heal. See /// [`Self::ensure_daemon_loss_watcher`]. daemon_loss_watcher: Arc>, + /// Test-only pause after a revival restart, before its ownership guard releases. + #[cfg(test)] + revival_completion_pause: Arc>>, + #[cfg(test)] + daemon_loss_watcher_bootstrapped: Arc, /// ep2-r3 test seam (`cfg(test)`-only): when armed for a durable id, the /// post-commit rescue tail parks BETWEEN its map re-lookup clone and the /// session-lock take — the exact window the ep2-r3 fresheyes Major pins @@ -302,6 +307,13 @@ struct RescueStallGate { release_rx: StdMutex>>, } +#[cfg(test)] +struct RevivalCompletionPause { + session_id: String, + entered: tokio::sync::oneshot::Sender<()>, + release: tokio::sync::oneshot::Receiver<()>, +} + /// The handles [`FreshOpencodeState::arm_rescue_stall`] hands the test /// (ep2-r3 test seam, `cfg(test)`-only): `entered` fires at the parked point; /// `release` lets the rescue proceed. @@ -860,6 +872,10 @@ impl FreshOpencodeState { condemned_sessions: Arc::new(std::sync::Mutex::new(HashMap::new())), daemon_loss_watcher: Arc::new(std::sync::OnceLock::new()), #[cfg(test)] + revival_completion_pause: Arc::new(StdMutex::new(None)), + #[cfg(test)] + daemon_loss_watcher_bootstrapped: Arc::new(tokio::sync::Notify::new()), + #[cfg(test)] rescue_stall: Arc::new(StdMutex::new(None)), #[cfg(test)] rescue_lookup_stall: Arc::new(StdMutex::new(None)), @@ -8202,6 +8218,8 @@ impl FreshOpencodeState { // history — a daemon that re-warmed before this subscription // must still get its dead bridges revived now. state.revive_dead_bridges_if_daemon_running().await; + #[cfg(test)] + state.daemon_loss_watcher_bootstrapped.notify_waiters(); loop { match signals.recv().await { Ok(DaemonSignal::Lost { reason }) => { @@ -8358,10 +8376,29 @@ impl FreshOpencodeState { // lock; the snapshot push goes ONLY to actually-restarted // bridges. `Ok(None)` (bridge alive against the CURRENT daemon / // unmaterialized) is the quiet no-op. - let restarted = { - let mut session = session_arc.lock().await; - self.restart_session_bridge_guarded(&mut session).await - }; + // Keep publication inside the same session/ownership window. Tuple + // fields drop in order, so ownership releases before the session + // mutex wakes a waiting kill, including when this future is cancelled. + let mut guards = (adopt_guard, session_arc.lock().await); + let restarted = self.restart_session_bridge_guarded(&mut guards.1).await; + #[cfg(test)] + { + let pause = { + let mut gate = self.revival_completion_pause.lock().unwrap(); + if gate + .as_ref() + .is_some_and(|pause| pause.session_id == durable) + { + gate.take() + } else { + None + } + }; + if let Some(pause) = pause { + let _ = pause.entered.send(()); + let _ = pause.release.await; + } + } match restarted { Ok(Some(real_id)) => { self.broadcast(&event_frame(&real_id, snapshot_event(&real_id, "idle"))); @@ -8376,8 +8413,7 @@ impl FreshOpencodeState { ); } } - // The guard covered the restart; release the window. - drop(adopt_guard); + drop(guards); } } @@ -9104,6 +9140,7 @@ mod tests { next_session: AtomicUsize, prompts: StdMutex>, aborts: StdMutex>, + prompt_dispatched: tokio::sync::Notify, } impl AbortRecordingHttp { fn session_of(url: &str, marker: &str) -> Option { @@ -9113,18 +9150,20 @@ mod tests { .next() .map(str::to_string) } - fn await_prompt(&self, budget: Duration) -> String { - let deadline = std::time::Instant::now() + budget; - loop { - if let Some(id) = self.prompts.lock().unwrap().first().cloned() { - return id; + async fn await_prompt(&self, budget: Duration) -> String { + tokio::time::timeout(budget, async { + loop { + let dispatched = self.prompt_dispatched.notified(); + tokio::pin!(dispatched); + dispatched.as_mut().enable(); + if let Some(id) = self.prompts.lock().unwrap().first().cloned() { + return id; + } + dispatched.await; } - assert!( - std::time::Instant::now() < deadline, - "the prompt POST was never dispatched" - ); - std::thread::sleep(Duration::from_millis(5)); - } + }) + .await + .expect("the prompt POST was never dispatched") } } impl ServeHttp for AbortRecordingHttp { @@ -9152,6 +9191,7 @@ mod tests { && matches!(req.method, freshell_opencode::serve::HttpMethod::Post) { self.prompts.lock().unwrap().push(id); + self.prompt_dispatched.notify_one(); } else if req.url.contains("/abort") && matches!(req.method, freshell_opencode::serve::HttpMethod::Post) { @@ -9163,6 +9203,46 @@ mod tests { } } + async fn abort_recording_state() -> ( + FreshOpencodeState, + Arc, + Arc, + tokio::sync::broadcast::Receiver, + ) { + let (tx, rx) = tokio::sync::broadcast::channel::(64); + let fresh_agent = FreshAgentState::new(Arc::new("tok".to_string()), Arc::new(tx)); + let http = Arc::new(AbortRecordingHttp::default()); + let deps = ServeDeps { + spawner: Arc::new(TrackedSpawner { + killed: Arc::new(std::sync::atomic::AtomicBool::new(false)), + }), + http: http.clone(), + ports: Arc::new(FakeAllocator), + events: Arc::new(NoopEventSource), + }; + let manager = OpencodeServeManager::new(deps, ServeConfig::default()); + manager + .ensure_started() + .await + .expect("healthy fake serve starts"); + fresh_agent.set_manager_for_test(manager).await; + let mut state = FreshOpencodeState::new(fresh_agent); + let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); + state.set_ownership(registry.clone()); + // Let the real watcher finish its initial pass while the map is empty, + // before this fixture creates the accepted daemon-side turn under test. + { + let ready = state.daemon_loss_watcher_bootstrapped.notified(); + tokio::pin!(ready); + ready.as_mut().enable(); + state.ensure_daemon_loss_watcher().await; + tokio::time::timeout(Duration::from_secs(10), ready) + .await + .expect("the initial daemon recovery pass completed"); + } + (state, registry, http, rx) + } + /// A `ServeProcess` fake that records whether it was ever killed, so tests can /// assert the SHARED sidecar survives a per-session `freshAgent.kill`. struct TrackedProcess { @@ -11746,26 +11826,7 @@ mod tests { /// committed the stop with the daemon turn still running. #[tokio::test(flavor = "multi_thread")] async fn a_kill_during_an_accepted_daemon_turn_aborts_it_before_the_stop_commits() { - let (tx, mut rx) = tokio::sync::broadcast::channel::(64); - let fresh_agent = FreshAgentState::new(Arc::new("tok".to_string()), Arc::new(tx)); - let http = Arc::new(AbortRecordingHttp::default()); - let deps = ServeDeps { - spawner: Arc::new(TrackedSpawner { - killed: Arc::new(std::sync::atomic::AtomicBool::new(false)), - }), - http: http.clone(), - ports: Arc::new(FakeAllocator), - events: Arc::new(NoopEventSource), - }; - let manager = OpencodeServeManager::new(deps, ServeConfig::default()); - manager - .ensure_started() - .await - .expect("healthy fake serve starts"); - fresh_agent.set_manager_for_test(manager).await; - let mut st = FreshOpencodeState::new(fresh_agent); - let registry = Arc::new(freshell_ownership::RuntimeOwnershipRegistry::new()); - st.set_ownership(registry.clone()); + let (st, registry, http, mut rx) = abort_recording_state().await; st.handle_create(create_msg("req-kill-daemon-abort"), None) .await; @@ -11785,9 +11846,18 @@ mod tests { // witness armed at the dispatch boundary); then the LOCAL task is // settled — the IdleTimeout shape (a finished local task with the // daemon-side turn still running). - let real_id = http.await_prompt(Duration::from_secs(10)); + let real_id = http.await_prompt(Duration::from_secs(10)).await; assert_eq!(real_id, "ses_1"); st.settle_local_turn_task_for_test(placeholder).await; + assert!( + session_arc + .lock() + .await + .daemon_turn_accepted + .load(Ordering::SeqCst), + "fixture: the daemon-side turn is still accepted after local settlement" + ); + let owner_before_kill = registry.observe("opencode", "ses_1"); while rx.try_recv().is_ok() {} st.handle_kill(FreshAgentKill { @@ -11800,6 +11870,14 @@ mod tests { }) .await; + let mut killed_frame = None; + while let Ok(raw) = rx.try_recv() { + let frame: serde_json::Value = serde_json::from_str(&raw).unwrap(); + if frame["type"] == "freshAgent.killed" { + killed_frame = Some(frame); + } + } + let killed_frame = killed_frame.expect("the kill answers freshAgent.killed"); // THE R3-2 regression: the kill issued the per-session daemon-side // abort for the REAL session id before declaring the reap // confirmed. @@ -11807,7 +11885,7 @@ mod tests { assert_eq!( aborts, vec!["ses_1".to_string()], - "the kill must abort the accepted daemon-side turn (per-session /abort)" + "the kill must abort the accepted daemon-side turn (per-session /abort); owner={owner_before_kill:?}; answer={killed_frame}" ); // The stop committed AFTER the abort: Stopping → Vacant. assert_eq!( @@ -11816,20 +11894,100 @@ mod tests { "the kill's coordinator stop must commit (Stopping → Vacant)" ); // The answer reports success (the quiesced kill). - let mut killed_frame = None; - while let Ok(raw) = rx.try_recv() { - let frame: serde_json::Value = serde_json::from_str(&raw).unwrap(); - if frame["type"] == "freshAgent.killed" { - killed_frame = Some(frame); - } - } - let killed_frame = killed_frame.expect("the kill answers freshAgent.killed"); assert_eq!( killed_frame["success"], true, "the quiesced kill answers success: {killed_frame}" ); } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_kill_waits_for_daemon_revival_to_release_its_ownership_guard() { + for restart_bridge in [false, true] { + let (st, registry, http, mut rx) = abort_recording_state().await; + // Drive the real revival pass explicitly after materialization, + // controlling the same ordering a delayed background pass can reach. + st.handle_create(create_msg("req-kill-revival"), None).await; + let placeholder = "freshopencode-req-kill-revival"; + st.handle_send(send_msg(placeholder, "accepted in the shared daemon")) + .await; + assert_eq!(http.await_prompt(Duration::from_secs(10)).await, "ses_1"); + st.settle_local_turn_task_for_test(placeholder).await; + if restart_bridge { + let session = st.sessions.lock().await[placeholder].clone(); + session.lock().await.serve_bridge.take().unwrap().abort(); + } + let _ = drain_frames(&mut rx); + + let (entered_tx, entered_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel(); + *st.revival_completion_pause.lock().unwrap() = Some(RevivalCompletionPause { + session_id: "ses_1".into(), + entered: entered_tx, + release: release_rx, + }); + let revival = { + let state = st.clone(); + tokio::spawn(async move { state.revive_dead_bridges_if_daemon_running().await }) + }; + tokio::time::timeout(Duration::from_secs(10), entered_rx) + .await + .unwrap() + .unwrap(); + let kill = tokio::task::unconstrained(st.handle_kill(FreshAgentKill { + provider: freshell_protocol::AgentProvider::Opencode, + session_id: placeholder.into(), + session_type: SessionType::Freshopencode, + observed_epoch: None, + observed_generation: None, + cwd: None, + })); + tokio::pin!(kill); + let waiting = std::future::poll_fn(|cx| { + std::task::Poll::Ready(std::future::Future::poll(kill.as_mut(), cx).is_pending()) + }) + .await; + assert!( + waiting, + "kill escaped the guarded session before revival released ownership: {:?}", + drain_frames(&mut rx) + ); + assert!(http.aborts.lock().unwrap().is_empty()); + + release_tx.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(10), revival) + .await + .unwrap() + .unwrap(); + tokio::time::timeout(Duration::from_secs(10), kill) + .await + .unwrap(); + assert_eq!(*http.aborts.lock().unwrap(), vec!["ses_1".to_string()]); + assert_eq!( + registry.observe("opencode", "ses_1").state, + freshell_ownership::OwnershipState::Vacant + ); + let frames = drain_frames(&mut rx); + let killed = frames + .iter() + .position(|frame| frame["type"] == "freshAgent.killed" && frame["success"] == true) + .expect("the quiesced kill succeeds"); + let snapshot = frames + .iter() + .position(|frame| frame["event"]["type"] == "freshAgent.session.snapshot"); + if restart_bridge { + assert!( + snapshot.expect("the restarted bridge publishes its snapshot") < killed, + "publication precedes the completed kill: {frames:?}" + ); + } else { + assert!( + snapshot.is_none(), + "a healthy bridge needs no restart publication" + ); + } + } + } + /// Delta-r6-r4 (focused-episode-6 round 3, Finding 3), the opencode /// lane's PERSISTED class on the pre-lock envelope: the whole identity /// set's journal record IS durable although its write reports failure. From c1007157ac1df289016bcca28ce3938298676045 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:54:31 -0700 Subject: [PATCH 098/100] chore(rust): replace deprecated atomic update calls --- crates/freshell-freshagent/src/session_handoff.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/freshell-freshagent/src/session_handoff.rs b/crates/freshell-freshagent/src/session_handoff.rs index c6de26542..7c5648188 100644 --- a/crates/freshell-freshagent/src/session_handoff.rs +++ b/crates/freshell-freshagent/src/session_handoff.rs @@ -3245,7 +3245,7 @@ impl SessionHandoffRunner { .load(std::sync::atomic::Ordering::SeqCst) && hooks .force_reap_timeout_fenced_skip - .fetch_update( + .try_update( std::sync::atomic::Ordering::SeqCst, std::sync::atomic::Ordering::SeqCst, |n| if n == 0 { None } else { Some(n - 1) }, @@ -3261,7 +3261,7 @@ impl SessionHandoffRunner { .load(std::sync::atomic::Ordering::SeqCst) && hooks .force_platform_limited_skip - .fetch_update( + .try_update( std::sync::atomic::Ordering::SeqCst, std::sync::atomic::Ordering::SeqCst, |n| if n == 0 { None } else { Some(n - 1) }, From 1b21bae785ec16055297bc96536976030eb45d1e Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:54:32 -0700 Subject: [PATCH 099/100] docs: explain sandbox selection for agent test runs --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 818132866..c563d59b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,7 @@ Package manager: this repo runs on pnpm, pinned exactly (see [Package Manager (p ## Destructive Test Sandbox - Process-kill, config-corruption, and restart-storm suites run inside a disposable Docker sandbox, never directly on host: `scripts/sandbox-test.sh ""` (or `pnpm run test:sandbox ""`). +- Set `FRESHELL_RUST_TEST_SANDBOX=1` for agent-launched `pnpm run test`, `check`, `verify`, `test:rust`, and `test:source-runtime`; the standard Rust and source-runtime runners honor it. Direct Cargo lanes (`test:server` and `test:integration`) still require the sandbox wrapper. - See `docs/development/test-sandbox.md` for the safety guarantees, the `--corpus` read-only real-data flag, and cache-volume management. ## Amplifier Skills Deployment (machine note) From b6fb292c08a1e2ea0cf71724d5f641684eeaece1 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:45:23 -0700 Subject: [PATCH 100/100] test(codex): confirm fixture death before crash recovery --- crates/freshell-freshagent/src/codex.rs | 103 +++++++++++++++++++----- 1 file changed, 81 insertions(+), 22 deletions(-) diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index e8a158b11..2e0bbc63c 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -23451,57 +23451,103 @@ pub(crate) mod tests { #[tokio::test] async fn crash_recovery_resume_wrong_thread_id_is_rejected_not_silently_recovered() { let _guard = ENV_LOCK.lock().await; - configure_fake_codex_cmd(r#"{"threadResumeThreadId":"thread-B-wrong"}"#); + let log_dir = tempfile::tempdir().unwrap(); + let operation_log = log_dir.path().join("thread-operations.jsonl"); + configure_fake_codex_cmd( + &json!({ + "threadResumeThreadId": "thread-B-wrong", + "appendThreadOperationLogPath": operation_log, + }) + .to_string(), + ); let (st, mut rx) = state_with_bus(); - // A dead in-process client + a child that exits immediately: the exit-watcher - // flips `exited`, so the next attach takes the crash-recovery resume path. + // Keep the owned child alive until its watcher captures the process + // identity. An immediately exiting `true` can leave an unconfirmable + // prior, which correctly refuses recovery before thread/resume. + let ownership_id = "codex-sidecar-test-term25-recovery"; let (transport, _peer) = freshell_codex::new_channel_transport(); let (client, _notifs) = CodexAppServerClient::connect(transport); - let mut cmd = tokio::process::Command::new("true"); - cmd.kill_on_drop(true); - let child = cmd.spawn().expect("spawn true fixture"); + let mut cmd = tokio::process::Command::new("node"); + cmd.args([ + "-e", + "process.stdin.resume(); process.stdin.once('end', () => process.exit(0)); process.stdout.write('ready\\n');", + ]) + .env(CODEX_SIDECAR_OWNERSHIP_ENV, ownership_id) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .kill_on_drop(true); + let mut child = cmd.spawn().expect("spawn controlled crash fixture"); + let exit_gate = child.stdin.take().expect("fixture exit gate"); + let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap()); + let mut ready = String::new(); + tokio::time::timeout( + std::time::Duration::from_secs(5), + tokio::io::AsyncBufReadExt::read_line(&mut output, &mut ready), + ) + .await + .expect("the fixture becomes ready within the budget") + .expect("read fixture readiness"); + assert_eq!(ready, "ready\n"); insert_fake_session( &st, "thread-A-crashed", Arc::new(client), Arc::new(StdMutex::new(None)), child, - "codex-sidecar-test-term25-recovery", + ownership_id, ) .await; - + // EOF makes the child exit on its own: this exercises the real + // unrequested-exit watcher, never the requested kill channel. + drop(exit_gate); tokio::time::timeout(std::time::Duration::from_secs(5), async { loop { - let exited = st - .sessions - .lock() - .await - .get("thread-A-crashed") - .map(|s| s.exited.load(Ordering::SeqCst)) - .unwrap_or(false); - if exited { - return; + let frame: Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap(); + if frame["sessionId"] == "thread-A-crashed" + && frame["event"]["type"] == "freshAgent.status" + && frame["event"]["status"] == "exited" + { + break; } - tokio::time::sleep(std::time::Duration::from_millis(50)).await; } }) .await - .expect("the exit-watcher observes the crash within the budget"); - + .expect("the real exit-watcher observes the crash within the budget"); + assert!(st.sessions.lock().await["thread-A-crashed"] + .exited + .load(Ordering::SeqCst)); + let prior_before_attach = st + .condemned_priors + .lock() + .unwrap() + .get("thread-A-crashed") + .cloned(); + assert!(prior_before_attach.is_none(), "the fixture's writer death must be confirmed before testing resume: {prior_before_attach:?}"); st.handle_attach(attach_msg("thread-A-crashed")).await; + let mut observed = Vec::new(); let frame: Value = tokio::time::timeout(std::time::Duration::from_secs(15), async { loop { let frame: Value = serde_json::from_str(&rx.recv().await.unwrap()).unwrap(); - if frame["type"] == "error" { + observed.push(frame.clone()); + if frame["type"] == "error" + || frame["event"]["type"] == "freshAgent.error" + || frame["event"]["type"] == "freshAgent.session.snapshot" + { return frame; } } }) .await - .expect("attach resolves within the budget"); + .unwrap_or_else(|error| { + panic!("attach resolves within the budget: {error:?}; frames={observed:?}") + }); + assert_eq!( + frame["type"], "error", + "the wrong-thread resume is rejected: {frame}" + ); let message = frame["message"].as_str().unwrap_or_default(); assert!( message.starts_with("CODEX_ATTACH_RESPAWN_FAILED:"), @@ -23515,6 +23561,19 @@ pub(crate) mod tests { !st.sessions.lock().await.contains_key("thread-B-wrong"), "the wrong thread id must never be adopted" ); + let operations: Vec = std::fs::read_to_string(operation_log) + .expect("the fake records real thread operations before answering") + .lines() + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!( + operations.len(), + 1, + "recovery never silently starts a fresh thread: {operations:?}" + ); + assert_eq!(operations[0]["method"], "thread/resume"); + assert_eq!(operations[0]["params"]["threadId"], "thread-A-crashed"); + assert_eq!(operations[0]["threadId"], "thread-B-wrong"); } // -- dead-thread negative cache (CODEX-FIRST triage Finding 2) --