Skip to main content

hatchery_node/
server.rs

1use crate::git_worktree::{
2    create_worktree as create_git_worktree,
3    create_worktree_with_timeout as create_git_worktree_with_timeout,
4    list_worktrees as list_git_worktrees,
5    list_worktrees_with_deadline as list_git_worktrees_with_deadline,
6    paths_equal as worktree_paths_equal, remove_worktree as remove_git_worktree,
7    removal_lookup_path as normalize_worktree_removal_target,
8    resolve_base_commit_with_timeout,
9    run_git_read_bounded, GitCommandOutput, GitWorktreeError, GitWorktreeErrorKind,
10    NativeGitWorktreeSnapshot,
11};
12use crate::host_directory_browser::{
13    browse_host_directories, HostDirectoryBrowseError, HostDirectoryBrowseErrorKind,
14};
15use crate::environment_profiles::{
16    EnvironmentProfileBinding, NodeEnvironmentProfile, MAX_NODE_ENVIRONMENT_PROFILES,
17};
18use crate::bundle_catalog::{BundleCatalog, NodeBundle};
19use self::bundle_delivery::{DeliveryStore, DeliveryStoreError};
20use self::context_pack_store::ContextPackStore;
21use crate::bundle_provider::{
22    bundle_launch_arguments, validate_bundle_binding, BundleProviderLayout,
23};
24use crate::context_pack::{
25    ContextPackCatalog, ContextPackRepository, ContextPackRepositoryFileSource,
26    ContextPackSelectedFileSkipReason, NodeContextPack, CONTEXT_PACK_SELECTED_FILES,
27};
28use crate::harness_mcp_proxy::{
29    HarnessMcpProxyError, HarnessMcpProxyRegistry, PreparedHarnessMcpSpawn,
30    ReviewedHarnessMcpProgram,
31};
32use crate::session_environment::{
33    MaterializationId, MaterializationOwner, MaterializationOwnershipRecord,
34    MaterializationState, NodeSecretResolver, NodeSessionMaterializationProfile,
35    SessionEnvironmentMaterializeError, SessionEnvironmentMaterializer,
36};
37use crate::worktree_service::{
38    exact_created_worktree, exact_owned_worktree, ManagedWorktreeLeaseRecord,
39    ManagedWorktreeProfile, ManagedWorktreeRegistry, ManagedWorktreeSessionHolder,
40};
41use crate::session_registry::{
42    self, validate_display_name, LoadedNodeState, ManagedWorktreeSpawnReplayRecordV10,
43    ManagedWorktreeSpawnReplayStateV10, MAX_MANAGED_SESSION_RECORDS,
44    MAX_MANAGED_WORKTREE_SPAWN_REPLAYS,
45};
46#[cfg(unix)]
47use crate::workspace_file_unix::{
48    create_workspace_directory as create_workspace_directory_on_disk,
49    create_workspace_file as create_workspace_file_on_disk,
50    read_workspace_file as read_workspace_file_from_disk,
51    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
52    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
53    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
54    WORKSPACE_ENTRY_CREATE_PENDING,
55};
56#[cfg(windows)]
57use crate::workspace_file_windows::{
58    create_workspace_directory as create_workspace_directory_on_disk,
59    create_workspace_file as create_workspace_file_on_disk,
60    read_workspace_file as read_workspace_file_from_disk,
61    write_workspace_file as write_workspace_file_to_disk, WorkspaceFileBytes,
62    WorkspaceFileReadError, WorkspaceFileReadErrorKind,
63    WORKSPACE_ENTRY_CREATE_CANCELED, WORKSPACE_ENTRY_CREATE_COMMITTING,
64    WORKSPACE_ENTRY_CREATE_PENDING,
65};
66use crate::platform;
67use crate::provider_runtime::{
68    require_policy, ProviderRuntimeAdmissionError, ProviderRuntimeMonitor,
69    ProviderRuntimeRequirement,
70};
71use crate::spawn_spec::SpawnProfileRegistry;
72use crate::standalone_workspace::{
73    prepare_standalone_workspace, StandaloneWorkspaceError, StandaloneWorkspaceErrorKind,
74};
75use crate::protocol::{
76    read_json_frame_limited_body_timeout, write_json_frame, write_json_frame_limited,
77    validate_node_negotiated_handshake_capacity, validate_provider_contract_manifest,
78    provider_id_is_legacy, AdapterContractRevision, AgentStreamChunkKindV1, AgentStreamChunkV1,
79    AgentStreamInteractionOptionV1, AgentStreamNamedIdV1,
80    CapabilityId, ClientFrame, ClientRole,
81    ControllerState, DeliveryBlobChunkHexV1, DeliveryBlobDigestV1,
82    DeliveryBundleManifestV2, DeliveryCommitReceiptV1, DeliveryStageId,
83    AgentProgressAttentionKindV1, AgentProgressAttentionV1, AgentProgressCurrentV1,
84    AgentProgressEventKindV1, AgentProgressUsageV1, AgentProgressV1, FrameError,
85    GitCommitDetails, GitDiff, GitDiffMode, GitDiffRequest, GitHistoryPage,
86    GitObjectId, GitCommitSummary, GitSignatureStatus, GitSnapshot, GitStatusEntry,
87    GitWorktreeSnapshot, HarnessMcpActivationDigest, HarnessMcpReservationId,
88    HostDirectoryListing,
89    NodeCompatibilitySupport, NodeEvent,
90    NodeEventEnvelope, NodeFailure, NodeFailureCode, NodeHello, NodeId, NodeIncarnationId,
91    NegotiatedNodeCompatibility, NodeRequest, NodeResponse, NodeSnapshot, OpaqueHostPath,
92    ProtocolRange,
93    ProviderAdapterContractSupport,
94    ProviderContractRevision, ProviderContractSupport, RepositoryPath, RequestEnvelope,
95    ProviderRuntimeStatuses, ResolvedBundleReceipt, ResolvedEnvironmentProfileReceipt,
96    ContextPackLineageReceipt, ContextPackBytesRead, ResolvedContextPackReceipt,
97    ResolvedHarnessMcpProxyReceiptV1, ResolvedSpawnReceipt,
98    ResolvedSpawnSpec, ResponseEnvelope,
99    ServerChallenge, ServerFrame, SessionAddress, SessionAgentProgress, SessionKey, SessionMode,
100    ManagedSessionRecord,
101    ManagedSessionState, ManagedWorktreeCleanupFailure, ManagedWorktreeLeaseId,
102    SessionRecordRetentionConfig,
103    ManagedWorktreeLeaseSnapshot, ManagedWorktreeLeaseState, ManagedWorktreeRetention,
104    ManagedWorktreeSpawnReceipt, ManagedWorktreeSpawnRequest,
105    ManagedWorktreeSpawnRequestV2, SessionRecordId,
106    NativeSessionCatalogRoute, NativeSessionSelection, SessionTaskBindingV1,
107    SessionTaskTargetV1, TaskId,
108    BlockAuthorityV1, HarnessMcpLaunchV1, SessionHistorySummaryV1, MAX_ACP_BLOCKED_HELP_BYTES, MAX_ACP_BLOCKED_REASON_BYTES,
109    StateSchemaSupport, WorkspaceEntry, WorkspaceEntryKind, WorktreeProfileId,
110    SpawnContextId, SpawnEnvironmentProfileId, SpawnIdempotencyKey,
111    SpawnProfileDefaults, SpawnRequiredCapabilities, SpawnSpec, SpawnSpecResolveError,
112    WorkspaceFileContent, WorkspaceFileRevision,
113    WorkspaceFileRead, WorkspaceId, WorkspaceInspection, WorkspaceInspectionTruncationV1,
114    WorkspaceSnapshot,
115    WorktreeServiceMode,
116    LaunchInventory, ManagedWorktreeProfileSummary, SpawnProfileSummary,
117    WorktreeProfileInventory, MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
118    DEFAULT_CONTROLLER_LEASE_MS,
119    MAX_CONTROLLER_LEASE_MS, MIN_CONTROLLER_LEASE_MS, NODE_COMPATIBILITY_METADATA_CAPABILITY,
120    CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
121    NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY, NODE_REPOSITORY_PATH_CAPABILITY,
122    NODE_PROVIDER_ID_OPEN_CAPABILITY,
123    NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
124    NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY, NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
125    NODE_HISTORY_CONTEXT_PACK_CAPABILITY, NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
126    NODE_NATIVE_SESSION_CATALOG_CAPABILITY, NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
127    NODE_NATIVE_SESSION_INDEX_CAPABILITY, NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
128    NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
129    NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY,
130    NODE_HARNESS_MCP_READ_PROXY_CAPABILITY,
131    NODE_SESSION_TASK_CORRELATION_CAPABILITY,
132    NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY,
133    NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
134    NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
135    NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
136    NODE_SPAWN_PROFILE_REVISION_CAPABILITY, NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
137    NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
138    NODE_AGENT_STREAM_EVENTS_CAPABILITY, NODE_ACP_CONTROL_CAPABILITY,
139    NODE_GIT_READ_CAPABILITY, NODE_WORKSPACE_FILE_READ_CAPABILITY,
140    NODE_WORKSPACE_FILE_WRITE_CAPABILITY, NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
141    NODE_WORKTREE_SELECTION_CAPABILITY,
142    BUILD_STAMP, MAX_NODE_CLIENT_FRAME_BYTES, MAX_NODE_HELLO_FRAME_BYTES,
143    MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS, MAX_AGENT_PROGRESS_ENTRY_BYTES,
144    MAX_NODE_TERMINAL_BYTES, MAX_NODE_TEXT_BYTES,
145    MAX_REPOSITORY_PATH_BYTES,
146    MAX_GIT_DIFF_BYTES, MAX_GIT_HISTORY_COMMITS, MAX_WORKSPACE_FILE_BYTES,
147    MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE,
148    MAX_WORKSPACE_ROOT_BYTES, NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10,
149    SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY, SPAWN_RUNTIME_RAW_PTY_LIFECYCLE,
150    SPAWN_RUNTIME_SEMANTIC_READINESS, SPAWN_RUNTIME_SEMANTIC_RESUME,
151    SPAWN_RUNTIME_STRUCTURED_PROMPT,
152};
153#[cfg(test)]
154use crate::protocol::WorktreeProfileRevision;
155use ring::digest::{digest, SHA256};
156use crate::protocol::correlation::{
157    interaction_correlation, sanitize_progress_tool_label, tool_class_label, tool_correlation,
158    truncate_text,
159};
160use gate4agent_catalog::{builtin_registry, AgentRegistry};
161use gate4agent_handle::{EventSubscription, Gate4AgentHandle, PortDispatchError};
162use gate4agent_runtime_native::{
163    NativeMcpServerLaunchOverlay, NativeInstanceLaunchOverlay,
164    NativeLaunchEnvironmentOverlay,
165    NativeHistoryConfig, NativeLaunchProfileControl, NativeRuntime, NativeRuntimeConfig,
166    NativeSessionCatalogAuthority, NativeSessionCatalogError, NativeSessionPreviewError,
167    ScopedNativeSessionCatalogEntry,
168};
169use gate4agent_runtime_native::shell_efficiency::ShellEfficiencyProfile;
170use std::sync::OnceLock;
171use gate4agent_runtime_native::tick_profile::TickProfileSnapshot;
172use drive_loop_profile::DriveLoopProfiler;
173#[cfg(test)]
174use gate4agent_runtime_native::{HistorySourceLayout, NativeHistoryRoot};
175use gate4agent_node_wire::{
176    auth_proof, negotiated_auth_proof, proofs_match, random_incarnation_id, random_nonce,
177    AuthDirection, OwnerOnlyLocalListener,
178};
179use gate4agent_types::{
180    AdapterBinding, AdapterFamily, AgentAdapterCapabilities, AgentCapabilities, AgentId,
181    AgentInstanceId, AgentReadinessSpec, AgentSpec, AgentTransportCapabilities, ApprovalLevel,
182    DetectionSpec, InitialPromptMode, LaunchSpec, ProcessMatcher, PromptSpec, SpecVerification,
183    CommandEnvelope, CommandId,
184    validate_candidate_id, ControlCommand, ControlEvent, ControlEventKind,
185    HistoryCandidateSummary, HistoryOperation, HistoryQuery, HistorySessionRecord,
186    HostDecisionAuthority as ProviderHostDecisionAuthority,
187    HostRequestDecision as ProviderHostRequestDecision,
188    InputAction,
189    PromptFraming, PromptPayload, ResumeLaunchRequest,
190    ProviderActivity, ProviderEvent, ProviderInteractionKind, ProviderInteractionStatus,
191    ProviderRuntimeCapability, ProviderRuntimePolicy, ProviderSessionIdentity, ProviderSessionKey,
192    ProviderSnapshot, ProviderStopReason,
193    ResumeTarget,
194    SessionGeneration, StartRequest,
195    TerminalControl, TerminalText,
196    SessionStatus, TerminalFrame, TransportKind, CONTROL_SESSIONS_MAX,
197    HISTORY_DISCOVERY_LIMIT_MAX, WORKING_DIRECTORY_MAX_BYTES,
198};
199use std::collections::{BTreeMap, VecDeque};
200use std::ffi::OsString;
201use std::io;
202use std::path::{Path, PathBuf};
203use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU64, AtomicUsize, Ordering};
204use std::sync::{Arc, Mutex, RwLock};
205use std::time::{Duration, Instant};
206use thiserror::Error;
207use tokio::sync::{broadcast, mpsc, Mutex as AsyncMutex, Notify, OwnedSemaphorePermit, Semaphore};
208use tokio::task::{AbortHandle, JoinSet};
209use tokio::time::{sleep, timeout};
210
211mod http_api;
212pub mod backend_paths;
213pub mod node_wake;
214
215tokio::task_local! {
216    /// Launch options (model / effort) of a `SpawnWithOptions` request, scoped around that
217    /// spawn so the provider's start carries them (g4a maps them to the CLI's own flags).
218    static SPAWN_SESSION_OPTIONS: Option<gate4agent_types::SessionOptionSelection>;
219    /// Provider session id to resume (node-start restore of journalled sessions).
220    static RESUME_PROVIDER_SESSION: Option<String>;
221    /// Origin (`human`/`agent`) and optional parent signature for the node session registry.
222    static SPAWN_ORIGIN: Option<String>;
223    static SPAWN_PARENT: Option<String>;
224}
225
226/// `SpawnSessionOptions` -> g4a selection. Effort needs a model (g4a options hang off a model).
227fn spawn_selection(
228    options: &gate4agent_node_protocol::SpawnSessionOptions,
229) -> Result<Option<gate4agent_types::SessionOptionSelection>, NodeFailure> {
230    options.validate().map_err(|e| failure(NodeFailureCode::InvalidRequest, e))?;
231    match (&options.model, &options.effort) {
232        (None, None) => Ok(None),
233        (None, Some(_)) => Err(failure(NodeFailureCode::InvalidRequest, "spawn effort needs a model")),
234        (Some(model), effort) => {
235            let mut sel = gate4agent_types::SessionOptionSelection::new(model.clone());
236            if let Some(e) = effort {
237                sel = sel.with_value("effort", e.clone());
238            }
239            Ok(Some(sel))
240        }
241    }
242}
243
244mod browser_bridge;
245
246use gate4agent_node_core::drive_loop_profile;
247
248#[path = "bundle_delivery.rs"]
249mod bundle_delivery;
250
251#[path = "context_pack_store.rs"]
252mod context_pack_store;
253
254#[cfg(windows)]
255pub use crate::platform::DEFAULT_NODE_ENDPOINT;
256
257const NODE_EVENT_HISTORY_MAX: usize = 4_096;
258const NODE_BROADCAST_CAPACITY: usize = 1_024;
259const NODE_TERMINAL_BROADCAST_CAPACITY: usize = 1;
260/// Unlike `NODE_TERMINAL_BROADCAST_CAPACITY`'s single replaceable slot (a
261/// terminal frame is a full-screen snapshot; only the latest ever matters),
262/// an `AgentStreamChunkV1` is one delta in an ordered sequence -- collapsing
263/// two pending sends into one would silently drop content a subscriber can
264/// never recover. This needs headroom for a burst instead.
265const NODE_AGENT_STREAM_BROADCAST_CAPACITY: usize = 256;
266/// The node never has more than `MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE`
267/// `NodeEvent::HarnessMcpReadCall`s in flight at once (`begin_call`'s own
268/// admission check), and `publish_transient` sends each exactly once as a
269/// single-envelope batch -- so this many slots covers the entire pending
270/// set without ever lagging the one subscriber that matters, the harness's
271/// own connection, the way sharing the durable `event_tx`'s bus could.
272const NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY: usize = MAX_HARNESS_MCP_PENDING_CALLS_PER_NODE;
273const NODE_CONNECTION_EVENT_BURST_MAX: usize = 16;
274const CONTROL_EVENT_SUBSCRIPTION_CAPACITY: usize = 1_024;
275const MAX_PREAUTH_CONNECTIONS: usize = 32;
276const MAX_AUTHENTICATED_CONNECTIONS: usize = 16;
277const AUTH_FRAME_TIMEOUT_MS: u64 = 5_000;
278const FRAME_BODY_TIMEOUT_MS: u64 = 5_000;
279const CONNECTION_SHUTDOWN_GRACE_MS: u64 = 250;
280const SPAWN_DISPATCH_TIMEOUT_MS: u64 = 2_000;
281/// Fresh Spawn stays provisional until HQ successfully attaches (first Resize /
282/// TerminalBytes). This is an *attach-never-arrived* timeout from spawn time —
283/// not the disconnect reconnect grace (HQ owns that, starting only on a real
284/// client WS close). A provider conversation id is a session; a PTY with no
285/// attached HQ view is still a draft and is reaped here if attach never lands.
286const PROVISIONAL_SPAWN_GRACE_MS: u64 = 90_000;
287/// How often `drive_runtime_until_shutdown`'s loop re-evaluates the session
288/// record retention policy (`NodeServerConfig::session_record_retention`).
289/// A sweep cadence, not a policy value -- the policy itself lives entirely
290/// in the operator-configured age/keep-N flags; this just bounds how stale
291/// the in-memory view of dead `Unavailable` records can get between sweeps
292/// on an otherwise-idle node, mirroring the once-per-startup sweep that
293/// also runs alongside `reconcile_managed_worktrees`.
294const SESSION_RECORD_RETENTION_SWEEP_PERIOD: Duration = Duration::from_secs(5 * 60);
295/// TTL for `NodeShared::rejected_command_reasons` entries -- see that
296/// field's own doc for why a spawn waiter needs this at all. Must outlive
297/// the longest a waiter can still be polling for this exact command's
298/// outcome: `gate4agent_node_protocol::MAX_SPAWN_DEADLINE_MS` (120s) is the
299/// largest `deadline_ms` any caller may request, and the margin on top
300/// absorbs the waiter's own poll cadence plus clock skew between the drive
301/// loop (writer) and the waiter (reader).
302const REJECTED_COMMAND_REASON_TTL_MS: u64 = 150_000;
303/// Bounds `NodeShared::rejected_command_reasons` against an unbounded
304/// backlog of asynchronously-rejected commands nobody ever collects (a
305/// waiter that stops polling, or a burst of always-rejected commands from a
306/// misbehaving caller): once at capacity, the oldest entry is evicted to
307/// make room for the newest, mirroring the eviction rule already used by
308/// `spawn_idempotency`.
309const REJECTED_COMMAND_REASON_MAX_ENTRIES: usize = 256;
310/// How long `wait_until_removed` waits between two `Remove` re-dispatches
311/// for the same session. The presence poll runs far faster (2ms); this
312/// only paces the sending, so an accepted `Remove` still in flight is not
313/// piled on by a dozen more that land on an already-dropped instance.
314const REMOVE_REDISPATCH_INTERVAL_MS: u64 = 100;
315const SPAWN_IDEMPOTENCY_MAX_ENTRIES: usize = 256;
316const SPAWN_IDEMPOTENCY_TTL_MS: u64 = 15 * 60 * 1_000;
317const PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS: u64 = 2_500;
318const MUTATION_SETTLE_TIMEOUT_MS: u64 = 5_000;
319const READINESS_SETTLE_HEADROOM_MS: u64 = 2_000;
320const MANAGED_RESUME_SETTLE_TIMEOUT_MS: u64 = 30_000;
321const WORKSPACE_INSPECTION_MAX_CONCURRENCY: usize = 4;
322const WORKSPACE_FILE_READ_TIMEOUT_MS: u64 = 2_000;
323const HOST_DIRECTORY_BROWSE_TIMEOUT_MS: u64 = 2_000;
324const NATIVE_SESSION_CATALOG_TIMEOUT_MS: u64 = 30_000;
325const WORKSPACE_TREE_MAX_DEPTH: usize = 6;
326const WORKSPACE_TREE_MAX_ENTRIES: usize = 512;
327/// Default total wall-clock budget for `inspect_workspace`'s walk+git
328/// chain, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS`.
329const WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT: u64 = 8_000;
330/// The harness relay enforces its own flat response deadline
331/// (`HOST_RUN_READ_RESPONSE_DEADLINE`, `hatchery-harness-service::runtime`,
332/// 12s) above this node-side budget. The two constants live in different
333/// crates with no shared definition, so clamp any override here comfortably
334/// below that external ceiling — a misconfigured value above it would
335/// reopen the exact deadline race this budget exists to close.
336const WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX: u64 = 11_000;
337/// Default cap on directory entries visited (not just returned) by one
338/// walk, overridable via `GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP`.
339const WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT: usize = 50_000;
340const WORKSPACE_INSPECTION_ENTRY_CAP_MAX: usize = 1_000_000;
341const GIT_STATUS_MAX_ENTRIES: usize = 128;
342const GIT_COMMIT_MAX_ENTRIES: usize = 12;
343const GIT_OUTPUT_MAX_BYTES: usize = 64 * 1_024;
344const GIT_DIAGNOSTIC_MAX_BYTES: usize = 1_024;
345const WORKSPACE_FILE_WRITE_TIMEOUT_MS: u64 = 5_000;
346const WORKSPACE_ENTRY_CREATE_TIMEOUT_MS: u64 = 5_000;
347const GIT_COMMAND_TIMEOUT_MS: u64 = 1_500;
348const WORKSPACE_UNAVAILABLE_ERROR: &str = "workspace-unavailable";
349const PROVIDER_SESSION_SCOPE_CONFLICT_ERROR: &str = "provider-session-scope-conflict";
350const PROVIDER_SESSION_LIVE_CONFLICT_ERROR: &str = "provider-session-live-conflict";
351const MANAGED_SESSION_CAPACITY_ERROR: &str = "managed-session-capacity";
352const PROVIDER_IDENTITY_ALLOCATION_ERROR: &str =
353    "provider-session-identity-allocation-failed";
354const PROVIDER_RESUME_REJECTED_ERROR: &str = "provider-resume-rejected";
355const DURABLE_STATE_COMMIT_FAILED_ERROR: &str = "durable-state-commit-failed";
356const DURABLE_STATE_LOCK_FAILED_ERROR: &str = "durable-state-lock-failed";
357const DURABLE_STATE_LOAD_FAILED_ERROR: &str = "durable-state-load-failed";
358const DURABLE_STATE_CONFLICT_ERROR: &str = "durable-state-conflict";
359const DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR: &str = "durable-state-schema-unsupported";
360const DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR: &str =
361    "durable-state-path-semantics-unsupported";
362
363fn opaque_windows_path(value: String) -> OpaqueHostPath {
364    OpaqueHostPath::utf8(value).expect("validated Windows node path must remain wire-safe")
365}
366
367fn agent_progress_from_provider_snapshot(
368    address: SessionAddress,
369    provider: &ProviderSnapshot,
370) -> Option<SessionAgentProgress> {
371    let mut truncated = false;
372    let active_tool_count = bounded_progress_count(provider.active_tools.len(), &mut truncated);
373    let mut active_tool_labels = Vec::with_capacity(
374        provider
375            .active_tools
376            .len()
377            .min(MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS),
378    );
379    for tool in &provider.active_tools {
380        if active_tool_labels.len() == MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS {
381            truncated = true;
382            break;
383        }
384        if let Some(label) = sanitize_progress_tool_label(&tool.name, &mut truncated) {
385            active_tool_labels.push(label);
386        }
387    }
388    let attention = provider
389        .interactions
390        .iter()
391        .find(|interaction| {
392            matches!(
393                interaction.status,
394                ProviderInteractionStatus::Pending | ProviderInteractionStatus::Resolving { .. }
395            )
396        })
397        .map(|interaction| AgentProgressAttentionV1 {
398            kind: match interaction.interaction_kind {
399                ProviderInteractionKind::Approval => AgentProgressAttentionKindV1::Approval,
400                ProviderInteractionKind::Question => AgentProgressAttentionKindV1::Question,
401            },
402            tool_label: sanitize_progress_tool_label(&interaction.tool_name, &mut truncated),
403        });
404    let subagent_count = bounded_progress_count(provider.subagents.len(), &mut truncated);
405    let usage = (provider.completed_turns > 0
406        || provider.usage.input_tokens > 0
407        || provider.usage.output_tokens > 0
408        || provider.usage.cache_read_tokens > 0
409        || provider.usage.cache_write_tokens > 0
410        || provider.usage.reasoning_tokens > 0)
411        .then_some(AgentProgressUsageV1 {
412            input_tokens: provider.usage.input_tokens,
413            output_tokens: provider.usage.output_tokens,
414            cache_read_tokens: provider.usage.cache_read_tokens,
415            cache_write_tokens: provider.usage.cache_write_tokens,
416            reasoning_tokens: provider.usage.reasoning_tokens,
417        });
418    let progress = AgentProgressV1 {
419        provider_sequence: provider.sequence,
420        activity: provider.activity,
421        completed_turns: provider.completed_turns,
422        usage,
423        current: AgentProgressCurrentV1::from(provider.activity),
424        active_tool_labels,
425        active_tool_count,
426        attention,
427        subagent_count,
428        last_event_kind: provider.last_event.as_ref().and_then(agent_progress_event_kind),
429        gap_count: provider.gap_count,
430        stale: provider.stale,
431        truncated,
432    };
433    let entry = SessionAgentProgress { address, progress };
434    serde_json::to_vec(&entry)
435        .ok()
436        .filter(|encoded| encoded.len() <= MAX_AGENT_PROGRESS_ENTRY_BYTES)
437        .map(|_| entry)
438}
439
440fn bounded_progress_count(count: usize, truncated: &mut bool) -> u32 {
441    match u32::try_from(count) {
442        Ok(count) => count,
443        Err(_) => {
444            *truncated = true;
445            u32::MAX
446        }
447    }
448}
449
450fn agent_progress_event_kind(event: &ProviderEvent) -> Option<AgentProgressEventKindV1> {
451    if matches!(event, ProviderEvent::ContextWindowUsage { .. }) {
452        return None;
453    }
454    Some(match event {
455        ProviderEvent::SessionStarted { .. } => AgentProgressEventKindV1::SessionStarted,
456        ProviderEvent::SessionIdentityObserved { .. } => {
457            AgentProgressEventKindV1::SessionIdentityObserved
458        }
459        ProviderEvent::TurnStarted { .. } => AgentProgressEventKindV1::TurnStarted,
460        ProviderEvent::WorkingObserved => AgentProgressEventKindV1::WorkingObserved,
461        ProviderEvent::Text { .. } => AgentProgressEventKindV1::Text,
462        ProviderEvent::Thinking { .. } => AgentProgressEventKindV1::Thinking,
463        ProviderEvent::ToolStarted { .. } => AgentProgressEventKindV1::ToolStarted,
464        ProviderEvent::ToolCompleted { .. } => AgentProgressEventKindV1::ToolCompleted,
465        ProviderEvent::TurnCompleted { .. } => AgentProgressEventKindV1::TurnCompleted,
466        ProviderEvent::TurnInterrupted => AgentProgressEventKindV1::TurnInterrupted,
467        ProviderEvent::SessionEnded { .. } => AgentProgressEventKindV1::SessionEnded,
468        ProviderEvent::Error { .. } => AgentProgressEventKindV1::Error,
469        ProviderEvent::Ready => AgentProgressEventKindV1::Ready,
470        ProviderEvent::InteractionRequested { .. } => {
471            AgentProgressEventKindV1::InteractionRequested
472        }
473        ProviderEvent::InteractionResolved { .. } => AgentProgressEventKindV1::InteractionResolved,
474        ProviderEvent::SubagentStarted { .. } => AgentProgressEventKindV1::SubagentStarted,
475        ProviderEvent::SubagentStopped { .. } => AgentProgressEventKindV1::SubagentStopped,
476        ProviderEvent::RateLimited { .. } => AgentProgressEventKindV1::RateLimited,
477        ProviderEvent::HostRequestObserved { .. } => AgentProgressEventKindV1::HostRequestObserved,
478        ProviderEvent::UnrecognizedNotification { .. } => {
479            AgentProgressEventKindV1::UnrecognizedNotification
480        }
481        ProviderEvent::ContextWindowUsage { .. } => unreachable!("handled above"),
482        // ACP session/update coverage beyond text/tool/turn streaming
483        // (`plan`, `available_commands_update`, `current_mode_update`,
484        // `session_info_update`, `usage_update`, `config_option_update`,
485        // `user_message_chunk`). `AgentProgressEventKindV1` is a versioned
486        // wire enum without a `#[serde(other)]` fallback; minting new
487        // variants for it is a deliberate wire-contract decision for
488        // whoever owns this protocol, not a side effect of parsing more of
489        // ACP's own wire -- until that decision is made, these fold into
490        // the same "we saw something, no dedicated kind for it yet" bucket
491        // as `UnrecognizedNotification`.
492        ProviderEvent::UserMessage { .. }
493        | ProviderEvent::Plan { .. }
494        | ProviderEvent::AvailableCommandsUpdated { .. }
495        | ProviderEvent::ModeChanged { .. }
496        | ProviderEvent::SessionInfoUpdated { .. }
497        | ProviderEvent::UsageUpdated { .. }
498        | ProviderEvent::ConfigOptionsUpdated { .. } => {
499            AgentProgressEventKindV1::UnrecognizedNotification
500        }
501    })
502}
503
504/// Maps this harness's own `HostDecisionAuthority` onto the wider
505/// Maps a host request decider onto the `BlockAuthorityV1` a `Blocked` stream
506/// chunk carries -- the four authorities a `Denied` decision is ever made
507/// under, one-for-one. The remaining `BlockAuthorityV1` variants
508/// (`ProviderClassifier`, `ProviderPermissionRule`, `ProviderSandbox`,
509/// `ProviderRefusal`, `UserRejected`, `ProviderQuota`, `Unknown`) describe a
510/// block the PROVIDER side of the wire reported -- this function never
511/// produces one, because `ProviderHostDecisionAuthority` has no vocabulary for
512/// them; the provider-side blocks below mint those directly from typed fields.
513/// Whether a provider source is a live session transport -- the only kind
514/// whose events reach the agent stream. History, resume, session-option and
515/// capability-probe sources are not sessions and stream nothing.
516fn provider_family_is_live(family: AdapterFamily) -> bool {
517    match family {
518        AdapterFamily::PtySemantic
519        | AdapterFamily::Hook
520        | AdapterFamily::ManagedHook
521        | AdapterFamily::Pipe
522        | AdapterFamily::OneShot
523        | AdapterFamily::Acp => true,
524        AdapterFamily::History
525        | AdapterFamily::Resume
526        | AdapterFamily::SessionOptions
527        | AdapterFamily::CapabilityProbe => false,
528    }
529}
530
531fn block_authority(by: ProviderHostDecisionAuthority) -> BlockAuthorityV1 {
532    match by {
533        ProviderHostDecisionAuthority::Gate => BlockAuthorityV1::HostGate,
534        ProviderHostDecisionAuthority::Policy => BlockAuthorityV1::HostPolicy,
535        ProviderHostDecisionAuthority::Operator => BlockAuthorityV1::Operator,
536        ProviderHostDecisionAuthority::DeadlinePolicy => BlockAuthorityV1::HostDeadline,
537    }
538}
539
540/// The `Blocked::reason` for a `Denied` decision this build's own
541/// `AgentEvent::RpcIncomingRequest::reason` did not attach text to --
542/// reachable today for `Operator`/`HostDeadline`: a deferred `session/
543/// request_permission` request resolved by `AcpSession::
544/// resolve_pending_request`/`expire_deadlines` (`src/acp/session.rs`),
545/// neither of which computes free text (an operator answers approve/reject,
546/// never a comment; an unattended deadline asks nobody anything). `reason`
547/// is a REQUIRED field on the wire (see `AgentStreamChunkKindV1::Blocked`)
548/// -- a categorical fallback naming the authority is the
549/// honest minimum, never a placeholder standing in for detail nobody
550/// reported.
551fn action_blocked_fallback_reason(authority: BlockAuthorityV1) -> &'static str {
552    match authority {
553        BlockAuthorityV1::HostGate => "denied by the dangerous-command gate",
554        BlockAuthorityV1::HostPolicy => "denied by host policy",
555        BlockAuthorityV1::HostDeadline => {
556            "denied by host policy after the operator deadline expired"
557        }
558        BlockAuthorityV1::Operator => "declined by the operator",
559        BlockAuthorityV1::ProviderClassifier => "denied by the provider's classifier",
560        BlockAuthorityV1::ProviderPermissionRule => "denied by a provider permission rule",
561        BlockAuthorityV1::ProviderSandbox => "denied by the provider's sandbox",
562        BlockAuthorityV1::ProviderRefusal => "refused by the provider",
563        BlockAuthorityV1::UserRejected => "rejected by the user",
564        BlockAuthorityV1::ProviderQuota => "the provider's quota or rate limit was exhausted",
565        BlockAuthorityV1::Unknown => "blocked; no reason was reported",
566    }
567}
568
569/// Computes `AgentStreamChunkKindV1::Blocked`'s `tool_class`/`authority`/`reason`
570/// for a `HostRequestObserved` `Denied` decision.
571fn host_request_denied_block(
572    method: &str,
573    by: ProviderHostDecisionAuthority,
574    reason: Option<&str>,
575) -> (String, BlockAuthorityV1, String) {
576    let authority = block_authority(by);
577    let reason_source = reason.unwrap_or_else(|| action_blocked_fallback_reason(authority));
578    // `AgentStreamChunkKindV1::validate` REJECTS an over-long reason outright
579    // rather than truncating -- this cut happens here, at the producer, before
580    // the validator ever sees the text.
581    let (reason_text, _) =
582        truncate_text(reason_source, MAX_ACP_BLOCKED_REASON_BYTES);
583    (tool_class_label(method), authority, reason_text)
584}
585
586// ---------------------------------------------------------------------------
587// K2 -- provider-side blocks read off ACP `tool_call_update._meta.
588// nonExecutionKind` (`ProviderEvent::ToolCompleted::non_execution_kind`) and
589// `SessionEnded::stop_reason` (`stopReason: "refusal"`, or a `session/
590// prompt` RPC error whose vendor code names a quota/limit/rate
591// exhaustion). See
592// `docs/gate4agent/plans/gate4agent-blocked-action-event-2026-09-02.md`
593// §3 slice K2.
594// ---------------------------------------------------------------------------
595
596/// Classifies ACP's `nonExecutionKind` into the typed authority a block
597/// this build mints under, or `None` when the tool truly did not run for a
598/// reason that is NOT a block -- `"interrupted"`/`"cancelled"` mean the
599/// TURN stopped (session closed, the operator cancelled), not that a
600/// policy/classifier/rule refused the call, so those (and any unrecognized
601/// future value) stay a plain `ToolCompleted { success: false }` only, per
602/// this plan's own rule that a tool failure is never inflated into a block
603/// this build cannot actually attribute.
604fn provider_tool_block_authority(kind: Option<&str>) -> Option<(BlockAuthorityV1, &'static str)> {
605    match kind? {
606        "user-rejected" => Some((BlockAuthorityV1::UserRejected, "user-rejected")),
607        "permission-rule" => Some((BlockAuthorityV1::ProviderPermissionRule, "permission-rule")),
608        _ => None,
609    }
610}
611
612/// Splits a provider's own denial sentence into `reason`/`help`, when it
613/// matches the fixed shape Claude Code's classifier/permission-rule denial
614/// uses -- verified live, the owner's sample fixture
615/// (`docs/gate4agent/research/gate4agent-blocked-action-signals-2026-09-02.md`
616/// appendix): "...denied by <authority>. Reason: <reason>. <help...>".
617/// `authority` is NEVER derived from this text (see this module's own rule
618/// that authority comes only from a typed field) -- only `reason`/`help`
619/// are. Falls back to the full text as `reason` with no `help` when the
620/// shape does not match (a provider whose text does not carry this exact
621/// wording) -- the honest minimum: the tool truly did not run, so SOME
622/// reason is always reported, never fabricated detail beyond what arrived.
623fn split_provider_denial_text(text: &str) -> (String, Option<String>) {
624    let trimmed = text.trim();
625    let Some((_, after_reason)) = trimmed.split_once("Reason:") else {
626        return (trimmed.to_owned(), None);
627    };
628    let tail = after_reason.trim_start();
629    let Some((reason, help)) = tail.split_once(". ") else {
630        // "Reason:" present but no ". " to split help off -- the whole
631        // tail is the reason, e.g. "Reason: Blocked by classifier." with
632        // no help text following on some providers/versions.
633        let reason = tail.strip_suffix('.').unwrap_or(tail).trim();
634        return (reason.to_owned(), None);
635    };
636    let help = help.trim();
637    if help.is_empty() {
638        (reason.trim().to_owned(), None)
639    } else {
640        (reason.trim().to_owned(), Some(help.to_owned()))
641    }
642}
643
644/// `tool_class` for a provider-side `ActionBlocked` this build has no typed
645/// tool name for -- `ProviderEvent::ToolCompleted` carries only `id`, never
646/// `name` (see `ProviderEvent::ToolStarted`, which does, but a tool's own
647/// name is never threaded through to its completion). Reads the class off
648/// the guidance tail's own "add a <Class> permission rule" sentence when
649/// present (the shape the owner's sample fixture uses) -- text matching
650/// fills `tool_class` here, same as `reason`/`help`, NEVER `authority` (see
651/// this module's own rule). Falls back to the same generic `"Tool"` class
652/// `ToolCompleted`'s own observation already uses when nothing names one.
653fn tool_class_from_denial_help(help: Option<&str>) -> String {
654    help.and_then(|text| text.split("add a ").nth(1))
655        .and_then(|tail| tail.split(' ').next())
656        .filter(|word| !word.is_empty())
657        .map(str::to_owned)
658        .unwrap_or_else(|| "Tool".to_owned())
659}
660
661/// Whether a `session/prompt` RPC error's vendor code (or, absent one, its
662/// message) names a quota/rate-limit exhaustion -- Codex's
663/// `data.codexErrorInfo: "usageLimitExceeded"` (measured live 2026-09-05)
664/// contains "limit", the word this build actually keys on; "quota"/"rate"
665/// are kept for whichever OTHER vendor's own code names one of those
666/// instead, never confirmed live, but the honest generalization of the
667/// one confirmed sample.
668fn provider_error_is_quota(vendor_code: Option<&str>, message: &str) -> bool {
669    fn names_quota(s: &str) -> bool {
670        let lower = s.to_ascii_lowercase();
671        lower.contains("limit") || lower.contains("quota") || lower.contains("rate")
672    }
673    vendor_code.is_some_and(names_quota) || names_quota(message)
674}
675
676/// The `Blocked` chunk this build mints when a turn stopped via a
677/// typed provider fact rather than a tool call -- `stopReason: "refusal"`
678/// (`authority: ProviderRefusal`) or a `session/prompt` RPC error whose
679/// vendor code (or message) names a quota/limit/rate exhaustion
680/// (`authority: ProviderQuota`, `reason` = the error message verbatim,
681/// `reason_kind` = the vendor code). `None` for every other stop --
682/// `end_turn`, `max_tokens`, `max_turn_requests`, `cancelled`, an untagged
683/// `Other`, or an RPC error whose vendor code/message names none of the
684/// three words: `SessionEnded` stays a plain `Exited`/turn-end fact for
685/// those, never inflated into a block this build cannot actually
686/// attribute. `ProviderRefusal`'s `reason` has no source text to read on
687/// `SessionEnded` itself (ACP's `refusal` stopReason carries none) -- the
688/// honest minimum names the fact that happened, never invented detail; a
689/// later slice that threads the turn's last agent text chunk through can
690/// replace it without changing this function's contract.
691fn provider_session_end_block(
692    stop_reason: &ProviderStopReason,
693) -> Option<(BlockAuthorityV1, String, String)> {
694    match stop_reason {
695        ProviderStopReason::Refusal => Some((
696            BlockAuthorityV1::ProviderRefusal,
697            "refusal".to_owned(),
698            "the provider ended the turn with stopReason: refusal".to_owned(),
699        )),
700        ProviderStopReason::ProviderError { message, vendor_code, .. }
701            if provider_error_is_quota(vendor_code.as_deref(), message) =>
702        {
703            let reason_kind = vendor_code.clone().unwrap_or_else(|| "provider-error".to_owned());
704            Some((BlockAuthorityV1::ProviderQuota, reason_kind, message.clone()))
705        }
706        _ => None,
707    }
708}
709
710/// Builds the `AgentStreamChunkV1` `event`'s `ProviderEvent` carries onto the
711/// `agent-stream-events-v1` channel -- see
712/// `docs/gate4agent/plans/gate4agent-acp-control-plane-on-the-wire-2026-09-02.md`
713/// §5. Two invariants keep the chunk correlatable with the `NodeEvent::Control`
714/// event it derives from, so a client joining the two sees one id and one
715/// ordering:
716///
717/// - `source_sequence` on the chunk is the `source_sequence` the provider
718///   event itself carries.
719/// - `InteractionPrompt::correlation_id` is minted by
720///   `correlation::interaction_correlation` with the
721///   `(instance_id, generation, provider_sequence)` triple -- never the
722///   provider's own `request_id`, whose opacity on the wire is pinned by a
723///   dedicated test.
724///
725/// Returns `None` for every `ControlEventKind` other than `ProviderEvent`
726/// (nothing else carries stream content), for a source that is not a live
727/// session transport, and for the `ProviderEvent` variants below with no
728/// declared chunk kind to carry them, or whose declared chunk kind this build
729/// has no source for yet:
730///
731/// - `ModelCatalog { current, available }` has no `ProviderEvent` source at
732///   all -- grok's vendor model switch is not bridged into `ProviderEvent`
733///   yet, the same gap the plan names explicitly.
734/// - `UserMessage { text, is_delta }` is an echo of the OPERATOR's own
735///   prompt, replayed on session resume -- `Text`/`Thinking` are documented
736///   as "what the agent is saying", so mapping the operator's words onto
737///   `Text` would misrepresent whose content it is. No dedicated
738///   user-message kind is declared, so this stays unproduced rather than
739///   mislabeled.
740///
741/// `HostRequestObserved { .. }` is its own arm below: a `Granted`/`Deferred`
742/// decision returns `None` (nothing for the operator to react to), a `Denied`
743/// decision returns `Some(Blocked { .. })`.
744///
745/// `ToolCompleted { .. }`/`SessionEnded { .. }` are likewise their own arms
746/// below (K2): each returns `None` unless
747/// `provider_tool_block_authority`/`provider_session_end_block` classifies the
748/// event as an actual block (ACP's `_meta.nonExecutionKind`, or `stopReason`/an
749/// RPC error naming a quota).
750fn agent_stream_chunk(event: &ControlEvent) -> Option<AgentStreamChunkV1> {
751    let ControlEventKind::ProviderEvent {
752        sequence: provider_sequence,
753        source,
754        source_sequence,
755        event: provider_event,
756    } = &event.event
757    else {
758        return None;
759    };
760    if !provider_family_is_live(source.family) {
761        return None;
762    }
763    let kind = match provider_event {
764        ProviderEvent::Text { text, is_delta } => AgentStreamChunkKindV1::Text {
765            text: text.clone(),
766            is_delta: *is_delta,
767        },
768        ProviderEvent::Thinking { text } => {
769            AgentStreamChunkKindV1::Thinking { text: text.clone() }
770        }
771        ProviderEvent::InteractionRequested {
772            interaction_kind,
773            tool_name,
774            title,
775            prompt,
776            options,
777            ..
778        } => AgentStreamChunkKindV1::InteractionPrompt {
779            correlation_id: interaction_correlation(
780                event.instance_id,
781                event.generation,
782                *provider_sequence,
783            ),
784            interaction_kind: *interaction_kind,
785            tool_name: tool_name.clone(),
786            // `title`/`options` are carried straight through from
787            // `ProviderEvent::InteractionRequested`, as the agent itself
788            // gave them on `session/request_permission`'s `PermissionTool
789            // Call.title`/`options` (see `gate4agent-shell-native`'s
790            // `provider_event`, the only ACP-transport producer). A PTY- or
791            // hook-sourced interaction carries `title: None` and
792            // `options: []` from its own producer because that source has
793            // none to offer, never because this mapping dropped one.
794            title: title.clone(),
795            prompt: prompt.clone(),
796            options: options
797                .iter()
798                .map(|option| AgentStreamInteractionOptionV1 {
799                    option_id: option.option_id.clone(),
800                    name: option.name.clone(),
801                    kind: option.kind.clone(),
802                })
803                .collect(),
804        },
805        ProviderEvent::ConfigOptionsUpdated { options } => {
806            AgentStreamChunkKindV1::ConfigOptions { options: options.clone() }
807        }
808        // `available` is the mode catalogue `AcpSession::available_modes()`
809        // read back at `session/new` and threaded through by
810        // `gate4agent-shell-native`'s `provider_event` (see that crate's
811        // `ProviderEvent::ModeChanged` arm) -- an empty list here is the
812        // agent's own honest "no modes announced", never "not read": ACP
813        // orders `session/new` before any `session/update`, and the
814        // catalogue is fixed at handshake, so a `ModeChanged` event cannot
815        // exist before the same session object's catalogue is already
816        // populated (see `gate4agent_types::ProviderEvent::ModeChanged`'s
817        // own doc comment for the ordering argument in full).
818        ProviderEvent::ModeChanged { mode_id, available } => AgentStreamChunkKindV1::ModeCatalog {
819            current: Some(mode_id.clone()),
820            available: available
821                .iter()
822                .map(|mode| AgentStreamNamedIdV1 {
823                    id: mode.id.clone(),
824                    name: mode.name.clone(),
825                    description: mode.description.clone(),
826                })
827                .collect(),
828        },
829        // A denied host request is a block -- see this function's own doc
830        // comment for why `HostRequestObserved` is not in the exclusion list
831        // below.
832        ProviderEvent::HostRequestObserved { method, decision, reason, .. } => {
833            let ProviderHostRequestDecision::Denied { by } = decision else {
834                return None;
835            };
836            let (tool_class, authority, reason_text) =
837                host_request_denied_block(method, *by, reason.as_deref());
838            AgentStreamChunkKindV1::Blocked {
839                correlation_id: None,
840                tool_class,
841                authority,
842                reason_kind: None,
843                reason: reason_text,
844                help: None,
845            }
846        }
847        // Same relationship as `HostRequestObserved` above, driven by ACP's
848        // own `_meta.nonExecutionKind` instead of the host's
849        // `HostRequestDecision`.
850        ProviderEvent::ToolCompleted { id, output, non_execution_kind, .. } => {
851            let (authority, reason_kind) =
852                provider_tool_block_authority(non_execution_kind.as_deref())?;
853            let (reason, help) = split_provider_denial_text(output);
854            let (reason, _) =
855                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
856            let help = help.map(|help| {
857                truncate_text(&help, MAX_ACP_BLOCKED_HELP_BYTES).0
858            });
859            AgentStreamChunkKindV1::Blocked {
860                correlation_id: Some(tool_correlation(event, source, id)),
861                tool_class: tool_class_from_denial_help(help.as_deref()),
862                authority,
863                reason_kind: Some(reason_kind.to_owned()),
864                reason,
865                help,
866            }
867        }
868        // Same relationship, driven by `stopReason: "refusal"` or a
869        // `session/prompt` RPC error naming a quota/limit/rate exhaustion
870        // instead of a tool-level fact.
871        ProviderEvent::SessionEnded { stop_reason, .. } => {
872            let (authority, reason_kind, reason) =
873                stop_reason.as_ref().and_then(provider_session_end_block)?;
874            let (reason, _) =
875                truncate_text(&reason, MAX_ACP_BLOCKED_REASON_BYTES);
876            AgentStreamChunkKindV1::Blocked {
877                correlation_id: None,
878                tool_class: "Session".to_owned(),
879                authority,
880                reason_kind: Some(reason_kind),
881                reason,
882                help: None,
883            }
884        }
885        ProviderEvent::SessionStarted { .. }
886        | ProviderEvent::SessionIdentityObserved { .. }
887        | ProviderEvent::TurnStarted { .. }
888        | ProviderEvent::WorkingObserved
889        | ProviderEvent::ToolStarted { .. }
890        | ProviderEvent::TurnCompleted { .. }
891        | ProviderEvent::ContextWindowUsage { .. }
892        | ProviderEvent::TurnInterrupted
893        | ProviderEvent::Error { .. }
894        | ProviderEvent::Ready
895        | ProviderEvent::InteractionResolved { .. }
896        | ProviderEvent::SubagentStarted { .. }
897        | ProviderEvent::SubagentStopped { .. }
898        | ProviderEvent::RateLimited { .. }
899        | ProviderEvent::UnrecognizedNotification { .. }
900        | ProviderEvent::UserMessage { .. }
901        | ProviderEvent::Plan { .. }
902        | ProviderEvent::AvailableCommandsUpdated { .. }
903        | ProviderEvent::SessionInfoUpdated { .. }
904        | ProviderEvent::UsageUpdated { .. } => return None,
905    };
906    Some(AgentStreamChunkV1 { source_sequence: *source_sequence, kind })
907}
908
909async fn run_native_session_catalog_operation<T, E, F>(
910    catalog: Arc<Mutex<NativeSessionCatalogAuthority>>,
911    operation: &'static str,
912    execute: F,
913) -> Result<T, NodeFailure>
914where
915    T: Send + 'static,
916    E: std::fmt::Display + Send + 'static,
917    F: FnOnce(&mut NativeSessionCatalogAuthority) -> Result<T, E> + Send + 'static,
918{
919    let task = tokio::task::spawn_blocking(move || {
920        let mut catalog = match catalog.try_lock() {
921            Ok(catalog) => catalog,
922            Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
923            Err(std::sync::TryLockError::WouldBlock) => {
924                return Err(failure(
925                    NodeFailureCode::BackendBusy,
926                    "native session catalog operation is already running",
927                ));
928            }
929        };
930        execute(&mut catalog).map_err(|error| {
931            failure(
932                NodeFailureCode::BackendOperationFailed,
933                &format!("{operation} failed: {error}"),
934            )
935        })
936    });
937    timeout(Duration::from_millis(NATIVE_SESSION_CATALOG_TIMEOUT_MS), task)
938        .await
939        .map_err(|_| {
940            failure(
941                NodeFailureCode::BackendBusy,
942                &format!("{operation} exceeded its bounded deadline"),
943            )
944        })?
945        .map_err(|_| {
946            failure(
947                NodeFailureCode::BackendOperationFailed,
948                &format!("{operation} task failed"),
949            )
950        })?
951}
952
953fn windows_path_text(path: &OpaqueHostPath) -> &str {
954    path.as_utf8()
955        .expect("Windows node only creates UTF-8 opaque host paths")
956}
957
958fn require_windows_path(path: OpaqueHostPath) -> Result<String, NodeFailure> {
959    path.as_utf8()
960        .map(str::to_owned)
961        .ok_or_else(|| failure(
962            NodeFailureCode::InvalidRequest,
963            "this node requires UTF-8 host paths",
964        ))
965}
966
967fn protocol_worktree(mut worktree: NativeGitWorktreeSnapshot) -> GitWorktreeSnapshot {
968    GitWorktreeSnapshot {
969        path: opaque_windows_path(worktree.path),
970        head: worktree.head,
971        branch: worktree.branch,
972        is_bare: worktree.is_bare,
973        is_main: worktree.is_main,
974        locked: worktree.locked,
975        lock_reason: worktree.lock_reason,
976        prunable: worktree.prunable,
977        prunable_reason: worktree.prunable_reason,
978        workspace_id: worktree.workspace_id.take(),
979    }
980}
981
982#[derive(Clone, Debug, Eq, PartialEq)]
983pub struct WorkspaceConfig {
984    workspace_id: WorkspaceId,
985    canonical_root: String,
986    worktree_service_mode: WorktreeServiceMode,
987    managed_worktree_profiles: BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>,
988}
989
990impl WorkspaceConfig {
991    pub fn new(
992        workspace_id: WorkspaceId,
993        root: impl AsRef<Path>,
994    ) -> Result<Self, NodeServerError> {
995        let root = root.as_ref();
996        if !root.is_absolute() {
997            return Err(NodeServerError::InvalidWorkspaceRoot {
998                workspace_id,
999                path: root.to_string_lossy().into_owned(),
1000                message: "path is not absolute".to_owned(),
1001            });
1002        }
1003        let canonical = std::fs::canonicalize(root).map_err(|error| {
1004            NodeServerError::InvalidWorkspaceRoot {
1005                workspace_id: workspace_id.clone(),
1006                path: root.to_string_lossy().into_owned(),
1007                message: error.to_string(),
1008            }
1009        })?;
1010        if !canonical.is_dir() {
1011            return Err(NodeServerError::InvalidWorkspaceRoot {
1012                workspace_id,
1013                path: root.to_string_lossy().into_owned(),
1014                message: "path is not a directory".to_owned(),
1015            });
1016        }
1017        let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
1018            NodeServerError::InvalidWorkspaceRoot {
1019                workspace_id: workspace_id.clone(),
1020                path: path.to_string_lossy().into_owned(),
1021                message: "canonical path is not valid Unicode".to_owned(),
1022            }
1023        })?;
1024        let canonical_root = platform::normalize_canonical_root(canonical_root);
1025        validate_workspace_root(&workspace_id, &canonical_root)?;
1026        Ok(Self {
1027            workspace_id,
1028            canonical_root,
1029            worktree_service_mode: WorktreeServiceMode::Manual,
1030            managed_worktree_profiles: BTreeMap::new(),
1031        })
1032    }
1033
1034    pub fn workspace_id(&self) -> &WorkspaceId {
1035        &self.workspace_id
1036    }
1037
1038    pub fn canonical_root(&self) -> &str {
1039        &self.canonical_root
1040    }
1041
1042    pub fn with_worktree_service_mode(mut self, mode: WorktreeServiceMode) -> Self {
1043        self.worktree_service_mode = mode;
1044        self
1045    }
1046
1047    pub fn with_managed_worktree_profile(
1048        mut self,
1049        profile: ManagedWorktreeProfile,
1050    ) -> Result<Self, NodeServerError> {
1051        profile.validate_for_workspace(&self.canonical_root).map_err(|message| {
1052            NodeServerError::InvalidManagedWorktreeProfile {
1053                workspace_id: self.workspace_id.clone(),
1054                profile_id: profile.profile_id().clone(),
1055                message,
1056            }
1057        })?;
1058        if self.managed_worktree_profiles.contains_key(profile.profile_id()) {
1059            return Err(NodeServerError::DuplicateManagedWorktreeProfile {
1060                workspace_id: self.workspace_id,
1061                profile_id: profile.profile_id().clone(),
1062            });
1063        }
1064        if self.managed_worktree_profiles.len()
1065            == MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE
1066        {
1067            return Err(NodeServerError::ManagedWorktreeProfileCapacity {
1068                workspace_id: self.workspace_id,
1069                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
1070            });
1071        }
1072        self.managed_worktree_profiles
1073            .insert(profile.profile_id().clone(), profile);
1074        Ok(self)
1075    }
1076}
1077
1078#[derive(Clone)]
1079pub struct NodeServerConfig {
1080    pub endpoint: String,
1081    api_listen: Option<std::net::SocketAddr>,
1082    /// Opt-in node-envelope HTTP+WS browser bridge (separate from `--api-listen`
1083    /// ops HTTP and from framed C2). Loopback only; default `None` = off.
1084    bridge_listen: Option<std::net::SocketAddr>,
1085    /// Optional bridge shared secret (`GATE4AGENT_BRIDGE_TOKEN`). Distinct from
1086    /// NODE_TOKEN; never logged. `None` = loopback bind is the only gate.
1087    bridge_token: Option<String>,
1088    /// Tip 6: optional mesh underlay UDP bind that relays to local `--bridge-listen`.
1089    /// Requires bridge_listen + bridge_token. HQ never accepts (NodePeer).
1090    bridge_underlay: Option<browser_bridge::BridgeUnderlayAccept>,
1091    /// A relay to CALL, rather than wait to be called by. `None` keeps the
1092    /// node purely a listener, which is what it has always been.
1093    call_home: Option<std::net::SocketAddr>,
1094    /// When set, call-home authenticates by signing the relay's challenge with this
1095    /// identity (enrolled node) and falls back to the token handshake against a relay
1096    /// that sends no challenge.
1097    call_home_identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
1098    pub node_id: NodeId,
1099    pub workspaces: Vec<WorkspaceConfig>,
1100    access_token: String,
1101    pub runtime: NativeRuntimeConfig,
1102    session_record_retention: SessionRecordRetentionConfig,
1103    state_path: Option<PathBuf>,
1104    spawn_profiles: SpawnProfileRegistry,
1105    session_environment: Option<NodeSessionEnvironmentConfig>,
1106    history: Option<NativeHistoryConfig>,
1107    harness_mcp_helper: Option<ReviewedHarnessMcpProgram>,
1108    /// Station network allowlist catalog (opaque ids). Empty default.
1109    /// Loaded at startup from `--network-allowlist-catalog` / env
1110    /// `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG` file. Never secrets.
1111    network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1112    #[cfg(feature = "fixture")]
1113    fixture_raw_pty_runtime: bool,
1114}
1115
1116#[derive(Clone)]
1117struct NodeSessionEnvironmentConfig {
1118    root: PathBuf,
1119    resolver: Arc<dyn NodeSecretResolver>,
1120}
1121
1122impl NodeServerConfig {
1123    pub fn new(
1124        endpoint: impl Into<String>,
1125        access_token: impl Into<String>,
1126        node_id: NodeId,
1127        workspaces: impl IntoIterator<Item = WorkspaceConfig>,
1128    ) -> Result<Self, NodeServerError> {
1129        let endpoint = endpoint.into();
1130        let access_token = access_token.into();
1131        if !platform::validate_endpoint(&endpoint) {
1132            return Err(NodeServerError::InvalidEndpoint);
1133        }
1134        if access_token.is_empty() || access_token.len() > 4_096 {
1135            return Err(NodeServerError::InvalidAccessToken);
1136        }
1137        let workspaces = workspaces.into_iter().collect::<Vec<_>>();
1138        if workspaces.is_empty() {
1139            return Err(NodeServerError::NoWorkspaces);
1140        }
1141        let mut workspace_ids = BTreeMap::new();
1142        let mut workspace_roots = BTreeMap::new();
1143        for workspace in &workspaces {
1144            if workspace.worktree_service_mode == WorktreeServiceMode::Managed
1145                && workspace.managed_worktree_profiles.is_empty()
1146            {
1147                return Err(NodeServerError::ManagedWorktreeProfileRequired(
1148                    workspace.workspace_id.clone(),
1149                ));
1150            }
1151            if workspace.worktree_service_mode != WorktreeServiceMode::Managed
1152                && !workspace.managed_worktree_profiles.is_empty()
1153            {
1154                return Err(NodeServerError::ManagedWorktreeProfileModeMismatch(
1155                    workspace.workspace_id.clone(),
1156                ));
1157            }
1158            if workspace_ids
1159                .insert(workspace.workspace_id.clone(), ())
1160                .is_some()
1161            {
1162                return Err(NodeServerError::DuplicateWorkspaceId(
1163                    workspace.workspace_id.clone(),
1164                ));
1165            }
1166            let root_key = platform::root_identity(&workspace.canonical_root);
1167            if let Some(existing) = workspace_roots.insert(
1168                root_key,
1169                workspace.workspace_id.clone(),
1170            ) {
1171                return Err(NodeServerError::DuplicateWorkspaceRoot {
1172                    first: existing,
1173                    second: workspace.workspace_id.clone(),
1174                    root: workspace.canonical_root.clone(),
1175                });
1176            }
1177        }
1178        Ok(Self {
1179            endpoint,
1180            api_listen: None,
1181            bridge_listen: None,
1182            bridge_token: None,
1183            bridge_underlay: None,
1184            call_home: None,
1185            call_home_identity: None,
1186            node_id,
1187            workspaces,
1188            access_token,
1189            // PTY output is polled by the runtime's effect worker; the upstream
1190            // default (20ms) added ~10-20ms to every keystroke echo.
1191            runtime: NativeRuntimeConfig {
1192                worker_poll_interval_ms: 4,
1193                ..NativeRuntimeConfig::default()
1194            },
1195            session_record_retention: SessionRecordRetentionConfig::default(),
1196            state_path: None,
1197            spawn_profiles: SpawnProfileRegistry::default(),
1198            session_environment: None,
1199            history: None,
1200            harness_mcp_helper: None,
1201            network_allowlist_catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog::new(),
1202            #[cfg(feature = "fixture")]
1203            fixture_raw_pty_runtime: false,
1204        })
1205    }
1206
1207    pub fn with_api_listen(
1208        mut self,
1209        api_listen: std::net::SocketAddr,
1210    ) -> Result<Self, NodeServerError> {
1211        if !api_listen.ip().is_loopback() {
1212            return Err(NodeServerError::InvalidApiListen(api_listen));
1213        }
1214        self.api_listen = Some(api_listen);
1215        Ok(self)
1216    }
1217
1218    /// Opt-in node-envelope HTTP+WS bridge listener. Loopback only (same spirit
1219    /// as `with_api_listen`). Does not enable by default — libraries and the
1220    /// binary leave this `None` until `--bridge-listen` / `with_bridge_listen`.
1221    pub fn with_bridge_listen(
1222        mut self,
1223        bridge_listen: std::net::SocketAddr,
1224    ) -> Result<Self, NodeServerError> {
1225        if !bridge_listen.ip().is_loopback() {
1226            return Err(NodeServerError::InvalidBridgeListen(bridge_listen));
1227        }
1228        self.bridge_listen = Some(bridge_listen);
1229        Ok(self)
1230    }
1231
1232    /// Optional bridge shared secret, distinct from the C2/node access token.
1233    /// Never log the value. Empty / oversize refused.
1234    pub fn with_bridge_token(
1235        mut self,
1236        bridge_token: impl Into<String>,
1237    ) -> Result<Self, NodeServerError> {
1238        let bridge_token = bridge_token.into();
1239        if bridge_token.is_empty() || bridge_token.len() > 4_096 {
1240            return Err(NodeServerError::InvalidBridgeToken);
1241        }
1242        self.bridge_token = Some(bridge_token);
1243        Ok(self)
1244    }
1245
1246    /// Tip 6: point bridge reachability over mesh underlay (UDP+AEAD relay to
1247    /// local loopback `--bridge-listen`). Requires bridge listen + bridge token
1248    /// already configured. Transport key is 32 raw bytes (never logged).
1249    /// Underlay auth token is distinct from BRIDGE_TOKEN (crypto ≠ auth).
1250    pub fn with_bridge_underlay(
1251        mut self,
1252        bind: std::net::SocketAddr,
1253        transport_key: [u8; 32],
1254        underlay_token: impl Into<String>,
1255    ) -> Result<Self, NodeServerError> {
1256        if self.bridge_listen.is_none() {
1257            return Err(NodeServerError::BridgeUnderlayRequiresBridgeListen);
1258        }
1259        if self.bridge_token.is_none() {
1260            return Err(NodeServerError::BridgeUnderlayRequiresBridgeToken);
1261        }
1262        let underlay_token = underlay_token.into();
1263        if underlay_token.is_empty() || underlay_token.len() > 4_096 {
1264            return Err(NodeServerError::InvalidBridgeUnderlayToken);
1265        }
1266        // Port 0 is allowed (ephemeral). Role lock (no HQ accept) lives in underlay.
1267        self.bridge_underlay = Some(browser_bridge::BridgeUnderlayAccept {
1268            bind,
1269            transport_key,
1270            underlay_token,
1271        });
1272        Ok(self)
1273    }
1274
1275    /// Also dial `relay` and serve this wire over the connection, in
1276    /// addition to accepting on the local endpoint.
1277    ///
1278    /// Who opens the socket and who is the protocol's server are separate
1279    /// questions, and this is the node's half of separating them. The node
1280    /// stays the server either way -- it answers `NodeRequest`s and emits
1281    /// `NodeEvent`s exactly as it does for a relay that dialled it. All
1282    /// that changes is that a node with no address anyone can reach makes
1283    /// the connection itself, and names itself once it arrives (see
1284    /// `gate4agent_node_wire::write_call_home_announce`).
1285    ///
1286    /// Loopback only, and that is not an oversight to be flagged and
1287    /// worked around. This wire carries terminal contents, keystrokes and
1288    /// file bytes as plaintext JSON; its mutual challenge-response
1289    /// authenticates both ends and encrypts nothing. Every listener in
1290    /// this stack is loopback-guarded for that reason, and a dialler must
1291    /// hold the same line -- a node calling a public relay would put the
1292    /// whole control plane on the wire in the clear. Reaching a relay on
1293    /// another host is a transport question, and it stays unanswered here
1294    /// on purpose: this change is about direction, not distance.
1295    pub fn with_call_home(
1296        mut self,
1297        relay: std::net::SocketAddr,
1298    ) -> Result<Self, NodeServerError> {
1299        // Loopback, or the concrete C2 WireGuard tunnel address. Unspecified
1300        // and port 0 refused — plaintext wire must not dial every interface.
1301        if relay.port() == 0 || relay.ip().is_unspecified() {
1302            return Err(NodeServerError::InvalidCallHome(relay));
1303        }
1304        self.call_home = Some(relay);
1305        Ok(self)
1306    }
1307
1308    /// Authenticate call-home with a node-held identity instead of a standing token.
1309    pub fn with_call_home_identity(mut self, identity: Arc<hatchery_enroll::NodeIdentity>) -> Self {
1310        self.call_home_identity = Some(identity);
1311        self
1312    }
1313
1314    pub fn with_state_path(
1315        mut self,
1316        state_path: impl Into<PathBuf>,
1317    ) -> Result<Self, NodeServerError> {
1318        let state_path = state_path.into();
1319        if !state_path.is_absolute() || state_path.file_name().is_none() {
1320            return Err(NodeServerError::InvalidStatePath(
1321                state_path.to_string_lossy().into_owned(),
1322            ));
1323        }
1324        self.state_path = Some(state_path);
1325        Ok(self)
1326    }
1327
1328    pub fn with_spawn_profiles(mut self, spawn_profiles: SpawnProfileRegistry) -> Self {
1329        self.spawn_profiles = spawn_profiles;
1330        self
1331    }
1332
1333    /// Sets the age/keep-N policy the drive loop's retention sweep uses to
1334    /// retire dead `ManagedSessionState::Unavailable` records. Both fields
1335    /// default to `0` (disabled) via `SessionRecordRetentionConfig::default`
1336    /// when this is never called -- a node never deletes a durable record
1337    /// until an operator opts in with a real value.
1338    pub fn with_session_record_retention(
1339        mut self,
1340        retention: SessionRecordRetentionConfig,
1341    ) -> Self {
1342        self.session_record_retention = retention;
1343        self
1344    }
1345
1346    pub fn with_session_environment_materialization(
1347        mut self,
1348        root: impl Into<PathBuf>,
1349        resolver: Arc<dyn NodeSecretResolver>,
1350    ) -> Result<Self, NodeServerError> {
1351        let root = root.into();
1352        if !root.is_absolute() {
1353            return Err(NodeServerError::InvalidSessionEnvironmentRoot);
1354        }
1355        self.session_environment = Some(NodeSessionEnvironmentConfig { root, resolver });
1356        Ok(self)
1357    }
1358
1359    /// Enables bounded provider-history discovery and loading from explicit
1360    /// host-owned roots. Ambient vendor homes are never inferred here.
1361    pub fn with_history(mut self, history: NativeHistoryConfig) -> Self {
1362        self.history = Some(history);
1363        self
1364    }
1365
1366    /// Enables the H3B read proxy with one exact operator-reviewed helper file.
1367    /// Linux/macOS use owner-only UDS; Windows uses owner-only named pipes.
1368    /// Both platforms review the helper the same way (absolute regular file +
1369    /// identity hash) — the old `cfg(not(windows))` hard-refuse was a leftover
1370    /// gate after Unix listener/peer-cred support landed.
1371    pub fn with_harness_mcp_helper(
1372        mut self,
1373        helper_program: impl Into<PathBuf>,
1374    ) -> Result<Self, NodeServerError> {
1375        self.harness_mcp_helper = Some(
1376            ReviewedHarnessMcpProgram::review(helper_program.into())
1377                .map_err(|_| NodeServerError::InvalidHarnessMcpHelper)?,
1378        );
1379        Ok(self)
1380    }
1381
1382    /// Install an in-memory station network allowlist catalog (opaque ids +
1383    /// optional node-local permits / provider-native mapping). Empty remains
1384    /// the default — unknown spawn ids refuse at resolve. Never cookies /
1385    /// OAuth / proxy credentials. C2 / inventory still list ids only.
1386    pub fn with_network_allowlist_catalog(
1387        mut self,
1388        catalog: crate::network_allowlist_catalog::NetworkAllowlistCatalog,
1389    ) -> Result<Self, NodeServerError> {
1390        if catalog.len() > crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES {
1391            return Err(NodeServerError::NetworkAllowlistCatalogCapacity {
1392                max: crate::network_allowlist_catalog::MAX_NETWORK_ALLOWLIST_CATALOG_ENTRIES,
1393            });
1394        }
1395        self.network_allowlist_catalog = catalog;
1396        Ok(self)
1397    }
1398
1399    /// Load station network allowlist catalog from an absolute regular file
1400    /// (v1 id-list or v2 JSON schema). Unset path is not used here — callers
1401    /// that want env fallback use `resolve_network_allowlist_catalog`.
1402    pub fn with_network_allowlist_catalog_file(
1403        self,
1404        path: impl AsRef<std::path::Path>,
1405    ) -> Result<Self, NodeServerError> {
1406        let catalog = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(path)
1407            .map_err(NodeServerError::from)?;
1408        self.with_network_allowlist_catalog(catalog)
1409    }
1410}
1411
1412pub fn default_state_path(node_id: &NodeId) -> Result<PathBuf, NodeServerError> {
1413    platform::default_state_path(node_id.as_str())
1414        .ok_or(NodeServerError::LocalStateDirectoryUnavailable)
1415}
1416
1417pub fn default_node_endpoint() -> Result<PathBuf, NodeServerError> {
1418    platform::default_node_endpoint().ok_or(NodeServerError::LocalRuntimeDirectoryUnavailable)
1419}
1420
1421fn delivery_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1422    let parent = state_path.parent()?;
1423    let mut name = state_path.file_name()?.to_os_string();
1424    name.push(".delivery-store");
1425    Some(parent.join(name))
1426}
1427
1428fn context_pack_store_root_for_state_path(state_path: &Path) -> Option<PathBuf> {
1429    let parent = state_path.parent()?;
1430    let mut name = state_path.file_name()?.to_os_string();
1431    name.push(".context-pack-store");
1432    Some(parent.join(name))
1433}
1434
1435pub struct NodeServer {
1436    config: NodeServerConfig,
1437    runtime: NativeRuntime,
1438    shared: Arc<NodeShared>,
1439    events: EventSubscription,
1440    state_path_lock: Option<session_registry::StatePathLock>,
1441}
1442
1443#[cfg(feature = "fixture")]
1444#[derive(Clone, Default)]
1445pub struct SpawnManagedWorktreeV2FailureProbe {
1446    latest_code: Arc<Mutex<Option<NodeFailureCode>>>,
1447}
1448
1449#[cfg(feature = "fixture")]
1450impl SpawnManagedWorktreeV2FailureProbe {
1451    pub fn latest_code(&self) -> Option<NodeFailureCode> {
1452        *self
1453            .latest_code
1454            .lock()
1455            .unwrap_or_else(|poisoned| poisoned.into_inner())
1456    }
1457
1458    fn record(&self, code: Option<NodeFailureCode>) {
1459        *self
1460            .latest_code
1461            .lock()
1462            .unwrap_or_else(|poisoned| poisoned.into_inner()) = code;
1463    }
1464}
1465
1466#[cfg(all(feature = "fixture", windows))]
1467struct IsolatedCodexHomeFixtureEnvironment;
1468
1469#[cfg(all(feature = "fixture", windows))]
1470impl gate4agent_runtime_native::NativeChildEnvironmentResolver
1471    for IsolatedCodexHomeFixtureEnvironment
1472{
1473    fn resolve_child_environment(
1474        &self,
1475    ) -> Result<
1476        Vec<gate4agent_catalog::EnvMutation>,
1477        gate4agent_runtime_native::NativeChildEnvironmentResolveError,
1478    > {
1479        Ok(vec![gate4agent_catalog::EnvMutation {
1480            key: OsString::from("USERPROFILE"),
1481            value: None,
1482        }])
1483    }
1484}
1485
1486impl NodeServer {
1487    /// The letter-wake handle for the door, when m4a accounts are on. `token` is what callers
1488    /// must present.
1489    /// Turn on the m4a signer proxy for PTY spawns (replaces `M4A_SESSION_CAP`).
1490    #[cfg(unix)]
1491    pub fn set_signer_proxy(&self, proxy: Arc<crate::keystore_proxy::SignerProxy>) -> bool {
1492        self.shared.signer_proxy.set(proxy).is_ok()
1493    }
1494
1495    pub fn wake_handle(&self, token: String, policy: Arc<crate::wake_pipeline::WakePolicy>) -> Option<node_wake::NodeWake> {
1496        self.shared.m4a_accounts.as_ref()?;
1497        Some(node_wake::NodeWake::new(Arc::clone(&self.shared), token, policy))
1498    }
1499
1500    pub fn new(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1501        let catalog = active_registry()?;
1502        Self::new_with_registry(config, catalog)
1503    }
1504
1505    #[cfg(feature = "fixture")]
1506    pub fn new_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
1507        let mut spec = gate4agent_testkit::interactive_agent_spec();
1508        spec.id = AgentId::new("claude").map_err(|error| NodeServerError::Registry(error.to_string()))?;
1509        spec.display_name = "Controlled Claude PTY fixture".to_owned();
1510        Self::new_fixture_with_spec(config, spec)
1511    }
1512
1513    #[cfg(feature = "fixture")]
1514    pub fn new_clean_exit_fixture(
1515        config: NodeServerConfig,
1516        fixture_root: PathBuf,
1517        started_marker: PathBuf,
1518        release_signal: PathBuf,
1519    ) -> Result<Self, NodeServerError> {
1520        let mut spec = gate4agent_testkit::controlled_clean_exit_agent_spec(
1521            &fixture_root,
1522            &started_marker,
1523            &release_signal,
1524        )
1525        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1526        spec.id = AgentId::new("claude")
1527            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1528        spec.display_name = "Controlled Claude clean-exit fixture".to_owned();
1529        Self::new_fixture_with_spec(config, spec)
1530    }
1531
1532    /// A `claude` source whose session establishes a verified provider
1533    /// identity via an early `SessionStart` Hook post (so it is eligible for
1534    /// auto-export-at-exit and its native history is independently
1535    /// loadable), waits for `release_signal`, then exits cleanly — paired
1536    /// on the same Node with a plain `codex` target that never validates
1537    /// anything about what it receives. Built for A2 durable ContextPack
1538    /// E2E coverage: the test itself inspects materialized files and
1539    /// managed-session-record fields directly, rather than having the
1540    /// target script self-validate.
1541    #[cfg(all(feature = "fixture", windows))]
1542    pub fn new_durable_context_pack_clean_exit_fixture(
1543        config: NodeServerConfig,
1544        fixture_root: PathBuf,
1545        started_marker: PathBuf,
1546        release_signal: PathBuf,
1547        session_id: &str,
1548    ) -> Result<Self, NodeServerError> {
1549        let mut source_spec = gate4agent_testkit::identified_clean_exit_agent_spec(
1550            &fixture_root,
1551            &started_marker,
1552            &release_signal,
1553            session_id,
1554        )
1555        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1556        let source_id = AgentId::new("claude")
1557            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1558        let source_provider = builtin_registry().get(&source_id).ok_or_else(|| {
1559            NodeServerError::Registry(
1560                "durable context pack clean-exit fixture source adapter is unavailable"
1561                    .to_owned(),
1562            )
1563        })?;
1564        source_spec.capabilities.adapters.history =
1565            source_provider.capabilities.adapters.history.clone();
1566        source_spec.id = source_id;
1567        source_spec.display_name =
1568            "Controlled Claude durable context-pack clean-exit fixture".to_owned();
1569        // Both specs launch via `powershell.exe`; the registry's ambiguity
1570        // check keys on `detection.command` (+ aliases), not `launch.program`
1571        // -- give each a distinct detection identity, same precedent as
1572        // `context_pack_fixture_catalog`'s per-provider
1573        // `gate4agent-{provider}-context-fixture.cmd` commands.
1574        source_spec.detection.command =
1575            "gate4agent-claude-durable-context-pack-fixture.cmd".to_owned();
1576        source_spec.detection.aliases.clear();
1577
1578        let mut target_spec = gate4agent_testkit::interactive_agent_spec();
1579        target_spec.id = AgentId::new("codex")
1580            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1581        target_spec.display_name =
1582            "Controlled Codex durable context-pack target fixture".to_owned();
1583        target_spec.detection.command =
1584            "gate4agent-codex-durable-context-pack-fixture.cmd".to_owned();
1585        target_spec.detection.aliases.clear();
1586
1587        let catalog = AgentRegistry::new([source_spec, target_spec])
1588            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1589        let mut server = Self::new_with_registry(config, catalog)?;
1590        Arc::get_mut(&mut server.shared)
1591            .expect("fixture Node shared state must remain uniquely owned before run")
1592            .fixture_semantic_hook_policy = true;
1593        Ok(server)
1594    }
1595
1596    #[cfg(feature = "fixture")]
1597    pub fn new_monitoring_hook_fixture(
1598        config: NodeServerConfig,
1599    ) -> Result<Self, NodeServerError> {
1600        let mut spec = gate4agent_testkit::monitoring_hook_agent_spec();
1601        spec.id = AgentId::new("claude")
1602            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1603        let mut server = Self::new_fixture_with_spec(config, spec)?;
1604        Arc::get_mut(&mut server.shared)
1605            .expect("fixture Node shared state must remain uniquely owned before run")
1606            .fixture_semantic_hook_policy = true;
1607        Ok(server)
1608    }
1609
1610    #[cfg(all(feature = "fixture", windows))]
1611    pub fn new_provider_bundle_argv_fixture(
1612        config: NodeServerConfig,
1613        agent_id: AgentId,
1614        proof_path: PathBuf,
1615    ) -> Result<Self, NodeServerError> {
1616        let spec = Self::provider_bundle_argv_fixture_spec(agent_id, proof_path, None)?;
1617        Self::new_fixture_with_spec(config, spec)
1618    }
1619
1620    #[cfg(all(feature = "fixture", windows))]
1621    pub fn new_provider_bundle_argv_hold_fixture(
1622        config: NodeServerConfig,
1623        agent_id: AgentId,
1624        proof_path: PathBuf,
1625        release_signal: PathBuf,
1626    ) -> Result<Self, NodeServerError> {
1627        let spec = Self::provider_bundle_argv_fixture_spec(
1628            agent_id,
1629            proof_path,
1630            Some(release_signal),
1631        )?;
1632        Self::new_fixture_with_spec(config, spec)
1633    }
1634
1635    #[cfg(all(feature = "fixture", windows))]
1636    fn provider_bundle_argv_fixture_spec(
1637        agent_id: AgentId,
1638        proof_path: PathBuf,
1639        release_signal: Option<PathBuf>,
1640    ) -> Result<gate4agent_types::AgentSpec, NodeServerError> {
1641        if !matches!(agent_id.as_str(), "claude" | "codex" | "kimi") {
1642            return Err(NodeServerError::Registry(
1643                "provider bundle argv fixture requires Claude, Codex, or Kimi".to_owned(),
1644            ));
1645        }
1646        let mut spec = gate4agent_testkit::interactive_agent_spec();
1647        spec.id = agent_id.clone();
1648        spec.display_name = format!("Controlled {agent_id} bundle argv fixture");
1649        let provider = builtin_registry()
1650            .get(&agent_id)
1651            .ok_or_else(|| {
1652                NodeServerError::Registry(
1653                    "provider bundle argv fixture adapter is unavailable".to_owned(),
1654                )
1655            })?;
1656        spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1657        if !proof_path.is_absolute()
1658            || !proof_path
1659                .parent()
1660                .is_some_and(|parent| parent.is_dir())
1661        {
1662            return Err(NodeServerError::Registry(
1663                "provider bundle argv proof path requires an existing absolute parent"
1664                    .to_owned(),
1665            ));
1666        }
1667        if let Some(release_signal) = release_signal.as_ref() {
1668            if !release_signal.is_absolute()
1669                || !release_signal
1670                    .parent()
1671                    .is_some_and(|parent| parent.is_dir())
1672            {
1673                return Err(NodeServerError::Registry(
1674                    "provider bundle argv release path requires an existing absolute parent"
1675                        .to_owned(),
1676                ));
1677            }
1678            if release_signal.exists() {
1679                return Err(NodeServerError::Registry(
1680                    "provider bundle argv release path must not exist".to_owned(),
1681                ));
1682            }
1683            let proof_parent = std::fs::canonicalize(
1684                proof_path
1685                    .parent()
1686                    .expect("validated provider bundle proof parent"),
1687            )
1688            .map_err(|_| {
1689                NodeServerError::Registry(
1690                    "provider bundle argv proof parent is unavailable".to_owned(),
1691                )
1692            })?;
1693            let release_parent = std::fs::canonicalize(
1694                release_signal
1695                    .parent()
1696                    .expect("validated provider bundle release parent"),
1697            )
1698            .map_err(|_| {
1699                NodeServerError::Registry(
1700                    "provider bundle argv release parent is unavailable".to_owned(),
1701                )
1702            })?;
1703            if proof_parent != release_parent {
1704                return Err(NodeServerError::Registry(
1705                    "provider bundle argv release path must share the proof parent".to_owned(),
1706                ));
1707            }
1708        }
1709        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1710            NodeServerError::Registry(
1711                "provider bundle argv proof path is not valid Unicode".to_owned(),
1712            )
1713        })?;
1714        let release_signal = release_signal.map(|release_signal| {
1715            release_signal.into_os_string().into_string().map_err(|_| {
1716                NodeServerError::Registry(
1717                    "provider bundle argv release path is not valid Unicode".to_owned(),
1718                )
1719            })
1720        }).transpose()?;
1721        let script = spec
1722            .launch
1723            .fixed_args
1724            .pop()
1725            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
1726        let bundle_validation = if agent_id.as_str() == "codex" {
1727            "if ($bundleArgs.Count -ne 0) { exit 91 }; $codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process'); $cwd = (Get-Location).ProviderPath; if ([string]::IsNullOrEmpty($codexHome)) { [IO.File]::WriteAllLines($proofPath, [string[]]@('unbundled', $cwd)); [Console]::Out.WriteLine('F63_CODEX_UNBUNDLED_VALIDATED') } elseif (-not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 } else { $skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'; if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }; $files = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File); if ($files.Count -ne 1 -or [IO.Path]::GetFullPath($files[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }; $sha = [Security.Cryptography.SHA256]::Create(); try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }; if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }; [IO.File]::WriteAllLines($proofPath, [string[]]@('bundled', $codexHome, $cwd, $skillHash)); [Console]::Out.WriteLine('F63_CODEX_BUNDLE_HOME_VALIDATED') }"
1728        } else if agent_id.as_str() == "claude" {
1729            "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--plugin-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if (-not (Test-Path -LiteralPath (Join-Path $bundleArgs[1] '.claude-plugin/plugin.json') -PathType Leaf)) { exit 92 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1730        } else {
1731            "if ($bundleArgs.Count -eq 0) { [IO.File]::WriteAllLines($proofPath, [string[]]@()); [Console]::Out.WriteLine('F62_NO_BUNDLE_ARGV') } elseif ($bundleArgs.Count -eq 2 -and $bundleArgs[0] -ceq '--skills-dir' -and [IO.Path]::IsPathRooted($bundleArgs[1]) -and (Test-Path -LiteralPath $bundleArgs[1] -PathType Container)) { if ((Split-Path -Leaf $bundleArgs[1]) -ne 'skills') { exit 93 }; [IO.File]::WriteAllLines($proofPath, [string[]]@($bundleArgs[0], $bundleArgs[1])); [Console]::Out.WriteLine('F62_BUNDLE_ARGV_VALIDATED') } else { exit 91 }"
1732        };
1733        if let Some(release_signal) = release_signal {
1734            spec.launch.fixed_args.push(format!(
1735                "& {{ param([string]$proofPath, [string]$releaseSignal, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; $deadline = [DateTime]::UtcNow.AddSeconds(45); while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf)) {{ if ([DateTime]::UtcNow -ge $deadline) {{ exit 96 }}; Start-Sleep -Milliseconds 20 }}; {script} }}",
1736            ));
1737            spec.launch.fixed_args.push(proof_path);
1738            spec.launch.fixed_args.push(release_signal);
1739        } else {
1740            spec.launch.fixed_args.push(format!(
1741                "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {bundle_validation}; {script} }}",
1742            ));
1743            spec.launch.fixed_args.push(proof_path);
1744        }
1745        Ok(spec)
1746    }
1747
1748    #[cfg(all(feature = "fixture", windows))]
1749    pub fn new_context_pack_fixture(
1750        config: NodeServerConfig,
1751        proof_path: PathBuf,
1752    ) -> Result<Self, NodeServerError> {
1753        let catalog = Self::context_pack_fixture_catalog(proof_path, None, false)?;
1754        Self::new_with_registry(config, catalog)
1755    }
1756
1757    #[cfg(all(feature = "fixture", windows))]
1758    pub fn new_monitoring_context_pack_fixture(
1759        config: NodeServerConfig,
1760        proof_path: PathBuf,
1761    ) -> Result<Self, NodeServerError> {
1762        let catalog = Self::context_pack_fixture_catalog(proof_path, None, true)?;
1763        let mut server = Self::new_with_registry(config, catalog)?;
1764        Arc::get_mut(&mut server.shared)
1765            .expect("fixture Node shared state must remain uniquely owned before run")
1766            .fixture_semantic_hook_policy = true;
1767        Ok(server)
1768    }
1769
1770    #[cfg(all(feature = "fixture", windows))]
1771    pub fn install_isolated_codex_home_environment_fixture(
1772        &mut self,
1773        profile_id: SpawnEnvironmentProfileId,
1774        profile_revision: crate::protocol::SpawnEnvironmentProfileRevision,
1775    ) -> Result<(), NodeServerError> {
1776        let provider = AgentId::new("codex")
1777            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1778        let native_profile = gate4agent_runtime_native::NativeLaunchProfile::new(
1779            gate4agent_runtime_native::NativeLaunchProfileId::new(format!(
1780                "{}-pty",
1781                profile_id.as_str(),
1782            ))
1783            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1784            provider.clone(),
1785            TransportKind::Pty,
1786            vec![OsString::from("USERPROFILE")],
1787            Arc::new(IsolatedCodexHomeFixtureEnvironment),
1788        )
1789        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1790        let materialization = NodeSessionMaterializationProfile::new(
1791            Vec::new(),
1792            vec![crate::session_environment::NodeSessionPathBinding::new(
1793                "CODEX_HOME",
1794                crate::session_environment::NodeSessionPathClass::ProviderHome,
1795            )
1796            .map_err(|error| NodeServerError::Registry(error.to_string()))?],
1797            Vec::new(),
1798        )
1799        .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1800        self.install_environment_profile(
1801            NodeEnvironmentProfile::new_with_materialization(
1802                profile_id,
1803                profile_revision,
1804                provider,
1805                [native_profile],
1806                Some(materialization),
1807            )
1808            .map_err(|error| NodeServerError::Registry(error.to_string()))?,
1809        )
1810    }
1811
1812    #[cfg(all(feature = "fixture", windows))]
1813    pub fn new_context_only_proof_fixture(
1814        config: NodeServerConfig,
1815        target_provider: AgentId,
1816        proof_path: PathBuf,
1817    ) -> Result<Self, NodeServerError> {
1818        if target_provider.as_str() != "kimi" {
1819            return Err(NodeServerError::Registry(
1820                "context-only proof fixture requires Kimi".to_owned(),
1821            ));
1822        }
1823        let catalog = Self::context_pack_fixture_catalog(
1824            proof_path,
1825            Some(&target_provider),
1826            false,
1827        )?;
1828        Self::new_with_registry(config, catalog)
1829    }
1830
1831    #[cfg(all(feature = "fixture", windows))]
1832    fn context_pack_fixture_catalog(
1833        proof_path: PathBuf,
1834        context_only_target: Option<&AgentId>,
1835        monitoring_claude_source: bool,
1836    ) -> Result<AgentRegistry, NodeServerError> {
1837        if !proof_path.is_absolute()
1838            || !proof_path
1839                .parent()
1840                .is_some_and(|parent| parent.is_dir())
1841        {
1842            return Err(NodeServerError::Registry(
1843                "context pack proof path requires an existing absolute parent".to_owned(),
1844            ));
1845        }
1846        let proof_path = proof_path.into_os_string().into_string().map_err(|_| {
1847            NodeServerError::Registry(
1848                "context pack proof path is not valid Unicode".to_owned(),
1849            )
1850        })?;
1851        let providers = ["claude", "codex", "grok", "kimi"];
1852        let builtins = builtin_registry();
1853        let mut specs = Vec::with_capacity(providers.len());
1854        for provider_id in providers {
1855            let agent_id = AgentId::new(provider_id)
1856                .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1857            let provider = builtins.get(&agent_id).ok_or_else(|| {
1858                NodeServerError::Registry(
1859                    "context pack fixture provider adapter is unavailable".to_owned(),
1860                )
1861            })?;
1862            let monitored_provider =
1863                monitoring_claude_source && matches!(provider_id, "claude" | "codex");
1864            let mut spec = if monitored_provider {
1865                gate4agent_testkit::monitoring_hook_agent_spec()
1866            } else {
1867                gate4agent_testkit::interactive_agent_spec()
1868            };
1869            spec.id = agent_id.clone();
1870            spec.display_name = format!("Controlled {agent_id} context pack fixture");
1871            spec.detection.command = format!("gate4agent-{provider_id}-context-fixture.cmd");
1872            spec.detection.aliases.clear();
1873            spec.capabilities.adapters.history = provider.capabilities.adapters.history.clone();
1874            if provider_id == "codex" {
1875                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1876                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1877                })?;
1878                let validation = r#"
1879$bundleArgs = @($bundleArgs)
1880if ($bundleArgs.Count -ne 0) { exit 91 }
1881$codexHome = [Environment]::GetEnvironmentVariable('CODEX_HOME', 'Process')
1882$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1883$cwd = (Get-Location).ProviderPath
1884if ([string]::IsNullOrEmpty($codexHome) -or -not [IO.Path]::IsPathRooted($codexHome) -or -not (Test-Path -LiteralPath $codexHome -PathType Container)) { exit 92 }
1885$skillPath = Join-Path $codexHome 'skills/review-code/SKILL.md'
1886if (-not (Test-Path -LiteralPath $skillPath -PathType Leaf)) { exit 93 }
1887$homeFiles = @(Get-ChildItem -LiteralPath $codexHome -Recurse -Force -File)
1888if ($homeFiles.Count -ne 1 -or [IO.Path]::GetFullPath($homeFiles[0].FullName) -ine [IO.Path]::GetFullPath($skillPath)) { exit 94 }
1889$sha = [Security.Cryptography.SHA256]::Create()
1890try { $skillHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($skillPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1891if ($skillHash -cne 'd78fe03be106b673fcf5415c8359e99f0298b5071cd11822c58ebcb27e52a68d') { exit 95 }
1892if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 96 }
1893$contextPath = Join-Path $contextRoot 'context-pack.json'
1894if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 97 }
1895$contextFiles = @(Get-ChildItem -LiteralPath $contextRoot -Recurse -Force -File)
1896if ($contextFiles.Count -ne 1 -or [IO.Path]::GetFullPath($contextFiles[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 98 }
1897$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1898$codexFull = [IO.Path]::GetFullPath($codexHome).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1899$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1900$separator = [IO.Path]::DirectorySeparatorChar
1901if ($contextFull.Equals($codexFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($codexFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 99 }
1902if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 100 }
1903$rawContext = [IO.File]::ReadAllText($contextPath)
1904if ($rawContext.Contains('g4a-private-provider-session-canary') -or $rawContext.Contains('private-provider-login-identity')) { exit 101 }
1905try { $document = $rawContext | ConvertFrom-Json -ErrorAction Stop } catch { exit 102 }
1906$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')
1907$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')
1908foreach ($property in @($document.PSObject.Properties.Name)) {
1909    if ($allowedTopLevel -cnotcontains [string]$property) { exit 103 }
1910}
1911foreach ($property in $requiredTopLevel) {
1912    if ($null -eq $document.PSObject.Properties[$property]) { exit 104 }
1913}
1914if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 105 }
1915if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 106 }
1916$messages = @($document.retained_messages)
1917if ($messages.Count -eq 0) { exit 107 }
1918foreach ($message in $messages) {
1919    $messageProperties = @($message.PSObject.Properties.Name)
1920    foreach ($property in $messageProperties) {
1921        if (@('role', 'text') -cnotcontains [string]$property) { exit 108 }
1922    }
1923    if ($messageProperties.Count -ne 2 -or @('user', 'assistant') -cnotcontains [string]$message.role -or [string]::IsNullOrEmpty([string]$message.text)) { exit 109 }
1924}
1925$sha = [Security.Cryptography.SHA256]::Create()
1926try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1927[IO.File]::WriteAllLines($proofPath, [string[]]@('contextual', $codexHome, $cwd, $skillHash, $contextRoot, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count, [string]$messages[0].text, [string]$messages[$messages.Count - 1].text))
1928$rawProof = [IO.File]::ReadAllText($proofPath)
1929if ($rawProof.Contains('g4a-private-provider-session-canary') -or $rawProof.Contains('private-provider-login-identity')) { exit 110 }
1930[Console]::Out.WriteLine('F7_CODEX_BUNDLE_CONTEXT_VALIDATED')
1931"#;
1932                spec.launch.fixed_args.push(format!(
1933                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1934                ));
1935                spec.launch.fixed_args.push(proof_path.clone());
1936            } else if context_only_target.map(AgentId::as_str) == Some(provider_id) {
1937                let script = spec.launch.fixed_args.pop().ok_or_else(|| {
1938                    NodeServerError::Registry("PTY fixture script is unavailable".to_owned())
1939                })?;
1940                let validation = r#"
1941$bundleArgs = @($bundleArgs)
1942if ($bundleArgs.Count -ne 0) { exit 91 }
1943$contextRoot = [Environment]::GetEnvironmentVariable('GATE4AGENT_CONTEXT_ROOT', 'Process')
1944$cwd = (Get-Location).ProviderPath
1945if ([string]::IsNullOrEmpty($contextRoot) -or -not [IO.Path]::IsPathRooted($contextRoot) -or -not (Test-Path -LiteralPath $contextRoot -PathType Container)) { exit 92 }
1946$contextPath = Join-Path $contextRoot 'context-pack.json'
1947if (-not (Test-Path -LiteralPath $contextPath -PathType Leaf)) { exit 93 }
1948$entries = @(Get-ChildItem -LiteralPath $contextRoot -Force)
1949if ($entries.Count -ne 1 -or $entries[0] -isnot [IO.FileInfo] -or [IO.Path]::GetFullPath($entries[0].FullName) -ine [IO.Path]::GetFullPath($contextPath)) { exit 94 }
1950$contextFull = [IO.Path]::GetFullPath($contextRoot).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1951$cwdFull = [IO.Path]::GetFullPath($cwd).TrimEnd([IO.Path]::DirectorySeparatorChar, [IO.Path]::AltDirectorySeparatorChar)
1952$separator = [IO.Path]::DirectorySeparatorChar
1953if ($contextFull.Equals($cwdFull, [StringComparison]::OrdinalIgnoreCase) -or $contextFull.StartsWith($cwdFull + $separator, [StringComparison]::OrdinalIgnoreCase) -or $cwdFull.StartsWith($contextFull + $separator, [StringComparison]::OrdinalIgnoreCase)) { exit 95 }
1954try { $document = [IO.File]::ReadAllText($contextPath) | ConvertFrom-Json -ErrorAction Stop } catch { exit 96 }
1955if ($document.schema -cne 'g4a-context-pack-v1' -or $null -ne $document.PSObject.Properties['cwd']) { exit 97 }
1956if (@('claude', 'codex', 'grok', 'kimi') -cnotcontains [string]$document.source_provider) { exit 98 }
1957$messages = @($document.retained_messages)
1958if ($messages.Count -eq 0) { exit 99 }
1959$hasUser = $false
1960$hasAssistant = $false
1961foreach ($message in $messages) {
1962    if ([string]::IsNullOrWhiteSpace([string]$message.text)) { exit 100 }
1963    if ([string]$message.role -ceq 'user') { $hasUser = $true }
1964    elseif ([string]$message.role -ceq 'assistant') { $hasAssistant = $true }
1965    else { exit 101 }
1966}
1967if (-not $hasUser -or -not $hasAssistant) { exit 102 }
1968$sha = [Security.Cryptography.SHA256]::Create()
1969try { $contextHash = ([BitConverter]::ToString($sha.ComputeHash([IO.File]::ReadAllBytes($contextPath)))).Replace('-', '').ToLowerInvariant() } finally { $sha.Dispose() }
1970[IO.File]::WriteAllLines($proofPath, [string[]]@('context-only', $contextRoot, $cwd, $contextHash, [string]$document.schema, [string]$document.source_provider, [string]$messages.Count))
1971[Console]::Out.WriteLine('F7_KIMI_CONTEXT_ONLY_VALIDATED')
1972"#;
1973                spec.launch.fixed_args.push(format!(
1974                    "& {{ param([string]$proofPath, [Parameter(ValueFromRemainingArguments=$true)][string[]]$bundleArgs) {validation}; {script} }}",
1975                ));
1976                spec.launch.fixed_args.push(proof_path.clone());
1977            }
1978            specs.push(spec);
1979        }
1980        let catalog = AgentRegistry::new(specs)
1981            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
1982        Ok(catalog)
1983    }
1984
1985    #[cfg(feature = "fixture")]
1986    pub fn new_exact_launcher_fixture(
1987        config: NodeServerConfig,
1988        agent_id: AgentId,
1989        launcher: String,
1990    ) -> Result<Self, NodeServerError> {
1991        if !matches!(agent_id.as_str(), "claude" | "codex" | "grok" | "kimi") {
1992            return Err(NodeServerError::Registry(
1993                "exact launcher fixture requires a supported provider".to_owned(),
1994            ));
1995        }
1996        if launcher.contains('\0') {
1997            return Err(NodeServerError::Registry(
1998                "exact launcher fixture path contains NUL".to_owned(),
1999            ));
2000        }
2001        let launcher_path = Path::new(&launcher);
2002        if !launcher_path.is_absolute() {
2003            return Err(NodeServerError::Registry(
2004                "exact launcher fixture path must be absolute".to_owned(),
2005            ));
2006        }
2007        let metadata = std::fs::metadata(launcher_path).map_err(|_| {
2008            NodeServerError::Registry("exact launcher fixture path is unavailable".to_owned())
2009        })?;
2010        if !metadata.is_file() {
2011            return Err(NodeServerError::Registry(
2012                "exact launcher fixture path must be a regular file".to_owned(),
2013            ));
2014        }
2015        let mut spec = builtin_registry()
2016            .get(&agent_id)
2017            .ok_or_else(|| {
2018                NodeServerError::Registry(
2019                    "exact launcher fixture provider is unavailable".to_owned(),
2020                )
2021            })?
2022            .clone();
2023        spec.launch.program = launcher;
2024        Self::new_fixture_with_spec(config, spec)
2025    }
2026
2027    /// Shared H3B validation and spec construction for
2028    /// `new_harness_mcp_proxy_fixture` and
2029    /// `new_harness_mcp_acp_launcher_fixture`: an absolute, exact-regular-
2030    /// file provider launcher plus at most 32 Unicode, NUL-free fixed
2031    /// arguments (65,536 bytes total), replacing `spec.launch` on a clone of
2032    /// the named builtin catalog entry -- detection command and expected
2033    /// process matcher rebased on the launcher's own basename, exactly the
2034    /// same shape `new_harness_mcp_proxy_fixture` always built.
2035    #[cfg(feature = "fixture")]
2036    fn h3b_provider_spec_with_program_and_args(
2037        agent_id: AgentId,
2038        provider_program: PathBuf,
2039        fixed_args: Vec<OsString>,
2040    ) -> Result<AgentSpec, NodeServerError> {
2041        if !provider_program.is_absolute() || fixed_args.len() > 32 {
2042            return Err(NodeServerError::Registry(
2043                "H3B fixture requires an absolute provider and at most 32 arguments".to_owned(),
2044            ));
2045        }
2046        let metadata = std::fs::symlink_metadata(&provider_program).map_err(|_| {
2047            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2048        })?;
2049        if !metadata.file_type().is_file() || metadata.file_type().is_symlink() {
2050            return Err(NodeServerError::Registry(
2051                "H3B fixture provider must be an exact regular file".to_owned(),
2052            ));
2053        }
2054        let program = provider_program.into_os_string().into_string().map_err(|_| {
2055            NodeServerError::Registry("H3B fixture provider path is not Unicode".to_owned())
2056        })?;
2057        let fixed_args = fixed_args.into_iter().map(|argument| {
2058            argument.into_string().map_err(|_| NodeServerError::Registry(
2059                "H3B fixture argument is not Unicode".to_owned(),
2060            ))
2061        }).collect::<Result<Vec<_>, _>>()?;
2062        let argument_bytes = fixed_args.iter()
2063            .try_fold(0usize, |total, argument| total.checked_add(argument.len()));
2064        if program.contains('\0')
2065            || fixed_args.iter().any(|argument| argument.contains('\0'))
2066            || !matches!(argument_bytes, Some(total) if total <= 65_536)
2067        {
2068            return Err(NodeServerError::Registry(
2069                "H3B fixture provider or arguments are invalid".to_owned(),
2070            ));
2071        }
2072        let mut spec = builtin_registry().get(&agent_id).ok_or_else(|| {
2073            NodeServerError::Registry("H3B fixture provider is unavailable".to_owned())
2074        })?.clone();
2075        let process_name = Path::new(&program).file_name().and_then(|name| name.to_str())
2076            .ok_or_else(|| NodeServerError::Registry(
2077                "H3B fixture provider basename is unavailable".to_owned(),
2078            ))?.to_owned();
2079        spec.detection.command = process_name.clone();
2080        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2081            name: process_name,
2082        }];
2083        spec.launch.program = program;
2084        spec.launch.fixed_args = fixed_args;
2085        Ok(spec)
2086    }
2087
2088    #[cfg(feature = "fixture")]
2089    pub fn new_harness_mcp_proxy_fixture(
2090        config: NodeServerConfig,
2091        provider_program: PathBuf,
2092        fixed_args: Vec<OsString>,
2093        harness_mcp_program: PathBuf,
2094    ) -> Result<Self, NodeServerError> {
2095        let mut config = config.with_harness_mcp_helper(harness_mcp_program)?;
2096        let agent_id = AgentId::new("codex")
2097            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2098        let mut spec =
2099            Self::h3b_provider_spec_with_program_and_args(agent_id, provider_program, fixed_args)?;
2100        spec.display_name = "Controlled H3B MCP proxy fixture".to_owned();
2101        config.fixture_raw_pty_runtime = true;
2102        Self::new_fixture_with_spec(config, spec)
2103    }
2104
2105    /// The ACP-mode sibling of `new_harness_mcp_proxy_fixture`: the same
2106    /// `--harness-mcp-helper` wiring and the same absolute-program-plus-
2107    /// fixed-args launcher replacement as `new_exact_launcher_fixture`, but
2108    /// admitting `ProviderRuntimeRequirement::Acp` the honest way instead of
2109    /// hardcoding it. `new_harness_mcp_proxy_fixture` forces `config.
2110    /// fixture_raw_pty_runtime = true`, which tears out the live
2111    /// `ProviderRuntimeMonitor` entirely (`NodeServer::new_with_registry`)
2112    /// and makes `NodeShared::admit_provider_runtime`'s no-monitor branch
2113    /// hardcode `acp_transport = false` for every provider, refusing
2114    /// `ProviderRuntimeRequirement::Acp` unconditionally regardless of what
2115    /// the catalog declares. This constructor leaves `fixture_raw_pty_
2116    /// runtime` at its default `false`, so a real `ProviderRuntimeMonitor`
2117    /// is built from the (cloned, launcher-replaced) catalog spec, and
2118    /// `ProviderRuntimeMonitor::supports_acp_transport` reads the honest,
2119    /// unmodified catalog fact: every builtin provider this accepts
2120    /// (`claude`, `codex`, `grok`, `kimi`) already declares `capabilities.
2121    /// transports.acp` in `gate4agent-catalog::builtin`, so no capability is
2122    /// invented here -- this constructor only removes the fixture-only
2123    /// bypass standing in its way. `provider_runtime::policy_for_transport`'s
2124    /// `TransportKind::Acp` arm (applied unconditionally at spawn-prep time
2125    /// for every ACP session, fixture or not) is what actually grants the
2126    /// ACP-shaped `ProviderRuntimePolicy` downstream of that admission --
2127    /// this constructor never calls it directly, and does not need to.
2128    #[cfg(feature = "fixture")]
2129    pub fn new_harness_mcp_acp_launcher_fixture(
2130        config: NodeServerConfig,
2131        helper_program: PathBuf,
2132        provider: AgentId,
2133        program: PathBuf,
2134        args: Vec<OsString>,
2135    ) -> Result<Self, NodeServerError> {
2136        if !matches!(provider.as_str(), "claude" | "codex" | "grok" | "kimi") {
2137            return Err(NodeServerError::Registry(
2138                "ACP harness-MCP launcher fixture requires a supported provider".to_owned(),
2139            ));
2140        }
2141        let config = config.with_harness_mcp_helper(helper_program)?;
2142        let mut spec = Self::h3b_provider_spec_with_program_and_args(provider, program, args)?;
2143        let launch = spec.launch.clone();
2144        let acp = spec.capabilities.transports.acp.as_mut().ok_or_else(|| {
2145            NodeServerError::Registry(
2146                "ACP harness-MCP launcher fixture provider does not declare an ACP transport"
2147                    .to_owned(),
2148            )
2149        })?;
2150        // Routes the ACP spawn itself through the exact replaced launcher
2151        // (`AcpSession::spawn_with_launch` -> a bare `Command::new(&launch.
2152        // program)`, `gate4agent`'s `src/acp/spawn.rs`) instead of the
2153        // catalog's own vendor-npm resolution (`acp_command`, same file) --
2154        // without this the ACP transport ignores `spec.launch` entirely and
2155        // tries to spawn the real vendor CLI (e.g. `npx -y @agentclientprotocol/
2156        // codex-acp@1.10.0`), which this fixture must never reach.
2157        acp.launch_override = Some(launch);
2158        spec.display_name = "Controlled ACP harness-MCP launcher fixture".to_owned();
2159        Self::new_fixture_with_spec(config, spec)
2160    }
2161
2162    #[cfg(feature = "fixture")]
2163    pub fn new_resume_fixture(config: NodeServerConfig) -> Result<Self, NodeServerError> {
2164        let mut spec = gate4agent_testkit::interactive_agent_spec();
2165        let claude_id = AgentId::new("claude")
2166            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2167        let claude = builtin_registry()
2168            .get(&claude_id)
2169            .ok_or_else(|| NodeServerError::Registry("Claude fixture adapter is unavailable".to_owned()))?;
2170        spec.id = claude_id;
2171        spec.display_name = "Resumable Claude PTY fixture".to_owned();
2172        spec.expected_processes = vec![gate4agent_types::ProcessMatcher::Exact {
2173            name: "claude".to_owned(),
2174        }];
2175        spec.capabilities.transports.pty_adapter = claude
2176            .capabilities
2177            .transports
2178            .pty_adapter
2179            .clone();
2180        spec.capabilities.adapters.resume = claude
2181            .capabilities
2182            .adapters
2183            .resume
2184            .clone();
2185        let script = spec
2186            .launch
2187            .fixed_args
2188            .pop()
2189            .ok_or_else(|| NodeServerError::Registry("PTY fixture script is unavailable".to_owned()))?;
2190        spec.launch.fixed_args.push(format!(
2191            "& {{ param([Parameter(ValueFromRemainingArguments=$true)][string[]]$ignored) {script} }}",
2192        ));
2193        Self::new_fixture_with_spec(config, spec)
2194    }
2195
2196    #[cfg(feature = "fixture")]
2197    pub fn new_cmd_cwd_fixture(
2198        config: NodeServerConfig,
2199        command: String,
2200    ) -> Result<Self, NodeServerError> {
2201        let mut spec = gate4agent_testkit::interactive_agent_spec();
2202        spec.id = AgentId::new("claude")
2203            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2204        spec.display_name = "Controlled cmd cwd fixture".to_owned();
2205        spec.detection.command = "gate4agent-cwd-fixture.cmd".to_owned();
2206        spec.launch.program = command;
2207        spec.launch.fixed_args.clear();
2208        Self::new_fixture_with_spec(config, spec)
2209    }
2210
2211    #[cfg(feature = "fixture")]
2212    fn new_fixture_with_spec(
2213        config: NodeServerConfig,
2214        spec: gate4agent_types::AgentSpec,
2215    ) -> Result<Self, NodeServerError> {
2216        let catalog = AgentRegistry::new([spec])
2217            .map_err(|error| NodeServerError::Registry(error.to_string()))?;
2218        Self::new_with_registry(config, catalog)
2219    }
2220
2221    fn new_with_registry(
2222        config: NodeServerConfig,
2223        catalog: AgentRegistry,
2224    ) -> Result<Self, NodeServerError> {
2225        for profile in config.spawn_profiles.iter() {
2226            if catalog.get(&profile.provider).is_none() {
2227                return Err(NodeServerError::Registry(
2228                    "spawn profile references an unavailable provider".to_owned(),
2229                ));
2230            }
2231        }
2232        let input_settle_timeout_ms = catalog
2233            .iter()
2234            .map(|spec| spec.readiness.timeout_ms)
2235            .max()
2236            .unwrap_or(MUTATION_SETTLE_TIMEOUT_MS)
2237            .saturating_add(READINESS_SETTLE_HEADROOM_MS)
2238            .max(MUTATION_SETTLE_TIMEOUT_MS);
2239        let (provider_contracts, provider_adapter_contracts) =
2240            provider_contract_manifest(&catalog)?;
2241        let compatibility_support = node_compatibility_support_for_manifest(
2242            &provider_contracts,
2243            &provider_adapter_contracts,
2244        )?;
2245        validate_node_negotiated_handshake_capacity(&compatibility_support)
2246            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2247        let enabled_providers = provider_contracts
2248            .iter()
2249            .map(|contract| contract.provider.clone())
2250            .collect();
2251        #[cfg(feature = "fixture")]
2252        let fixture_raw_pty_runtime = config.fixture_raw_pty_runtime;
2253        #[cfg(not(feature = "fixture"))]
2254        let fixture_raw_pty_runtime = false;
2255        let (provider_runtime_monitor, provider_runtime_statuses) = if fixture_raw_pty_runtime {
2256            let statuses = ProviderRuntimeStatuses::new(catalog.iter().map(|spec| {
2257                crate::protocol::ProviderRuntimeStatus::raw_passthrough(spec.id.clone(), None)
2258            })).expect("fixture provider catalog remains bounded");
2259            (None, statuses)
2260        } else {
2261            let monitor = Arc::new(ProviderRuntimeMonitor::new(&catalog));
2262            let statuses = monitor.collect();
2263            (Some(monitor), statuses)
2264        };
2265        let state_path_lock =
2266            session_registry::StatePathLock::acquire(config.state_path.as_deref())
2267                .map_err(|error| {
2268                    durable_state_server_error(error, DURABLE_STATE_LOCK_FAILED_ERROR)
2269                })?;
2270        let (delivery_store, delivered_bundles) = match config.state_path.as_ref() {
2271            Some(state_path) => {
2272                let delivery_root = delivery_store_root_for_state_path(state_path)
2273                    .ok_or_else(|| {
2274                        NodeServerError::DeliveryStore(
2275                            "delivery store root could not be derived from state path".to_owned(),
2276                        )
2277                    })?;
2278                let (store, bundles) = DeliveryStore::open(delivery_root).map_err(|error| {
2279                    let detail = match &error {
2280                        DeliveryStoreError::Storage(io_error) => {
2281                            format!("{error}: {io_error}")
2282                        }
2283                        _ => error.to_string(),
2284                    };
2285                    NodeServerError::DeliveryStore(detail)
2286                })?;
2287                (Some(store), bundles)
2288            }
2289            None => (None, Vec::new()),
2290        };
2291        let delivered_catalog = BundleCatalog::new(delivered_bundles).map_err(|error| {
2292            NodeServerError::DeliveryStore(format!("bundle catalog: {error}"))
2293        })?;
2294        let (context_pack_store, durable_context_packs) = match config.state_path.as_ref() {
2295            Some(state_path) => {
2296                let context_pack_root = context_pack_store_root_for_state_path(state_path)
2297                    .ok_or(NodeServerError::ContextPackStore)?;
2298                let (store, packs) = ContextPackStore::open(context_pack_root)
2299                    .map_err(|_| NodeServerError::ContextPackStore)?;
2300                (Some(store), packs)
2301            }
2302            None => (None, Vec::new()),
2303        };
2304        let mut durable_context_catalog = ContextPackCatalog::default();
2305        for pack in durable_context_packs {
2306            durable_context_catalog
2307                .insert(pack)
2308                .map_err(|_| NodeServerError::ContextPackStore)?;
2309        }
2310        let session_environment_materializer = config
2311            .session_environment
2312            .as_ref()
2313            .map(|environment| {
2314                SessionEnvironmentMaterializer::new(
2315                    environment.root.clone(),
2316                    Arc::clone(&environment.resolver),
2317                )
2318                .map_err(|_| NodeServerError::SessionEnvironmentMaterializer)
2319            })
2320            .transpose()?;
2321        // Session launchers go through the unprivileged `exec-as` wrapper when run-as is set
2322        // (the provider version probe above already used the original catalog).
2323        #[cfg(unix)]
2324        let catalog = crate::isolation::wrap_registry(catalog).map_err(NodeServerError::Registry)?;
2325        let native_session_catalog = config.history.clone().map(|history| {
2326            Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)))
2327        });
2328        let (handle, runtime) = match config.history.clone() {
2329            Some(history) => NativeRuntime::new_with_history(catalog, config.runtime, history),
2330            None => NativeRuntime::new(catalog, config.runtime),
2331        };
2332        let native_launch_profile_control = runtime.native_launch_profile_control();
2333        let harness_mcp_registry = config
2334            .harness_mcp_helper
2335            .clone()
2336            .map(HarnessMcpProxyRegistry::new);
2337        let events = handle.subscribe(CONTROL_EVENT_SUBSCRIPTION_CAPACITY);
2338        let incarnation_id = random_incarnation_id()
2339            .map_err(NodeServerError::IncarnationIdentity)?;
2340        let mut loaded = session_registry::load(config.state_path.as_deref(), &config.node_id)
2341            .map_err(durable_state_load_error)?;
2342        let managed_worktrees = std::mem::take(&mut loaded.managed_worktrees);
2343        let managed_worktree_tombstones =
2344            std::mem::take(&mut loaded.managed_worktree_tombstones);
2345        let materializations = std::mem::take(&mut loaded.materializations);
2346        let managed_spawn_replays = std::mem::take(&mut loaded.managed_spawn_replays);
2347        let (workspaces, records, persistence_warning) =
2348            merge_durable_state(&config.workspaces, loaded)?;
2349        let mut shared = NodeShared::new_with_incarnation(
2350            handle,
2351            config.access_token.clone(),
2352            config.node_id.clone(),
2353            incarnation_id,
2354            workspaces,
2355            enabled_providers,
2356            provider_runtime_statuses,
2357            provider_runtime_monitor,
2358            provider_contracts,
2359            provider_adapter_contracts,
2360            config.spawn_profiles.clone(),
2361            Some(native_launch_profile_control),
2362            native_session_catalog,
2363            session_environment_materializer,
2364            config.state_path.clone(),
2365            records,
2366            managed_worktrees,
2367            managed_worktree_tombstones,
2368            materializations,
2369            persistence_warning,
2370            input_settle_timeout_ms,
2371        );
2372        shared.harness_mcp_registry = harness_mcp_registry.clone();
2373        shared.session_record_retention = config.session_record_retention;
2374        shared.network_allowlist_catalog =
2375            RwLock::new(config.network_allowlist_catalog.clone());
2376        shared.bundle_catalog = RwLock::new(delivered_catalog);
2377        shared.delivery_store = Mutex::new(delivery_store);
2378        shared.context_catalog = RwLock::new(durable_context_catalog);
2379        shared.context_pack_store = Mutex::new(context_pack_store);
2380        shared.managed_spawn_replays = Mutex::new(
2381            managed_spawn_replays
2382                .into_iter()
2383                .map(|record| (record.idempotency_key.clone(), record))
2384                .collect(),
2385        );
2386        let shared = Arc::new(shared);
2387        if let Some(registry) = harness_mcp_registry {
2388            let weak = Arc::downgrade(&shared);
2389            registry.set_event_sink(Arc::new(move |event| match weak.upgrade() {
2390                Some(shared) => shared.publish_transient(event),
2391                None => false,
2392            }));
2393        }
2394        if shared.persistence_error().is_none() {
2395            shared.persist_state().map_err(|error| {
2396                durable_state_server_error(error, DURABLE_STATE_COMMIT_FAILED_ERROR)
2397            })?;
2398        }
2399        Ok(Self {
2400            config,
2401            runtime,
2402            shared,
2403            events,
2404            state_path_lock,
2405        })
2406    }
2407
2408    pub fn shutdown_handle(&self) -> NodeShutdownHandle {
2409        NodeShutdownHandle {
2410            shared: Arc::clone(&self.shared),
2411        }
2412    }
2413
2414    #[cfg(feature = "fixture")]
2415    pub fn spawn_managed_worktree_v2_failure_probe_fixture(
2416        &self,
2417    ) -> SpawnManagedWorktreeV2FailureProbe {
2418        self.shared
2419            .fixture_spawn_managed_worktree_v2_failure_probe
2420            .clone()
2421    }
2422
2423    /// Installs one immutable host-local environment profile before the server runs.
2424    ///
2425    /// Native resolvers remain inside the runtime; the shared node registry keeps
2426    /// only public IDs, revisions, provider bindings, and opaque native profile IDs.
2427    pub fn install_environment_profile(
2428        &mut self,
2429        profile: NodeEnvironmentProfile,
2430    ) -> Result<(), NodeServerError> {
2431        if !self.shared.enabled_providers.contains(profile.provider()) {
2432            return Err(NodeServerError::EnvironmentProfileProviderUnavailable(
2433                profile.provider().clone(),
2434            ));
2435        }
2436        let (binding, native_profiles, materialization) = profile.into_parts();
2437        if materialization.is_some() && self.shared.session_environment_materializer.is_none() {
2438            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2439        }
2440        let mut profiles = self
2441            .shared
2442            .environment_profiles
2443            .write()
2444            .unwrap_or_else(|poisoned| poisoned.into_inner());
2445        if profiles.contains_key(&binding.id) {
2446            return Err(NodeServerError::DuplicateEnvironmentProfile(
2447                binding.id.clone(),
2448            ));
2449        }
2450        if profiles.len() >= MAX_NODE_ENVIRONMENT_PROFILES {
2451            return Err(NodeServerError::EnvironmentProfileCapacity {
2452                max: MAX_NODE_ENVIRONMENT_PROFILES,
2453            });
2454        }
2455        for native_id in binding.native_profile_ids() {
2456            if profiles.values().any(|current| {
2457                current.native_profile_ids().any(|current_id| current_id == native_id)
2458            }) {
2459                return Err(NodeServerError::DuplicateNativeEnvironmentProfile(
2460                    native_id.to_string(),
2461                ));
2462            }
2463        }
2464
2465        let mut installed = Vec::with_capacity(native_profiles.len());
2466        for native_profile in native_profiles {
2467            let native_id = native_profile.id().clone();
2468            if let Err(error) = self.runtime.upsert_native_launch_profile(native_profile) {
2469                for installed_id in installed.into_iter().rev() {
2470                    let _ = self.runtime.remove_native_launch_profile(&installed_id);
2471                }
2472                return Err(NodeServerError::NativeEnvironmentProfile(error.to_string()));
2473            }
2474            installed.push(native_id);
2475        }
2476        if let Some(materialization) = materialization {
2477            self.shared
2478                .environment_materialization_profiles
2479                .write()
2480                .unwrap_or_else(|poisoned| poisoned.into_inner())
2481                .insert(binding.id.clone(), materialization);
2482        }
2483        profiles.insert(binding.id.clone(), binding);
2484        Ok(())
2485    }
2486
2487    /// Installs one immutable host-local bundle before the server runs.
2488    pub fn install_bundle(&mut self, bundle: NodeBundle) -> Result<(), NodeServerError> {
2489        if self.shared.session_environment_materializer.is_none() {
2490            return Err(NodeServerError::SessionEnvironmentMaterializerRequired);
2491        }
2492        let mut catalog = self
2493            .shared
2494            .bundle_catalog
2495            .write()
2496            .unwrap_or_else(|poisoned| poisoned.into_inner());
2497        catalog
2498            .insert_idempotent(bundle)
2499            .map_err(|error| NodeServerError::BundleCatalog(error.to_string()))?;
2500        Ok(())
2501    }
2502
2503    pub async fn run_until_ctrl_signal(self) -> Result<(), NodeServerError> {
2504        let shutdown = self.shutdown_handle();
2505        let run = self.run();
2506        tokio::pin!(run);
2507        tokio::select! {
2508            result = &mut run => result,
2509            signal = wait_for_ctrl_signal() => {
2510                signal?;
2511                let shutdown_result = shutdown.request_shutdown().await;
2512                let run_result = run.await;
2513                shutdown_result.and(run_result)
2514            }
2515        }
2516    }
2517
2518    pub async fn run(self) -> Result<(), NodeServerError> {
2519        let NodeServer {
2520            config,
2521            mut runtime,
2522            shared,
2523            events,
2524            state_path_lock: _state_path_lock,
2525        } = self;
2526        shared.reconcile_materializations();
2527        shared.reconcile_managed_worktrees().await;
2528        shared.retire_unavailable_session_records().await;
2529        shared.reconcile_context_pack_exports().await;
2530        {
2531            let restore = Arc::clone(&shared);
2532            tokio::spawn(async move {
2533                sleep(Duration::from_secs(3)).await;
2534                restore.restore_journalled_sessions().await;
2535                loop {
2536                    restore.refresh_session_journal();
2537                    if restore.shutdown.load(Ordering::Acquire) { break; }
2538                    sleep(Duration::from_secs(10)).await;
2539                }
2540            });
2541        }
2542        // Lifecycle hooks are retired (owner ruling 2026-09-25): the node
2543        // observes sessions through ACP where a provider has it; there is no
2544        // hook ingress.
2545        let endpoint = config.endpoint.clone();
2546        let call_home = config.call_home;
2547        let call_home_identity = config.call_home_identity.clone();
2548        let api_listen = config.api_listen;
2549        let bridge_listen = config.bridge_listen;
2550        let bridge_underlay = config.bridge_underlay;
2551        let bridge_auth = browser_bridge::BridgeAuth::from_optional(config.bridge_token)
2552            .map_err(|_| NodeServerError::InvalidBridgeToken)?;
2553        let accept_shared = Arc::clone(&shared);
2554        let api_shared = Arc::clone(&shared);
2555        let bridge_shared = Arc::clone(&shared);
2556        let shutdown_shared = Arc::clone(&shared);
2557        let shutdown_timeout = Duration::from_millis(
2558            config.runtime.provider_shutdown_timeout_ms.max(1),
2559        );
2560        let result = {
2561            let runtime_loop = drive_runtime_until_shutdown(
2562                &mut runtime,
2563                events,
2564                Arc::clone(&shared),
2565                shutdown_timeout,
2566            );
2567            let accept_loop = accept_connections(&endpoint, call_home, call_home_identity, accept_shared);
2568            let api_loop = http_api::run(api_listen, api_shared);
2569            let bridge_loop = browser_bridge::run(bridge_listen, bridge_auth, bridge_shared, bridge_underlay);
2570            tokio::pin!(runtime_loop);
2571            tokio::pin!(accept_loop);
2572            tokio::pin!(api_loop);
2573            tokio::pin!(bridge_loop);
2574            tokio::select! {
2575                runtime_result = &mut runtime_loop => {
2576                    let shutdown_result = shutdown_shared
2577                        .begin_shutdown()
2578                        .await
2579                        .map_err(NodeServerError::ShutdownDispatch);
2580                    let (accept_result, api_result, bridge_result) =
2581                        tokio::join!(&mut accept_loop, &mut api_loop, &mut bridge_loop);
2582                    match runtime_result {
2583                        Err(error) => Err(error),
2584                        Ok(()) => shutdown_result
2585                            .and(accept_result)
2586                            .and(api_result.map_err(NodeServerError::HttpApi))
2587                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2588                    }
2589                }
2590                accept_result = &mut accept_loop => {
2591                    let shutdown_result = shutdown_shared
2592                        .begin_shutdown()
2593                        .await
2594                        .map_err(NodeServerError::ShutdownDispatch);
2595                    let (runtime_result, api_result, bridge_result) =
2596                        tokio::join!(&mut runtime_loop, &mut api_loop, &mut bridge_loop);
2597                    match accept_result {
2598                        Err(error) => Err(error),
2599                        Ok(()) => shutdown_result
2600                            .and(runtime_result)
2601                            .and(api_result.map_err(NodeServerError::HttpApi))
2602                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2603                    }
2604                }
2605                api_result = &mut api_loop => {
2606                    let shutdown_result = shutdown_shared
2607                        .begin_shutdown()
2608                        .await
2609                        .map_err(NodeServerError::ShutdownDispatch);
2610                    let (runtime_result, accept_result, bridge_result) =
2611                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut bridge_loop);
2612                    match api_result {
2613                        Err(error) => Err(NodeServerError::HttpApi(error)),
2614                        Ok(()) => shutdown_result
2615                            .and(runtime_result)
2616                            .and(accept_result)
2617                            .and(bridge_result.map_err(NodeServerError::BridgeApi)),
2618                    }
2619                }
2620                bridge_result = &mut bridge_loop => {
2621                    let shutdown_result = shutdown_shared
2622                        .begin_shutdown()
2623                        .await
2624                        .map_err(NodeServerError::ShutdownDispatch);
2625                    let (runtime_result, accept_result, api_result) =
2626                        tokio::join!(&mut runtime_loop, &mut accept_loop, &mut api_loop);
2627                    match bridge_result {
2628                        Err(error) => Err(NodeServerError::BridgeApi(error)),
2629                        Ok(()) => shutdown_result
2630                            .and(runtime_result)
2631                            .and(accept_result)
2632                            .and(api_result.map_err(NodeServerError::HttpApi)),
2633                    }
2634                }
2635            }
2636        };
2637        // Persistence gap fix: durable DeliveryStore / ContextPackStore hold
2638        // exclusive flock (Unix) / share-mode locks (Windows) inside NodeShared.
2639        // NodeShutdownHandle also clones that Arc, so without an explicit release
2640        // here a supervisor that keeps the shutdown handle cannot reopen the same
2641        // state path after run() returns (EAGAIN / Resource temporarily unavailable).
2642        // StatePathLock already drops with this stack frame (`_state_path_lock`).
2643        if let Ok(mut guard) = shared.delivery_store.lock() {
2644            *guard = None;
2645        }
2646        if let Ok(mut guard) = shared.context_pack_store.lock() {
2647            *guard = None;
2648        }
2649        result
2650    }
2651}
2652
2653async fn drive_runtime_until_shutdown(
2654    runtime: &mut NativeRuntime,
2655    events: EventSubscription,
2656    shared: Arc<NodeShared>,
2657    shutdown_timeout: Duration,
2658) -> Result<(), NodeServerError> {
2659    // Published once, never per iteration. The worker loops inside
2660    // `NativeRuntime` fold into this profile continuously; turning it into
2661    // percentiles is what costs, and that is `GET /metrics`'s bill to pay.
2662    let _ = shared
2663        .shell_efficiency_profile
2664        .set(runtime.shell_efficiency_profile());
2665    let mut shutdown_started = None;
2666    let mut last_retention_sweep = Instant::now();
2667    let mut last_provisional_reap = Instant::now();
2668    loop {
2669        // Every phase below is timed unconditionally -- see
2670        // `drive_loop_profile`'s own doc comment for why an always-on,
2671        // allocation-free ring is cheap enough to never need a feature
2672        // flag. This is the only place these numbers can be taken: the
2673        // phases are calls made directly from this loop, not something a
2674        // callee could time on our behalf.
2675        let runtime_tick_start = Instant::now();
2676        let tick_result = runtime.tick().await;
2677        let runtime_tick_elapsed = runtime_tick_start.elapsed();
2678
2679        let event_drain_start = Instant::now();
2680        // Every command the kernel refused, named. `NativeRuntimeTick`
2681        // has carried these outcomes all along and nothing read them, so a
2682        // rejected command -- a hook event denied by runtime policy, a
2683        // stale generation, an unsupported capability -- vanished at the
2684        // last step of the loop that produced it. A refusal the operator
2685        // cannot see is indistinguishable from an event that never arrived,
2686        // and telling those two apart is most of debugging this stack.
2687        for outcome in &tick_result.command_outcomes {
2688            if let Err(error) = &outcome.result {
2689                shared.rejected_commands_total.fetch_add(1, Ordering::Relaxed);
2690                tracing::warn!(
2691                    command_id = outcome.command_id.0,
2692                    cause = %error,
2693                    "control command rejected"
2694                );
2695                shared.record_rejected_command(
2696                    outcome.command_id,
2697                    error.to_string(),
2698                    error.is_unsupported_transport(),
2699                );
2700            }
2701        }
2702        let mut events_drained: u64 = 0;
2703        while let Ok(event) = events.try_recv() {
2704            events_drained += 1;
2705            let clean_exit = matches!(
2706                event.event,
2707                ControlEventKind::Exited { exit_code: Some(0), forced: false },
2708            );
2709            let instance_id = event.instance_id;
2710            let generation = event.generation;
2711            // Resolve and spawn before publish_control: publish_control's own
2712            // managed-record bookkeeping (reconcile_managed_record) downgrades
2713            // this exact record from Live to Dormant and clears
2714            // active_session for every Exited event, inside the same
2715            // synchronous call. Capturing (record_id, session) here — before
2716            // that runs — is the only way to observe them still Live and
2717            // still populated; the export itself must still run on its own
2718            // detached task (never awaited inline on this loop) because it
2719            // needs NativeRuntime::tick() to keep running to ever settle, and
2720            // tick() is only ever driven by this same loop. See
2721            // reconcile_context_pack_export_for_record's doc comment.
2722            if clean_exit {
2723                if let Some((record_id, session)) =
2724                    shared.managed_record_export_target_for_instance(instance_id, generation)
2725                {
2726                    let export_shared = Arc::clone(&shared);
2727                    tokio::spawn(async move {
2728                        export_shared
2729                            .reconcile_context_pack_export_for_record(&record_id, session)
2730                            .await;
2731                    });
2732                }
2733            }
2734            shared.publish_control(event);
2735        }
2736        let event_drain_elapsed = event_drain_start.elapsed();
2737
2738        let publish_terminal_frames_start = Instant::now();
2739        shared.publish_terminal_frames();
2740        let publish_terminal_frames_elapsed = publish_terminal_frames_start.elapsed();
2741
2742        // Physical PTY count and the runtime's own per-phase tick timings
2743        // both live inside `NativeRuntime`, which `GET /metrics` (reading
2744        // only `NodeShared`) cannot reach directly -- this loop is the one
2745        // place holding `&mut NativeRuntime`, so it is the only place that
2746        // can copy either out.
2747        shared
2748            .native_session_gauge
2749            .store(runtime.active_native_sessions(), Ordering::Relaxed);
2750        *shared
2751            .runtime_tick_profile
2752            .lock()
2753            .unwrap_or_else(|poisoned| poisoned.into_inner()) = runtime.tick_profile_snapshot();
2754
2755        let remainder_start = Instant::now();
2756        if shared.shutdown.load(Ordering::Acquire) {
2757            let started = *shutdown_started.get_or_insert_with(Instant::now);
2758            let snapshot = shared.handle.snapshot();
2759            let sessions_settled = snapshot.sessions.iter().all(|session| {
2760                !matches!(
2761                    session.status,
2762                    gate4agent_types::SessionStatus::Starting
2763                        | gate4agent_types::SessionStatus::Running
2764                        | gate4agent_types::SessionStatus::Stopping
2765                )
2766            });
2767            if runtime.active_native_sessions() == 0 && sessions_settled {
2768                return Ok(());
2769            }
2770            if started.elapsed() >= shutdown_timeout {
2771                return Err(NodeServerError::ShutdownTimedOut {
2772                    active_native_sessions: runtime.active_native_sessions(),
2773                });
2774            }
2775        }
2776        let remainder_elapsed = remainder_start.elapsed();
2777
2778        {
2779            let mut profile = shared
2780                .drive_loop_profile
2781                .lock()
2782                .unwrap_or_else(|poisoned| poisoned.into_inner());
2783            profile.record_runtime_tick(runtime_tick_elapsed);
2784            profile.record_event_drain(event_drain_elapsed);
2785            profile.record_publish_terminal_frames(publish_terminal_frames_elapsed);
2786            profile.record_remainder(remainder_elapsed);
2787            profile.note_iteration(Instant::now());
2788        }
2789
2790        // "Produced nothing" means this iteration's `NativeRuntime::tick`
2791        // applied zero observations (`tick_result.observations_applied`,
2792        // which already folds in any terminal frames collected -- see
2793        // `NativeEffectDispatcher::drain_observations`) AND the
2794        // control-event subscription drained above forwarded zero events.
2795        // Command ingress and dispatched effects are deliberately NOT part
2796        // of this definition: an idle node still runs `step_control_plane`
2797        // and every provider supervisor's `tick` regardless, every 10ms --
2798        // so "nothing" here means nothing OBSERVABLE left this iteration,
2799        // matching what a reader would assume from `drive_loop_iterations`'s
2800        // own field name, not that the tick itself was free of work.
2801        shared.drive_loop_iterations_total.fetch_add(1, Ordering::Relaxed);
2802        if tick_result.observations_applied == 0 && events_drained == 0 {
2803            shared.drive_loop_iterations_idle.fetch_add(1, Ordering::Relaxed);
2804        }
2805
2806        // A sweep cadence, not a policy value -- see
2807        // `SESSION_RECORD_RETENTION_SWEEP_PERIOD`'s own doc comment. The
2808        // policy itself (whether this does anything at all) lives entirely
2809        // in `NodeShared::session_record_retention`.
2810        if last_retention_sweep.elapsed() >= SESSION_RECORD_RETENTION_SWEEP_PERIOD {
2811            shared.retire_unavailable_session_records().await;
2812            last_retention_sweep = Instant::now();
2813        }
2814        // Provisional Spawn grace: kill PTYs that never got an HQ attach.
2815        if last_provisional_reap.elapsed() >= Duration::from_secs(2) {
2816            for address in shared.take_due_provisional_spawns() {
2817                shared.reap_provisional_spawn(address).await;
2818            }
2819            last_provisional_reap = Instant::now();
2820        }
2821
2822        // Fast-poll for 300ms after PTY input so its echo is read and published
2823        // within ~1ms; otherwise nap 10ms but wake at once on new input.
2824        let hot = shared
2825            .pty_input_marks
2826            .lock()
2827            .unwrap_or_else(|poisoned| poisoned.into_inner())
2828            .values()
2829            .any(|(at, _)| at.elapsed() < Duration::from_millis(300));
2830        if hot {
2831            sleep(Duration::from_millis(1)).await;
2832        } else {
2833            tokio::select! {
2834                _ = sleep(Duration::from_millis(10)) => {}
2835                _ = shared.pty_input_wake.notified() => {}
2836            }
2837        }
2838    }
2839}
2840
2841#[derive(Clone)]
2842pub struct NodeShutdownHandle {
2843    shared: Arc<NodeShared>,
2844}
2845
2846impl NodeShutdownHandle {
2847    pub async fn request_shutdown(&self) -> Result<(), NodeServerError> {
2848        self.shared
2849            .begin_shutdown()
2850            .await
2851            .map_err(NodeServerError::ShutdownDispatch)
2852    }
2853}
2854
2855async fn wait_for_ctrl_signal() -> Result<(), NodeServerError> {
2856    #[cfg(windows)]
2857    {
2858    let mut ctrl_c = tokio::signal::windows::ctrl_c()?;
2859    let mut ctrl_break = tokio::signal::windows::ctrl_break()?;
2860    return tokio::select! {
2861        signal = ctrl_c.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2862        signal = ctrl_break.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2863    };
2864    }
2865    #[cfg(unix)]
2866    {
2867        let mut interrupt = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::interrupt())?;
2868        let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?;
2869        tokio::select! {
2870            signal = interrupt.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2871            signal = terminate.recv() => signal.ok_or(NodeServerError::SignalStreamClosed),
2872        }
2873    }
2874}
2875
2876/// The providers this node actually runs.
2877///
2878/// `builtin_registry()` is a reference catalog of everything the project has
2879/// ever described; the fleet is this list, and it is deliberately short. A
2880/// provider only belongs here if it can be exercised for real -- there is a
2881/// subscription behind it and a failure in it can be reproduced and
2882/// debugged. Carrying one that cannot is carrying code nobody can verify,
2883/// which is worse than not supporting it: it looks supported.
2884///
2885/// A provider dropped from the fleet by this rule is removed outright, not
2886/// carried dark: no catalog entry, no adapter, no dead code claiming support
2887/// for something nobody can verify runs.
2888/// Host interactive shell for HQ terminal attach (no provider CLI, no network).
2889fn shell_host_spec() -> AgentSpec {
2890    // Windows nodes (incl. MSYS-hosted) have no `/bin/bash` for Win32 Path::is_file;
2891    // fleet inventory on op-box resolves `bash` to the WSL stub at
2892    // `/c/Windows/system32/bash`, which is not a host shell. Use ComSpec/cmd.
2893    #[cfg(windows)]
2894    let (detect_command, program, fixed_args, process_name) = {
2895        let program = std::env::var("ComSpec").unwrap_or_else(|_| {
2896            r"C:\Windows\System32\cmd.exe".to_owned()
2897        });
2898        ("cmd".to_owned(), program, Vec::<String>::new(), "cmd".to_owned())
2899    };
2900    #[cfg(not(windows))]
2901    let (detect_command, program, fixed_args, process_name) = (
2902        "bash".to_owned(),
2903        "/bin/bash".to_owned(),
2904        vec!["-i".to_owned()],
2905        "bash".to_owned(),
2906    );
2907    AgentSpec {
2908        id: AgentId::new("shell").expect("shell agent id"),
2909        revision: "hatchery:shell-pty-1".to_owned(),
2910        display_name: "Host shell".to_owned(),
2911        detection: DetectionSpec {
2912            command: detect_command,
2913            aliases: vec!["sh".to_owned(), "shell".to_owned(), "cmd".to_owned()],
2914            required_commands: Vec::new(),
2915            unsupported_platforms: Vec::new(),
2916        },
2917        launch: LaunchSpec {
2918            program,
2919            fixed_args,
2920        },
2921        expected_processes: vec![ProcessMatcher::Exact {
2922            name: process_name,
2923        }],
2924        prompt: PromptSpec {
2925            initial: InitialPromptMode::None,
2926            native_draft: None,
2927        },
2928        readiness: AgentReadinessSpec::default(),
2929        capabilities: AgentCapabilities {
2930            agent_commands: None,
2931            transports: AgentTransportCapabilities {
2932                pty: true,
2933                pty_adapter: None,
2934                pipe: None,
2935                acp: None,
2936            },
2937            adapters: AgentAdapterCapabilities::default(),
2938        },
2939        verification: SpecVerification::Gate4AgentVerified,
2940    }
2941}
2942
2943fn active_registry() -> Result<AgentRegistry, NodeServerError> {
2944    let mut specs = vec![shell_host_spec()];
2945    specs.extend(
2946        builtin_registry()
2947            .iter()
2948            .filter(|spec| matches!(spec.id.as_str(), "claude" | "codex" | "grok" | "kimi"))
2949            .cloned(),
2950    );
2951    AgentRegistry::new(specs).map_err(|error| NodeServerError::Registry(error.to_string()))
2952}
2953
2954fn provider_contract_manifest(
2955    catalog: &AgentRegistry,
2956) -> Result<
2957    (
2958        Vec<ProviderContractSupport>,
2959        Vec<ProviderAdapterContractSupport>,
2960    ),
2961    NodeServerError,
2962> {
2963    let mut provider_contracts = Vec::new();
2964    let mut provider_adapter_contracts = Vec::new();
2965    for spec in catalog.iter() {
2966        let provider = spec.id.clone();
2967        let revision = ProviderContractRevision::new(spec.revision.clone())
2968            .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2969        provider_contracts.push(ProviderContractSupport {
2970            provider: provider.clone(),
2971            revision,
2972        });
2973        for (family, binding) in declared_adapter_bindings(spec)? {
2974            let revision = AdapterContractRevision::new(binding.revision.clone())
2975                .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2976            provider_adapter_contracts.push(ProviderAdapterContractSupport {
2977                provider: provider.clone(),
2978                family,
2979                adapter_id: binding.id.clone(),
2980                revision,
2981            });
2982        }
2983    }
2984    validate_provider_contract_manifest(&provider_contracts, &provider_adapter_contracts)
2985        .map_err(|error| NodeServerError::ProviderContractManifest(error.to_string()))?;
2986    Ok((provider_contracts, provider_adapter_contracts))
2987}
2988
2989fn declared_adapter_bindings(
2990    spec: &AgentSpec,
2991) -> Result<Vec<(AdapterFamily, &AdapterBinding)>, NodeServerError> {
2992    let mut bindings = Vec::new();
2993    push_declared_binding(
2994        &mut bindings,
2995        AdapterFamily::PtySemantic,
2996        spec.capabilities.transports.pty_adapter.as_ref(),
2997    )?;
2998    push_declared_binding(
2999        &mut bindings,
3000        AdapterFamily::Pipe,
3001        spec.capabilities.adapters.pty_sidecar.as_ref(),
3002    )?;
3003    let pipe_transport = spec.capabilities.transports.pipe.as_ref();
3004    let pipe_family = pipe_transport.map(|transport| match transport.protocol {
3005        gate4agent_types::PipeProtocol::SemanticNdjson
3006        | gate4agent_types::PipeProtocol::StructuredJsonl => AdapterFamily::Pipe,
3007        gate4agent_types::PipeProtocol::OneShotText => AdapterFamily::OneShot,
3008    });
3009    push_declared_binding(
3010        &mut bindings,
3011        AdapterFamily::Pipe,
3012        pipe_transport
3013            .filter(|_| pipe_family == Some(AdapterFamily::Pipe))
3014            .map(|transport| &transport.adapter),
3015    )?;
3016    push_declared_binding(
3017        &mut bindings,
3018        AdapterFamily::Acp,
3019        spec.capabilities
3020            .transports
3021            .acp
3022            .as_ref()
3023            .map(|transport| &transport.adapter),
3024    )?;
3025    push_declared_binding(
3026        &mut bindings,
3027        AdapterFamily::Hook,
3028        spec.capabilities.adapters.hook.as_ref(),
3029    )?;
3030    push_declared_binding(
3031        &mut bindings,
3032        AdapterFamily::ManagedHook,
3033        spec.capabilities.adapters.managed_hook.as_ref(),
3034    )?;
3035    push_declared_binding(
3036        &mut bindings,
3037        AdapterFamily::OneShot,
3038        pipe_transport
3039            .filter(|_| pipe_family == Some(AdapterFamily::OneShot))
3040            .map(|transport| &transport.adapter),
3041    )?;
3042    push_declared_binding(
3043        &mut bindings,
3044        AdapterFamily::OneShot,
3045        spec.capabilities.adapters.one_shot.as_ref(),
3046    )?;
3047    push_declared_binding(
3048        &mut bindings,
3049        AdapterFamily::History,
3050        spec.capabilities.adapters.history.as_ref(),
3051    )?;
3052    push_declared_binding(
3053        &mut bindings,
3054        AdapterFamily::Resume,
3055        spec.capabilities.adapters.resume.as_ref(),
3056    )?;
3057    push_declared_binding(
3058        &mut bindings,
3059        AdapterFamily::SessionOptions,
3060        spec.capabilities.adapters.session_options.as_ref(),
3061    )?;
3062    push_declared_binding(
3063        &mut bindings,
3064        AdapterFamily::CapabilityProbe,
3065        spec.capabilities.adapters.capability_probe.as_ref(),
3066    )?;
3067    Ok(bindings)
3068}
3069
3070fn push_declared_binding<'a>(
3071    bindings: &mut Vec<(AdapterFamily, &'a AdapterBinding)>,
3072    family: AdapterFamily,
3073    binding: Option<&'a AdapterBinding>,
3074) -> Result<(), NodeServerError> {
3075    let Some(binding) = binding else {
3076        return Ok(());
3077    };
3078    if let Some((_, existing)) = bindings
3079        .iter()
3080        .find(|(existing_family, _)| *existing_family == family)
3081    {
3082        if *existing == binding {
3083            return Ok(());
3084        }
3085        return Err(NodeServerError::ProviderContractManifest(format!(
3086            "provider declares conflicting {family:?} adapter bindings"
3087        )));
3088    }
3089    bindings.push((family, binding));
3090    Ok(())
3091}
3092
3093fn merge_durable_state(
3094    configured: &[WorkspaceConfig],
3095    mut loaded: LoadedNodeState,
3096) -> Result<(Vec<WorkspaceConfig>, Vec<ManagedSessionRecord>, Option<String>), NodeServerError> {
3097    let mut workspaces = configured
3098        .iter()
3099        .cloned()
3100        .map(|workspace| (workspace.workspace_id.clone(), workspace))
3101        .collect::<BTreeMap<_, _>>();
3102    let mut roots = workspaces
3103        .values()
3104        .map(|workspace| {
3105            (
3106                platform::root_identity(&workspace.canonical_root),
3107                workspace.workspace_id.clone(),
3108            )
3109        })
3110        .collect::<BTreeMap<_, _>>();
3111    let mut warning = loaded
3112        .warning
3113        .take()
3114        .map(|message| session_registry::sanitized_persistence_summary(&message));
3115
3116    for (workspace_id, canonical_root) in loaded.workspaces {
3117        if let Some(existing) = workspaces.get(&workspace_id) {
3118            if !platform::roots_equal(&existing.canonical_root, &canonical_root) {
3119                return Err(NodeServerError::DurableState(io::Error::new(
3120                    io::ErrorKind::InvalidData,
3121                    DURABLE_STATE_CONFLICT_ERROR,
3122                )));
3123            }
3124            continue;
3125        }
3126        if roots.get(&platform::root_identity(&canonical_root)).is_some() {
3127            return Err(NodeServerError::DurableState(io::Error::new(
3128                io::ErrorKind::InvalidData,
3129                DURABLE_STATE_CONFLICT_ERROR,
3130            )));
3131        }
3132        if !Path::new(&canonical_root).is_dir() {
3133            warning.get_or_insert_with(|| {
3134                session_registry::DURABLE_STATE_WORKSPACE_WARNING.to_owned()
3135            });
3136            continue;
3137        }
3138        let workspace = WorkspaceConfig::new(workspace_id.clone(), &canonical_root).map_err(|_| {
3139            NodeServerError::DurableState(io::Error::new(
3140                io::ErrorKind::InvalidData,
3141                DURABLE_STATE_CONFLICT_ERROR,
3142            ))
3143        })?;
3144        roots.insert(
3145            platform::root_identity(&workspace.canonical_root),
3146            workspace.workspace_id.clone(),
3147        );
3148        workspaces.insert(workspace_id, workspace);
3149    }
3150
3151    for record in &mut loaded.records {
3152        match workspaces.get(&record.workspace_id) {
3153            Some(workspace)
3154                if platform::roots_equal(
3155                    &workspace.canonical_root,
3156                    windows_path_text(&record.canonical_root),
3157                ) => {}
3158            Some(_) => {
3159                return Err(NodeServerError::DurableState(io::Error::new(
3160                    io::ErrorKind::InvalidData,
3161                    DURABLE_STATE_CONFLICT_ERROR,
3162                )));
3163            }
3164            None => {
3165                record.state = ManagedSessionState::Unavailable;
3166                record.active_session = None;
3167                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
3168            }
3169        }
3170    }
3171    Ok((workspaces.into_values().collect(), loaded.records, warning))
3172}
3173
3174/// One entry of `NodeShared::rejected_command_reasons` -- see that field's
3175/// own doc for the mechanism this exists to close.
3176struct RejectedCommandReason {
3177    message: String,
3178    /// True when the kernel refused this command specifically because the
3179    /// agent does not declare the requested transport (`KernelCommandError::
3180    /// UnsupportedTransport`) -- lets the reader choose the named
3181    /// `NodeFailureCode::UnsupportedTransport` over the generic
3182    /// `BackendOperationFailed` bucket, so this one well-known rejection
3183    /// keeps its identity all the way to the operator wire.
3184    unsupported_transport: bool,
3185    expires_at: Instant,
3186}
3187
3188struct NodeShared {
3189    handle: Gate4AgentHandle,
3190    access_token: String,
3191    node_id: NodeId,
3192    incarnation_id: NodeIncarnationId,
3193    started_at_unix_ms: u64,
3194    workspaces: RwLock<BTreeMap<WorkspaceId, String>>,
3195    worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode>,
3196    managed_worktree_profiles:
3197        BTreeMap<WorkspaceId, BTreeMap<WorktreeProfileId, ManagedWorktreeProfile>>,
3198    managed_worktrees: Mutex<ManagedWorktreeRegistry>,
3199    enabled_providers: Vec<AgentId>,
3200    provider_runtime_statuses: ProviderRuntimeStatuses,
3201    provider_runtime_status_updates:
3202        RwLock<BTreeMap<AgentId, crate::protocol::ProviderRuntimeStatus>>,
3203    provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
3204    provider_contracts: Vec<ProviderContractSupport>,
3205    provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
3206    spawn_profiles: SpawnProfileRegistry,
3207    environment_profiles:
3208        RwLock<BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>>,
3209    environment_materialization_profiles:
3210        RwLock<BTreeMap<SpawnEnvironmentProfileId, NodeSessionMaterializationProfile>>,
3211    /// Station **network** allowlist catalog (opaque policy ids only).
3212    /// Empty by default — any `network_allowlist` on spawn must be registered
3213    /// here or resolve refuses with `UnknownNetworkAllowlist`. Optional
3214    /// startup load via `NodeServerConfig::with_network_allowlist_catalog(_file)`
3215    /// / env `GATE4AGENT_NODE_NETWORK_ALLOWLIST_CATALOG`. Plan
3216    /// `station-network-and-browser-profile-knobs-2026-10-02.md` §2.1 / §4.
3217    /// Never holds cookies / OAuth / proxy credentials.
3218    network_allowlist_catalog: RwLock<crate::network_allowlist_catalog::NetworkAllowlistCatalog>,
3219    /// Node-local exclusive dig2browser station profile leases (feature
3220    /// `dig2-station-probe`). Opaque `browser_profile_id` ↔ session holder;
3221    /// cleared on session end. Never ImportSession / cookies on C2.
3222    #[cfg(feature = "dig2-station-probe")]
3223    browser_station_leases: crate::dig2_station_lease::BrowserStationLeaseTable,
3224    bundle_catalog: RwLock<BundleCatalog>,
3225    delivery_store: Mutex<Option<DeliveryStore>>,
3226    context_pack_store: Mutex<Option<ContextPackStore>>,
3227    context_catalog: RwLock<ContextPackCatalog>,
3228    native_launch_profile_control: Option<NativeLaunchProfileControl>,
3229    harness_mcp_registry: Option<HarnessMcpProxyRegistry>,
3230    native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
3231    session_environment_materializer: Option<SessionEnvironmentMaterializer>,
3232    /// Per-session m4a accounts assigned at spawn (see [`crate::m4a_accounts`]).
3233    m4a_accounts: Option<crate::m4a_accounts::M4aSessionAccounts>,
3234    /// nick -> the session that holds that m4a account (for letter wakes).
3235    m4a_live: Mutex<BTreeMap<String, (AgentInstanceId, AgentId)>>,
3236    /// m4a session key per runtime this node gave an account.
3237    m4a_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3238    /// Keys a resume hands to its new runtime (same nick and mailbox).
3239    m4a_reuse_keys: Mutex<BTreeMap<AgentInstanceId, String>>,
3240    /// Per-session grok leader sockets (never the operator's shared leader).
3241    grok_leaders: Mutex<BTreeMap<AgentInstanceId, std::path::PathBuf>>,
3242    /// m4a signer proxy (option A): when set, PTY sessions get a process-bound handle
3243    /// instead of a bearer capability.
3244    #[cfg(unix)]
3245    signer_proxy: std::sync::OnceLock<Arc<crate::keystore_proxy::SignerProxy>>,
3246    materializations: Mutex<BTreeMap<MaterializationId, MaterializationOwnershipRecord>>,
3247    spawn_idempotency: Mutex<SpawnIdempotencyCache>,
3248    managed_spawn_replays:
3249        Mutex<BTreeMap<SpawnIdempotencyKey, ManagedWorktreeSpawnReplayRecordV10>>,
3250    controller: Mutex<Option<ControllerLease>>,
3251    /// Fresh spawns waiting for their first successful HQ attach. Removed on
3252    /// Resize/TerminalBytes (sessionize) or when the grace reaper stops them.
3253    provisional_spawns: Mutex<BTreeMap<AgentInstanceId, (SessionAddress, Instant)>>,
3254    /// ACP launch options waiting for the agent to advertise its config options.
3255    pending_acp_options: Mutex<BTreeMap<AgentInstanceId, (Option<String>, Option<String>)>>,
3256    history: Mutex<NodeEventHistory>,
3257    event_tx: broadcast::Sender<NodeEventEnvelope>,
3258    terminal_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3259    /// The outbound `NodeEvent::AgentStream` push channel -- its own
3260    /// dedicated, capability-gated broadcast, mirroring `terminal_event_tx`
3261    /// exactly rather than riding the durable `event_tx` history (see
3262    /// `publish`'s own assertion): a subscriber who never negotiated
3263    /// `NODE_AGENT_STREAM_EVENTS_CAPABILITY` must never receive one of
3264    /// these, the same guarantee `include_terminal_frame_events` already
3265    /// gives terminal frames.
3266    agent_stream_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3267    /// The outbound `NodeEvent::HarnessMcpReadCall` push channel -- mirrors
3268    /// `agent_stream_event_tx` exactly, its own dedicated, capability-gated
3269    /// broadcast rather than a ride on the durable `event_tx` history. A
3270    /// `HarnessMcpReadCall` is a request/response call a live proxy call is
3271    /// waiting on (`harness_mcp_proxy.rs`'s `begin_call`); the shared bus's
3272    /// finite capacity and its willingness to silently evict a lagging
3273    /// subscriber's backlog is exactly how the call vanished with neither
3274    /// side ever logging it (see `publish_transient`'s own doc).
3275    harness_mcp_proxy_event_tx: broadcast::Sender<Arc<Vec<NodeEventEnvelope>>>,
3276    terminal_frame_watermarks:
3277        Mutex<BTreeMap<AgentInstanceId, (SessionAddress, u64)>>,
3278    /// Wakes the drive loop as soon as PTY input is queued (no 10ms nap).
3279    pty_input_wake: tokio::sync::Notify,
3280    /// Per session: when the last PTY input was dispatched (latency probe +
3281    /// a short fast-poll window so the echo is published within ~1ms).
3282    pty_input_marks: Mutex<BTreeMap<AgentInstanceId, (Instant, bool)>>,
3283    next_connection_id: AtomicU64,
3284    next_instance_id: AtomicU64,
3285    next_command_id: AtomicU64,
3286    shutdown: AtomicBool,
3287    shutdown_notify: Notify,
3288    preauth_slots: Arc<Semaphore>,
3289    authenticated_slots: Arc<Semaphore>,
3290    inspection_slots: Arc<Semaphore>,
3291    mutation_gate: AsyncMutex<()>,
3292    session_bindings: Mutex<BTreeMap<AgentInstanceId, SessionBinding>>,
3293    session_records: Mutex<SessionRecords>,
3294    state_path: Option<PathBuf>,
3295    persistence_error: RwLock<Option<String>>,
3296    state_transaction: Mutex<()>,
3297    input_settle_timeout_ms: u64,
3298    /// Drive-loop phase/rate timings -- see `drive_loop_profile`'s own doc
3299    /// comment. Written once per iteration by
3300    /// `drive_runtime_until_shutdown`, read by `GET /metrics`.
3301    drive_loop_profile: Mutex<DriveLoopProfiler>,
3302    /// Latest copy of `NativeRuntime::tick_profile_snapshot()` -- `Copy`,
3303    /// so an overwrite each iteration is cheap. The runtime itself is not
3304    /// reachable from here (it is owned locally by
3305    /// `drive_runtime_until_shutdown`), so this is how its own per-phase
3306    /// tick timings reach `GET /metrics`.
3307    runtime_tick_profile: Mutex<TickProfileSnapshot>,
3308    /// The live shell-efficiency profile, shared with the per-instance
3309    /// worker loops that fold into it.
3310    ///
3311    /// Deliberately NOT the per-iteration snapshot copy `runtime_tick_
3312    /// profile` above keeps: producing a snapshot sorts every ring behind
3313    /// it, and that sort belongs on the read. A `OnceLock` rather than a
3314    /// field set at construction because `NodeShared` is built before the
3315    /// `NativeRuntime` that owns the profile exists -- the drive loop
3316    /// publishes it once, before its first iteration. Unset means the
3317    /// runtime has not started driving yet, which `GET /metrics` reports as
3318    /// empty series rather than as zeroes it never measured.
3319    shell_efficiency_profile: OnceLock<Arc<Mutex<ShellEfficiencyProfile>>>,
3320    /// Physical PTY sessions the native runtime currently owns
3321    /// (`NativeRuntime::active_native_sessions`), refreshed once per drive
3322    /// loop iteration -- deliberately a separate count from
3323    /// `handle.snapshot().sessions.len()` (the control-plane kernel's own
3324    /// session bookkeeping), since the two can disagree and the gap is
3325    /// itself a diagnostic signal `GET /metrics` exposes.
3326    native_session_gauge: AtomicUsize,
3327    /// Lifetime totals for the per-connection serve loop, summed across
3328    /// every live connection.
3329    ///
3330    /// The drive loop's own phase timings account for about ten
3331    /// microseconds an iteration on an idle node, yet the process burns a
3332    /// quarter of a core — and burns it ONLY while a c2 is attached (0.5%
3333    /// with none, 23% with two, measured). That gap is somewhere in
3334    /// serving a connection, and there was no way to see it: iterations
3335    /// says whether the loop is spinning rather than sleeping, and events
3336    /// says whether it is spinning over real work or over nothing.
3337    connection_loop_iterations: AtomicU64,
3338    connection_events_sent: AtomicU64,
3339    /// Lifetime totals for `drive_runtime_until_shutdown`'s own iterations
3340    /// -- `drive_loop_iterations_idle` counts iterations that applied zero
3341    /// `NativeRuntime::tick` observations AND forwarded zero control
3342    /// events; see the exact "produced nothing" definition at the site in
3343    /// `drive_runtime_until_shutdown` that increments these. Read twice
3344    /// and divide by the interval, same rule as `connections` above --
3345    /// `idle / total` is backlog item 5's "wake on work" number.
3346    /// Lifetime count of control commands the kernel refused.
3347    rejected_commands_total: AtomicU64,
3348    /// The named reason behind a recent kernel rejection, by the
3349    /// `CommandId` `dispatch_bounded` handed back when it enqueued that
3350    /// command -- populated by `drive_runtime_until_shutdown` for every
3351    /// `Err` in a tick's `command_outcomes`, and drained by whichever
3352    /// spawn-dispatch waiter recognizes its own `Register`/`Start`
3353    /// `CommandId` among them.
3354    ///
3355    /// A `ControlCommand` only ever fails synchronously (a `NodeFailure`
3356    /// out of `dispatch`/`dispatch_bounded` itself) for a port-level ingress
3357    /// problem -- full or disconnected -- never for the kernel refusing the
3358    /// command's content (an unsupported transport, an unknown agent, ...);
3359    /// that verdict exists only once `NativeRuntime::tick` has processed the
3360    /// command, one drive-loop iteration later, in a different task than
3361    /// whichever caller is awaiting the spawn. Without this map, a spawn
3362    /// wait loop that polls `self.handle.snapshot()` for a session that
3363    /// will now never appear has no way to learn why -- it can only spin
3364    /// until its own commit deadline and report a generic "did not commit"
3365    /// timeout, discarding a reason the kernel already named precisely (see
3366    /// `KernelCommandError`'s variants, e.g. `UnsupportedTransport`).
3367    rejected_command_reasons: Mutex<BTreeMap<CommandId, RejectedCommandReason>>,
3368    drive_loop_iterations_total: AtomicU64,
3369    drive_loop_iterations_idle: AtomicU64,
3370    /// The operator-chosen age/keep-N policy the retention sweep applies to
3371    /// `ManagedSessionState::Unavailable` records. Both `0` by default --
3372    /// see `SessionRecordRetentionConfig`'s own doc comment.
3373    session_record_retention: SessionRecordRetentionConfig,
3374    /// Lifetime count of managed session records the retention sweep has
3375    /// retired. Same tier as `rejected_commands_total` above: a live,
3376    /// process-lifetime instrument, never persisted and never reloaded from
3377    /// `state-v1.json` -- a restart legitimately resets it, matching every
3378    /// other `*_total` counter here.
3379    retired_records_total: AtomicU64,
3380    #[cfg(feature = "fixture")]
3381    fixture_semantic_hook_policy: bool,
3382    #[cfg(feature = "fixture")]
3383    fixture_spawn_managed_worktree_v2_failure_probe: SpawnManagedWorktreeV2FailureProbe,
3384}
3385
3386impl Drop for NodeShared {
3387    fn drop(&mut self) {
3388        SessionLifecycleHook::on_shutdown(self);
3389    }
3390}
3391
3392#[derive(Clone, Debug, Eq, PartialEq)]
3393struct SessionBinding {
3394    workspace_id: WorkspaceId,
3395    generation: SessionGeneration,
3396    runtime_policy: ProviderRuntimePolicy,
3397    pending_resume: Option<(SessionGeneration, CommandId, ProviderRuntimePolicy)>,
3398    record_id: Option<SessionRecordId>,
3399    managed_worktree_lease_id: Option<ManagedWorktreeLeaseId>,
3400    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
3401    bundle: Option<ResolvedBundleReceipt>,
3402    context: Option<ResolvedContextPackReceipt>,
3403    materialization_id: Option<MaterializationId>,
3404}
3405
3406#[derive(Clone, Copy, Debug, Eq, PartialEq)]
3407enum SpawnRecordPolicy {
3408    ProviderIdentityOnly,
3409    Always,
3410}
3411
3412struct NativeEnvironmentSelectionGuard {
3413    control: Option<NativeLaunchProfileControl>,
3414    instance_id: AgentInstanceId,
3415}
3416
3417struct NativeInstanceOverlayGuard {
3418    control: Option<NativeLaunchProfileControl>,
3419    instance_id: AgentInstanceId,
3420}
3421
3422struct NativeHarnessMcpOverlayGuard {
3423    control: NativeLaunchProfileControl,
3424    instance_id: AgentInstanceId,
3425}
3426
3427impl NativeHarnessMcpOverlayGuard {
3428    fn retain(self) { std::mem::forget(self); }
3429}
3430
3431impl Drop for NativeHarnessMcpOverlayGuard {
3432    fn drop(&mut self) {
3433        self.control
3434            .clear_native_mcp_server_launch_overlay(self.instance_id);
3435    }
3436}
3437
3438impl NativeInstanceOverlayGuard {
3439    fn retain(mut self) {
3440        self.control = None;
3441    }
3442}
3443
3444impl Drop for NativeInstanceOverlayGuard {
3445    fn drop(&mut self) {
3446        if let Some(control) = self.control.as_ref() {
3447            control.clear_native_instance_launch_overlay(self.instance_id);
3448        }
3449    }
3450}
3451
3452enum PreparedNativeLaunchOverlay {
3453    Environment(NativeLaunchEnvironmentOverlay),
3454    Instance(NativeInstanceLaunchOverlay),
3455}
3456
3457impl NativeEnvironmentSelectionGuard {
3458    fn retain(mut self) {
3459        self.control = None;
3460    }
3461}
3462
3463impl Drop for NativeEnvironmentSelectionGuard {
3464    fn drop(&mut self) {
3465        if let Some(control) = self.control.as_ref() {
3466            control.clear_native_launch_profile_selection(self.instance_id);
3467        }
3468    }
3469}
3470
3471struct SessionMaterializationGuard<'a> {
3472    shared: &'a NodeShared,
3473    id: Option<MaterializationId>,
3474    /// Absolute ProviderHome (`home/`) under the session materialization root.
3475    /// Used for Claude station network `--settings` sidecar writes.
3476    provider_home: Option<PathBuf>,
3477}
3478
3479impl SessionMaterializationGuard<'_> {
3480    fn id(&self) -> Option<&MaterializationId> {
3481        self.id.as_ref()
3482    }
3483
3484    fn provider_home(&self) -> Option<&Path> {
3485        self.provider_home.as_deref()
3486    }
3487
3488    fn retain(mut self) {
3489        self.id = None;
3490    }
3491}
3492
3493impl Drop for SessionMaterializationGuard<'_> {
3494    fn drop(&mut self) {
3495        if let Some(id) = self.id.take() {
3496            let _ = self.shared.cleanup_materialization(&id);
3497        }
3498    }
3499}
3500
3501
3502/// Port: product-side reactions to session lifecycle transitions
3503/// (station lease, harness MCP overlay/registry, environment launch overlays).
3504trait SessionLifecycleHook {
3505    /// A binding was removed (unbind or spawn rollback).
3506    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>);
3507    /// A session reached a terminal control event that revokes its harness MCP door.
3508    fn on_session_ended(&self, address: &SessionAddress);
3509    /// Node shutdown (or drop): revoke every harness MCP reservation.
3510    fn on_shutdown(&self);
3511    /// A harness MCP reservation was aborted before activation.
3512    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId);
3513    /// Release session-owned materialization for a removed binding.
3514    fn cleanup_session_materialization(
3515        &self,
3516        address: &SessionAddress,
3517        binding: &SessionBinding,
3518    ) -> Result<(), NodeFailure>;
3519}
3520
3521impl SessionLifecycleHook for NodeShared {
3522    fn on_session_ended(&self, address: &SessionAddress) {
3523        self.retire_session_grok_leader(address.session.instance_id);
3524        if !self.shutdown.load(Ordering::Acquire) {
3525            if let Some(file) = self.session_resume_journal() {
3526                crate::pty_resume::remove(&file, address.session.instance_id.0);
3527            }
3528        }
3529        if let Some(accounts) = self.m4a_accounts.as_ref() {
3530            let instance = address.session.instance_id;
3531            let live = self.m4a_live.lock().unwrap_or_else(|p| p.into_inner());
3532            for (nick, _) in live.iter().filter(|(_, (i, _))| *i == instance) {
3533                accounts.remove_session_file(nick);
3534            }
3535        }
3536        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3537            registry.revoke_session(address);
3538        }
3539        if let Some(control) = self.native_launch_profile_control.as_ref() {
3540            control.clear_native_mcp_server_launch_overlay(address.session.instance_id);
3541        }
3542    }
3543
3544    fn on_shutdown(&self) {
3545        let leaders: Vec<_> = std::mem::take(&mut *self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()))
3546            .into_values()
3547            .collect();
3548        for socket in leaders {
3549            crate::grok_leader::retire(&socket);
3550        }
3551        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3552            let instances = registry.shutdown();
3553            if let Some(control) = self.native_launch_profile_control.as_ref() {
3554                for instance_id in instances {
3555                    control.clear_native_mcp_server_launch_overlay(instance_id);
3556                }
3557            }
3558        }
3559    }
3560
3561    fn on_harness_reservation_aborted(&self, instance_id: AgentInstanceId) {
3562        if let Some(control) = self.native_launch_profile_control.as_ref() {
3563            control.clear_native_mcp_server_launch_overlay(instance_id);
3564        }
3565    }
3566
3567    fn cleanup_session_materialization(
3568        &self,
3569        address: &SessionAddress,
3570        binding: &SessionBinding,
3571    ) -> Result<(), NodeFailure> {
3572        self.cleanup_session_owned_materialization(address, binding)
3573    }
3574
3575    fn on_binding_removed(&self, address: &SessionAddress, removed: Option<&SessionBinding>) {
3576        // Exclusive dig2browser station profile lease is node-local; clear
3577        // on every session unbind / rollback. Never ImportSession/cookies.
3578        #[cfg(feature = "dig2-station-probe")]
3579        {
3580            crate::dig2_station_lease::release_lease_for_holder(
3581                &self.browser_station_leases,
3582                address.session.instance_id,
3583            );
3584        }
3585        if let Some(control) = self.native_launch_profile_control.as_ref() {
3586            control.clear_native_mcp_server_launch_overlay(
3587                address.session.instance_id,
3588            );
3589        }
3590        if let Some(registry) = self.harness_mcp_registry.as_ref() {
3591            registry.revoke_session(address);
3592        }
3593        if removed
3594            .is_some_and(|binding| {
3595                binding.environment_profile.is_some()
3596                    || binding.bundle.is_some()
3597                    || binding.context.is_some()
3598            })
3599        {
3600            if let Some(control) = self.native_launch_profile_control.as_ref() {
3601                control.clear_native_launch_profile_selection(
3602                    address.session.instance_id,
3603                );
3604                control.clear_native_instance_launch_overlay(
3605                    address.session.instance_id,
3606                );
3607            }
3608        }
3609    }
3610}
3611
3612struct SpawnPrepareRequest<'r> {
3613    instance_id: AgentInstanceId,
3614    address: &'r SessionAddress,
3615    provider: &'r AgentId,
3616    mode: SessionMode,
3617    environment_profile: Option<&'r ResolvedEnvironmentProfileReceipt>,
3618    bundle: Option<&'r ResolvedBundleReceipt>,
3619    context: Option<&'r ResolvedContextPackReceipt>,
3620    managed_authority: Option<&'r ManagedWorktreeLeaseId>,
3621    harness_mcp: Option<&'r PreparedHarnessMcpSpawn>,
3622    approval_level: ApprovalLevel,
3623    network_allowlist: Option<&'r crate::protocol::SpawnNetworkAllowlistId>,
3624    browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
3625}
3626
3627/// Guards held between "session identity minted" and "launch committed".
3628/// Field order is the drop order and matches the pre-refactor local order
3629/// (instance overlay, environment selection, materialization, harness, station lease).
3630struct PreparedSpawn<'a> {
3631    instance_overlay: Option<NativeInstanceOverlayGuard>,
3632    environment_selection: Option<NativeEnvironmentSelectionGuard>,
3633    materialization_guard: Option<SessionMaterializationGuard<'a>>,
3634    harness_mcp_overlay: Option<NativeHarnessMcpOverlayGuard>,
3635    #[cfg(feature = "dig2-station-probe")]
3636    browser_station_lease_guard: Option<crate::dig2_station_lease::BrowserStationLeaseGuard>,
3637    materialization_id: Option<MaterializationId>,
3638}
3639
3640impl PreparedSpawn<'_> {
3641    /// Session-owned launch state now belongs to the binding.
3642    fn retain_session_guards(&mut self) {
3643        if let Some(selection) = self.environment_selection.take() {
3644            selection.retain();
3645        }
3646        if let Some(overlay) = self.instance_overlay.take() {
3647            overlay.retain();
3648        }
3649        if let Some(guard) = self.materialization_guard.take() {
3650            guard.retain();
3651        }
3652    }
3653
3654    /// Launch committed: keep harness overlay and station lease for the session lifetime.
3655    fn retain_launch_guards(&mut self) {
3656        if let Some(overlay) = self.harness_mcp_overlay.take() {
3657            overlay.retain();
3658        }
3659        #[cfg(feature = "dig2-station-probe")]
3660        if let Some(guard) = self.browser_station_lease_guard.take() {
3661            guard.retain();
3662        }
3663    }
3664}
3665
3666/// Port: everything product-side that must be prepared before a session is
3667/// bound and started (station lease, harness MCP overlay, materialization,
3668/// environment selection, network overlays, launch overlay).
3669trait SpawnPreparer {
3670    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure>;
3671    /// Select the native launch profile for an environment-profile session.
3672    fn select_environment(
3673        &self,
3674        instance_id: AgentInstanceId,
3675        provider: &AgentId,
3676        mode: SessionMode,
3677        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3678    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure>;
3679    /// Install a prepared launch overlay for the instance.
3680    fn install_launch_overlay(
3681        &self,
3682        instance_id: AgentInstanceId,
3683        overlay: PreparedNativeLaunchOverlay,
3684    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure>;
3685}
3686
3687impl NodeShared {
3688    /// m4a session key of a new runtime: a resume reuses the key (and so the nick and mailbox)
3689    /// of the runtime it replaces; a fresh spawn gets `<incarnation>-<instance>`.
3690    fn m4a_session_key(&self, instance_id: AgentInstanceId) -> String {
3691        let reused = self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
3692        let key = reused.unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0));
3693        self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key.clone());
3694        if let Some(file) = self.session_resume_journal() {
3695            crate::pty_resume::set_session_key(&file, instance_id.0, &key);
3696        }
3697        key
3698    }
3699
3700    fn session_workdir(&self, root: &std::path::Path, instance_id: AgentInstanceId, provider: &AgentId) -> Option<std::path::PathBuf> {
3701        let accounts = self.m4a_accounts.as_ref()?;
3702        let nick = self.m4a_nick_of(instance_id)?;
3703        match accounts.session_workdir(root, provider.as_str(), &nick) {
3704            Ok(dir) => dir,
3705            Err(error) => {
3706                tracing::warn!(%nick, %error, "session folder not prepared; the session runs in the workspace root");
3707                None
3708            }
3709        }
3710    }
3711
3712    fn m4a_nick_of(&self, instance_id: AgentInstanceId) -> Option<String> {
3713        self.m4a_live.lock().unwrap_or_else(|p| p.into_inner()).iter().find(|(_, (i, _))| *i == instance_id).map(|(n, _)| n.clone())
3714    }
3715
3716    /// The managed record of a node session carries its m4a nick as its name (HQ shows it,
3717    /// `/api/spawn` returns it).
3718    fn name_record_after_nick(&self, instance_id: AgentInstanceId, nick: &str) {
3719        let mut records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
3720        for record in records.records.values_mut() {
3721            if record.active_session.as_ref().is_some_and(|a| a.session.instance_id == instance_id) {
3722                record.display_name = nick.to_owned();
3723            }
3724        }
3725    }
3726
3727    /// Gives a node session its own m4a account: identity env, session file, MCP entry and
3728    /// the mailbox listener that wakes it.
3729    fn m4a_session_overlay(
3730        &self,
3731        accounts: &crate::m4a_accounts::M4aSessionAccounts,
3732        m4a_provider: &str,
3733        provider: &AgentId,
3734        instance_id: AgentInstanceId,
3735        environment_overlay: Option<PreparedNativeLaunchOverlay>,
3736        session_key: &str,
3737        mode: SessionMode,
3738    ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3739        let session_key = session_key.to_owned();
3740        if mode == SessionMode::Acp {
3741            return self.m4a_acp_mcp_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key);
3742        }
3743        Ok(                match accounts.provision(m4a_provider, &session_key) {
3744                    Ok(account) => {
3745                        crate::pty_resume::note_account_ref(&session_key, &account.session_id);
3746                        let mut identity = account.env_mutations();
3747                        identity.extend(crate::m4a_accounts::provider_session_env(provider.as_str()));
3748                        let merged = match environment_overlay {
3749                            None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity)
3750                                .map(PreparedNativeLaunchOverlay::Instance),
3751                            Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
3752                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3753                            }
3754                            Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
3755                                overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
3756                            }
3757                        }
3758                        .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a session account overlay is invalid"))?;
3759                        tracing::info!(provider = %provider, instance_id = ?instance_id, nick = %account.nick, "m4a session account assigned at spawn");
3760                        let mut merged = merged;
3761                        match accounts.write_session_file(&account) {
3762                            Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a session file not written"),
3763                            Ok(_) => match accounts.mcp_launch_args(provider.as_str(), &account.nick) {
3764                                Ok(args) if !args.is_empty() => {
3765                                    merged = match merged {
3766                                        PreparedNativeLaunchOverlay::Instance(mut overlay) => overlay
3767                                            .append_extra_args(args)
3768                                            .map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
3769                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3770                                        PreparedNativeLaunchOverlay::Environment(overlay) => overlay
3771                                            .into_instance_with_extra_args(args)
3772                                            .map(PreparedNativeLaunchOverlay::Instance)
3773                                            .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a mcp overlay is invalid"))?,
3774                                    };
3775                                    tracing::info!(nick = %account.nick, "m4a mcp server delivered at spawn");
3776                                }
3777                                Ok(_) => {}
3778                                Err(error) => tracing::warn!(nick = %account.nick, %error, "m4a mcp server not delivered"),
3779                            },
3780                        }
3781                        self.m4a_live
3782                            .lock()
3783                            .unwrap_or_else(|p| p.into_inner())
3784                            .insert(account.nick.clone(), (instance_id, provider.clone()));
3785                        self.name_record_after_nick(instance_id, &account.nick);
3786                        tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a nick bound to session");
3787                        // Letters to this account wake the session: the node listens on its mailbox.
3788                        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3789                        Some(merged)
3790                    }
3791                    Err(error) => {
3792                        tracing::warn!(provider = %provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3793                        environment_overlay
3794                    }
3795                })
3796    }
3797}
3798
3799impl NodeShared {
3800    /// ACP has no env/argv overlay for this: the session's m4a account rides as an MCP server
3801    /// handed to the agent in session/new, with the account env on that server only.
3802    fn m4a_acp_mcp_overlay(
3803        &self,
3804        accounts: &crate::m4a_accounts::M4aSessionAccounts,
3805        m4a_provider: &str,
3806        provider: &AgentId,
3807        instance_id: AgentInstanceId,
3808        environment_overlay: Option<PreparedNativeLaunchOverlay>,
3809        session_key: &str,
3810    ) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
3811        let Some(control) = self.native_launch_profile_control.as_ref() else {
3812            tracing::warn!(%provider, "m4a acp account not assigned: no launch profile control");
3813            return Ok(environment_overlay);
3814        };
3815        let account = match accounts.provision(m4a_provider, session_key) {
3816            Ok(a) => a,
3817            Err(error) => {
3818                tracing::warn!(%provider, instance_id = ?instance_id, %error, "m4a session account not assigned");
3819                return Ok(environment_overlay);
3820            }
3821        };
3822        let session_file = match accounts.write_session_file(&account) {
3823            Ok(p) => p,
3824            Err(error) => {
3825                tracing::warn!(nick = %account.nick, %error, "m4a session file not written");
3826                return Ok(environment_overlay);
3827            }
3828        };
3829        let env_entries: Vec<(OsString, OsString)> = account
3830            .env_mutations()
3831            .into_iter()
3832            .chain(crate::m4a_accounts::provider_session_env(provider.as_str()))
3833            .filter_map(|m| m.value.map(|v| (m.key, v)))
3834            .collect();
3835        let args: Vec<OsString> = vec![
3836            "--session-file".into(),
3837            session_file.into_os_string(),
3838            "mcp".into(),
3839            "--provider".into(),
3840            provider.as_str().into(),
3841        ];
3842        let overlay = NativeMcpServerLaunchOverlay::new(
3843            provider.clone(),
3844            crate::m4a_accounts::mcp_server_name(provider.as_str()).as_str(),
3845            accounts.bin().as_os_str().to_os_string(),
3846            args,
3847            env_entries,
3848            Vec::new(),
3849        );
3850        match overlay.map_err(|e| e.to_string()).and_then(|o| control.install_native_mcp_server_launch_overlay(instance_id, o).map_err(|e| e.to_string())) {
3851            Ok(_) => tracing::info!(nick = %account.nick, instance = instance_id.0, "m4a mcp server delivered over acp"),
3852            Err(error) => {
3853                tracing::warn!(nick = %account.nick, %error, "m4a acp mcp server not delivered");
3854                return Ok(environment_overlay);
3855            }
3856        }
3857        self.m4a_live
3858            .lock()
3859            .unwrap_or_else(|p| p.into_inner())
3860            .insert(account.nick.clone(), (instance_id, provider.clone()));
3861        self.name_record_after_nick(instance_id, &account.nick);
3862        crate::m4a_wake::watch_spawned(provider.as_str(), &account.nick, &account.session_id, account.store_root.as_deref(), None);
3863        Ok(environment_overlay)
3864    }
3865}
3866
3867impl SpawnPreparer for NodeShared {
3868    fn select_environment(
3869        &self,
3870        instance_id: AgentInstanceId,
3871        provider: &AgentId,
3872        mode: SessionMode,
3873        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
3874    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
3875        self.select_environment_profile(instance_id, provider, mode, environment_profile)
3876    }
3877
3878    fn install_launch_overlay(
3879        &self,
3880        instance_id: AgentInstanceId,
3881        overlay: PreparedNativeLaunchOverlay,
3882    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
3883        self.install_prepared_launch_overlay(instance_id, overlay)
3884    }
3885
3886    fn prepare<'a>(&'a self, request: SpawnPrepareRequest<'_>) -> Result<PreparedSpawn<'a>, NodeFailure> {
3887        let SpawnPrepareRequest {
3888            instance_id,
3889            address,
3890            provider,
3891            mode,
3892            environment_profile,
3893            bundle,
3894            context,
3895            managed_authority,
3896            harness_mcp,
3897            approval_level,
3898            network_allowlist,
3899            browser_profile_id,
3900        } = request;
3901        // Track A follow-on: exclusive node-local BrowserStationLease after
3902        // dig2 probe (feature dig2-station-probe). Opaque profile id only —
3903        // never ImportSession / cookies on C2. Linux/feature-off: probe
3904        // already refused or stub; this block is cfg'd out when feature off.
3905        #[cfg(feature = "dig2-station-probe")]
3906        let mut browser_station_lease_guard = if let Some(profile_id) = browser_profile_id {
3907            use crate::dig2_station_lease::{acquire_lease, BrowserStationLeaseError};
3908            match acquire_lease(
3909                &self.browser_station_leases,
3910                profile_id,
3911                instance_id,
3912                unix_time_ms(),
3913            ) {
3914                Ok(guard) => Some(guard),
3915                Err(BrowserStationLeaseError::ProfileBusy { .. }) => {
3916                    return Err(failure(
3917                        NodeFailureCode::BrowserStationProfileBusy,
3918                        "dig2browser station profile is already leased by another session",
3919                    ));
3920                }
3921                Err(BrowserStationLeaseError::CapacityExceeded) => {
3922                    return Err(failure(
3923                        NodeFailureCode::BrowserStationProfileBusy,
3924                        "dig2browser station profile lease capacity exhausted",
3925                    ));
3926                }
3927                Err(BrowserStationLeaseError::HolderAlreadyLeased) => {
3928                    return Err(failure(
3929                        NodeFailureCode::BrowserStationProfileBusy,
3930                        "session already holds a dig2browser station profile lease",
3931                    ));
3932                }
3933            }
3934        } else {
3935            None
3936        };
3937        #[cfg(not(feature = "dig2-station-probe"))]
3938        let _ = browser_profile_id;
3939        let mut harness_mcp_overlay = if let Some(prepared) = harness_mcp {
3940            // Slice A(ii) of gate4agent-arc-mailbox-and-task-layer: the door
3941            // exists for PTY (env vars) and ACP (`session/new.mcpServers`,
3942            // `gate4agent-shell-native`'s `mcp_server_acp_entry`) alike --
3943            // both read the identical overlay resolved below, one as child
3944            // environment, the other translated into one stdio MCP server
3945            // entry. `Inline`/`Pipe` stays refused: this arc's proof is ACP
3946            // only, and nothing downstream reads a harness-MCP overlay for
3947            // that transport yet.
3948            if !matches!(mode, SessionMode::Pty | SessionMode::Acp) || prepared.provider != *provider
3949            {
3950                return Err(failure(
3951                    NodeFailureCode::BindingMismatch,
3952                    "harness MCP reservation does not match the exact PTY/ACP provider",
3953                ));
3954            }
3955            prepared.verify_helper_program().map_err(harness_mcp_failure)?;
3956            let control = self.native_launch_profile_control.clone().ok_or_else(|| {
3957                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP launch control is unavailable")
3958            })?;
3959            // Every name below is the caller's, carried in the reservation's
3960            // launch description: which environment variables receive the
3961            // endpoint, token and helper program, what the MCP server is called,
3962            // which argv it gets and which stale variables are erased.
3963            let launch = prepared.launch();
3964            let mut env_entries = vec![
3965                (
3966                    OsString::from(launch.endpoint_env.as_str()),
3967                    prepared.endpoint().as_os_str().to_os_string(),
3968                ),
3969                (
3970                    OsString::from(launch.token_env.as_str()),
3971                    prepared.token().expose().into(),
3972                ),
3973                (
3974                    OsString::from(launch.program_env.as_str()),
3975                    prepared.helper_program().as_os_str().to_os_string(),
3976                ),
3977            ];
3978            if let Some(trace_entry) = harness_mcp_trace_env_entry(launch) {
3979                env_entries.push(trace_entry);
3980            }
3981            if let Some(door) = crate::door::door_listen_addr() {
3982                env_entries.push((
3983                    OsString::from("HATCHERY_NODE_DOOR"),
3984                    OsString::from(door.to_string()),
3985                ));
3986                // Same key shape as HQ browser/draft ids:
3987                // `pty-{incarnation}-{workspace}-{instance}` — never reuse a
3988                // slot across node boots when the instance counter resets.
3989                let session_key = format!(
3990                    "pty-{}-{}-{}",
3991                    self.incarnation_id,
3992                    address.workspace_id.as_str(),
3993                    address.session.instance_id.0
3994                );
3995                env_entries.push((
3996                    OsString::from("HATCHERY_LOCAL_SERVER_SESSION"),
3997                    OsString::from(session_key),
3998                ));
3999            }
4000            let overlay = NativeMcpServerLaunchOverlay::new(
4001                provider.clone(),
4002                launch.server_name.as_str(),
4003                prepared.helper_program().as_os_str().to_os_string(),
4004                launch.args.iter().map(OsString::from).collect(),
4005                env_entries,
4006                launch.scrub_env.iter().map(OsString::from).collect(),
4007            ).map_err(|_| failure(
4008                NodeFailureCode::HarnessMcpUnavailable,
4009                "harness MCP launch overlay is unavailable",
4010            ))?;
4011            control
4012                .install_native_mcp_server_launch_overlay(instance_id, overlay)
4013                .map_err(|_| failure(
4014                    NodeFailureCode::HarnessMcpUnavailable,
4015                    "harness MCP launch overlay could not be installed",
4016                ))?;
4017            // State-change line: the overlay is installed and will be read
4018            // back by the PTY child's environment or, for ACP,
4019            // `gate4agent-shell-native`'s `mcp_server_acp_entry` at spawn
4020            // time -- no secrets here, only which instance/provider/mode
4021            // now carries a harness-MCP door.
4022            tracing::info!(
4023                node_id = %self.node_id,
4024                instance_id = ?instance_id,
4025                provider = %provider,
4026                mode = ?mode,
4027                "harness MCP overlay installed for spawn",
4028            );
4029            Some(NativeHarnessMcpOverlayGuard { control, instance_id })
4030        } else {
4031            None
4032        };
4033        let prepared_materialization = self.prepare_session_materialization(
4034            &address,
4035            &provider,
4036            mode,
4037            environment_profile,
4038            bundle,
4039            context,
4040            managed_authority.cloned(),
4041        )?;
4042        let (environment_overlay, mut materialization_guard) = match prepared_materialization {
4043            Some((overlay, guard)) => (Some(overlay), Some(guard)),
4044            None => (None, None),
4045        };
4046        let materialization_id = materialization_guard
4047            .as_ref()
4048            .and_then(SessionMaterializationGuard::id)
4049            .cloned();
4050        let mut environment_selection = SpawnPreparer::select_environment(self, 
4051            instance_id,
4052            &provider,
4053            mode,
4054            environment_profile,
4055        )?;
4056        // Station network overlays: Codex `-c network_access` and/or Claude
4057        // ProviderHome `--settings` sidecar (permits → allowedDomains).
4058        let provider_home = materialization_guard
4059            .as_ref()
4060            .and_then(SessionMaterializationGuard::provider_home);
4061        let network_overlay_args = self.resolve_network_allowlist_overlay_args(
4062            &provider,
4063            mode,
4064            approval_level,
4065            network_allowlist,
4066            provider_home,
4067        )?;
4068        let mut network_overlay_args = network_overlay_args;
4069        // Auto-permit (default on): pre-trust cwd / onboarding in provider config + auto/yolo flags.
4070        {
4071            let cwd = self.workspace_root(&address.workspace_id).ok().map(PathBuf::from);
4072            let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4073            if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") {
4074                homes.extend(std::env::split_paths(&extra));
4075            }
4076            if let Some(h) = provider_home { homes.push(h.to_path_buf()); }
4077            crate::auto_permit::prepare(provider.as_str(), &homes, cwd.as_deref());
4078            network_overlay_args.extend(crate::auto_permit::launch_args(provider.as_str(), mode));
4079        }
4080        if mode == SessionMode::Acp && crate::auto_permit::acp_auto_approve(provider.as_str()) {
4081            crate::auto_permit::register_acp(instance_id.0, provider.as_str());
4082        }
4083        // Session registry: journal the session so it is resumed after a node restart.
4084        if matches!(mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::enabled() && crate::pty_resume::supported(provider.as_str()) {
4085            if let Some(file) = self.session_resume_journal() {
4086                crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
4087                    instance_id: instance_id.0,
4088                    provider: provider.as_str().to_owned(),
4089                    workspace_id: address.workspace_id.as_str().to_owned(),
4090                    provider_session_id: String::new(),
4091                    session_key: self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned()
4092                        .or_else(|| self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned())
4093                        .unwrap_or_else(|| format!("{}-{}", self.incarnation_id, instance_id.0)),
4094                    cwd: String::new(),
4095                    acp: mode == SessionMode::Acp,
4096                    options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
4097                    cols: 0,
4098                    rows: 0,
4099                    origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4100                    parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4101                });
4102                if let Some(dir) = file.parent() {
4103                    crate::session_meta::upsert(dir, instance_id.0, crate::session_meta::Meta {
4104                        origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
4105                        parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
4106                        provider: Some(provider.as_str().to_owned()),
4107                        ..crate::session_meta::Meta::default()
4108                    });
4109                    crate::session_meta::revive(dir, instance_id.0);
4110                }
4111                tracing::info!(instance_id = instance_id.0, provider = %provider, "session journalled for restart resume");
4112            }
4113        }
4114        if let Some(socket) = self.session_grok_leader_socket(&provider, mode, instance_id) {
4115            network_overlay_args.extend(crate::grok_leader::leader_socket_args(&socket));
4116        }
4117        let environment_overlay = match (environment_overlay.flatten(), network_overlay_args) {
4118            (prepared, args) if args.is_empty() => prepared,
4119            (Some(PreparedNativeLaunchOverlay::Instance(mut overlay)), args) => {
4120                overlay.append_extra_args(args).map_err(|_| {
4121                    failure(
4122                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4123                        "network allowlist overlay could not merge with instance launch args",
4124                    )
4125                })?;
4126                Some(PreparedNativeLaunchOverlay::Instance(overlay))
4127            }
4128            (Some(PreparedNativeLaunchOverlay::Environment(overlay)), args) => {
4129                Some(PreparedNativeLaunchOverlay::Instance(
4130                    overlay
4131                        .into_instance_with_extra_args(args)
4132                        .map_err(|_| {
4133                            failure(
4134                                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4135                                "network allowlist overlay could not merge with environment overlay",
4136                            )
4137                        })?,
4138                ))
4139            }
4140            (None, args) => Some(PreparedNativeLaunchOverlay::Instance(
4141                NativeInstanceLaunchOverlay::new(
4142                    provider.clone(),
4143                    match mode {
4144                        SessionMode::Pty => TransportKind::Pty,
4145                        SessionMode::Acp => TransportKind::Acp,
4146                        SessionMode::Inline => TransportKind::Pipe,
4147                    },
4148                    Vec::new(),
4149                    args,
4150                )
4151                .map_err(|_| {
4152                    failure(
4153                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4154                        "network allowlist overlay is invalid for this transport",
4155                    )
4156                })?,
4157            )),
4158        };
4159        // Per-session m4a account: own nick + capability for this PTY child only, on its own
4160        // overlay or appended to the overlay the spawn already carries.
4161        #[cfg(unix)]
4162        // `HATCHERY_M4A_SIGNER_SHELL=1` also gives operator shells an identity (live canaries).
4163        let signer_provider = crate::m4a_accounts::m4a_provider(provider.as_str()).or_else(|| {
4164            (provider.as_str() == "shell" && std::env::var_os("HATCHERY_M4A_SIGNER_SHELL").is_some_and(|v| v == "1")).then_some("shell")
4165        });
4166        #[cfg(unix)]
4167        // A provider session with its own m4a account (MCP server + cap) takes that path; the
4168        // signer handle is for sessions without one (operator shells).
4169        let signer_provider = signer_provider.filter(|p| *p == "shell" || self.m4a_accounts.is_none());
4170        #[cfg(unix)]
4171        let signer_identity = match (mode, self.signer_proxy.get(), signer_provider) {
4172            (SessionMode::Pty, Some(proxy), Some(m4a_provider)) => {
4173                let session_key = format!("{}-{}", self.incarnation_id, instance_id.0);
4174                let uid = unsafe { libc_geteuid() };
4175                let handle = proxy.reserve(&session_key, m4a_provider, uid);
4176                let sock = std::env::var_os(crate::keystore_proxy::SOCKET_ENV).unwrap_or_default();
4177                tracing::info!(provider = %provider, instance_id = ?instance_id, "m4a signer handle reserved at spawn");
4178                Some(vec![
4179                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::HANDLE_ENV), value: Some(std::ffi::OsString::from(handle)) },
4180                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from(crate::keystore_proxy::SOCKET_ENV), value: Some(sock) },
4181                    gate4agent_catalog::EnvMutation { key: std::ffi::OsString::from("M4A_SESSION_ID"), value: Some(std::ffi::OsString::from(session_key)) },
4182                ])
4183            }
4184            _ => None,
4185        };
4186        #[cfg(not(unix))]
4187        let signer_identity: Option<Vec<gate4agent_catalog::EnvMutation>> = None;
4188        let environment_overlay = match (mode, self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(provider.as_str()), signer_identity) {
4189            (SessionMode::Pty, _, _, Some(identity)) => {
4190                let merged = match environment_overlay {
4191                    None => NativeInstanceLaunchOverlay::new_session_identity(provider.clone(), identity).map(PreparedNativeLaunchOverlay::Instance),
4192                    Some(PreparedNativeLaunchOverlay::Instance(mut overlay)) => {
4193                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Instance(overlay))
4194                    }
4195                    Some(PreparedNativeLaunchOverlay::Environment(mut overlay)) => {
4196                        overlay.append_session_identity(identity).map(|()| PreparedNativeLaunchOverlay::Environment(overlay))
4197                    }
4198                }
4199                .map_err(|_| failure(NodeFailureCode::BackendOperationFailed, "m4a signer handle overlay is invalid"))?;
4200                Some(merged)
4201            }
4202            (SessionMode::Pty | SessionMode::Acp, Some(accounts), Some(m4a_provider), None) => {
4203                let session_key = self.m4a_session_key(instance_id);
4204                self.m4a_session_overlay(accounts, m4a_provider, provider, instance_id, environment_overlay, &session_key, mode)?
4205            }
4206            (SessionMode::Pty, Some(_), None, _) => {
4207                tracing::warn!(provider = %provider, instance_id = ?instance_id, "m4a session account not assigned: provider has no m4a mapping");
4208                environment_overlay
4209            }
4210            _ => environment_overlay,
4211        };
4212        let mut instance_overlay = environment_overlay
4213            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
4214            .transpose()?
4215            .flatten();
4216        Ok(PreparedSpawn {
4217            instance_overlay,
4218            environment_selection,
4219            materialization_guard,
4220            harness_mcp_overlay,
4221            #[cfg(feature = "dig2-station-probe")]
4222            browser_station_lease_guard,
4223            materialization_id,
4224        })
4225    }
4226}
4227
4228struct SessionRecords {
4229    records: BTreeMap<SessionRecordId, ManagedSessionRecord>,
4230}
4231
4232struct SpawnIdempotencyCache {
4233    entries: BTreeMap<SpawnIdempotencyKey, SpawnIdempotencyEntry>,
4234}
4235
4236struct SpawnIdempotencyEntry {
4237    value: SpawnIdempotencyValue,
4238    expires_at: Instant,
4239}
4240
4241enum SpawnIdempotencyValue {
4242    Standard {
4243        spec: SpawnSpec,
4244        result: Result<ResolvedSpawnReceipt, NodeFailure>,
4245    },
4246    Managed {
4247        request: ManagedWorktreeSpawnRequest,
4248        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
4249    },
4250    HarnessMcp,
4251}
4252
4253enum DurableManagedSpawnReplayDecision {
4254    Reserved,
4255    PendingLinked,
4256    Committed(ManagedWorktreeSpawnReceipt),
4257}
4258
4259impl SpawnIdempotencyValue {
4260    fn references_context(&self, context_id: &SpawnContextId) -> bool {
4261        match self {
4262            Self::Standard { result, .. } => result
4263                .as_ref()
4264                .ok()
4265                .and_then(|receipt| receipt.context.as_ref()),
4266            Self::Managed { result, .. } => result
4267                .as_ref()
4268                .ok()
4269                .and_then(|receipt| receipt.spawn.context.as_ref()),
4270            Self::HarnessMcp => None,
4271        }
4272        .is_some_and(|receipt| &receipt.id == context_id)
4273    }
4274}
4275
4276#[derive(Clone, Copy)]
4277struct ControllerLease {
4278    connection_id: u64,
4279    expires_at: Instant,
4280}
4281
4282struct NodeEventHistory {
4283    last_sequence: u64,
4284    replay_floor_sequence: u64,
4285    events: VecDeque<NodeEventEnvelope>,
4286    record_providers: BTreeMap<SessionRecordId, AgentId>,
4287    removed_record_providers: BTreeMap<u64, AgentId>,
4288}
4289
4290impl NodeEventHistory {
4291    fn new(record_providers: BTreeMap<SessionRecordId, AgentId>) -> Self {
4292        Self {
4293            last_sequence: 0,
4294            replay_floor_sequence: 1,
4295            events: VecDeque::with_capacity(NODE_EVENT_HISTORY_MAX),
4296            record_providers,
4297            removed_record_providers: BTreeMap::new(),
4298        }
4299    }
4300}
4301
4302impl NodeShared {
4303    /// A node-spawned grok PTY session gets its own leader socket, so its agent backend runs
4304    /// as a descendant of this session's process (with the session's own environment) and
4305    /// never attaches to an operator's shared leader. Same grok home, same login.
4306    fn session_resume_journal(&self) -> Option<PathBuf> {
4307        self.state_path.as_ref().and_then(|p| p.parent()).map(crate::pty_resume::path)
4308    }
4309
4310    /// Node start: resume every journalled session through its provider's resume path,
4311    /// under its old instance number, so HQ shows the same chat.
4312    async fn restore_journalled_sessions(&self) {
4313        let Some(file) = self.session_resume_journal() else { return };
4314        if !crate::pty_resume::enabled() { return; }
4315        for e in crate::pty_resume::load(&file) {
4316            crate::pty_resume::remove(&file, e.instance_id);
4317            // Same m4a session key as before the restart: the session keeps its account and nick.
4318            if !e.session_key.is_empty() {
4319                self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(AgentInstanceId(e.instance_id), e.session_key.clone());
4320            }
4321            let (Ok(ws), Ok(provider)) = (WorkspaceId::new(e.workspace_id.clone()), AgentId::new(e.provider.clone())) else { continue };
4322            if e.acp {
4323                let record_id = {
4324                    let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4325                    records.records.values().find(|r| r.mode == SessionMode::Acp && r.provider_session.as_ref().is_some_and(|i| !e.provider_session_id.is_empty() && i.id == e.provider_session_id)).map(|r| r.record_id.clone())
4326                };
4327                let cur = self.next_instance_id.load(Ordering::Acquire);
4328                if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4329                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4330                let resumed = match record_id {
4331                    Some(rid) => SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&rid, size, None)).await.map(|(_, a)| a),
4332                    None => Err(failure(NodeFailureCode::SessionRecordNotResumable, "no record")),
4333                };
4334                // An agent without ACP session/load (claude) cannot resume: same number, fresh chat.
4335                let r = match resumed {
4336                    Ok(a) => Ok(a),
4337                    Err(err) => {
4338                        tracing::info!(instance_id = e.instance_id, message = %err.message, "acp resume unavailable; starting fresh under the same number");
4339                        self.next_instance_id.store(e.instance_id.max(self.next_instance_id.load(Ordering::Acquire).min(e.instance_id)), Ordering::Release);
4340                        SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Acp, size, None)).await
4341                    }
4342                };
4343                match r {
4344                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled acp session restored"),
4345                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled acp session restore failed"),
4346                }
4347                continue;
4348            }
4349            if e.provider_session_id.is_empty() || !provider_session_exists(&e.provider, &e.provider_session_id) {
4350                // Nothing said yet in that chat: start it fresh under the same number.
4351                let cur = self.next_instance_id.load(Ordering::Acquire);
4352                if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4353                let size = gate4agent_types::TerminalSize { rows: 40, columns: 120 };
4354                match SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.spawn_session(ws, provider, SessionMode::Pty, size, None)).await {
4355                    Ok(addr) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session had no transcript; started fresh"),
4356                    Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session restart failed"),
4357                }
4358                continue;
4359            }
4360            let folder = std::path::Path::new(&e.cwd);
4361            let in_session_dir = folder.parent().and_then(|p| p.file_name()).is_some_and(|n| n == ".hatchery-sessions");
4362            let name = match (in_session_dir, folder.file_name()) {
4363                (true, Some(n)) => n.to_string_lossy().into_owned(),
4364                _ => format!("{} #{}", e.provider, e.instance_id),
4365            };
4366            let identity = ProviderSessionIdentity { key: gate4agent_types::ProviderSessionKey::SessionId, id: e.provider_session_id.clone(), transcript_path: None };
4367            let record = match self.index_provider_session(ws, provider, identity, name) {
4368                Ok(r) => r,
4369                Err(err) => { tracing::warn!(instance_id = e.instance_id, message = %err.message, "journalled session not indexed"); continue; }
4370            };
4371            let cur = self.next_instance_id.load(Ordering::Acquire);
4372            if e.instance_id >= cur { self.next_instance_id.store(e.instance_id, Ordering::Release); }
4373            let size = gate4agent_types::TerminalSize { rows: if e.rows > 0 { e.rows } else { 40 }, columns: if e.cols > 0 { e.cols } else { 120 } };
4374            let r = SPAWN_SESSION_OPTIONS.scope(e.options.clone(), self.resume_session_record(&record.record_id, size, None)).await;
4375            match r {
4376                Ok((_, addr)) => tracing::info!(instance_id = addr.session.instance_id.0, provider = %e.provider, "journalled session resumed"),
4377                Err(err) => tracing::warn!(instance_id = e.instance_id, provider = %e.provider, message = %err.message, "journalled session resume failed"),
4378            }
4379        }
4380    }
4381
4382    fn refresh_session_journal(&self) {
4383        let Some(file) = self.session_resume_journal() else { return };
4384        let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
4385        if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
4386        crate::pty_resume::refresh(&file, &homes);
4387        let ids: Vec<(u64, String)> = {
4388            let records = self.session_records.lock().unwrap_or_else(|p| p.into_inner());
4389            records.records.values().filter_map(|r| Some((r.active_session.as_ref()?.session.instance_id.0, r.provider_session.as_ref()?.id.clone()))).collect()
4390        };
4391        for (inst, sid) in ids {
4392            crate::pty_resume::set_session_id(&file, inst, &sid);
4393        }
4394    }
4395
4396    fn session_grok_leader_socket(
4397        &self,
4398        provider: &AgentId,
4399        mode: SessionMode,
4400        instance_id: AgentInstanceId,
4401    ) -> Option<std::path::PathBuf> {
4402        if provider.as_str() != "grok" || mode != SessionMode::Pty || crate::grok_leader::disabled() {
4403            return None;
4404        }
4405        let root = self
4406            .m4a_accounts
4407            .as_ref()
4408            .and_then(|a| a.base().parent().map(std::path::Path::to_path_buf))
4409            .unwrap_or_else(std::env::temp_dir);
4410        let socket = crate::grok_leader::socket_path(&root, &format!("{}-{}", self.incarnation_id, instance_id.0));
4411        if let Some(previous) = self
4412            .grok_leaders
4413            .lock()
4414            .unwrap_or_else(|p| p.into_inner())
4415            .insert(instance_id, socket.clone())
4416        {
4417            crate::grok_leader::retire(&previous);
4418        }
4419        Some(socket)
4420    }
4421
4422    /// Stops this session's own grok leader, if it had one. Never touches any other leader.
4423    fn retire_session_grok_leader(&self, instance_id: AgentInstanceId) {
4424        let socket = self.grok_leaders.lock().unwrap_or_else(|p| p.into_inner()).remove(&instance_id);
4425        if let Some(socket) = socket {
4426            crate::grok_leader::retire(&socket);
4427        }
4428    }
4429
4430    fn new_with_incarnation(
4431        handle: Gate4AgentHandle,
4432        access_token: String,
4433        node_id: NodeId,
4434        incarnation_id: NodeIncarnationId,
4435        workspaces: Vec<WorkspaceConfig>,
4436        enabled_providers: Vec<AgentId>,
4437        provider_runtime_statuses: ProviderRuntimeStatuses,
4438        provider_runtime_monitor: Option<Arc<ProviderRuntimeMonitor>>,
4439        provider_contracts: Vec<ProviderContractSupport>,
4440        provider_adapter_contracts: Vec<ProviderAdapterContractSupport>,
4441        spawn_profiles: SpawnProfileRegistry,
4442        native_launch_profile_control: Option<NativeLaunchProfileControl>,
4443        native_session_catalog: Option<Arc<Mutex<NativeSessionCatalogAuthority>>>,
4444        session_environment_materializer: Option<SessionEnvironmentMaterializer>,
4445        state_path: Option<PathBuf>,
4446        records: Vec<ManagedSessionRecord>,
4447        managed_worktrees: Vec<ManagedWorktreeLeaseRecord>,
4448        managed_worktree_tombstones: Vec<ManagedWorktreeLeaseRecord>,
4449        materializations: Vec<MaterializationOwnershipRecord>,
4450        persistence_warning: Option<String>,
4451        input_settle_timeout_ms: u64,
4452    ) -> Self {
4453        let (event_tx, _) = broadcast::channel(NODE_BROADCAST_CAPACITY);
4454        let (terminal_event_tx, _) = broadcast::channel(NODE_TERMINAL_BROADCAST_CAPACITY);
4455        let (agent_stream_event_tx, _) = broadcast::channel(NODE_AGENT_STREAM_BROADCAST_CAPACITY);
4456        let (harness_mcp_proxy_event_tx, _) =
4457            broadcast::channel(NODE_HARNESS_MCP_PROXY_BROADCAST_CAPACITY);
4458        let record_providers = records
4459            .iter()
4460            .map(|record| (record.record_id.clone(), record.provider.clone()))
4461            .collect();
4462        let mut worktree_service_modes: BTreeMap<WorkspaceId, WorktreeServiceMode> = workspaces
4463            .iter()
4464            .map(|workspace| {
4465                (workspace.workspace_id.clone(), workspace.worktree_service_mode)
4466            })
4467            .collect();
4468        for lease in &managed_worktrees {
4469            worktree_service_modes.insert(lease.workspace_id.clone(), WorktreeServiceMode::Off);
4470        }
4471        let managed_worktree_profiles = workspaces.iter()
4472            .map(|workspace| (
4473                workspace.workspace_id.clone(),
4474                workspace.managed_worktree_profiles.clone(),
4475            ))
4476            .collect();
4477        let mut managed_worktrees = ManagedWorktreeRegistry::from_records(
4478            managed_worktrees,
4479            managed_worktree_tombstones,
4480        ).expect("durable managed worktree registry was validated while loading");
4481        managed_worktrees.clear_stale_session_holders(incarnation_id, unix_time_ms());
4482        managed_worktrees.reattach_record_holders(
4483            &managed_worktree_record_holders(&records),
4484            unix_time_ms(),
4485        );
4486        Self {
4487            handle,
4488            access_token,
4489            node_id,
4490            incarnation_id,
4491            started_at_unix_ms: std::time::SystemTime::now()
4492                .duration_since(std::time::UNIX_EPOCH)
4493                .unwrap_or_default()
4494                .as_millis()
4495                .min(u64::MAX as u128) as u64,
4496            workspaces: RwLock::new(
4497                workspaces
4498                    .into_iter()
4499                    .map(|workspace| (workspace.workspace_id, workspace.canonical_root))
4500                    .collect(),
4501            ),
4502            worktree_service_modes,
4503            managed_worktree_profiles,
4504            managed_worktrees: Mutex::new(managed_worktrees),
4505            enabled_providers,
4506            provider_runtime_statuses,
4507            provider_runtime_status_updates: RwLock::new(BTreeMap::new()),
4508            provider_runtime_monitor,
4509            provider_contracts,
4510            provider_adapter_contracts,
4511            spawn_profiles,
4512            environment_profiles: RwLock::new(BTreeMap::new()),
4513            environment_materialization_profiles: RwLock::new(BTreeMap::new()),
4514            network_allowlist_catalog: RwLock::new(crate::network_allowlist_catalog::NetworkAllowlistCatalog::new()),
4515            #[cfg(feature = "dig2-station-probe")]
4516            browser_station_leases: crate::dig2_station_lease::new_lease_table(),
4517            bundle_catalog: RwLock::new(BundleCatalog::default()),
4518            delivery_store: Mutex::new(None),
4519            context_pack_store: Mutex::new(None),
4520            context_catalog: RwLock::new(ContextPackCatalog::default()),
4521            native_launch_profile_control,
4522            harness_mcp_registry: None,
4523            native_session_catalog,
4524            session_environment_materializer,
4525            m4a_accounts: crate::m4a_accounts::M4aSessionAccounts::from_env(),
4526            m4a_live: Mutex::new(BTreeMap::new()),
4527            m4a_keys: Mutex::new(BTreeMap::new()),
4528            m4a_reuse_keys: Mutex::new(BTreeMap::new()),
4529            grok_leaders: Mutex::new(BTreeMap::new()),
4530            #[cfg(unix)]
4531            #[cfg(unix)]
4532            signer_proxy: std::sync::OnceLock::new(),
4533            materializations: Mutex::new(
4534                materializations
4535                    .into_iter()
4536                    .map(|record| (record.id().clone(), record))
4537                    .collect(),
4538            ),
4539            spawn_idempotency: Mutex::new(SpawnIdempotencyCache {
4540                entries: BTreeMap::new(),
4541            }),
4542            managed_spawn_replays: Mutex::new(BTreeMap::new()),
4543            controller: Mutex::new(None),
4544            provisional_spawns: Mutex::new(BTreeMap::new()),
4545            pending_acp_options: Mutex::new(BTreeMap::new()),
4546            history: Mutex::new(NodeEventHistory::new(record_providers)),
4547            event_tx,
4548            terminal_event_tx,
4549            agent_stream_event_tx,
4550            harness_mcp_proxy_event_tx,
4551            terminal_frame_watermarks: Mutex::new(BTreeMap::new()),
4552            pty_input_wake: tokio::sync::Notify::new(),
4553            pty_input_marks: Mutex::new(BTreeMap::new()),
4554            next_connection_id: AtomicU64::new(1),
4555            next_instance_id: AtomicU64::new(1),
4556            next_command_id: AtomicU64::new(1),
4557            shutdown: AtomicBool::new(false),
4558            shutdown_notify: Notify::new(),
4559            preauth_slots: Arc::new(Semaphore::new(MAX_PREAUTH_CONNECTIONS)),
4560            authenticated_slots: Arc::new(Semaphore::new(MAX_AUTHENTICATED_CONNECTIONS)),
4561            inspection_slots: Arc::new(Semaphore::new(WORKSPACE_INSPECTION_MAX_CONCURRENCY)),
4562            mutation_gate: AsyncMutex::new(()),
4563            session_bindings: Mutex::new(BTreeMap::new()),
4564            session_records: Mutex::new(SessionRecords {
4565                records: records
4566                    .into_iter()
4567                    .map(|record| (record.record_id.clone(), record))
4568                    .collect(),
4569            }),
4570            state_path,
4571            persistence_error: RwLock::new(
4572                persistence_warning
4573                    .map(|message| session_registry::sanitized_persistence_summary(&message)),
4574            ),
4575            state_transaction: Mutex::new(()),
4576            input_settle_timeout_ms,
4577            drive_loop_profile: Mutex::new(DriveLoopProfiler::default()),
4578            runtime_tick_profile: Mutex::new(TickProfileSnapshot::default()),
4579            shell_efficiency_profile: OnceLock::new(),
4580            native_session_gauge: AtomicUsize::new(0),
4581            connection_loop_iterations: AtomicU64::new(0),
4582            connection_events_sent: AtomicU64::new(0),
4583            rejected_commands_total: AtomicU64::new(0),
4584            rejected_command_reasons: Mutex::new(BTreeMap::new()),
4585            drive_loop_iterations_total: AtomicU64::new(0),
4586            drive_loop_iterations_idle: AtomicU64::new(0),
4587            session_record_retention: SessionRecordRetentionConfig::default(),
4588            retired_records_total: AtomicU64::new(0),
4589            #[cfg(feature = "fixture")]
4590            fixture_semantic_hook_policy: false,
4591            #[cfg(feature = "fixture")]
4592            fixture_spawn_managed_worktree_v2_failure_probe:
4593                SpawnManagedWorktreeV2FailureProbe::default(),
4594        }
4595    }
4596
4597    #[cfg(test)]
4598    fn new(
4599        handle: Gate4AgentHandle,
4600        access_token: String,
4601        node_id: NodeId,
4602        workspaces: Vec<WorkspaceConfig>,
4603        enabled_providers: Vec<AgentId>,
4604    ) -> Self {
4605        Self::new_with_incarnation(
4606            handle,
4607            access_token,
4608            node_id,
4609            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
4610            workspaces,
4611            enabled_providers,
4612            ProviderRuntimeStatuses::default(),
4613            None,
4614            Vec::new(),
4615            Vec::new(),
4616            SpawnProfileRegistry::default(),
4617            None,
4618            None,
4619            None,
4620            None,
4621            Vec::new(),
4622            Vec::new(),
4623            Vec::new(),
4624            Vec::new(),
4625            None,
4626            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
4627        )
4628    }
4629
4630    fn persistence_error(&self) -> Option<String> {
4631        self.persistence_error
4632            .read()
4633            .unwrap_or_else(|poisoned| poisoned.into_inner())
4634            .clone()
4635    }
4636
4637    async fn admit_provider_runtime(
4638        &self,
4639        provider: &AgentId,
4640        requirement: ProviderRuntimeRequirement,
4641    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
4642        let admission = if let Some(monitor) = self.provider_runtime_monitor.clone() {
4643            let pipe_transport = monitor.supports_pipe_transport(provider);
4644            let acp_transport = monitor.supports_acp_transport(provider);
4645            let provider = provider.clone();
4646            let refresh = tokio::task::spawn_blocking(move || monitor.evaluate(&provider));
4647            let (status, admission) = timeout(
4648                Duration::from_millis(PROVIDER_RUNTIME_ADMISSION_TIMEOUT_MS),
4649                refresh,
4650            )
4651            .await
4652            .map_err(|_| failure(
4653                NodeFailureCode::BackendBusy,
4654                "provider runtime probe exceeded its bounded deadline",
4655            ))?
4656            .map_err(|_| failure(
4657                NodeFailureCode::BackendOperationFailed,
4658                "provider runtime probe task failed",
4659            ))?;
4660            if let Some(status) = status {
4661                self
4662                    .provider_runtime_status_updates
4663                    .write()
4664                    .unwrap_or_else(|poisoned| poisoned.into_inner())
4665                    .insert(status.provider().clone(), status);
4666            }
4667            admission.and_then(|policy| {
4668                require_policy(policy, pipe_transport, acp_transport, requirement).map(|()| policy)
4669            })
4670        } else {
4671            // No live monitor means no catalog to consult -- there is
4672            // nothing here to confirm this provider declares a Pipe or ACP
4673            // transport, so Inline/Acp are rejected the same conservative
4674            // way an undeclared provider always was, rather than admitted
4675            // on trust.
4676            crate::provider_runtime::admit_status(
4677                &self.provider_runtime_statuses,
4678                provider,
4679                false,
4680                false,
4681                requirement,
4682            )
4683        };
4684        let admission = admission.map(|policy| self.admit_declared_hook_semantics(provider, policy));
4685        admission.map_err(|error| match error {
4686            ProviderRuntimeAdmissionError::LauncherUnavailable => failure(
4687                NodeFailureCode::BackendOperationFailed,
4688                "provider launcher is unavailable",
4689            ),
4690            ProviderRuntimeAdmissionError::SemanticCapabilityUnverified => failure(
4691                NodeFailureCode::UnsupportedCapability,
4692                "provider semantic capability is not verified",
4693            ),
4694            ProviderRuntimeAdmissionError::ProbeBusy => failure(
4695                NodeFailureCode::BackendBusy,
4696                "provider runtime probe is already in progress",
4697            ),
4698        })
4699    }
4700
4701    /// Whether the catalog declares a Pipe transport for `provider` --
4702    /// the fact `require_policy` needs to admit or reject
4703    /// `ProviderRuntimeRequirement::Inline`. With no live monitor there is no
4704    /// catalog to consult, so this stays conservative and rejects, matching
4705    /// `admit_provider_runtime`'s own no-monitor branch.
4706    fn provider_supports_pipe_transport(&self, provider: &AgentId) -> bool {
4707        match self.provider_runtime_monitor.as_deref() {
4708            Some(monitor) => monitor.supports_pipe_transport(provider),
4709            None => false,
4710        }
4711    }
4712
4713    /// Whether the catalog declares an ACP transport for `provider` -- the
4714    /// fact `require_policy` needs to admit or reject
4715    /// `ProviderRuntimeRequirement::Acp`. Mirrors
4716    /// `provider_supports_pipe_transport` exactly, including the
4717    /// conservative no-monitor default.
4718    fn provider_supports_acp_transport(&self, provider: &AgentId) -> bool {
4719        match self.provider_runtime_monitor.as_deref() {
4720            Some(monitor) => monitor.supports_acp_transport(provider),
4721            None => false,
4722        }
4723    }
4724
4725    /// Grants hook ingestion to a provider whose catalog entry declares a
4726    /// Hook adapter, whatever runtime mode its launcher probe landed in.
4727    ///
4728    /// `admit_status`'s `RawPassthrough` arm hands back
4729    /// `ProviderRuntimePolicy::raw_pty()`, which grants nothing semantic --
4730    /// correct for everything it was written to answer, and wrong for hooks
4731    /// specifically. A hook event is not inferred from terminal behaviour,
4732    /// so it has nothing to do with whether a launcher probe verified this
4733    /// CLI version's PTY contract; the authority is the catalog declaring
4734    /// the adapter plus the ingress authenticating the route. Without this
4735    /// the node installs a provider's hooks, the CLI faithfully calls them,
4736    /// and every event is refused -- which is exactly the state grok was in.
4737    ///
4738    /// Applied to both admission paths so the monitor's own derivation and
4739    /// this coarse fallback cannot disagree about the same provider.
4740    fn admit_declared_hook_semantics(
4741        &self,
4742        provider: &AgentId,
4743        policy: ProviderRuntimePolicy,
4744    ) -> ProviderRuntimePolicy {
4745        if policy.hook_semantics || !policy.raw_pty_lifecycle {
4746            return policy;
4747        }
4748        let declares_hook = self.provider_adapter_contracts.iter().any(|contract| {
4749            contract.provider == *provider && contract.family == AdapterFamily::Hook
4750        });
4751        if !declares_hook {
4752            return policy;
4753        }
4754        ProviderRuntimePolicy::new(
4755            policy.raw_pty_lifecycle,
4756            policy.semantic_readiness,
4757            policy.structured_prompt,
4758            policy.provider_session_identity,
4759            policy.semantic_resume,
4760            true,
4761        )
4762        .expect("granting hook semantics over a raw-PTY policy is internally valid")
4763    }
4764
4765    /// Station network allowlist → provider launch overlay argv.
4766    ///
4767    /// - **Codex**: first-slice `-c sandbox_workspace_write.network_access` from
4768    ///   `provider_native` under Moderate (PTY or ACP).
4769    /// - **Claude**: catalog `permits` → ProviderHome sidecar settings JSON +
4770    ///   documented `--settings <path>` on **PTY** only (Linux/macOS/WSL2).
4771    ///   ACP refuses (no measured ACP settings-path surface). Native Windows
4772    ///   refuses (no vendor Bash sandbox). Membership-only (empty permits) =
4773    ///   no overlay.
4774    /// - **Kimi / Grok**: no first-party settings/argv network surface here —
4775    ///   empty overlay when no `provider_native`; invented native keys already
4776    ///   refuse at load / resolve.
4777    ///
4778    /// Empty when no allowlist / nothing to map. Never cookies / secrets.
4779    fn resolve_network_allowlist_overlay_args(
4780        &self,
4781        provider: &AgentId,
4782        mode: SessionMode,
4783        approval_level: ApprovalLevel,
4784        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4785        provider_home: Option<&Path>,
4786    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4787        let Some(allowlist_id) = network_allowlist else {
4788            return Ok(Vec::new());
4789        };
4790        let catalog = self
4791            .network_allowlist_catalog
4792            .read()
4793            .unwrap_or_else(|poisoned| poisoned.into_inner());
4794        let Some(entry) = catalog.get(allowlist_id) else {
4795            return Err(failure(
4796                NodeFailureCode::UnknownNetworkAllowlist,
4797                "spawn network allowlist is unavailable on this node",
4798            ));
4799        };
4800
4801        // Claude: settings-shaped overlay from permits (not provider_native).
4802        if provider.as_str() == "claude" && !entry.permits.is_empty() {
4803            crate::network_allowlist_catalog::claude_bash_sandbox_network_os_supported().map_err(
4804                |message| {
4805                    failure(
4806                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4807                        &message,
4808                    )
4809                },
4810            )?;
4811            if !matches!(mode, SessionMode::Pty) {
4812                return Err(failure(
4813                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4814                    "Claude sandbox.network --settings overlay requires PTY transport; ACP has no measured settings-path surface (refuse rather than invent RPC)",
4815                ));
4816            }
4817            let Some(home) = provider_home else {
4818                return Err(failure(
4819                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4820                    "Claude sandbox.network --settings overlay requires ProviderHome materialization (CLAUDE_CONFIG_DIR)",
4821                ));
4822            };
4823            let domains =
4824                crate::network_allowlist_catalog::claude_allowed_domains_from_permits(&entry.permits);
4825            if domains.is_empty() {
4826                return Err(failure(
4827                    NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4828                    "Claude sandbox.network overlay could not map catalog permits to allowedDomains",
4829                ));
4830            }
4831            let settings_path = home.join(
4832                crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
4833            );
4834            let json =
4835                crate::network_allowlist_catalog::claude_station_network_settings_json(&domains);
4836            crate::session_environment::secure_create_file(&settings_path, json.as_bytes()).map_err(
4837                |_| {
4838                    failure(
4839                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4840                        "Claude station network settings sidecar could not be written under ProviderHome",
4841                    )
4842                },
4843            )?;
4844            let overlay =
4845                crate::network_allowlist_catalog::claude_settings_network_overlay_args(&settings_path);
4846            return Ok(overlay.into_iter().map(std::ffi::OsString::from).collect());
4847        }
4848
4849        let Some(native) = entry.provider_native.as_ref() else {
4850            return Ok(Vec::new());
4851        };
4852        let overlay = crate::network_allowlist_catalog::resolve_provider_native_launch_overlay(
4853            provider.as_str(),
4854            approval_level,
4855            native,
4856        )
4857        .map_err(|message| {
4858            failure(
4859                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4860                &message,
4861            )
4862        })?;
4863        if overlay.is_empty() {
4864            return Ok(Vec::new());
4865        }
4866        if !matches!(mode, SessionMode::Pty | SessionMode::Acp) {
4867            return Err(failure(
4868                NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4869                "Codex network_access -c overlay requires PTY or ACP transport",
4870            ));
4871        }
4872        Ok(overlay.into_iter().map(std::ffi::OsString::from).collect())
4873    }
4874
4875    /// Backward-compatible alias used by Codex-focused unit tests.
4876    fn resolve_codex_network_access_overlay_args(
4877        &self,
4878        provider: &AgentId,
4879        mode: SessionMode,
4880        approval_level: ApprovalLevel,
4881        network_allowlist: Option<&crate::protocol::SpawnNetworkAllowlistId>,
4882    ) -> Result<Vec<std::ffi::OsString>, NodeFailure> {
4883        self.resolve_network_allowlist_overlay_args(
4884            provider,
4885            mode,
4886            approval_level,
4887            network_allowlist,
4888            None,
4889        )
4890    }
4891
4892    fn resolve_environment_profile(
4893        &self,
4894        resolved: &ResolvedSpawnSpec,
4895    ) -> Result<Option<ResolvedEnvironmentProfileReceipt>, NodeFailure> {
4896        // Station network allowlist catalog enforce (plan
4897        // station-network-and-browser-profile-knobs §2.1 / §4): empty-default
4898        // catalog — unknown ids refuse rather than silent ambient. Runs even
4899        // when no environment_profile_id is present so a bare allowlist still
4900        // cannot bypass the catalog. Never cookies / OAuth / proxy credentials.
4901        if let Some(allowlist_id) = resolved.network_allowlist.as_ref() {
4902            let catalog = self
4903                .network_allowlist_catalog
4904                .read()
4905                .unwrap_or_else(|poisoned| poisoned.into_inner());
4906            let Some(entry) = catalog.get(allowlist_id) else {
4907                return Err(failure(
4908                    NodeFailureCode::UnknownNetworkAllowlist,
4909                    "spawn network allowlist is unavailable on this node",
4910                ));
4911            };
4912            // Track B: refuse provider-native mappings this provider cannot
4913            // honor (e.g. codex_network_access on Claude). Never silent ambient.
4914            if let Some(native) = entry.provider_native.as_ref() {
4915                if let Err(message) = crate::network_allowlist_catalog::provider_native_mapping_supported(
4916                    resolved.provider.as_str(),
4917                    resolved.approval_level,
4918                    native,
4919                ) {
4920                    return Err(failure(
4921                        NodeFailureCode::UnsupportedNetworkAllowlistMapping,
4922                        &message,
4923                    ));
4924                }
4925            }
4926        }
4927        // Station knobs Track A (dig2browser-station-probe-and-network-permit-set):
4928        // when feature `dig2-station-probe` is on and browser_profile_id is set,
4929        // cheap named-pipe path reachability must succeed (Windows
4930        // `\\.\pipe\{suffix}`); platform-unsupported or unreachable → clear
4931        // NodeFailureCode. Runs even without environment_profile_id (same
4932        // placement spirit as allowlist catalog). Feature off keeps stub echo.
4933        // Never cookies / OAuth / proxy credentials. Exclusive lease is
4934        // acquired later in spawn_session_with_deadline (after instance id).
4935        #[cfg(feature = "dig2-station-probe")]
4936        if resolved.browser_profile_id.is_some() {
4937            use crate::dig2_station_probe::{probe_station_reachable, StationProbeError};
4938            match probe_station_reachable() {
4939                Ok(()) => {}
4940                Err(StationProbeError::UnsupportedPlatform)
4941                | Err(StationProbeError::InvalidPipeSuffix) => {
4942                    return Err(failure(
4943                        NodeFailureCode::BrowserStationProbeUnavailable,
4944                        "dig2browser station probe unavailable on this platform",
4945                    ));
4946                }
4947                Err(StationProbeError::Unreachable) => {
4948                    return Err(failure(
4949                        NodeFailureCode::BrowserStationUnreachable,
4950                        "dig2browser station named-pipe is unreachable",
4951                    ));
4952                }
4953            }
4954        }
4955        let Some(profile_id) = resolved.environment_profile_id.as_ref() else {
4956            return Ok(None);
4957        };
4958        let profiles = self
4959            .environment_profiles
4960            .read()
4961            .unwrap_or_else(|poisoned| poisoned.into_inner());
4962        let binding = profiles.get(profile_id).ok_or_else(|| {
4963            failure(
4964                NodeFailureCode::UnknownEnvironmentProfile,
4965                "spawn environment profile is unavailable on this node",
4966            )
4967        })?;
4968        if binding.provider != resolved.provider
4969            || binding.native_profile_id(resolved.mode).is_none()
4970        {
4971            return Err(failure(
4972                NodeFailureCode::EnvironmentProfileBindingMismatch,
4973                "spawn environment profile does not match the provider and mode",
4974            ));
4975        }
4976        // Echo opaque network_allowlist / browser_profile_id onto the receipt
4977        // after catalog + optional dig2 probe above. Empty/whitespace ids
4978        // already refuse at Spawn*Id construction.
4979        Ok(Some(ResolvedEnvironmentProfileReceipt {
4980            profile_id: binding.id.clone(),
4981            profile_revision: binding.revision.clone(),
4982            network_allowlist: resolved.network_allowlist.clone(),
4983            browser_profile_id: resolved.browser_profile_id.clone(),
4984        }))
4985    }
4986
4987    fn resolve_bundle(
4988        &self,
4989        resolved: &ResolvedSpawnSpec,
4990        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
4991    ) -> Result<Option<ResolvedBundleReceipt>, NodeFailure> {
4992        let Some(bundle_id) = resolved.bundle_id.as_ref() else {
4993            return Ok(None);
4994        };
4995        let catalog = self.bundle_catalog
4996            .read()
4997            .unwrap_or_else(|poisoned| poisoned.into_inner());
4998        let bundle = catalog.get(bundle_id).ok_or_else(|| {
4999                failure(
5000                    NodeFailureCode::UnknownBundle,
5001                    "spawn bundle is unavailable on this node",
5002                )
5003            })?;
5004        self.bundle_layout(
5005            &resolved.provider,
5006            resolved.mode,
5007            environment_profile,
5008            bundle,
5009        )?;
5010        Ok(Some(bundle.receipt()))
5011    }
5012
5013    fn begin_delivery_stage(
5014        &self,
5015        manifest: DeliveryBundleManifestV2,
5016    ) -> Result<NodeResponse, NodeFailure> {
5017        let mut store = self
5018            .delivery_store
5019            .lock()
5020            .unwrap_or_else(|poisoned| poisoned.into_inner());
5021        let store = store.as_mut().ok_or_else(|| {
5022            failure(
5023                NodeFailureCode::DeliveryStageStorageFailed,
5024                "node delivery store is unavailable",
5025            )
5026        })?;
5027        let begun = store.begin(manifest).map_err(delivery_failure)?;
5028        Ok(NodeResponse::DeliveryStageBegun {
5029            stage_id: begun.stage_id,
5030            manifest_digest: begun.manifest_digest,
5031            missing_blobs: begun.missing_blobs,
5032        })
5033    }
5034
5035    fn put_delivery_blob_chunk(
5036        &self,
5037        stage_id: DeliveryStageId,
5038        blob_digest: DeliveryBlobDigestV1,
5039        offset: u64,
5040        chunk_hex: DeliveryBlobChunkHexV1,
5041    ) -> Result<NodeResponse, NodeFailure> {
5042        let mut store = self
5043            .delivery_store
5044            .lock()
5045            .unwrap_or_else(|poisoned| poisoned.into_inner());
5046        let store = store.as_mut().ok_or_else(|| {
5047            failure(
5048                NodeFailureCode::DeliveryStageStorageFailed,
5049                "node delivery store is unavailable",
5050            )
5051        })?;
5052        let accepted = store
5053            .put_chunk(&stage_id, &blob_digest, offset, &chunk_hex.decode())
5054            .map_err(delivery_failure)?;
5055        Ok(NodeResponse::DeliveryBlobChunkAccepted {
5056            stage_id: accepted.stage_id,
5057            blob_digest: accepted.blob_digest,
5058            next_offset: accepted.next_offset,
5059        })
5060    }
5061
5062    fn commit_delivery_stage(
5063        &self,
5064        stage_id: DeliveryStageId,
5065    ) -> Result<DeliveryCommitReceiptV1, NodeFailure> {
5066        let mut store = self
5067            .delivery_store
5068            .lock()
5069            .unwrap_or_else(|poisoned| poisoned.into_inner());
5070        let store = store.as_mut().ok_or_else(|| {
5071            failure(
5072                NodeFailureCode::DeliveryStageStorageFailed,
5073                "node delivery store is unavailable",
5074            )
5075        })?;
5076        let prepared = store.prepare_commit(&stage_id).map_err(delivery_failure)?;
5077        let mut catalog = self
5078            .bundle_catalog
5079            .write()
5080            .unwrap_or_else(|poisoned| poisoned.into_inner());
5081        let mut next_catalog = catalog.clone();
5082        next_catalog
5083            .insert_idempotent(prepared.bundle().clone())
5084            .map_err(|_| {
5085                failure(
5086                    NodeFailureCode::DeliveryStageConflict,
5087                    "delivery bundle identity conflicts with installed content",
5088                )
5089            })?;
5090        let receipt = store.publish_commit(prepared).map_err(delivery_failure)?;
5091        *catalog = next_catalog;
5092        Ok(receipt)
5093    }
5094
5095    fn abort_delivery_stage(
5096        &self,
5097        stage_id: &DeliveryStageId,
5098    ) -> Result<(), NodeFailure> {
5099        let mut store = self
5100            .delivery_store
5101            .lock()
5102            .unwrap_or_else(|poisoned| poisoned.into_inner());
5103        let store = store.as_mut().ok_or_else(|| {
5104            failure(
5105                NodeFailureCode::DeliveryStageStorageFailed,
5106                "node delivery store is unavailable",
5107            )
5108        })?;
5109        store.abort(stage_id).map_err(delivery_failure)
5110    }
5111
5112    fn resolve_context(
5113        &self,
5114        resolved: &ResolvedSpawnSpec,
5115    ) -> Result<Option<ResolvedContextPackReceipt>, NodeFailure> {
5116        let Some(context_id) = resolved.context_id.as_ref() else {
5117            return Ok(None);
5118        };
5119        self.context_catalog
5120            .read()
5121            .unwrap_or_else(|poisoned| poisoned.into_inner())
5122            .get(context_id)
5123            .map(|context| context.receipt().clone())
5124            .ok_or_else(|| {
5125                failure(
5126                    NodeFailureCode::UnknownContextPack,
5127                    "spawn context pack is unavailable on this node",
5128                )
5129            })
5130            .map(Some)
5131    }
5132
5133    fn bundle_layout(
5134        &self,
5135        provider: &AgentId,
5136        mode: SessionMode,
5137        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5138        bundle: &NodeBundle,
5139    ) -> Result<BundleProviderLayout, NodeFailure> {
5140        let layout = validate_bundle_binding(provider, mode, bundle).map_err(|_| {
5141            failure(
5142                NodeFailureCode::BundleBindingMismatch,
5143                "spawn bundle does not match the provider and mode",
5144            )
5145        })?;
5146        if layout != BundleProviderLayout::Codex {
5147            return Ok(layout);
5148        }
5149        let profile = environment_profile.and_then(|receipt| {
5150            let profiles = self
5151                .environment_profiles
5152                .read()
5153                .unwrap_or_else(|poisoned| poisoned.into_inner());
5154            let binding = profiles.get(&receipt.profile_id)?;
5155            if binding.revision != receipt.profile_revision
5156                || &binding.provider != provider
5157                || binding.native_profile_id(mode).is_none()
5158            {
5159                return None;
5160            }
5161            self.environment_materialization_profiles
5162                .read()
5163                .unwrap_or_else(|poisoned| poisoned.into_inner())
5164                .get(&receipt.profile_id)
5165                .cloned()
5166        });
5167        if !profile.is_some_and(|profile| profile.supports_bundle_layout(layout)) {
5168            return Err(failure(
5169                NodeFailureCode::BundleBindingMismatch,
5170                "Codex bundle requires an exact isolated CODEX_HOME profile",
5171            ));
5172        }
5173        Ok(layout)
5174    }
5175
5176    fn select_environment_profile(
5177        &self,
5178        instance_id: AgentInstanceId,
5179        provider: &AgentId,
5180        mode: SessionMode,
5181        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5182    ) -> Result<Option<NativeEnvironmentSelectionGuard>, NodeFailure> {
5183        let Some(environment_profile) = environment_profile else {
5184            return Ok(None);
5185        };
5186        let native_profile_id = {
5187            let profiles = self
5188                .environment_profiles
5189                .read()
5190                .unwrap_or_else(|poisoned| poisoned.into_inner());
5191            let binding = profiles
5192                .get(&environment_profile.profile_id)
5193                .ok_or_else(|| {
5194                    failure(
5195                        NodeFailureCode::UnknownEnvironmentProfile,
5196                        "session environment profile is unavailable on this node",
5197                    )
5198                })?;
5199            if &binding.revision != &environment_profile.profile_revision
5200                || &binding.provider != provider
5201            {
5202                return Err(failure(
5203                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5204                    "session environment profile revision or provider changed",
5205                ));
5206            }
5207            binding.native_profile_id(mode).cloned().ok_or_else(|| {
5208                failure(
5209                    NodeFailureCode::EnvironmentProfileBindingMismatch,
5210                    "session environment profile does not support this mode",
5211                )
5212            })?
5213        };
5214        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5215            failure(
5216                NodeFailureCode::BackendOperationFailed,
5217                "native environment profile controller is unavailable",
5218            )
5219        })?;
5220        control
5221            .select_native_launch_profile(instance_id, native_profile_id)
5222            .map_err(|_| {
5223                failure(
5224                    NodeFailureCode::BackendOperationFailed,
5225                    "native environment profile selection failed",
5226                )
5227            })?;
5228        Ok(Some(NativeEnvironmentSelectionGuard {
5229            control: Some(control),
5230            instance_id,
5231        }))
5232    }
5233
5234    fn install_prepared_launch_overlay(
5235        &self,
5236        instance_id: AgentInstanceId,
5237        overlay: PreparedNativeLaunchOverlay,
5238    ) -> Result<Option<NativeInstanceOverlayGuard>, NodeFailure> {
5239        let control = self.native_launch_profile_control.clone().ok_or_else(|| {
5240            failure(
5241                NodeFailureCode::BackendOperationFailed,
5242                "native launch profile controller is unavailable",
5243            )
5244        })?;
5245        match overlay {
5246            PreparedNativeLaunchOverlay::Environment(overlay) => {
5247                control
5248                    .install_native_launch_environment_overlay(instance_id, overlay)
5249                    .map_err(|_| failure(
5250                        NodeFailureCode::BackendOperationFailed,
5251                        "session environment overlay installation failed",
5252                    ))?;
5253                Ok(None)
5254            }
5255            PreparedNativeLaunchOverlay::Instance(overlay) => {
5256                control
5257                    .install_native_instance_launch_overlay(instance_id, overlay)
5258                    .map_err(|_| failure(
5259                        NodeFailureCode::BundleBindingMismatch,
5260                        "session bundle launch overlay installation failed",
5261                    ))?;
5262                Ok(Some(NativeInstanceOverlayGuard {
5263                    control: Some(control),
5264                    instance_id,
5265                }))
5266            }
5267        }
5268    }
5269
5270    fn environment_materialization_profile(
5271        &self,
5272        environment_profile: &ResolvedEnvironmentProfileReceipt,
5273    ) -> Option<NodeSessionMaterializationProfile> {
5274        self.environment_materialization_profiles
5275            .read()
5276            .unwrap_or_else(|poisoned| poisoned.into_inner())
5277            .get(&environment_profile.profile_id)
5278            .cloned()
5279    }
5280
5281    fn materialization_profile(
5282        &self,
5283        provider: &AgentId,
5284        mode: SessionMode,
5285        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5286        bundle: Option<&ResolvedBundleReceipt>,
5287        context: Option<&ResolvedContextPackReceipt>,
5288    ) -> Result<Option<(
5289        NodeSessionMaterializationProfile,
5290        bool,
5291        Option<BundleProviderLayout>,
5292    )>, NodeFailure> {
5293        let environment = environment_profile
5294            .and_then(|receipt| self.environment_materialization_profile(receipt));
5295        let (bundle, bundle_layout) = match bundle {
5296            Some(receipt) => {
5297                let catalog = self.bundle_catalog.read()
5298                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5299                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5300                    NodeFailureCode::UnknownBundle,
5301                    "session bundle is unavailable on this node",
5302                ))?;
5303                if installed.receipt() != *receipt {
5304                    return Err(failure(
5305                        NodeFailureCode::BundleBindingMismatch,
5306                        "session bundle revision or digest changed",
5307                    ));
5308                }
5309                let layout = self.bundle_layout(
5310                    provider,
5311                    mode,
5312                    environment_profile,
5313                    installed,
5314                )?;
5315                (Some(installed.clone()), Some(layout))
5316            }
5317            None => (None, None),
5318        };
5319        let has_environment_overlay = environment.is_some() || context.is_some();
5320        let profile = match (environment, bundle.as_ref(), bundle_layout) {
5321            (None, None, None) => None,
5322            (Some(profile), None, None) => Some(Ok(profile)),
5323            (None, Some(bundle), Some(layout)) => {
5324                Some(NodeSessionMaterializationProfile::from_bundle(bundle, layout))
5325            }
5326            (Some(profile), Some(bundle), Some(layout)) => {
5327                Some(profile.with_bundle(bundle, layout))
5328            }
5329            _ => {
5330                return Err(failure(
5331                    NodeFailureCode::BundleBindingMismatch,
5332                    "session bundle provider layout is unavailable",
5333                ));
5334            }
5335        };
5336        let profile = profile
5337            .transpose()
5338            .map_err(|_| failure(
5339                NodeFailureCode::BundleMaterializationFailed,
5340                "session bundle materialization profile is invalid",
5341            ))?;
5342        let profile = match (profile, context) {
5343            (Some(profile), Some(receipt)) => {
5344                let catalog = self.context_catalog
5345                    .read()
5346                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5347                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5348                    NodeFailureCode::UnknownContextPack,
5349                    "session context pack is unavailable on this node",
5350                ))?;
5351                if pack.receipt() != receipt {
5352                    return Err(failure(
5353                        NodeFailureCode::ContextPackMaterializationFailed,
5354                        "session context pack identity changed",
5355                    ));
5356                }
5357                profile.with_context(pack)
5358            }
5359            (None, Some(receipt)) => {
5360                let catalog = self.context_catalog
5361                    .read()
5362                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5363                let pack = catalog.get(&receipt.id).ok_or_else(|| failure(
5364                    NodeFailureCode::UnknownContextPack,
5365                    "session context pack is unavailable on this node",
5366                ))?;
5367                if pack.receipt() != receipt {
5368                    return Err(failure(
5369                        NodeFailureCode::ContextPackMaterializationFailed,
5370                        "session context pack identity changed",
5371                    ));
5372                }
5373                NodeSessionMaterializationProfile::from_context(pack)
5374            }
5375            (Some(profile), None) => Ok(profile),
5376            (None, None) => return Ok(None),
5377        }
5378        .map_err(|_| failure(
5379            NodeFailureCode::ContextPackMaterializationFailed,
5380            "session context pack materialization profile is invalid",
5381        ))?;
5382        Ok(Some((profile, has_environment_overlay, bundle_layout)))
5383    }
5384
5385    fn prepare_session_materialization<'a>(
5386        &'a self,
5387        address: &SessionAddress,
5388        provider: &AgentId,
5389        mode: SessionMode,
5390        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5391        bundle: Option<&ResolvedBundleReceipt>,
5392        context: Option<&ResolvedContextPackReceipt>,
5393        managed_lease_id: Option<ManagedWorktreeLeaseId>,
5394    ) -> Result<Option<(Option<PreparedNativeLaunchOverlay>, SessionMaterializationGuard<'a>)>, NodeFailure> {
5395        let Some((profile, has_environment_overlay, bundle_layout)) =
5396            self.materialization_profile(provider, mode, environment_profile, bundle, context)?
5397        else {
5398            return Ok(None);
5399        };
5400        let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5401            failure(
5402                NodeFailureCode::BackendOperationFailed,
5403                "session environment materializer is unavailable",
5404            )
5405        })?;
5406        let materialization_failure_code = if context.is_some() {
5407            NodeFailureCode::ContextPackMaterializationFailed
5408        } else if bundle.is_some() {
5409            NodeFailureCode::BundleMaterializationFailed
5410        } else {
5411            NodeFailureCode::BackendOperationFailed
5412        };
5413        let id = MaterializationId::new(format!(
5414            "mat-{}-{}-{}",
5415            self.incarnation_id,
5416            address.session.instance_id.0,
5417            address.session.generation.0,
5418        ))
5419        .map_err(|_| {
5420            failure(
5421                NodeFailureCode::BackendOperationFailed,
5422                "session environment materialization identity failed",
5423            )
5424        })?;
5425        let owner = MaterializationOwner::Session {
5426            incarnation_id: self.incarnation_id,
5427            instance_id: address.session.instance_id,
5428            generation: address.session.generation,
5429        };
5430        let mut ownership = materializer
5431            .begin(
5432                id.clone(),
5433                environment_profile.cloned(),
5434                bundle.cloned(),
5435                context.cloned(),
5436                owner,
5437                managed_lease_id,
5438                &profile,
5439                unix_time_ms(),
5440            )
5441            .map_err(|_| {
5442                failure(
5443                    materialization_failure_code,
5444                    "session materialization preparation failed",
5445                )
5446            })?;
5447        {
5448            let _transaction = self
5449                .state_transaction
5450                .lock()
5451                .unwrap_or_else(|poisoned| poisoned.into_inner());
5452            let duplicate = self
5453                .materializations
5454                .lock()
5455                .unwrap_or_else(|poisoned| poisoned.into_inner())
5456                .insert(id.clone(), ownership.clone());
5457            if duplicate.is_some() {
5458                return Err(failure(
5459                    NodeFailureCode::BackendOperationFailed,
5460                    "session environment materialization identity conflict",
5461                ));
5462            }
5463            if let Err(error) = self.persist_state_locked() {
5464                self.materializations
5465                    .lock()
5466                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5467                    .remove(&id);
5468                return Err(persistence_failure(error));
5469            }
5470        }
5471        let environment = match materializer.materialize(&mut ownership, &profile, unix_time_ms()) {
5472            Ok(environment) => environment,
5473            Err(_) => {
5474                let transaction = self
5475                    .state_transaction
5476                    .lock()
5477                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5478                self.materializations
5479                    .lock()
5480                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5481                    .insert(id.clone(), ownership.clone());
5482                if self.persist_state_locked().is_err() {
5483                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5484                }
5485                drop(transaction);
5486                if ownership.state() == MaterializationState::CleanupRequired {
5487                    let _ = self.cleanup_materialization(&id);
5488                }
5489                return Err(failure(
5490                    materialization_failure_code,
5491                    "session materialization failed",
5492                ));
5493            }
5494        };
5495        {
5496            let _transaction = self
5497                .state_transaction
5498                .lock()
5499                .unwrap_or_else(|poisoned| poisoned.into_inner());
5500            self.materializations
5501                .lock()
5502                .unwrap_or_else(|poisoned| poisoned.into_inner())
5503                .insert(id.clone(), ownership.clone());
5504            if let Err(error) = self.persist_state_locked() {
5505                drop(_transaction);
5506                let _ = self.cleanup_materialization(&id);
5507                return Err(persistence_failure(error));
5508            }
5509        }
5510        let guard = SessionMaterializationGuard {
5511            shared: self,
5512            id: Some(id),
5513            provider_home: Some(ownership.provider_home().to_path_buf()),
5514        };
5515        let bundle_arguments = match bundle {
5516            Some(receipt) => {
5517                let catalog = self.bundle_catalog.read()
5518                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5519                let installed = catalog.get(&receipt.id).ok_or_else(|| failure(
5520                    NodeFailureCode::UnknownBundle,
5521                    "session bundle is unavailable on this node",
5522                ))?;
5523                if installed.receipt() != *receipt {
5524                    return Err(failure(
5525                        NodeFailureCode::BundleBindingMismatch,
5526                        "session bundle revision or digest changed",
5527                    ));
5528                }
5529                let layout = bundle_layout.ok_or_else(|| failure(
5530                    NodeFailureCode::BundleBindingMismatch,
5531                    "materialized bundle provider layout is unavailable",
5532                ))?;
5533                materializer.revalidate_bundle(&ownership, installed, layout).map_err(|_| {
5534                    failure(
5535                        NodeFailureCode::BundleMaterializationFailed,
5536                        "session bundle byte revalidation failed",
5537                    )
5538                })?;
5539                Some(bundle_launch_arguments(
5540                    layout,
5541                    ownership.bundle_root(),
5542                    ownership.provider_home(),
5543                ).map_err(|_| failure(
5544                    NodeFailureCode::BundleBindingMismatch,
5545                    "session bundle launch binding is invalid",
5546                ))?)
5547            }
5548            None => None,
5549        };
5550        if let Some(receipt) = context {
5551            materializer
5552                .revalidate_context(&ownership, receipt)
5553                .map_err(|_| failure(
5554                    NodeFailureCode::ContextPackMaterializationFailed,
5555                    "session context pack byte revalidation failed",
5556                ))?;
5557        }
5558        let transport = match mode {
5559            SessionMode::Pty => TransportKind::Pty,
5560            SessionMode::Inline => TransportKind::Pipe,
5561            SessionMode::Acp => TransportKind::Acp,
5562        };
5563        let overlay = if let Some(extra_args) = bundle_arguments {
5564            Some(PreparedNativeLaunchOverlay::Instance(
5565                NativeInstanceLaunchOverlay::new(
5566                    provider.clone(),
5567                    transport,
5568                    if has_environment_overlay {
5569                        environment
5570                    } else {
5571                        Vec::new()
5572                    },
5573                    extra_args,
5574                )
5575                .map_err(|_| {
5576                    failure(
5577                        NodeFailureCode::BundleBindingMismatch,
5578                        "session bundle launch overlay is invalid",
5579                    )
5580                })?,
5581            ))
5582        } else if has_environment_overlay {
5583            let context_only = environment_profile.is_none()
5584                && bundle.is_none()
5585                && context.is_some();
5586            let overlay = if context_only {
5587                NativeLaunchEnvironmentOverlay::new_context_root(
5588                    provider.clone(),
5589                    transport,
5590                    environment,
5591                )
5592                .map_err(|_| {
5593                    failure(
5594                        NodeFailureCode::ContextPackMaterializationFailed,
5595                        "context-only launch overlay is invalid",
5596                    )
5597                })?
5598            } else {
5599                NativeLaunchEnvironmentOverlay::new(
5600                    provider.clone(),
5601                    transport,
5602                    environment,
5603                )
5604                .map_err(|_| {
5605                    failure(
5606                        NodeFailureCode::BackendOperationFailed,
5607                        "session environment overlay is invalid",
5608                    )
5609                })?
5610            };
5611            Some(PreparedNativeLaunchOverlay::Environment(overlay))
5612        } else {
5613            None
5614        };
5615        Ok(Some((overlay, guard)))
5616    }
5617
5618    fn cleanup_materialization(&self, id: &MaterializationId) -> Result<(), NodeFailure> {
5619        let Some(mut ownership) = self
5620            .materializations
5621            .lock()
5622            .unwrap_or_else(|poisoned| poisoned.into_inner())
5623            .get(id)
5624            .cloned()
5625        else {
5626            return Ok(());
5627        };
5628        if ownership.state() != MaterializationState::CleanupRequired {
5629            ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5630                failure(
5631                    NodeFailureCode::BackendOperationFailed,
5632                    "session environment cleanup state is invalid",
5633                )
5634            })?;
5635        }
5636        {
5637            let _transaction = self
5638                .state_transaction
5639                .lock()
5640                .unwrap_or_else(|poisoned| poisoned.into_inner());
5641            let previous = self
5642                .materializations
5643                .lock()
5644                .unwrap_or_else(|poisoned| poisoned.into_inner())
5645                .insert(id.clone(), ownership.clone());
5646            if let Err(error) = self.persist_state_locked() {
5647                let mut materializations = self
5648                    .materializations
5649                    .lock()
5650                    .unwrap_or_else(|poisoned| poisoned.into_inner());
5651                if let Some(previous) = previous {
5652                    materializations.insert(id.clone(), previous);
5653                }
5654                return Err(persistence_failure(error));
5655            }
5656        }
5657        let cleanup = self
5658            .session_environment_materializer
5659            .as_ref()
5660            .ok_or(())
5661            .and_then(|materializer| materializer.cleanup(&ownership).map_err(|_| ()));
5662        if cleanup.is_err() {
5663            let _ = ownership.mark_recovery_required(unix_time_ms());
5664            self.materializations
5665                .lock()
5666                .unwrap_or_else(|poisoned| poisoned.into_inner())
5667                .insert(id.clone(), ownership);
5668            if self.persist_state().is_err() {
5669                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5670            }
5671            return Err(failure(
5672                NodeFailureCode::BackendOperationFailed,
5673                "session environment cleanup requires recovery",
5674            ));
5675        }
5676        let _transaction = self
5677            .state_transaction
5678            .lock()
5679            .unwrap_or_else(|poisoned| poisoned.into_inner());
5680        self.materializations
5681            .lock()
5682            .unwrap_or_else(|poisoned| poisoned.into_inner())
5683            .remove(id);
5684        if let Err(error) = self.persist_state_locked() {
5685            self.materializations
5686                .lock()
5687                .unwrap_or_else(|poisoned| poisoned.into_inner())
5688                .insert(id.clone(), ownership);
5689            return Err(persistence_failure(error));
5690        }
5691        Ok(())
5692    }
5693
5694    fn resolve_record_materialization(
5695        &self,
5696        record_id: &SessionRecordId,
5697        provider: &AgentId,
5698        mode: SessionMode,
5699        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
5700        bundle: Option<&ResolvedBundleReceipt>,
5701        context: Option<&ResolvedContextPackReceipt>,
5702    ) -> Result<Option<(MaterializationId, Option<PreparedNativeLaunchOverlay>)>, NodeFailure> {
5703        let ownership = {
5704            let materializations = self
5705                .materializations
5706                .lock()
5707                .unwrap_or_else(|poisoned| poisoned.into_inner());
5708            let mut matches = materializations.values().filter(|record| {
5709                record.owner()
5710                    == &MaterializationOwner::Record {
5711                        record_id: record_id.clone(),
5712                    }
5713            });
5714            let ownership = matches.next().cloned();
5715            if matches.next().is_some() {
5716                return Err(failure(
5717                    NodeFailureCode::BackendOperationFailed,
5718                    "managed session has conflicting environment ownership",
5719                ));
5720            }
5721            ownership
5722        };
5723        if let (Some(ownership), Some(receipt)) = (ownership.as_ref(), context) {
5724            let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5725                failure(
5726                    NodeFailureCode::ContextPackMaterializationFailed,
5727                    "session context materializer is unavailable",
5728                )
5729            })?;
5730            let pack = materializer
5731                .revalidate_context(ownership, receipt)
5732                .map_err(|_| {
5733                    self.mark_materialization_recovery_required(ownership.id());
5734                    failure(
5735                        NodeFailureCode::ContextPackMaterializationFailed,
5736                        "managed session context pack revalidation failed",
5737                    )
5738                })?;
5739            self.context_catalog
5740                .write()
5741                .unwrap_or_else(|poisoned| poisoned.into_inner())
5742                .insert(pack)
5743                .map_err(|_| failure(
5744                    NodeFailureCode::ContextPackMaterializationFailed,
5745                    "managed session context pack catalog restore failed",
5746                ))?;
5747        }
5748        let materialization = self.materialization_profile(
5749            provider,
5750            mode,
5751            environment_profile,
5752            bundle,
5753            context,
5754        )?;
5755        match (ownership, materialization) {
5756            (None, None) => Ok(None),
5757            (None, Some(_)) | (Some(_), None) => Err(failure(
5758                NodeFailureCode::BackendOperationFailed,
5759                "managed session materialization is unavailable",
5760            )),
5761            (Some(ownership), Some((profile, has_environment_materialization, bundle_layout))) => {
5762                if ownership.environment_profile() != environment_profile
5763                    || ownership.bundle() != bundle
5764                    || ownership.context() != context
5765                    || ownership.state() != MaterializationState::Ready
5766                {
5767                    self.mark_materialization_recovery_required(ownership.id());
5768                    return Err(failure(
5769                        NodeFailureCode::BackendOperationFailed,
5770                        "managed session environment ownership requires recovery",
5771                    ));
5772                }
5773                let materializer = self.session_environment_materializer.as_ref().ok_or_else(|| {
5774                    failure(
5775                        NodeFailureCode::BackendOperationFailed,
5776                        "session environment materializer is unavailable",
5777                    )
5778                })?;
5779                if materializer.revalidate(&ownership).is_err() {
5780                    self.mark_materialization_recovery_required(ownership.id());
5781                    return Err(failure(
5782                        NodeFailureCode::BackendOperationFailed,
5783                        "managed session environment revalidation failed",
5784                    ));
5785                }
5786                let bundle_arguments = if let Some(bundle_receipt) = bundle {
5787                    let catalog = self.bundle_catalog.read()
5788                        .unwrap_or_else(|poisoned| poisoned.into_inner());
5789                    let Some(installed) = catalog.get(&bundle_receipt.id) else {
5790                        return Err(failure(
5791                            NodeFailureCode::UnknownBundle,
5792                            "managed session bundle is unavailable",
5793                        ));
5794                    };
5795                    let layout = bundle_layout.ok_or_else(|| failure(
5796                        NodeFailureCode::BundleBindingMismatch,
5797                        "record bundle provider layout is unavailable",
5798                    ))?;
5799                    if materializer.revalidate_bundle(&ownership, installed, layout).is_err() {
5800                        drop(catalog);
5801                        self.mark_materialization_recovery_required(ownership.id());
5802                        return Err(failure(
5803                            NodeFailureCode::BundleMaterializationFailed,
5804                            "managed session bundle revalidation failed",
5805                        ));
5806                    }
5807                    Some(bundle_launch_arguments(
5808                        layout,
5809                        ownership.bundle_root(),
5810                        ownership.provider_home(),
5811                    ).map_err(|_| failure(
5812                        NodeFailureCode::BundleBindingMismatch,
5813                        "managed session bundle launch binding is invalid",
5814                    ))?)
5815                } else {
5816                    None
5817                };
5818                if !has_environment_materialization {
5819                    let overlay = bundle_arguments.map(|extra_args| {
5820                        NativeInstanceLaunchOverlay::new(
5821                            provider.clone(),
5822                            TransportKind::Pty,
5823                            Vec::new(),
5824                            extra_args,
5825                        )
5826                        .map(PreparedNativeLaunchOverlay::Instance)
5827                        .map_err(|_| failure(
5828                            NodeFailureCode::BundleBindingMismatch,
5829                            "managed session bundle launch overlay is invalid",
5830                        ))
5831                    }).transpose()?;
5832                    return Ok(Some((ownership.id().clone(), overlay)));
5833                }
5834                let environment = match materializer.resolve_environment(&ownership, &profile) {
5835                    Ok(environment) => environment,
5836                    Err(
5837                        SessionEnvironmentMaterializeError::SecretUnavailable
5838                        | SessionEnvironmentMaterializeError::SecretDenied
5839                        | SessionEnvironmentMaterializeError::InvalidSecretValue,
5840                    ) => {
5841                        return Err(failure(
5842                            NodeFailureCode::BackendOperationFailed,
5843                            "managed session environment resolution failed",
5844                        ));
5845                    }
5846                    Err(
5847                        SessionEnvironmentMaterializeError::InvalidRoot
5848                        | SessionEnvironmentMaterializeError::OwnershipMismatch
5849                        | SessionEnvironmentMaterializeError::Filesystem(_)
5850                        | SessionEnvironmentMaterializeError::Record(_),
5851                    ) => {
5852                        self.mark_materialization_recovery_required(ownership.id());
5853                        return Err(failure(
5854                            NodeFailureCode::BackendOperationFailed,
5855                            "managed session environment resolution failed",
5856                        ));
5857                    }
5858                };
5859                let transport = match mode {
5860                    SessionMode::Pty => TransportKind::Pty,
5861                    SessionMode::Inline => TransportKind::Pipe,
5862                    SessionMode::Acp => TransportKind::Acp,
5863                };
5864                let overlay = match bundle_arguments {
5865                    Some(extra_args) => PreparedNativeLaunchOverlay::Instance(
5866                        NativeInstanceLaunchOverlay::new(
5867                            provider.clone(),
5868                            transport,
5869                            environment,
5870                            extra_args,
5871                        )
5872                        .map_err(|_| failure(
5873                            NodeFailureCode::BundleBindingMismatch,
5874                            "managed session bundle launch overlay is invalid",
5875                        ))?,
5876                    ),
5877                    None => PreparedNativeLaunchOverlay::Environment(
5878                        NativeLaunchEnvironmentOverlay::new(
5879                            provider.clone(),
5880                            transport,
5881                            environment,
5882                        )
5883                        .map_err(|_| {
5884                            failure(
5885                                NodeFailureCode::BackendOperationFailed,
5886                                "managed session environment overlay is invalid",
5887                            )
5888                        })?,
5889                    ),
5890                };
5891                Ok(Some((ownership.id().clone(), Some(overlay))))
5892            }
5893        }
5894    }
5895
5896    fn destroy_record_materialization(
5897        &self,
5898        record_id: &SessionRecordId,
5899    ) -> Result<bool, NodeFailure> {
5900        let ids = self
5901            .materializations
5902            .lock()
5903            .unwrap_or_else(|poisoned| poisoned.into_inner())
5904            .values()
5905            .filter_map(|record| {
5906                (record.owner()
5907                    == &MaterializationOwner::Record {
5908                        record_id: record_id.clone(),
5909                    })
5910                .then(|| record.id().clone())
5911            })
5912            .collect::<Vec<_>>();
5913        if ids.len() > 1 {
5914            return Err(failure(
5915                NodeFailureCode::BackendOperationFailed,
5916                "managed session has conflicting environment ownership",
5917            ));
5918        }
5919        let Some(id) = ids.first().cloned() else {
5920            return Ok(false);
5921        };
5922        let (terminal_record, mut cleanup_ownership) = {
5923            let _transaction = self
5924                .state_transaction
5925                .lock()
5926                .unwrap_or_else(|poisoned| poisoned.into_inner());
5927            let original_record = self.record(record_id)?;
5928            let mut terminal_record = original_record.clone();
5929            terminal_record.provider_session = None;
5930            terminal_record.active_session = None;
5931            terminal_record.state = ManagedSessionState::Unavailable;
5932            terminal_record.last_error = Some("environment-profile-unavailable".to_owned());
5933            terminal_record.updated_at_unix_ms = unix_time_ms();
5934            let original_ownership = self
5935                .materializations
5936                .lock()
5937                .unwrap_or_else(|poisoned| poisoned.into_inner())
5938                .get(&id)
5939                .cloned()
5940                .ok_or_else(|| {
5941                    failure(
5942                        NodeFailureCode::BackendOperationFailed,
5943                        "managed session environment ownership disappeared during cleanup",
5944                    )
5945                })?;
5946            let mut cleanup_ownership = original_ownership.clone();
5947            if cleanup_ownership.state() != MaterializationState::CleanupRequired {
5948                cleanup_ownership.mark_cleanup_required(unix_time_ms()).map_err(|_| {
5949                    failure(
5950                        NodeFailureCode::BackendOperationFailed,
5951                        "managed session environment cleanup state is invalid",
5952                    )
5953                })?;
5954            }
5955            self.session_records
5956                .lock()
5957                .unwrap_or_else(|poisoned| poisoned.into_inner())
5958                .records
5959                .insert(record_id.clone(), terminal_record.clone());
5960            self.materializations
5961                .lock()
5962                .unwrap_or_else(|poisoned| poisoned.into_inner())
5963                .insert(id.clone(), cleanup_ownership.clone());
5964            if let Err(error) = self.persist_state_locked() {
5965                self.session_records
5966                    .lock()
5967                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5968                    .records
5969                    .insert(record_id.clone(), original_record);
5970                self.materializations
5971                    .lock()
5972                    .unwrap_or_else(|poisoned| poisoned.into_inner())
5973                    .insert(id.clone(), original_ownership);
5974                return Err(persistence_failure(error));
5975            }
5976            (terminal_record, cleanup_ownership)
5977        };
5978        let cleanup = self
5979            .session_environment_materializer
5980            .as_ref()
5981            .ok_or(())
5982            .and_then(|materializer| materializer.cleanup(&cleanup_ownership).map_err(|_| ()));
5983        if cleanup.is_err() {
5984            let _ = cleanup_ownership.mark_recovery_required(unix_time_ms());
5985            self.materializations
5986                .lock()
5987                .unwrap_or_else(|poisoned| poisoned.into_inner())
5988                .insert(id.clone(), cleanup_ownership);
5989            if self.persist_state().is_err() {
5990                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
5991            }
5992            return Err(failure(
5993                NodeFailureCode::BackendOperationFailed,
5994                "managed session environment cleanup requires recovery",
5995            ));
5996        }
5997        let _transaction = self
5998            .state_transaction
5999            .lock()
6000            .unwrap_or_else(|poisoned| poisoned.into_inner());
6001        self.remove_record_memory(record_id);
6002        self.materializations
6003            .lock()
6004            .unwrap_or_else(|poisoned| poisoned.into_inner())
6005            .remove(&id);
6006        if let Err(error) = self.persist_state_locked() {
6007            self.session_records
6008                .lock()
6009                .unwrap_or_else(|poisoned| poisoned.into_inner())
6010                .records
6011                .insert(record_id.clone(), terminal_record);
6012            self.materializations
6013                .lock()
6014                .unwrap_or_else(|poisoned| poisoned.into_inner())
6015                .insert(id, cleanup_ownership);
6016            return Err(persistence_failure(error));
6017        }
6018        Ok(true)
6019    }
6020
6021    fn mark_materialization_recovery_required(&self, id: &MaterializationId) {
6022        let changed = {
6023            let mut materializations = self
6024                .materializations
6025                .lock()
6026                .unwrap_or_else(|poisoned| poisoned.into_inner());
6027            let Some(ownership) = materializations.get_mut(id) else {
6028                return;
6029            };
6030            ownership.mark_recovery_required(unix_time_ms()).is_ok()
6031        };
6032        if changed && self.persist_state().is_err() {
6033            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6034        }
6035    }
6036
6037    fn reconcile_materializations(&self) {
6038        let records = self
6039            .materializations
6040            .lock()
6041            .unwrap_or_else(|poisoned| poisoned.into_inner())
6042            .values()
6043            .cloned()
6044            .collect::<Vec<_>>();
6045        for mut ownership in records {
6046            let result = match (ownership.state(), ownership.owner()) {
6047                (MaterializationState::Preparing, _)
6048                | (MaterializationState::CleanupRequired, _) => {
6049                    self.cleanup_materialization(ownership.id())
6050                }
6051                (MaterializationState::Ready, MaterializationOwner::Session { .. }) => {
6052                    if ownership.mark_recovery_required(unix_time_ms()).is_ok() {
6053                        self.materializations
6054                            .lock()
6055                            .unwrap_or_else(|poisoned| poisoned.into_inner())
6056                            .insert(ownership.id().clone(), ownership.clone());
6057                        self.persist_state().map_err(persistence_failure)
6058                    } else {
6059                        Err(failure(
6060                            NodeFailureCode::BackendOperationFailed,
6061                            "session environment reconciliation failed",
6062                        ))
6063                    }
6064                }
6065                (MaterializationState::Ready, MaterializationOwner::Record { record_id }) => self
6066                    .session_environment_materializer
6067                    .as_ref()
6068                    .ok_or_else(|| {
6069                        failure(
6070                            NodeFailureCode::BackendOperationFailed,
6071                            "session environment materializer is unavailable",
6072                        )
6073                    })
6074                    .and_then(|materializer| {
6075                        materializer.revalidate(&ownership).map_err(|_| failure(
6076                            NodeFailureCode::BackendOperationFailed,
6077                            "session materialization revalidation failed",
6078                        ))?;
6079                        let record = self.record(record_id)?;
6080                        if record.environment_profile.as_ref() != ownership.environment_profile()
6081                            || record.bundle.as_ref() != ownership.bundle()
6082                            || record.context.as_ref() != ownership.context()
6083                        {
6084                            return Err(failure(
6085                                NodeFailureCode::BackendOperationFailed,
6086                                "session materialization ownership changed after restart",
6087                            ));
6088                        }
6089                        if let Some(receipt) = ownership.bundle() {
6090                            let catalog = self.bundle_catalog.read()
6091                                .unwrap_or_else(|poisoned| poisoned.into_inner());
6092                            let bundle = catalog.get(&receipt.id).ok_or_else(|| failure(
6093                                NodeFailureCode::UnknownBundle,
6094                                "session bundle is unavailable after restart",
6095                            ))?;
6096                            if bundle.receipt() != *receipt {
6097                                return Err(failure(
6098                                    NodeFailureCode::BundleBindingMismatch,
6099                                    "session bundle changed after restart",
6100                                ));
6101                            }
6102                            let layout = self.bundle_layout(
6103                                &record.provider,
6104                                record.mode,
6105                                record.environment_profile.as_ref(),
6106                                bundle,
6107                            )?;
6108                            materializer
6109                                .revalidate_bundle(&ownership, bundle, layout)
6110                                .map_err(|_| failure(
6111                                    NodeFailureCode::BundleMaterializationFailed,
6112                                    "session bundle revalidation failed",
6113                                ))?;
6114                        }
6115                        if let Some(receipt) = ownership.context() {
6116                            let pack = materializer
6117                                .revalidate_context(&ownership, receipt)
6118                                .map_err(|_| {
6119                                    failure(
6120                                        NodeFailureCode::ContextPackMaterializationFailed,
6121                                        "session context revalidation failed",
6122                                    )
6123                                })?;
6124                            self.context_catalog
6125                                .write()
6126                                .unwrap_or_else(|poisoned| poisoned.into_inner())
6127                                .insert(pack)
6128                                .map_err(|_| {
6129                                    failure(
6130                                        NodeFailureCode::ContextPackMaterializationFailed,
6131                                        "session context catalog restore failed",
6132                                    )
6133                                })?;
6134                        }
6135                        Ok(())
6136                    }),
6137                (MaterializationState::RecoveryRequired, _) => Ok(()),
6138            };
6139            if result.is_err() && ownership.state() != MaterializationState::RecoveryRequired {
6140                let _ = ownership.mark_recovery_required(unix_time_ms());
6141                self.materializations
6142                    .lock()
6143                    .unwrap_or_else(|poisoned| poisoned.into_inner())
6144                    .insert(ownership.id().clone(), ownership);
6145                if self.persist_state().is_err() {
6146                    self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6147                }
6148            }
6149        }
6150        let materialized_profiles = self
6151            .environment_materialization_profiles
6152            .read()
6153            .unwrap_or_else(|poisoned| poisoned.into_inner())
6154            .keys()
6155            .cloned()
6156            .collect::<Vec<_>>();
6157        let ownership = self
6158            .materializations
6159            .lock()
6160            .unwrap_or_else(|poisoned| poisoned.into_inner())
6161            .values()
6162            .cloned()
6163            .collect::<Vec<_>>();
6164        let changed = {
6165            let mut session_records = self
6166                .session_records
6167                .lock()
6168                .unwrap_or_else(|poisoned| poisoned.into_inner());
6169            let mut changed = false;
6170            for record in session_records.records.values_mut() {
6171                let environment_profile = record.environment_profile.as_ref();
6172                if environment_profile.is_none()
6173                    && record.bundle.is_none()
6174                    && record.context.is_none()
6175                {
6176                    continue;
6177                }
6178                if record.bundle.is_none()
6179                    && environment_profile.is_some_and(|profile| {
6180                        !materialized_profiles.contains(&profile.profile_id)
6181                    })
6182                {
6183                    continue;
6184                }
6185                let has_exact_ownership = ownership.iter().any(|candidate| {
6186                    candidate.state() == MaterializationState::Ready
6187                        && candidate.environment_profile() == environment_profile
6188                        && candidate.bundle() == record.bundle.as_ref()
6189                        && candidate.context() == record.context.as_ref()
6190                        && candidate.owner()
6191                            == &MaterializationOwner::Record {
6192                                record_id: record.record_id.clone(),
6193                            }
6194                });
6195                if !has_exact_ownership {
6196                    record.provider_session = None;
6197                    record.active_session = None;
6198                    record.state = ManagedSessionState::Unavailable;
6199                    record.last_error = Some(if record.bundle.is_some() {
6200                        "bundle-unavailable".to_owned()
6201                    } else if record.context.is_some() {
6202                        "context-unavailable".to_owned()
6203                    } else {
6204                        "environment-profile-unavailable".to_owned()
6205                    });
6206                    record.updated_at_unix_ms = unix_time_ms();
6207                    changed = true;
6208                }
6209            }
6210            changed
6211        };
6212        if changed && self.persist_state().is_err() {
6213            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6214        }
6215    }
6216
6217    fn replay_spawn_spec(
6218        &self,
6219        spec: &SpawnSpec,
6220        now: Instant,
6221    ) -> Result<Option<Result<ResolvedSpawnReceipt, NodeFailure>>, NodeFailure> {
6222        let mut cache = self
6223            .spawn_idempotency
6224            .lock()
6225            .unwrap_or_else(|poisoned| poisoned.into_inner());
6226        cache.entries.retain(|_, entry| entry.expires_at > now);
6227        if let Some(entry) = cache.entries.get(&spec.idempotency_key) {
6228            let SpawnIdempotencyValue::Standard { spec: existing, result } = &entry.value else {
6229                return Err(failure(
6230                    NodeFailureCode::SpawnIdempotencyConflict,
6231                    "spawn idempotency key was reused across spawn kinds",
6232                ));
6233            };
6234            if existing != spec {
6235                return Err(failure(
6236                    NodeFailureCode::SpawnIdempotencyConflict,
6237                    "spawn idempotency key was reused with a different specification",
6238                ));
6239            }
6240            return Ok(Some(result.clone()));
6241        }
6242        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6243            return Err(failure(
6244                NodeFailureCode::SpawnIdempotencyCapacity,
6245                "spawn idempotency cache reached its bounded capacity",
6246            ));
6247        }
6248        Ok(None)
6249    }
6250
6251    fn remember_spawn_spec(
6252        &self,
6253        spec: SpawnSpec,
6254        result: Result<ResolvedSpawnReceipt, NodeFailure>,
6255        accepted_at: Instant,
6256    ) {
6257        self
6258            .spawn_idempotency
6259            .lock()
6260            .unwrap_or_else(|poisoned| poisoned.into_inner())
6261            .entries
6262            .insert(
6263                spec.idempotency_key.clone(),
6264                SpawnIdempotencyEntry {
6265                    value: SpawnIdempotencyValue::Standard { spec, result },
6266                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6267                },
6268            );
6269    }
6270
6271    fn claim_harness_mcp_spawn(
6272        &self,
6273        _reservation_id: &HarnessMcpReservationId,
6274        _activation_digest: &HarnessMcpActivationDigest,
6275        spec: &SpawnSpec,
6276        now: Instant,
6277    ) -> Result<(), NodeFailure> {
6278        let mut cache = self.spawn_idempotency.lock()
6279            .unwrap_or_else(|poisoned| poisoned.into_inner());
6280        cache.entries.retain(|_, entry| entry.expires_at > now);
6281        if cache.entries.contains_key(&spec.idempotency_key) {
6282            return Err(failure(
6283                NodeFailureCode::SpawnIdempotencyConflict,
6284                "spawn idempotency key was reused across spawn kinds",
6285            ));
6286        }
6287        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6288            return Err(failure(
6289                NodeFailureCode::SpawnIdempotencyCapacity,
6290                "spawn idempotency cache reached its bounded capacity",
6291            ));
6292        }
6293        cache.entries.insert(spec.idempotency_key.clone(), SpawnIdempotencyEntry {
6294            value: SpawnIdempotencyValue::HarnessMcp,
6295            expires_at: now + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6296        });
6297        Ok(())
6298    }
6299
6300    fn replay_managed_spawn(
6301        &self,
6302        request: &ManagedWorktreeSpawnRequest,
6303        now: Instant,
6304    ) -> Result<Option<Result<ManagedWorktreeSpawnReceipt, NodeFailure>>, NodeFailure> {
6305        let mut cache = self.spawn_idempotency.lock()
6306            .unwrap_or_else(|poisoned| poisoned.into_inner());
6307        cache.entries.retain(|_, entry| entry.expires_at > now);
6308        if let Some(entry) = cache.entries.get(&request.spawn_spec.idempotency_key) {
6309            let SpawnIdempotencyValue::Managed { request: existing, result } = &entry.value else {
6310                return Err(failure(
6311                    NodeFailureCode::SpawnIdempotencyConflict,
6312                    "spawn idempotency key was reused across spawn kinds",
6313                ));
6314            };
6315            if existing != request {
6316                return Err(failure(
6317                    NodeFailureCode::SpawnIdempotencyConflict,
6318                    "spawn idempotency key was reused with a different managed specification",
6319                ));
6320            }
6321            return Ok(Some(result.clone()));
6322        }
6323        if cache.entries.len() >= SPAWN_IDEMPOTENCY_MAX_ENTRIES {
6324            return Err(failure(
6325                NodeFailureCode::SpawnIdempotencyCapacity,
6326                "spawn idempotency cache reached its bounded capacity",
6327            ));
6328        }
6329        Ok(None)
6330    }
6331
6332    fn remember_managed_spawn(
6333        &self,
6334        request: ManagedWorktreeSpawnRequest,
6335        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6336        accepted_at: Instant,
6337    ) {
6338        self.spawn_idempotency.lock()
6339            .unwrap_or_else(|poisoned| poisoned.into_inner())
6340            .entries
6341            .insert(
6342                request.spawn_spec.idempotency_key.clone(),
6343                SpawnIdempotencyEntry {
6344                    value: SpawnIdempotencyValue::Managed { request, result },
6345                    expires_at: accepted_at + Duration::from_millis(SPAWN_IDEMPOTENCY_TTL_MS),
6346                },
6347            );
6348    }
6349
6350    fn remember_managed_spawn_attempt(
6351        &self,
6352        durable_v2_key: Option<&SpawnIdempotencyKey>,
6353        request: ManagedWorktreeSpawnRequest,
6354        result: Result<ManagedWorktreeSpawnReceipt, NodeFailure>,
6355        accepted_at: Instant,
6356    ) {
6357        if durable_v2_key.is_none() {
6358            self.remember_managed_spawn(request, result, accepted_at);
6359        }
6360    }
6361
6362    fn acquire_durable_managed_spawn_v2(
6363        &self,
6364        request: &ManagedWorktreeSpawnRequestV2,
6365        request_digest: String,
6366    ) -> Result<DurableManagedSpawnReplayDecision, NodeFailure> {
6367        if self.state_path.is_none() {
6368            return Err(failure(
6369                NodeFailureCode::BackendOperationFailed,
6370                "managed worktree V2 requires durable node state",
6371            ));
6372        }
6373        let _transaction = self.state_transaction
6374            .lock()
6375            .unwrap_or_else(|poisoned| poisoned.into_inner());
6376        let key = request.spawn_spec.idempotency_key.clone();
6377        let mut previous = None;
6378        {
6379            let mut replays = self.managed_spawn_replays
6380                .lock()
6381                .unwrap_or_else(|poisoned| poisoned.into_inner());
6382            if let Some(record) = replays.get_mut(&key) {
6383                if record.request_digest != request_digest {
6384                    return Err(failure(
6385                        NodeFailureCode::SpawnIdempotencyConflict,
6386                        "managed worktree V2 idempotency key was reused with a different request",
6387                    ));
6388                }
6389                match &record.state {
6390                    ManagedWorktreeSpawnReplayStateV10::Pending {
6391                        owner_incarnation_id,
6392                        lease_id: None,
6393                    } if owner_incarnation_id != &self.incarnation_id => {
6394                        previous = Some(record.clone());
6395                        record.state = ManagedWorktreeSpawnReplayStateV10::Pending {
6396                            owner_incarnation_id: self.incarnation_id,
6397                            lease_id: None,
6398                        };
6399                        record.updated_at_unix_ms = unix_time_ms();
6400                    }
6401                    ManagedWorktreeSpawnReplayStateV10::Pending {
6402                        lease_id: None,
6403                        ..
6404                    } => {
6405                        return Err(failure(
6406                            NodeFailureCode::BackendBusy,
6407                            "managed worktree V2 reservation is already active",
6408                        ));
6409                    }
6410                    ManagedWorktreeSpawnReplayStateV10::Pending {
6411                        lease_id: Some(_),
6412                        ..
6413                    } => {
6414                        return Ok(DurableManagedSpawnReplayDecision::PendingLinked);
6415                    }
6416                    ManagedWorktreeSpawnReplayStateV10::Committed { receipt } => {
6417                        return Ok(DurableManagedSpawnReplayDecision::Committed(
6418                            receipt.clone(),
6419                        ));
6420                    }
6421                }
6422            } else if replays.len() >= MAX_MANAGED_WORKTREE_SPAWN_REPLAYS {
6423                return Err(failure(
6424                    NodeFailureCode::SpawnIdempotencyCapacity,
6425                    "durable managed worktree replay ledger reached its bounded capacity",
6426                ));
6427            } else {
6428                let now = unix_time_ms();
6429                replays.insert(key.clone(), ManagedWorktreeSpawnReplayRecordV10 {
6430                    idempotency_key: key.clone(),
6431                    request_digest,
6432                    source_workspace_id: request.spawn_spec.target.workspace_id.clone(),
6433                    profile_id: request.worktree_profile_id.clone(),
6434                    expected_profile_revision: request.expected_profile_revision.clone(),
6435                    state: ManagedWorktreeSpawnReplayStateV10::Pending {
6436                        owner_incarnation_id: self.incarnation_id,
6437                        lease_id: None,
6438                    },
6439                    created_at_unix_ms: now,
6440                    updated_at_unix_ms: now,
6441                });
6442            }
6443        }
6444        if let Err(error) = self.persist_state_locked() {
6445            let mut replays = self.managed_spawn_replays
6446                .lock()
6447                .unwrap_or_else(|poisoned| poisoned.into_inner());
6448            match previous {
6449                Some(previous) => {
6450                    replays.insert(key, previous);
6451                }
6452                None => {
6453                    replays.remove(&key);
6454                }
6455            }
6456            return Err(persistence_failure(error));
6457        }
6458        Ok(DurableManagedSpawnReplayDecision::Reserved)
6459    }
6460
6461    fn link_durable_managed_spawn_v2_lease_locked(
6462        &self,
6463        key: &SpawnIdempotencyKey,
6464        lease_id: &ManagedWorktreeLeaseId,
6465    ) -> Result<ManagedWorktreeSpawnReplayRecordV10, NodeFailure> {
6466        let mut replays = self.managed_spawn_replays
6467            .lock()
6468            .unwrap_or_else(|poisoned| poisoned.into_inner());
6469        let record = replays.get_mut(key).ok_or_else(|| {
6470            failure(
6471                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6472                "managed worktree V2 reservation disappeared before allocation",
6473            )
6474        })?;
6475        let previous = record.clone();
6476        match &mut record.state {
6477            ManagedWorktreeSpawnReplayStateV10::Pending {
6478                lease_id: current,
6479                ..
6480            }
6481                if current.is_none() =>
6482            {
6483                *current = Some(lease_id.clone());
6484                record.updated_at_unix_ms = unix_time_ms();
6485                Ok(previous)
6486            }
6487            _ => Err(failure(
6488                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6489                "managed worktree V2 reservation is not allocation-ready",
6490            )),
6491        }
6492    }
6493
6494    fn restore_durable_managed_spawn_v2_locked(
6495        &self,
6496        key: &SpawnIdempotencyKey,
6497        previous: ManagedWorktreeSpawnReplayRecordV10,
6498    ) {
6499        self.managed_spawn_replays
6500            .lock()
6501            .unwrap_or_else(|poisoned| poisoned.into_inner())
6502            .insert(key.clone(), previous);
6503    }
6504
6505    fn commit_durable_managed_spawn_v2(
6506        &self,
6507        request: &ManagedWorktreeSpawnRequestV2,
6508        receipt: ManagedWorktreeSpawnReceipt,
6509    ) -> Result<(), NodeFailure> {
6510        let _transaction = self.state_transaction
6511            .lock()
6512            .unwrap_or_else(|poisoned| poisoned.into_inner());
6513        let key = &request.spawn_spec.idempotency_key;
6514        let previous = {
6515            let mut replays = self.managed_spawn_replays
6516                .lock()
6517                .unwrap_or_else(|poisoned| poisoned.into_inner());
6518            let record = replays.get_mut(key).ok_or_else(|| {
6519                failure(
6520                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
6521                    "managed worktree V2 reservation disappeared before commit",
6522                )
6523            })?;
6524            let previous = record.clone();
6525            match &record.state {
6526                ManagedWorktreeSpawnReplayStateV10::Pending {
6527                    lease_id: Some(lease_id),
6528                    ..
6529                } if lease_id == &receipt.lease.lease_id => {}
6530                _ => {
6531                    return Err(failure(
6532                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
6533                        "managed worktree V2 reservation is not linked to the spawned lease",
6534                    ));
6535                }
6536            }
6537            record.state = ManagedWorktreeSpawnReplayStateV10::Committed { receipt };
6538            record.updated_at_unix_ms = unix_time_ms();
6539            previous
6540        };
6541        if let Err(error) = self.persist_state_locked() {
6542            self.restore_durable_managed_spawn_v2_locked(key, previous);
6543            return Err(persistence_failure(error));
6544        }
6545        Ok(())
6546    }
6547
6548    fn remove_unallocated_durable_managed_spawn_v2(
6549        &self,
6550        request: &ManagedWorktreeSpawnRequestV2,
6551    ) -> Result<(), NodeFailure> {
6552        let _transaction = self.state_transaction
6553            .lock()
6554            .unwrap_or_else(|poisoned| poisoned.into_inner());
6555        let key = &request.spawn_spec.idempotency_key;
6556        let removed = {
6557            let mut replays = self.managed_spawn_replays
6558                .lock()
6559                .unwrap_or_else(|poisoned| poisoned.into_inner());
6560            match replays.get(key).map(|record| &record.state) {
6561                Some(ManagedWorktreeSpawnReplayStateV10::Pending {
6562                    lease_id: None,
6563                    ..
6564                }) => {
6565                    replays.remove(key)
6566                }
6567                _ => None,
6568            }
6569        };
6570        let Some(removed) = removed else {
6571            return Ok(());
6572        };
6573        if let Err(error) = self.persist_state_locked() {
6574            self.managed_spawn_replays
6575                .lock()
6576                .unwrap_or_else(|poisoned| poisoned.into_inner())
6577                .insert(key.clone(), removed);
6578            return Err(persistence_failure(error));
6579        }
6580        Ok(())
6581    }
6582
6583    fn resolve_spawn_spec(&self, spec: &SpawnSpec) -> Result<ResolvedSpawnSpec, NodeFailure> {
6584        if spec.target.node_id != self.node_id {
6585            return Err(failure(
6586                NodeFailureCode::SpawnTargetMismatch,
6587                "spawn target does not match this node",
6588            ));
6589        }
6590        let defaults = self.spawn_profiles.get(&spec.profile_id).ok_or_else(|| {
6591            failure(
6592                NodeFailureCode::UnknownSpawnProfile,
6593                "spawn profile is unavailable on this node",
6594            )
6595        })?;
6596        let resolved = spec.resolve(defaults).map_err(|error| match error {
6597            SpawnSpecResolveError::ProfileRevisionMismatch { .. } => failure(
6598                NodeFailureCode::SpawnProfileRevisionMismatch,
6599                "spawn profile revision does not match the loaded profile",
6600            ),
6601            _ => failure(
6602                NodeFailureCode::InvalidRequest,
6603                "spawn specification could not be resolved",
6604            ),
6605        })?;
6606        if resolved.target.worktree_id.is_some() {
6607            self.require_worktree_service(&resolved.target.workspace_id)?;
6608        }
6609        let environment_profile = self.resolve_environment_profile(&resolved)?;
6610        self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6611        self.resolve_context(&resolved)?;
6612        Ok(resolved)
6613    }
6614
6615    async fn resolve_spawn_workspace(
6616        &self,
6617        resolved: &ResolvedSpawnSpec,
6618        deadline: Instant,
6619    ) -> Result<WorkspaceId, NodeFailure> {
6620        let Some(worktree_id) = resolved.target.worktree_id.as_ref() else {
6621            return Ok(resolved.target.workspace_id.clone());
6622        };
6623        if self.managed_worktrees.lock()
6624            .unwrap_or_else(|poisoned| poisoned.into_inner())
6625            .lease_for_workspace(worktree_id)
6626            .is_some()
6627        {
6628            return Err(failure(
6629                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6630                "managed worktree targets may only be spawned through their lease request",
6631            ));
6632        }
6633        self.require_worktree_service(&resolved.target.workspace_id)?;
6634        let source_root = self.workspace_root(&resolved.target.workspace_id)?;
6635        let selected_root = self.workspace_root(worktree_id)?;
6636        let remaining = spawn_deadline_remaining(deadline)?;
6637        let worktrees = timeout(remaining, list_git_worktrees(&source_root))
6638            .await
6639            .map_err(|_| {
6640                failure(
6641                    NodeFailureCode::SpawnDeadlineExceeded,
6642                    "Git worktree selection exceeded the spawn deadline",
6643                )
6644            })?
6645            .map_err(git_worktree_failure)?;
6646        validate_selected_worktree(&source_root, &selected_root, &worktrees)?;
6647        Ok(worktree_id.clone())
6648    }
6649
6650    async fn spawn_from_spec(
6651        &self,
6652        spec: SpawnSpec,
6653    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6654        self.spawn_from_spec_with_proxy(spec, None).await
6655    }
6656
6657    async fn spawn_from_spec_with_proxy(
6658        &self,
6659        spec: SpawnSpec,
6660        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
6661    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6662        let accepted_at = Instant::now();
6663        if harness_mcp.is_none() {
6664            if let Some(replayed) = self.replay_spawn_spec(&spec, accepted_at)? {
6665                return replayed;
6666            }
6667        }
6668        let resolved = self.resolve_spawn_spec(&spec)?;
6669        let environment_profile = self.resolve_environment_profile(&resolved)?;
6670        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6671        let context = self.resolve_context(&resolved)?;
6672        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6673        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6674        let spawn_workspace_id = self.resolve_spawn_workspace(&resolved, deadline).await;
6675        let spawn_workspace_id = match spawn_workspace_id {
6676            Ok(workspace_id) => workspace_id,
6677            Err(error) => {
6678                let result = Err(error);
6679                if harness_mcp.is_none() {
6680                    self.remember_spawn_spec(spec, result.clone(), accepted_at);
6681                }
6682                return result;
6683            }
6684        };
6685        if self.managed_worktrees.lock()
6686            .unwrap_or_else(|poisoned| poisoned.into_inner())
6687            .lease_for_workspace(&spawn_workspace_id)
6688            .is_some()
6689        {
6690            let result = Err(failure(
6691                NodeFailureCode::ManagedWorktreeOwnershipConflict,
6692                "managed worktree targets may only be spawned through their lease request",
6693            ));
6694            if harness_mcp.is_none() {
6695                self.remember_spawn_spec(spec, result.clone(), accepted_at);
6696            }
6697            return result;
6698        }
6699        let result = self
6700            .spawn_session_with_deadline(
6701                spawn_workspace_id,
6702                resolved.provider.clone(),
6703                resolved.mode,
6704                resolved.terminal_size,
6705                resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
6706                environment_profile.clone(),
6707                bundle.clone(),
6708                context.clone(),
6709                None,
6710                None,
6711                SpawnRecordPolicy::Always,
6712                Some(deadline),
6713                &required_capabilities,
6714                harness_mcp,
6715                resolved.approval_level,
6716                resolved.network_allowlist.clone(),
6717                resolved.browser_profile_id.clone(),
6718            )
6719            .await
6720            .map(|(session, _runtime_policy)| {
6721                resolved.receipt_with_materialization(
6722                    self.incarnation_id,
6723                    session,
6724                    environment_profile,
6725                    bundle,
6726                    context,
6727                )
6728            });
6729        if harness_mcp.is_none() {
6730            self.remember_spawn_spec(spec, result.clone(), accepted_at);
6731        }
6732        result
6733    }
6734
6735    async fn spawn_from_spec_with_harness_mcp(
6736        &self,
6737        reservation_id: HarnessMcpReservationId,
6738        activation_digest: HarnessMcpActivationDigest,
6739        spec: SpawnSpec,
6740        deadline_unix_ms: u64,
6741    ) -> Result<ResolvedSpawnReceipt, NodeFailure> {
6742        let registry = self.harness_mcp_registry.as_ref().ok_or_else(|| {
6743            failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
6744        })?;
6745        if let Some(receipt) = registry
6746            .replay_spawn(&reservation_id, &activation_digest, &spec)
6747            .map_err(harness_mcp_failure)?
6748        {
6749            return Ok(receipt);
6750        }
6751        let resolved = self.resolve_spawn_spec(&spec)?;
6752        let prepared = registry
6753            .prepare_spawn(
6754                &reservation_id,
6755                &activation_digest,
6756                &spec,
6757                deadline_unix_ms,
6758                resolved.provider.clone(),
6759            )
6760            .map_err(harness_mcp_failure)?;
6761        self.claim_harness_mcp_spawn(
6762            &reservation_id,
6763            &activation_digest,
6764            &spec,
6765            Instant::now(),
6766        )?;
6767        let spawn = self.spawn_from_spec_with_proxy(spec, Some(&prepared)).await;
6768        let mut receipt = match spawn {
6769            Ok(receipt) => receipt,
6770            Err(error) => {
6771                let _ = registry.abort(&reservation_id, &activation_digest);
6772                return Err(error);
6773            }
6774        };
6775        let record_id = self
6776            .session_bindings
6777            .lock()
6778            .unwrap_or_else(|poisoned| poisoned.into_inner())
6779            .get(&receipt.session.session.instance_id)
6780            .and_then(|binding| binding.record_id.clone());
6781        let record_id = match record_id {
6782            Some(record_id) => record_id,
6783            None => {
6784                let rollback = self.remove_session(&receipt.session).await;
6785                let _ = registry.abort(&reservation_id, &activation_digest);
6786                if let Err(rollback_error) = rollback {
6787                    return Err(rollback_error);
6788                }
6789                return Err(failure(
6790                    NodeFailureCode::BindingMismatch,
6791                    "harness MCP spawn did not retain its exact managed record",
6792                ));
6793            }
6794        };
6795        receipt.harness_mcp_proxy = Some(ResolvedHarnessMcpProxyReceiptV1 {
6796            reservation_id: reservation_id.clone(),
6797            activation_digest: activation_digest.clone(),
6798        });
6799        if let Err(error) = registry.mark_spawned(
6800            &prepared,
6801            receipt.session.clone(),
6802            record_id,
6803            receipt.clone(),
6804        ) {
6805            let rollback = self.remove_session(&receipt.session).await;
6806            let _ = registry.abort(&reservation_id, &activation_digest);
6807            if let Err(rollback_error) = rollback {
6808                return Err(rollback_error);
6809            }
6810            return Err(harness_mcp_failure(error));
6811        }
6812        Ok(receipt)
6813    }
6814
6815    async fn spawn_managed_worktree(
6816        &self,
6817        request: ManagedWorktreeSpawnRequest,
6818    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6819        self.spawn_managed_worktree_inner(request, None).await
6820    }
6821
6822    async fn spawn_managed_worktree_inner(
6823        &self,
6824        request: ManagedWorktreeSpawnRequest,
6825        durable_v2_key: Option<&SpawnIdempotencyKey>,
6826    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
6827        let accepted_at = Instant::now();
6828        if durable_v2_key.is_none() {
6829            if let Some(replayed) = self.replay_managed_spawn(&request, accepted_at)? {
6830                return replayed;
6831            }
6832        }
6833        let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
6834        if request.spawn_spec.target.worktree_id.is_some() {
6835            let result = Err(failure(
6836                NodeFailureCode::InvalidRequest,
6837                "managed worktree spawn must not select a caller-provided worktree",
6838            ));
6839            self.remember_managed_spawn_attempt(
6840                durable_v2_key,
6841                request,
6842                result.clone(),
6843                accepted_at,
6844            );
6845            return result;
6846        }
6847        let environment_profile = self.resolve_environment_profile(&resolved)?;
6848        let bundle = self.resolve_bundle(&resolved, environment_profile.as_ref())?;
6849        let context = self.resolve_context(&resolved)?;
6850        let source_workspace_id = resolved.target.workspace_id.clone();
6851        let profile = self.managed_profile(&source_workspace_id, &request.worktree_profile_id)?;
6852        let deadline = accepted_at + Duration::from_millis(resolved.deadline_ms.get());
6853        let required_capabilities = spawn_runtime_capabilities(&resolved.required_capabilities)?;
6854        let runtime_requirement = match (resolved.mode, resolved.prompt.is_some()) {
6855            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
6856            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
6857            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
6858            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
6859        };
6860        let runtime_policy = timeout(
6861            spawn_deadline_remaining(deadline)?,
6862            self.admit_provider_runtime(&resolved.provider, runtime_requirement),
6863        ).await.map_err(|_| failure(
6864            NodeFailureCode::SpawnDeadlineExceeded,
6865            "provider admission exceeded the managed spawn deadline",
6866        ))??;
6867        if required_capabilities.iter().any(|capability| !runtime_policy.admits(*capability)) {
6868            return Err(failure(
6869                NodeFailureCode::UnsupportedSpawnCapability,
6870                "provider runtime does not admit a required spawn capability",
6871            ));
6872        }
6873        self.ensure_binding_capacity()?;
6874        let source_root = self.workspace_root(&source_workspace_id)?;
6875        profile.validate_for_workspace(&source_root)
6876            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message))?;
6877        let base_timeout_ms = u64::try_from(
6878            spawn_deadline_remaining(deadline)?.as_millis(),
6879        ).unwrap_or(u64::MAX).max(1);
6880        let base_commit = resolve_base_commit_with_timeout(
6881            &source_root,
6882            profile.base(),
6883            base_timeout_ms,
6884        ).await.map_err(|error| {
6885            if Instant::now() >= deadline || error.message.contains("timed out") {
6886                failure(
6887                    NodeFailureCode::SpawnDeadlineExceeded,
6888                    "managed worktree base resolution exceeded the spawn deadline",
6889                )
6890            } else {
6891                managed_git_worktree_failure(error)
6892            }
6893        })?;
6894
6895        let lease = {
6896            let _transaction = self.state_transaction.lock()
6897                .unwrap_or_else(|poisoned| poisoned.into_inner());
6898            let previous = self.managed_worktrees.lock()
6899                .unwrap_or_else(|poisoned| poisoned.into_inner()).clone();
6900            let lease = self.managed_worktrees.lock()
6901                .unwrap_or_else(|poisoned| poisoned.into_inner())
6902                .allocate(source_workspace_id.clone(), &profile, base_commit.clone(), unix_time_ms())
6903                .map_err(|message| failure(NodeFailureCode::BackendBusy, &message))?;
6904            let previous_replay = match durable_v2_key
6905                .map(|key| self.link_durable_managed_spawn_v2_lease_locked(key, &lease.lease_id))
6906                .transpose()
6907            {
6908                Ok(previous) => previous,
6909                Err(error) => {
6910                    *self.managed_worktrees.lock()
6911                        .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6912                    return Err(error);
6913                }
6914            };
6915            if let Err(error) = self.persist_state_locked() {
6916                *self.managed_worktrees.lock()
6917                    .unwrap_or_else(|poisoned| poisoned.into_inner()) = previous;
6918                if let (Some(key), Some(previous_replay)) =
6919                    (durable_v2_key, previous_replay)
6920                {
6921                    self.restore_durable_managed_spawn_v2_locked(key, previous_replay);
6922                }
6923                return Err(persistence_failure(error));
6924            }
6925            lease
6926        };
6927        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() });
6928        if let Err(message) = profile.validate_target_authority(&lease.lease_id, &lease.target_root) {
6929            self.managed_worktrees.lock()
6930                .unwrap_or_else(|poisoned| poisoned.into_inner())
6931                .remove_unmutated(&lease.lease_id);
6932            if self.persist_state().is_err() {
6933                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
6934            }
6935            let result = Err(failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message));
6936            self.remember_managed_spawn_attempt(
6937                durable_v2_key,
6938                request,
6939                result.clone(),
6940                accepted_at,
6941            );
6942            return result;
6943        }
6944        let mutation_timeout_ms = match spawn_deadline_remaining(deadline) {
6945            Ok(remaining) => u64::try_from(remaining.as_millis()).unwrap_or(u64::MAX).max(1),
6946            Err(error) => {
6947                self.reconcile_failed_allocation(&lease, &source_root).await;
6948                let result = Err(error);
6949                self.remember_managed_spawn_attempt(
6950                    durable_v2_key,
6951                    request,
6952                    result.clone(),
6953                    accepted_at,
6954                );
6955                return result;
6956            }
6957        };
6958        let created = match create_git_worktree_with_timeout(
6959                &source_root,
6960                &lease.target_root,
6961                &lease.branch,
6962                Some(&base_commit),
6963                mutation_timeout_ms,
6964            ).await {
6965            Ok(created) => created,
6966            Err(error) => {
6967                self.reconcile_failed_allocation(&lease, &source_root).await;
6968                let result = Err(if Instant::now() >= deadline
6969                    || error.message.contains("timed out")
6970                    || error.message.contains("deadline")
6971                {
6972                    failure(
6973                        NodeFailureCode::SpawnDeadlineExceeded,
6974                        "managed worktree creation exceeded the spawn deadline",
6975                    )
6976                } else {
6977                    managed_git_worktree_failure(error)
6978                });
6979                self.remember_managed_spawn_attempt(
6980                    durable_v2_key,
6981                    request,
6982                    result.clone(),
6983                    accepted_at,
6984                );
6985                return result;
6986            }
6987        };
6988        if !exact_created_worktree(&lease, &created.path, created.branch.as_deref(), &created.head) {
6989            self.mark_managed_recovery_required(&lease.lease_id, ManagedWorktreeCleanupFailure::OwnershipConflict);
6990            let result = Err(failure(
6991                NodeFailureCode::ManagedWorktreeRecoveryRequired,
6992                "created worktree identity did not match its durable lease",
6993            ));
6994            self.remember_managed_spawn_attempt(
6995                durable_v2_key,
6996                request,
6997                result.clone(),
6998                accepted_at,
6999            );
7000            return result;
7001        }
7002        {
7003            let mut registry = self.managed_worktrees.lock()
7004                .unwrap_or_else(|poisoned| poisoned.into_inner());
7005            let current = registry.get_mut(&lease.lease_id)
7006                .expect("allocated managed worktree lease remains present");
7007            current.expected_head = Some(created.head.clone());
7008            current.state = ManagedWorktreeLeaseState::Ready;
7009            current.updated_at_unix_ms = unix_time_ms();
7010        }
7011        if let Err(error) = self.persist_state() {
7012            self.mark_managed_recovery_required(
7013                &lease.lease_id,
7014                ManagedWorktreeCleanupFailure::Backend,
7015            );
7016            let result = Err(persistence_failure(error));
7017            self.remember_managed_spawn_attempt(
7018                durable_v2_key,
7019                request,
7020                result.clone(),
7021                accepted_at,
7022            );
7023            return result;
7024        }
7025        let ready = self.managed_worktrees.lock()
7026            .unwrap_or_else(|poisoned| poisoned.into_inner())
7027            .get(&lease.lease_id)
7028            .expect("ready managed worktree lease remains present")
7029            .snapshot();
7030        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: ready });
7031        if let Err(error) = self.register_workspace(lease.workspace_id.clone(), created.path.clone()).await {
7032            let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7033            let result = Err(error);
7034            self.remember_managed_spawn_attempt(
7035                durable_v2_key,
7036                request,
7037                result.clone(),
7038                accepted_at,
7039            );
7040            return result;
7041        }
7042        let spawn = self.spawn_session_with_deadline(
7043            lease.workspace_id.clone(),
7044            resolved.provider.clone(),
7045            resolved.mode,
7046            resolved.terminal_size,
7047            resolved.prompt.as_ref().map(|prompt| prompt.as_str().to_owned()),
7048            environment_profile.clone(),
7049            bundle.clone(),
7050            context.clone(),
7051            Some(lease.lease_id.clone()),
7052            Some(runtime_policy),
7053            SpawnRecordPolicy::Always,
7054            Some(deadline),
7055            &required_capabilities,
7056            None,
7057            resolved.approval_level,
7058            resolved.network_allowlist.clone(),
7059            resolved.browser_profile_id.clone(),
7060        ).await;
7061        let (session, effective_runtime_policy) = match spawn {
7062            Ok(spawned) => spawned,
7063            Err(error) => {
7064                if self
7065                    .cleanup_managed_worktree(&lease.lease_id, true)
7066                    .await
7067                    .is_err()
7068                {
7069                    self.mark_managed_recovery_required(
7070                        &lease.lease_id,
7071                        ManagedWorktreeCleanupFailure::Backend,
7072                    );
7073                }
7074                let result = Err(error);
7075                self.remember_managed_spawn_attempt(
7076                    durable_v2_key,
7077                    request,
7078                    result.clone(),
7079                    accepted_at,
7080                );
7081                return result;
7082            }
7083        };
7084        let (record_id, raw_inventory_record_id) = {
7085            let mut bindings = self.session_bindings.lock()
7086                .unwrap_or_else(|poisoned| poisoned.into_inner());
7087            let binding = bindings.get_mut(&session.session.instance_id)
7088                .expect("successful spawn retains its session binding");
7089            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
7090            managed_spawn_record_ownership(binding, effective_runtime_policy)
7091        };
7092        let bound = self.managed_worktrees.lock()
7093            .unwrap_or_else(|poisoned| poisoned.into_inner())
7094            .bind_session(
7095                &lease.lease_id,
7096                ManagedWorktreeSessionHolder {
7097                    incarnation_id: self.incarnation_id,
7098                    instance_id: session.session.instance_id,
7099                    generation: session.session.generation,
7100                },
7101                record_id,
7102                unix_time_ms(),
7103            )
7104            .map_err(|message| failure(NodeFailureCode::ManagedWorktreeRecoveryRequired, &message));
7105        let snapshot = match bound {
7106            Ok(snapshot) => snapshot,
7107            Err(error) => {
7108                if raw_inventory_record_id
7109                    .as_ref()
7110                    .is_some_and(|record_id| self.discard_record(record_id).is_err())
7111                {
7112                    self.mark_managed_recovery_required(
7113                        &lease.lease_id,
7114                        ManagedWorktreeCleanupFailure::Backend,
7115                    );
7116                }
7117                let _ = self.remove_session(&session).await;
7118                self.mark_managed_recovery_required(
7119                    &lease.lease_id,
7120                    ManagedWorktreeCleanupFailure::Backend,
7121                );
7122                let result = Err(error);
7123                self.remember_managed_spawn_attempt(
7124                    durable_v2_key,
7125                    request,
7126                    result.clone(),
7127                    accepted_at,
7128                );
7129                return result;
7130            }
7131        };
7132        if let Err(error) = self.persist_state() {
7133            if raw_inventory_record_id
7134                .as_ref()
7135                .is_some_and(|record_id| self.discard_record(record_id).is_err())
7136            {
7137                self.mark_managed_recovery_required(
7138                    &lease.lease_id,
7139                    ManagedWorktreeCleanupFailure::Backend,
7140                );
7141            }
7142            let _ = self.remove_session(&session).await;
7143            if self
7144                .cleanup_managed_worktree(&lease.lease_id, true)
7145                .await
7146                .is_err()
7147            {
7148                self.mark_managed_recovery_required(
7149                    &lease.lease_id,
7150                    ManagedWorktreeCleanupFailure::Backend,
7151                );
7152            }
7153            let result = Err(persistence_failure(error));
7154            self.remember_managed_spawn_attempt(
7155                durable_v2_key,
7156                request,
7157                result.clone(),
7158                accepted_at,
7159            );
7160            return result;
7161        }
7162        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7163        let receipt = managed_spawn_receipt(
7164            &resolved,
7165            self.incarnation_id,
7166            session,
7167            snapshot,
7168            environment_profile,
7169            bundle,
7170            context,
7171        );
7172        let result = Ok(receipt);
7173        self.remember_managed_spawn_attempt(
7174            durable_v2_key,
7175            request,
7176            result.clone(),
7177            accepted_at,
7178        );
7179        result
7180    }
7181
7182    async fn spawn_managed_worktree_v2(
7183        &self,
7184        request: ManagedWorktreeSpawnRequestV2,
7185    ) -> Result<ManagedWorktreeSpawnReceipt, NodeFailure> {
7186        if request.spawn_spec.target.node_id != self.node_id {
7187            return Err(failure(
7188                NodeFailureCode::SpawnTargetMismatch,
7189                "spawn target does not match this node",
7190            ));
7191        }
7192        if request.spawn_spec.target.worktree_id.is_some() {
7193            return Err(failure(
7194                NodeFailureCode::InvalidRequest,
7195                "managed worktree spawn must not select a caller-provided worktree",
7196            ));
7197        }
7198        let request_digest = managed_spawn_request_digest_v2(&request)?;
7199        let replay = self.acquire_durable_managed_spawn_v2(&request, request_digest)?;
7200        match replay {
7201            DurableManagedSpawnReplayDecision::Committed(receipt) => return Ok(receipt),
7202            DurableManagedSpawnReplayDecision::PendingLinked => {
7203                return Err(failure(
7204                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
7205                    "managed worktree V2 reservation has an incomplete durable allocation",
7206                ));
7207            }
7208            DurableManagedSpawnReplayDecision::Reserved => {}
7209        }
7210        let validated = (|| {
7211            let resolved = self.resolve_spawn_spec(&request.spawn_spec)?;
7212            let profile = self.managed_profile(
7213                &resolved.target.workspace_id,
7214                &request.worktree_profile_id,
7215            )?;
7216            if profile.revision() != &request.expected_profile_revision {
7217                return Err(failure(
7218                    NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
7219                    "managed worktree profile revision does not match the loaded profile",
7220                ));
7221            }
7222            Ok(())
7223        })();
7224        match validated {
7225            Ok(()) => {}
7226            Err(error) => {
7227                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7228                return Err(error);
7229            }
7230        }
7231        let legacy = ManagedWorktreeSpawnRequest {
7232            spawn_spec: request.spawn_spec.clone(),
7233            worktree_profile_id: request.worktree_profile_id.clone(),
7234        };
7235        match self
7236            .spawn_managed_worktree_inner(
7237                legacy,
7238                Some(&request.spawn_spec.idempotency_key),
7239            )
7240            .await
7241        {
7242            Ok(receipt) => {
7243                self.commit_durable_managed_spawn_v2(&request, receipt.clone())?;
7244                Ok(receipt)
7245            }
7246            Err(error) => {
7247                self.remove_unallocated_durable_managed_spawn_v2(&request)?;
7248                Err(error)
7249            }
7250        }
7251    }
7252
7253    fn managed_profile(
7254        &self,
7255        workspace_id: &WorkspaceId,
7256        profile_id: &WorktreeProfileId,
7257    ) -> Result<ManagedWorktreeProfile, NodeFailure> {
7258        if self.worktree_service_modes.get(workspace_id).copied()
7259            != Some(WorktreeServiceMode::Managed)
7260        {
7261            return Err(failure(
7262                NodeFailureCode::UnsupportedSpawnCapability,
7263                "workspace does not enable managed worktrees",
7264            ));
7265        }
7266        self.managed_worktree_profiles.get(workspace_id)
7267            .and_then(|profiles| profiles.get(profile_id))
7268            .cloned()
7269            .ok_or_else(|| failure(
7270                NodeFailureCode::InvalidRequest,
7271                "managed worktree profile is unavailable for this workspace",
7272            ))
7273    }
7274
7275    async fn reconcile_failed_allocation(
7276        &self,
7277        lease: &ManagedWorktreeLeaseRecord,
7278        source_root: &str,
7279    ) {
7280        match list_git_worktrees(source_root).await {
7281            Ok(worktrees) => {
7282                match worktrees.iter().find(|item| worktree_paths_equal(&item.path, &lease.target_root)) {
7283                    None => {
7284                        match std::fs::symlink_metadata(&lease.target_root) {
7285                            Err(error) if error.kind() == io::ErrorKind::NotFound => {
7286                                self.managed_worktrees.lock()
7287                                    .unwrap_or_else(|poisoned| poisoned.into_inner())
7288                                    .remove_unmutated(&lease.lease_id);
7289                                let _ = self.persist_state();
7290                            }
7291                            _ => self.mark_managed_recovery_required(
7292                                &lease.lease_id,
7293                                ManagedWorktreeCleanupFailure::OwnershipConflict,
7294                            ),
7295                        }
7296                    }
7297                    Some(item) if exact_owned_worktree(
7298                        lease,
7299                        &item.path,
7300                        item.branch.as_deref(),
7301                        &item.head,
7302                    ) => {
7303                        let _ = self.cleanup_managed_worktree(&lease.lease_id, true).await;
7304                    }
7305                    Some(_) => self.mark_managed_recovery_required(
7306                        &lease.lease_id,
7307                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7308                    ),
7309                }
7310            }
7311            Err(_) => self.mark_managed_recovery_required(
7312                &lease.lease_id,
7313                ManagedWorktreeCleanupFailure::Backend,
7314            ),
7315        }
7316    }
7317
7318    async fn reconcile_managed_worktrees(&self) {
7319        let leases = self.managed_worktrees.lock()
7320            .unwrap_or_else(|poisoned| poisoned.into_inner())
7321            .active_records()
7322            .cloned()
7323            .collect::<Vec<_>>();
7324        for lease in leases {
7325            let profile = match self.managed_profile(&lease.source_workspace_id, &lease.profile_id) {
7326                Ok(profile) if profile.revision() == &lease.profile_revision => profile,
7327                _ => {
7328                    self.mark_managed_recovery_required(
7329                        &lease.lease_id,
7330                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7331                    );
7332                    continue;
7333                }
7334            };
7335            if profile.validate_target_authority(&lease.lease_id, &lease.target_root).is_err() {
7336                self.mark_managed_recovery_required(
7337                    &lease.lease_id,
7338                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7339                );
7340                continue;
7341            }
7342            let source_root = match self.workspace_root(&lease.source_workspace_id) {
7343                Ok(root) => root,
7344                Err(_) => {
7345                    self.mark_managed_recovery_required(
7346                        &lease.lease_id,
7347                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7348                    );
7349                    continue;
7350                }
7351            };
7352            let worktrees = match list_git_worktrees(&source_root).await {
7353                Ok(worktrees) => worktrees,
7354                Err(_) => {
7355                    self.mark_managed_recovery_required(
7356                        &lease.lease_id,
7357                        ManagedWorktreeCleanupFailure::Backend,
7358                    );
7359                    continue;
7360                }
7361            };
7362            let listed = worktrees.iter()
7363                .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
7364            let Some(listed) = listed else {
7365                match std::fs::symlink_metadata(&lease.target_root) {
7366                    Err(error) if error.kind() == io::ErrorKind::NotFound
7367                        && lease.state == ManagedWorktreeLeaseState::Allocating
7368                        && !lease.has_holders() => {
7369                            let removed = self.managed_worktrees.lock()
7370                                .unwrap_or_else(|poisoned| poisoned.into_inner())
7371                                .tombstone(&lease.lease_id, unix_time_ms());
7372                            if self.persist_state().is_ok() && removed.is_some() {
7373                                self.publish(NodeEvent::ManagedWorktreeRemoved {
7374                                    lease_id: lease.lease_id.clone(),
7375                                });
7376                            }
7377                        }
7378                    _ => self.mark_managed_recovery_required(
7379                        &lease.lease_id,
7380                        ManagedWorktreeCleanupFailure::OwnershipConflict,
7381                    ),
7382                }
7383                continue;
7384            };
7385            if listed.is_main || listed.is_bare || !exact_owned_worktree(
7386                &lease,
7387                &listed.path,
7388                listed.branch.as_deref(),
7389                &listed.head,
7390            ) {
7391                self.mark_managed_recovery_required(
7392                    &lease.lease_id,
7393                    ManagedWorktreeCleanupFailure::OwnershipConflict,
7394                );
7395                continue;
7396            }
7397            if !self.workspaces.read()
7398                .unwrap_or_else(|poisoned| poisoned.into_inner())
7399                .contains_key(&lease.workspace_id)
7400            {
7401                if self.register_workspace(lease.workspace_id.clone(), listed.path.clone()).await.is_err() {
7402                    self.mark_managed_recovery_required(
7403                        &lease.lease_id,
7404                        ManagedWorktreeCleanupFailure::Backend,
7405                    );
7406                    continue;
7407                }
7408            }
7409            let snapshot = {
7410                let mut registry = self.managed_worktrees.lock()
7411                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7412                let current = registry.get_mut(&lease.lease_id)
7413                    .expect("reconciled managed lease remains present");
7414                current.expected_head = Some(listed.head.clone());
7415                current.cleanup_failure = None;
7416                current.state = if current.has_holders() {
7417                    ManagedWorktreeLeaseState::InUse
7418                } else if current.retention == ManagedWorktreeRetention::Retain {
7419                    ManagedWorktreeLeaseState::Retained
7420                } else {
7421                    ManagedWorktreeLeaseState::Ready
7422                };
7423                current.updated_at_unix_ms = unix_time_ms();
7424                current.snapshot()
7425            };
7426            if self.persist_state().is_err() {
7427                self.mark_managed_recovery_required(
7428                    &lease.lease_id,
7429                    ManagedWorktreeCleanupFailure::Backend,
7430                );
7431                continue;
7432            }
7433            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
7434            if snapshot.state == ManagedWorktreeLeaseState::Ready {
7435                let _ = self.cleanup_managed_worktree(&lease.lease_id, false).await;
7436            }
7437        }
7438    }
7439
7440    /// Crash-recovery startup sweep: durably capture the pack for every
7441    /// `Live` managed record whose most recent session has not yet produced
7442    /// one (recovers a session whose `Exited{0}` event was observed but the
7443    /// Node crashed before the export finished). The reactive fast path for
7444    /// a single just-exited record does not go through this — see
7445    /// `drive_runtime_until_shutdown`, which resolves and spawns directly so
7446    /// it can capture the exact `SessionAddress` before `publish_control`
7447    /// clears it, rather than re-deriving it from (by then likely already
7448    /// `Dormant`) record state the way a rescan naturally would.
7449    ///
7450    /// Returns as soon as every eligible record's export has been *spawned*,
7451    /// not completed — each one runs on its own detached task (see
7452    /// `reconcile_context_pack_export_for_record`'s own doc comment for why:
7453    /// `NativeRuntime::tick()` has not even started its own loop yet at the
7454    /// point this is called from `NodeServer::run()`, so nothing could ever
7455    /// drain the history round trip if this awaited it inline here either).
7456    /// Best-effort throughout: never panics, no unwrap/expect; every failure
7457    /// is a silent skip for THIS record — an unusable source or a racing
7458    /// record is not surfaced to any caller.
7459    async fn reconcile_context_pack_exports(self: &Arc<Self>) {
7460        let candidates: Vec<(SessionRecordId, SessionAddress)> = self
7461            .session_records
7462            .lock()
7463            .unwrap_or_else(|poisoned| poisoned.into_inner())
7464            .records
7465            .values()
7466            .filter(|record| {
7467                record.state == ManagedSessionState::Live
7468                    && record.exported_context.is_none()
7469                    && record.provider_session.is_some()
7470            })
7471            .filter_map(|record| {
7472                record
7473                    .active_session
7474                    .clone()
7475                    .map(|session| (record.record_id.clone(), session))
7476            })
7477            .collect();
7478        for (record_id, session) in candidates {
7479            let shared = Arc::clone(self);
7480            tokio::spawn(async move {
7481                shared
7482                    .reconcile_context_pack_export_for_record(&record_id, session)
7483                    .await;
7484            });
7485        }
7486    }
7487
7488    /// Runs on its own detached task (`tokio::spawn`, see both call sites),
7489    /// never inline on `drive_runtime_until_shutdown`'s own task: the history
7490    /// round trip this performs (`discover_history`/`load_history`, via
7491    /// `export_context_pack_for_session_record`) can only ever settle once
7492    /// `NativeRuntime::tick()` runs again to drain the authority worker's
7493    /// completion — and `tick()` is exclusively owned by, and only ever
7494    /// called from, that same drive-loop task. Awaiting this inline there
7495    /// would starve `tick()` and this call could never observe its own
7496    /// command complete.
7497    ///
7498    /// Because this now genuinely races the drive loop's own
7499    /// `publish_control` (which downgrades this exact record from `Live` to
7500    /// `Dormant`, clearing `active_session`, for the very `Exited` event that
7501    /// triggered this reconcile), the record legitimately may already be
7502    /// `Dormant` by the time this reads it, or may transition mid-flight —
7503    /// `allow_clean_detachment: true` on the downstream export call is what
7504    /// tolerates exactly that transition (and only that transition; see
7505    /// `session_record_export_target_matches`).
7506    async fn reconcile_context_pack_export_for_record(
7507        &self,
7508        record_id: &SessionRecordId,
7509        session: SessionAddress,
7510    ) {
7511        let Ok(record) = self.record(record_id) else {
7512            return;
7513        };
7514        if !matches!(
7515            record.state,
7516            ManagedSessionState::Live | ManagedSessionState::Dormant
7517        ) || record.exported_context.is_some()
7518            || record.provider_session.is_none()
7519        {
7520            return;
7521        }
7522        // Pack bytes are already durably committed inside this call, via
7523        // commit_context_pack_for_session_record's own durable
7524        // write-through (context_pack_store) — no separate step here.
7525        // Unclean exits (non-zero/forced/no loadable history) are already
7526        // rejected by context_pack_source_status_is_usable inside
7527        // materialize_context_pack: this simply returns Err and is skipped.
7528        let Ok(receipt) = self
7529            .export_context_pack_for_session_record(&record.record_id, &session, true)
7530            .await
7531        else {
7532            return;
7533        };
7534        self.set_exported_context(&record.record_id, receipt);
7535    }
7536
7537    /// Records the record's most recent clean-exit pack. No-op (not an
7538    /// error) if the record disappeared, or if it already carries an
7539    /// `exported_context` — first-write-wins, matching the harness-side
7540    /// idempotent reconciliation that consumes this field.
7541    fn set_exported_context(&self, record_id: &SessionRecordId, receipt: ResolvedContextPackReceipt) {
7542        let transaction = self
7543            .state_transaction
7544            .lock()
7545            .unwrap_or_else(|poisoned| poisoned.into_inner());
7546        let (previous, updated) = {
7547            let mut records = self
7548                .session_records
7549                .lock()
7550                .unwrap_or_else(|poisoned| poisoned.into_inner());
7551            let Some(record) = records.records.get_mut(record_id) else {
7552                return;
7553            };
7554            if record.exported_context.is_some() {
7555                return;
7556            }
7557            let previous = record.clone();
7558            record.exported_context = Some(receipt);
7559            record.updated_at_unix_ms = unix_time_ms().max(record.updated_at_unix_ms);
7560            (previous, record.clone())
7561        };
7562        if self.persist_state_locked().is_err() {
7563            self.session_records
7564                .lock()
7565                .unwrap_or_else(|poisoned| poisoned.into_inner())
7566                .records
7567                .insert(record_id.clone(), previous);
7568            return;
7569        }
7570        drop(transaction);
7571        self.publish_record(updated);
7572    }
7573
7574    /// Resolves the managed record and its exact `SessionAddress` currently
7575    /// bound to `instance_id` at exactly `generation`, used by the reactive
7576    /// `Exited{0}` hook in `drive_runtime_until_shutdown` to target a single
7577    /// record without a full rescan. Mirrors the lookup already inlined in
7578    /// `publish_control`. Captured synchronously, before `publish_control`
7579    /// for the same event clears the record's own `active_session` — the
7580    /// caller passes the resolved address through to a detached
7581    /// reconciliation task rather than re-deriving it from the record later,
7582    /// after that race has necessarily already happened.
7583    fn managed_record_export_target_for_instance(
7584        &self,
7585        instance_id: AgentInstanceId,
7586        generation: SessionGeneration,
7587    ) -> Option<(SessionRecordId, SessionAddress)> {
7588        let bindings = self
7589            .session_bindings
7590            .lock()
7591            .unwrap_or_else(|poisoned| poisoned.into_inner());
7592        let binding = bindings.get(&instance_id).filter(|binding| binding.generation == generation)?;
7593        let record_id = binding.record_id.clone()?;
7594        Some((
7595            record_id,
7596            SessionAddress {
7597                workspace_id: binding.workspace_id.clone(),
7598                session: SessionKey { instance_id, generation },
7599            },
7600        ))
7601    }
7602
7603    fn mark_managed_recovery_required(
7604        &self,
7605        lease_id: &ManagedWorktreeLeaseId,
7606        failure_kind: ManagedWorktreeCleanupFailure,
7607    ) {
7608        let snapshot = {
7609            let mut registry = self.managed_worktrees.lock()
7610                .unwrap_or_else(|poisoned| poisoned.into_inner());
7611            let Some(lease) = registry.get_mut(lease_id) else { return };
7612            lease.state = ManagedWorktreeLeaseState::RecoveryRequired;
7613            lease.cleanup_failure = Some(failure_kind);
7614            lease.updated_at_unix_ms = unix_time_ms();
7615            lease.snapshot()
7616        };
7617        let _ = self.persist_state();
7618        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
7619    }
7620
7621    fn set_persistence_error(&self, error: Option<String>) {
7622        *self
7623            .persistence_error
7624            .write()
7625            .unwrap_or_else(|poisoned| poisoned.into_inner()) = error
7626            .map(|message| session_registry::sanitized_persistence_summary(&message));
7627    }
7628
7629    fn persist_state(&self) -> io::Result<()> {
7630        let _transaction = self
7631            .state_transaction
7632            .lock()
7633            .unwrap_or_else(|poisoned| poisoned.into_inner());
7634        self.persist_state_locked()
7635    }
7636
7637    fn persist_state_locked(&self) -> io::Result<()> {
7638        let workspaces = self
7639            .workspaces
7640            .read()
7641            .unwrap_or_else(|poisoned| poisoned.into_inner())
7642            .clone();
7643        let records = self
7644            .session_records
7645            .lock()
7646            .unwrap_or_else(|poisoned| poisoned.into_inner())
7647            .records
7648            .values()
7649            .cloned()
7650            .collect::<Vec<_>>();
7651        let mut managed = self.managed_worktrees
7652            .lock()
7653            .unwrap_or_else(|poisoned| poisoned.into_inner());
7654        managed.reattach_record_holders(
7655            &managed_worktree_record_holders(&records),
7656            unix_time_ms(),
7657        );
7658        let materializations = self
7659            .materializations
7660            .lock()
7661            .unwrap_or_else(|poisoned| poisoned.into_inner())
7662            .values()
7663            .cloned()
7664            .collect::<Vec<_>>();
7665        let managed_spawn_replays = self.managed_spawn_replays
7666            .lock()
7667            .unwrap_or_else(|poisoned| poisoned.into_inner())
7668            .values()
7669            .cloned()
7670            .collect::<Vec<_>>();
7671        let result = session_registry::save_v10(
7672            self.state_path.as_deref(),
7673            &self.node_id,
7674            &workspaces,
7675            &records,
7676            &managed.records(),
7677            &managed.tombstones(),
7678            &materializations,
7679            &managed_spawn_replays,
7680        );
7681        match &result {
7682            Ok(warning) => self.set_persistence_error(warning.clone()),
7683            Err(_) => self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned())),
7684        }
7685        result.map(|_| ())
7686    }
7687
7688    async fn begin_shutdown(&self) -> Result<(), NodeFailure> {
7689        let _mutation_guard = self.mutation_gate.lock().await;
7690        self.begin_shutdown_locked().await
7691    }
7692
7693    async fn begin_shutdown_locked(&self) -> Result<(), NodeFailure> {
7694        if self.shutdown.swap(true, Ordering::AcqRel) {
7695            return Ok(());
7696        }
7697        SessionLifecycleHook::on_shutdown(self);
7698        let deadline = Instant::now() + Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
7699        let mut first_error = None;
7700        for session in &self.handle.snapshot().sessions {
7701            let remaining = deadline.saturating_duration_since(Instant::now());
7702            if remaining.is_zero() {
7703                first_error.get_or_insert_with(|| {
7704                    failure(NodeFailureCode::BackendBusy, "stop-all dispatch exceeded its bounded deadline")
7705                });
7706                break;
7707            }
7708            if let Err(error) = self
7709                .dispatch_bounded(
7710                    ControlCommand::Stop {
7711                        instance_id: session.instance_id,
7712                        force: true,
7713                    },
7714                    remaining,
7715                )
7716                .await
7717            {
7718                first_error.get_or_insert(error);
7719            }
7720        }
7721        self.shutdown_notify.notify_waiters();
7722        match first_error {
7723            Some(error) => Err(error),
7724            None => Ok(()),
7725        }
7726    }
7727
7728    fn ensure_binding_capacity(&self) -> Result<(), NodeFailure> {
7729        let bindings = self
7730            .session_bindings
7731            .lock()
7732            .unwrap_or_else(|poisoned| poisoned.into_inner());
7733        if bindings.len() >= CONTROL_SESSIONS_MAX {
7734            Err(failure(
7735                NodeFailureCode::BackendBusy,
7736                "node session-binding capacity is full; remove an existing session first",
7737            ))
7738        } else {
7739            Ok(())
7740        }
7741    }
7742
7743    /// One-owner workspace/env collision guard: a live session on
7744    /// `workspace_id` with a different `environment_profile` (option
7745    /// equality on the resolved receipt, including Some vs None) blocks
7746    /// another spawn on that same cwd. Same profile may still share the
7747    /// workspace; distinct profiles need distinct workspace ids (managed
7748    /// worktree).
7749    fn ensure_workspace_environment_profile_compatible(
7750        &self,
7751        workspace_id: &WorkspaceId,
7752        environment_profile: Option<&ResolvedEnvironmentProfileReceipt>,
7753    ) -> Result<(), NodeFailure> {
7754        let bindings = self
7755            .session_bindings
7756            .lock()
7757            .unwrap_or_else(|poisoned| poisoned.into_inner());
7758        for binding in bindings.values() {
7759            if &binding.workspace_id != workspace_id {
7760                continue;
7761            }
7762            if binding.environment_profile.as_ref() != environment_profile {
7763                return Err(failure(
7764                    NodeFailureCode::WorkspaceBusy,
7765                    "workspace already has a live session with a different environment profile; use a managed worktree for concurrent distinct ProviderHome/env bindings on one repo",
7766                ));
7767            }
7768        }
7769        Ok(())
7770    }
7771
7772    #[cfg(test)]
7773    fn bind_session(
7774        &self,
7775        instance_id: AgentInstanceId,
7776        workspace_id: WorkspaceId,
7777        generation: SessionGeneration,
7778    ) {
7779        self.bind_session_with_policy(
7780            &SessionAddress {
7781                workspace_id,
7782                session: SessionKey { instance_id, generation },
7783            },
7784            ProviderRuntimePolicy::raw_pty(),
7785            None,
7786        );
7787    }
7788
7789    #[cfg(test)]
7790    fn bind_session_with_policy(
7791        &self,
7792        address: &SessionAddress,
7793        runtime_policy: ProviderRuntimePolicy,
7794        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7795    ) {
7796        self.bind_session_with_materialization(
7797            address,
7798            runtime_policy,
7799            environment_profile,
7800            None,
7801            None,
7802            None,
7803        );
7804    }
7805
7806    fn bind_session_with_materialization(
7807        &self,
7808        address: &SessionAddress,
7809        runtime_policy: ProviderRuntimePolicy,
7810        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7811        bundle: Option<ResolvedBundleReceipt>,
7812        context: Option<ResolvedContextPackReceipt>,
7813        materialization_id: Option<MaterializationId>,
7814    ) {
7815        let mut bindings = self
7816            .session_bindings
7817            .lock()
7818            .unwrap_or_else(|poisoned| poisoned.into_inner());
7819        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7820        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7821        bindings.insert(address.session.instance_id, SessionBinding {
7822            workspace_id: address.workspace_id.clone(),
7823            generation: address.session.generation,
7824            runtime_policy,
7825            pending_resume: None,
7826            record_id: None,
7827            managed_worktree_lease_id: None,
7828            environment_profile,
7829            bundle,
7830            context,
7831            materialization_id,
7832        });
7833    }
7834
7835    #[cfg(test)]
7836    fn bind_managed_session(
7837        &self,
7838        address: &SessionAddress,
7839        record_id: SessionRecordId,
7840        runtime_policy: ProviderRuntimePolicy,
7841        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7842    ) {
7843        self.bind_managed_session_with_materialization(
7844            address,
7845            record_id,
7846            runtime_policy,
7847            environment_profile,
7848            None,
7849            None,
7850            None,
7851        );
7852    }
7853
7854    fn bind_managed_session_with_materialization(
7855        &self,
7856        address: &SessionAddress,
7857        record_id: SessionRecordId,
7858        runtime_policy: ProviderRuntimePolicy,
7859        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7860        bundle: Option<ResolvedBundleReceipt>,
7861        context: Option<ResolvedContextPackReceipt>,
7862        materialization_id: Option<MaterializationId>,
7863    ) {
7864        let mut bindings = self
7865            .session_bindings
7866            .lock()
7867            .unwrap_or_else(|poisoned| poisoned.into_inner());
7868        debug_assert!(bindings.len() < CONTROL_SESSIONS_MAX);
7869        debug_assert!(!bindings.contains_key(&address.session.instance_id));
7870        bindings.insert(
7871            address.session.instance_id,
7872            SessionBinding {
7873                workspace_id: address.workspace_id.clone(),
7874                generation: address.session.generation,
7875                runtime_policy,
7876                pending_resume: None,
7877                record_id: Some(record_id),
7878                managed_worktree_lease_id: None,
7879                environment_profile,
7880                bundle,
7881                context,
7882                materialization_id,
7883            },
7884        );
7885    }
7886
7887    #[cfg(test)]
7888    fn bind_spawn_session(
7889        &self,
7890        address: &SessionAddress,
7891        provider: AgentId,
7892        mode: SessionMode,
7893        runtime_policy: ProviderRuntimePolicy,
7894        record_policy: SpawnRecordPolicy,
7895        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7896    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7897        self.bind_spawn_session_with_materialization(
7898            address,
7899            provider,
7900            mode,
7901            runtime_policy,
7902            record_policy,
7903            environment_profile,
7904            None,
7905            None,
7906            None,
7907        )
7908    }
7909
7910    fn bind_spawn_session_with_materialization(
7911        &self,
7912        address: &SessionAddress,
7913        provider: AgentId,
7914        mode: SessionMode,
7915        runtime_policy: ProviderRuntimePolicy,
7916        record_policy: SpawnRecordPolicy,
7917        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
7918        bundle: Option<ResolvedBundleReceipt>,
7919        context: Option<ResolvedContextPackReceipt>,
7920        materialization_id: Option<MaterializationId>,
7921    ) -> Result<Option<SessionRecordId>, NodeFailure> {
7922        if record_policy == SpawnRecordPolicy::ProviderIdentityOnly
7923            && !runtime_policy.provider_session_identity
7924        {
7925            self.bind_session_with_materialization(
7926                address,
7927                runtime_policy,
7928                environment_profile,
7929                bundle,
7930                context,
7931                materialization_id,
7932            );
7933            return Ok(None);
7934        }
7935        let record_owns_lifecycle = runtime_policy.provider_session_identity;
7936        let record = self.new_record(
7937            address,
7938            provider,
7939            mode,
7940            active_record_state(runtime_policy.provider_session_identity, false),
7941            None,
7942            record_owns_lifecycle.then(|| environment_profile.clone()).flatten(),
7943            record_owns_lifecycle.then(|| bundle.clone()).flatten(),
7944            record_owns_lifecycle.then(|| context.clone()).flatten(),
7945        )?;
7946        let record_id = record.record_id.clone();
7947        let transaction = self
7948            .state_transaction
7949            .lock()
7950            .unwrap_or_else(|poisoned| poisoned.into_inner());
7951        self.insert_record(record.clone())?;
7952        let previous_materialization = if record_owns_lifecycle {
7953            if let Some(materialization_id) = materialization_id.as_ref() {
7954                let mut materializations = self
7955                    .materializations
7956                    .lock()
7957                    .unwrap_or_else(|poisoned| poisoned.into_inner());
7958                let Some(ownership) = materializations.get_mut(materialization_id) else {
7959                    drop(materializations);
7960                    self.remove_record_memory(&record_id);
7961                    return Err(failure(
7962                        NodeFailureCode::BackendOperationFailed,
7963                        "session environment ownership disappeared before binding",
7964                    ));
7965                };
7966                let previous = ownership.clone();
7967                if ownership
7968                    .transfer_to_record(record_id.clone(), unix_time_ms())
7969                    .is_err()
7970                {
7971                    drop(materializations);
7972                    self.remove_record_memory(&record_id);
7973                    return Err(failure(
7974                        NodeFailureCode::BackendOperationFailed,
7975                        "session environment ownership transfer failed",
7976                    ));
7977                }
7978                Some(previous)
7979            } else {
7980                None
7981            }
7982        } else {
7983            None
7984        };
7985        self.bind_managed_session_with_materialization(
7986            address,
7987            record_id.clone(),
7988            runtime_policy,
7989            environment_profile,
7990            bundle,
7991            context,
7992            materialization_id.clone(),
7993        );
7994        if let Err(error) = self.persist_state_locked() {
7995            self.remove_binding(address);
7996            self.remove_record_memory(&record_id);
7997            if let (Some(materialization_id), Some(previous)) =
7998                (materialization_id.as_ref(), previous_materialization)
7999            {
8000                self.materializations
8001                    .lock()
8002                    .unwrap_or_else(|poisoned| poisoned.into_inner())
8003                    .insert(materialization_id.clone(), previous);
8004            }
8005            return Err(persistence_failure(error));
8006        }
8007        drop(transaction);
8008        self.publish_record(record);
8009        Ok(Some(record_id))
8010    }
8011
8012    fn allocate_record_id(&self) -> Result<SessionRecordId, NodeFailure> {
8013        for _ in 0..8 {
8014            let bytes = random_nonce().map_err(|error| {
8015                failure(
8016                    NodeFailureCode::BackendOperationFailed,
8017                    &format!("session record identity generation failed: {error}"),
8018                )
8019            })?;
8020            let mut value = String::with_capacity(27);
8021            value.push_str("sr-");
8022            for byte in &bytes[..12] {
8023                use std::fmt::Write as _;
8024                let _ = write!(&mut value, "{byte:02x}");
8025            }
8026            let record_id = SessionRecordId::new(value).map_err(|error| {
8027                failure(NodeFailureCode::BackendOperationFailed, &error.to_string())
8028            })?;
8029            let records = self
8030                .session_records
8031                .lock()
8032                .unwrap_or_else(|poisoned| poisoned.into_inner());
8033            if !records.records.contains_key(&record_id) {
8034                return Ok(record_id);
8035            }
8036        }
8037        Err(failure(
8038            NodeFailureCode::BackendBusy,
8039            "could not allocate a unique session record ID",
8040        ))
8041    }
8042
8043    fn allocate_task_id(&self) -> Result<TaskId, NodeFailure> {
8044        for _ in 0..8 {
8045            let bytes = random_nonce().map_err(|error| {
8046                failure(
8047                    NodeFailureCode::BackendOperationFailed,
8048                    &format!("task identity generation failed: {error}"),
8049                )
8050            })?;
8051            let mut nonce = [0_u8; 12];
8052            nonce.copy_from_slice(&bytes[..12]);
8053            let task_id = TaskId::from_nonce(nonce);
8054            let records = self
8055                .session_records
8056                .lock()
8057                .unwrap_or_else(|poisoned| poisoned.into_inner());
8058            if !records.records.values().any(|record| {
8059                record.task_binding.as_ref().and_then(|binding| binding.task_id.as_ref())
8060                    == Some(&task_id)
8061            }) {
8062                return Ok(task_id);
8063            }
8064        }
8065        Err(failure(
8066            NodeFailureCode::BackendBusy,
8067            "could not allocate a unique task ID",
8068        ))
8069    }
8070
8071    fn default_record_name(&self, provider: &AgentId) -> String {
8072        let records = self
8073            .session_records
8074            .lock()
8075            .unwrap_or_else(|poisoned| poisoned.into_inner());
8076        let ordinal = records
8077            .records
8078            .values()
8079            .filter(|record| &record.provider == provider)
8080            .count()
8081            .saturating_add(1);
8082        format!("{} #{ordinal}", provider.as_str())
8083    }
8084
8085    fn new_record(
8086        &self,
8087        address: &SessionAddress,
8088        provider: AgentId,
8089        mode: SessionMode,
8090        state: ManagedSessionState,
8091        provider_session: Option<gate4agent_types::ProviderSessionIdentity>,
8092        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
8093        bundle: Option<ResolvedBundleReceipt>,
8094        context: Option<ResolvedContextPackReceipt>,
8095    ) -> Result<ManagedSessionRecord, NodeFailure> {
8096        let canonical_root = self.workspace_root(&address.workspace_id)?;
8097        let now = unix_time_ms();
8098        Ok(ManagedSessionRecord {
8099            record_id: self.allocate_record_id()?,
8100            display_name: self.m4a_nick_of(address.session.instance_id).unwrap_or_else(|| self.default_record_name(&provider)),
8101            provider,
8102            mode,
8103            state,
8104            workspace_id: address.workspace_id.clone(),
8105            canonical_root: opaque_windows_path(canonical_root),
8106            provider_session,
8107            active_session: Some(address.clone()),
8108            environment_profile,
8109            bundle,
8110            context_id: context.as_ref().map(|receipt| receipt.id.clone()),
8111            context,
8112            exported_context: None,
8113            task_binding: None,
8114            created_at_unix_ms: now,
8115            updated_at_unix_ms: now,
8116            last_error: None,
8117        })
8118    }
8119
8120    fn insert_record(&self, record: ManagedSessionRecord) -> Result<(), NodeFailure> {
8121        let mut records = self
8122            .session_records
8123            .lock()
8124            .unwrap_or_else(|poisoned| poisoned.into_inner());
8125        if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8126            return Err(failure(
8127                NodeFailureCode::BackendBusy,
8128                "managed session record capacity is full; forget an inactive record first",
8129            ));
8130        }
8131        if let Some(identity) = record.provider_session.as_ref() {
8132            if records.records.values().any(|candidate| {
8133                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8134                    session_registry::same_provider_session(
8135                        &candidate.provider,
8136                        candidate_identity,
8137                        &record.provider,
8138                        identity,
8139                    )
8140                })
8141            }) {
8142                return Err(failure(
8143                    NodeFailureCode::SessionRecordConflict,
8144                    "provider session is already represented by another managed record",
8145                ));
8146            }
8147        }
8148        match records.records.entry(record.record_id.clone()) {
8149            std::collections::btree_map::Entry::Vacant(entry) => {
8150                entry.insert(record);
8151            }
8152            std::collections::btree_map::Entry::Occupied(_) => {
8153                return Err(failure(
8154                    NodeFailureCode::SessionRecordConflict,
8155                    "session record ID collision",
8156                ));
8157            }
8158        }
8159        Ok(())
8160    }
8161
8162    fn record(&self, record_id: &SessionRecordId) -> Result<ManagedSessionRecord, NodeFailure> {
8163        self.session_records
8164            .lock()
8165            .unwrap_or_else(|poisoned| poisoned.into_inner())
8166            .records
8167            .get(record_id)
8168            .cloned()
8169            .ok_or_else(|| {
8170                failure(
8171                    NodeFailureCode::UnknownSessionRecord,
8172                    "managed session record does not exist",
8173                )
8174            })
8175    }
8176
8177    fn revalidate_session_record_preview(
8178        &self,
8179        expected: &ManagedSessionRecord,
8180        identity: &ProviderSessionIdentity,
8181    ) -> Result<(), NodeFailure> {
8182        let current = self.record(&expected.record_id)?;
8183        let current_root = self.workspace_root(&current.workspace_id)?;
8184        if current.provider != expected.provider
8185            || current.workspace_id != expected.workspace_id
8186            || current.canonical_root != expected.canonical_root
8187            || current.provider_session.as_ref() != Some(identity)
8188            || !platform::roots_equal(
8189                &current_root,
8190                windows_path_text(&current.canonical_root),
8191            )
8192        {
8193            return Err(failure(
8194                NodeFailureCode::SessionRecordConflict,
8195                "managed session changed while its preview was loading",
8196            ));
8197        }
8198        Ok(())
8199    }
8200
8201    fn revalidate_session_record_context_export(
8202        &self,
8203        expected: &ManagedSessionRecord,
8204        identity: Option<&ProviderSessionIdentity>,
8205        session: &SessionAddress,
8206        allow_clean_detachment: bool,
8207    ) -> Result<(), NodeFailure> {
8208        let current = self.record(&expected.record_id)?;
8209        let current_root = self.workspace_root(&current.workspace_id)?;
8210        let snapshot = self.internal_session_snapshot(session)?;
8211        let binding = self
8212            .session_bindings
8213            .lock()
8214            .unwrap_or_else(|poisoned| poisoned.into_inner())
8215            .get(&session.session.instance_id)
8216            .cloned()
8217            .ok_or_else(|| {
8218                failure(
8219                    NodeFailureCode::SessionRecordConflict,
8220                    "managed session runtime binding disappeared during context export",
8221                )
8222            })?;
8223        if !session_record_context_export_binding_is_exact(
8224            expected,
8225            &current,
8226            identity,
8227            session,
8228            &binding,
8229            &snapshot.agent_id,
8230            &current_root,
8231            allow_clean_detachment,
8232        ) || !session_record_context_export_source_is_usable(
8233            &current,
8234            &snapshot.status,
8235            allow_clean_detachment,
8236        ) {
8237            return Err(failure(
8238                NodeFailureCode::SessionRecordConflict,
8239                "managed session binding changed during context export",
8240            ));
8241        }
8242        Ok(())
8243    }
8244
8245    fn revalidate_loaded_history(
8246        &self,
8247        session: &SessionAddress,
8248        candidate_id: &str,
8249        loaded: &HistorySessionRecord,
8250    ) -> Result<(), NodeFailure> {
8251        let snapshot = self.internal_session_snapshot(session)?;
8252        if snapshot.history.pending.is_some()
8253            || snapshot.history.loaded_candidate_id.as_deref() != Some(candidate_id)
8254            || snapshot.history.loaded.as_ref() != Some(loaded)
8255        {
8256            return Err(failure(
8257                NodeFailureCode::SessionRecordConflict,
8258                "managed session history changed during context export",
8259            ));
8260        }
8261        Ok(())
8262    }
8263
8264    fn remove_record_memory(&self, record_id: &SessionRecordId) -> Option<ManagedSessionRecord> {
8265        self.session_records
8266            .lock()
8267            .unwrap_or_else(|poisoned| poisoned.into_inner())
8268            .records
8269            .remove(record_id)
8270    }
8271
8272    fn discard_record(&self, record_id: &SessionRecordId) -> Result<(), NodeFailure> {
8273        if self.destroy_record_materialization(record_id)? {
8274            self.publish(NodeEvent::SessionRecordRemoved {
8275                record_id: record_id.clone(),
8276            });
8277            return Ok(());
8278        }
8279        let transaction = self
8280            .state_transaction
8281            .lock()
8282            .unwrap_or_else(|poisoned| poisoned.into_inner());
8283        let Some(removed) = self.remove_record_memory(record_id) else {
8284            return Ok(());
8285        };
8286        if let Err(error) = self.persist_state_locked() {
8287            let _ = self.insert_record(removed);
8288            return Err(persistence_failure(error));
8289        }
8290        drop(transaction);
8291        self.publish(NodeEvent::SessionRecordRemoved {
8292            record_id: record_id.clone(),
8293        });
8294        Ok(())
8295    }
8296
8297    fn publish_record(&self, record: ManagedSessionRecord) {
8298        self.publish(NodeEvent::SessionRecordUpserted { record });
8299    }
8300
8301    fn mark_record_error(
8302        &self,
8303        record_id: &SessionRecordId,
8304        message: &str,
8305    ) -> Result<(), NodeFailure> {
8306        let transaction = self
8307            .state_transaction
8308            .lock()
8309            .unwrap_or_else(|poisoned| poisoned.into_inner());
8310        let previous = {
8311            let mut records = self
8312                .session_records
8313                .lock()
8314                .unwrap_or_else(|poisoned| poisoned.into_inner());
8315            let Some(record) = records.records.get_mut(record_id) else {
8316                return Ok(());
8317            };
8318            let previous = record.clone();
8319            record.state = ManagedSessionState::Unavailable;
8320            record.last_error = Some(session_registry::sanitized_record_error_summary(message));
8321            record.updated_at_unix_ms = unix_time_ms();
8322            previous
8323        };
8324        if let Err(error) = self.persist_state_locked() {
8325            self.session_records
8326                .lock()
8327                .unwrap_or_else(|poisoned| poisoned.into_inner())
8328                .records
8329                .insert(record_id.clone(), previous);
8330            return Err(persistence_failure(error));
8331        }
8332        let record = self.record(record_id)?;
8333        drop(transaction);
8334        self.publish_record(record);
8335        Ok(())
8336    }
8337
8338    fn rename_session_record(
8339        &self,
8340        record_id: &SessionRecordId,
8341        display_name: String,
8342    ) -> Result<ManagedSessionRecord, NodeFailure> {
8343        validate_display_name(&display_name)
8344            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8345        let _transaction = self
8346            .state_transaction
8347            .lock()
8348            .unwrap_or_else(|poisoned| poisoned.into_inner());
8349        let previous = {
8350            let mut records = self
8351                .session_records
8352                .lock()
8353                .unwrap_or_else(|poisoned| poisoned.into_inner());
8354            let record = records.records.get_mut(record_id).ok_or_else(|| {
8355                failure(
8356                    NodeFailureCode::UnknownSessionRecord,
8357                    "managed session record does not exist",
8358                )
8359            })?;
8360            let previous = record.clone();
8361            record.display_name = display_name;
8362            record.updated_at_unix_ms = unix_time_ms();
8363            previous
8364        };
8365        if let Err(error) = self.persist_state_locked() {
8366            self.session_records
8367                .lock()
8368                .unwrap_or_else(|poisoned| poisoned.into_inner())
8369                .records
8370                .insert(record_id.clone(), previous);
8371            return Err(persistence_failure(error));
8372        }
8373        let updated = self.record(record_id)?;
8374        self.publish_record(updated.clone());
8375        Ok(updated)
8376    }
8377
8378    fn set_session_task(
8379        &self,
8380        record_id: &SessionRecordId,
8381        expected_revision: u64,
8382        target: SessionTaskTargetV1,
8383    ) -> Result<ManagedSessionRecord, NodeFailure> {
8384        let transaction = self
8385            .state_transaction
8386            .lock()
8387            .unwrap_or_else(|poisoned| poisoned.into_inner());
8388        let current = self.record(record_id)?;
8389        let current_task_id = current
8390            .task_binding
8391            .as_ref()
8392            .and_then(|binding| binding.task_id.as_ref());
8393        let current_revision = current
8394            .task_binding
8395            .as_ref()
8396            .map_or(0, |binding| binding.revision);
8397        if expected_revision != current_revision {
8398            return Err(failure(
8399                NodeFailureCode::SessionRecordConflict,
8400                "managed session task binding revision changed",
8401            ));
8402        }
8403        let exact = match &target {
8404            SessionTaskTargetV1::Existing { task_id } => current_task_id == Some(task_id),
8405            SessionTaskTargetV1::Clear => current_task_id.is_none(),
8406            SessionTaskTargetV1::New => false,
8407        };
8408        if exact {
8409            return Ok(current);
8410        }
8411        let revision = current_revision.checked_add(1).ok_or_else(|| {
8412            failure(
8413                NodeFailureCode::SessionRecordConflict,
8414                "managed session task binding revision is exhausted",
8415            )
8416        })?;
8417        let task_id = match target {
8418            SessionTaskTargetV1::New => Some(self.allocate_task_id()?),
8419            SessionTaskTargetV1::Existing { task_id } => Some(task_id),
8420            SessionTaskTargetV1::Clear => None,
8421        };
8422        let changed_at_unix_ms = unix_time_ms()
8423            .max(current.updated_at_unix_ms.checked_add(1).ok_or_else(|| {
8424                failure(
8425                    NodeFailureCode::SessionRecordConflict,
8426                    "managed session record timestamp is exhausted",
8427                )
8428            })?);
8429        let updated = {
8430            let mut records = self
8431                .session_records
8432                .lock()
8433                .unwrap_or_else(|poisoned| poisoned.into_inner());
8434            let record = records.records.get_mut(record_id).ok_or_else(|| {
8435                failure(
8436                    NodeFailureCode::UnknownSessionRecord,
8437                    "managed session record does not exist",
8438                )
8439            })?;
8440            record.task_binding = Some(SessionTaskBindingV1 {
8441                revision,
8442                task_id,
8443                changed_at_unix_ms,
8444            });
8445            record.updated_at_unix_ms = changed_at_unix_ms;
8446            record.clone()
8447        };
8448        if let Err(error) = self.persist_state_locked() {
8449            self.session_records
8450                .lock()
8451                .unwrap_or_else(|poisoned| poisoned.into_inner())
8452                .records
8453                .insert(record_id.clone(), current);
8454            return Err(persistence_failure(error));
8455        }
8456        drop(transaction);
8457        self.publish_record(updated.clone());
8458        Ok(updated)
8459    }
8460
8461    fn index_provider_session(
8462        &self,
8463        workspace_id: WorkspaceId,
8464        provider: AgentId,
8465        identity: ProviderSessionIdentity,
8466        display_name: String,
8467    ) -> Result<ManagedSessionRecord, NodeFailure> {
8468        self.index_provider_session_with_policy(
8469            workspace_id,
8470            provider,
8471            identity,
8472            display_name,
8473            false,
8474        )
8475    }
8476
8477    fn index_provider_session_with_policy(
8478        &self,
8479        workspace_id: WorkspaceId,
8480        provider: AgentId,
8481        identity: ProviderSessionIdentity,
8482        display_name: String,
8483        allow_validated_transcript_path: bool,
8484    ) -> Result<ManagedSessionRecord, NodeFailure> {
8485        validate_display_name(&display_name)
8486            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8487        identity
8488            .validate()
8489            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8490        if identity.transcript_path.is_some() && !allow_validated_transcript_path {
8491            return Err(failure(
8492                NodeFailureCode::InvalidRequest,
8493                "provider session reference index v1 does not accept transcript paths",
8494            ));
8495        }
8496        let canonical_root = self.workspace_root(&workspace_id)?;
8497        if !self.enabled_providers.contains(&provider) {
8498            return Err(failure(
8499                NodeFailureCode::InvalidRequest,
8500                "provider is not enabled on this node",
8501            ));
8502        }
8503
8504        let transaction = self
8505            .state_transaction
8506            .lock()
8507            .unwrap_or_else(|poisoned| poisoned.into_inner());
8508        {
8509            let records = self
8510                .session_records
8511                .lock()
8512                .unwrap_or_else(|poisoned| poisoned.into_inner());
8513            if let Some(existing) = records.records.values().find(|candidate| {
8514                candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8515                    candidate_identity.key == identity.key && candidate_identity.id == identity.id
8516                })
8517            }) {
8518                if existing.provider == provider && existing.workspace_id == workspace_id {
8519                    return Ok(existing.clone());
8520                }
8521                return Err(failure(
8522                    NodeFailureCode::SessionRecordConflict,
8523                    "provider session reference conflicts with another provider or workspace",
8524                ));
8525            }
8526            if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
8527                return Err(failure(
8528                    NodeFailureCode::BackendBusy,
8529                    "managed session record capacity is full; forget an inactive record first",
8530                ));
8531            }
8532        }
8533
8534        let now = unix_time_ms();
8535        let record = ManagedSessionRecord {
8536            record_id: self.allocate_record_id()?,
8537            display_name,
8538            provider,
8539            mode: SessionMode::Pty,
8540            state: ManagedSessionState::Dormant,
8541            workspace_id,
8542            canonical_root: opaque_windows_path(canonical_root),
8543            provider_session: Some(identity),
8544            active_session: None,
8545            environment_profile: None,
8546            bundle: None,
8547            context_id: None,
8548            context: None,
8549            exported_context: None,
8550            task_binding: None,
8551            created_at_unix_ms: now,
8552            updated_at_unix_ms: now,
8553            last_error: None,
8554        };
8555        self.insert_record(record.clone())?;
8556        if let Err(error) = self.persist_state_locked() {
8557            self.remove_record_memory(&record.record_id);
8558            return Err(persistence_failure(error));
8559        }
8560        drop(transaction);
8561        self.publish_record(record.clone());
8562        Ok(record)
8563    }
8564
8565    fn bound_address_for_record(&self, record_id: &SessionRecordId) -> Option<SessionAddress> {
8566        self.session_bindings
8567            .lock()
8568            .unwrap_or_else(|poisoned| poisoned.into_inner())
8569            .iter()
8570            .find_map(|(instance_id, binding)| {
8571                (binding.record_id.as_ref() == Some(record_id)).then(|| SessionAddress {
8572                    workspace_id: binding.workspace_id.clone(),
8573                    session: SessionKey {
8574                        instance_id: *instance_id,
8575                        generation: binding.generation,
8576                    },
8577                })
8578            })
8579    }
8580
8581    async fn forget_session_record(
8582        &self,
8583        record_id: &SessionRecordId,
8584    ) -> Result<(), NodeFailure> {
8585        let record = self.record(record_id)?;
8586        if record.active_session.is_some()
8587            || matches!(
8588                record.state,
8589                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8590            )
8591        {
8592            return Err(failure(
8593                NodeFailureCode::SessionRecordBusy,
8594                "stop the managed session before forgetting it",
8595            ));
8596        }
8597        if let Some(address) = self.bound_address_for_record(record_id) {
8598            self.remove_session(&address).await?;
8599        }
8600        self.remove_managed_session_record(record_id, &record.workspace_id).await
8601    }
8602
8603    /// Removes one managed session record from durable state and notifies
8604    /// every listener -- the shared transaction body behind both
8605    /// `forget_session_record`'s operator-driven path (busy-checked above
8606    /// it, and clears any still-live session binding before calling in
8607    /// here) and the retention sweep (`retire_unavailable_session_records`,
8608    /// which only ever selects `ManagedSessionState::Unavailable` records
8609    /// that are already known never-busy and never bound, so it calls
8610    /// straight into this with neither check). Cleans a managed-worktree
8611    /// lease if the record held one, removes the record's environment
8612    /// materialization (or, absent one, removes it from memory directly),
8613    /// persists under `state_transaction` with rollback on persist failure,
8614    /// then publishes `NodeEvent::SessionRecordRemoved` so c2's own
8615    /// legacy-provider bookkeeping (`record_is_legacy`) stays in sync
8616    /// exactly as it does for an operator-driven forget.
8617    async fn remove_managed_session_record(
8618        &self,
8619        record_id: &SessionRecordId,
8620        workspace_id: &WorkspaceId,
8621    ) -> Result<(), NodeFailure> {
8622        let managed_lease_id = self.managed_worktrees.lock()
8623            .unwrap_or_else(|poisoned| poisoned.into_inner())
8624            .lease_for_workspace(workspace_id);
8625        if !self.destroy_record_materialization(record_id)? {
8626            let transaction = self
8627                .state_transaction
8628                .lock()
8629                .unwrap_or_else(|poisoned| poisoned.into_inner());
8630            let removed = self.remove_record_memory(record_id).ok_or_else(|| {
8631                failure(
8632                    NodeFailureCode::UnknownSessionRecord,
8633                    "managed session record does not exist",
8634                )
8635            })?;
8636            if let Err(error) = self.persist_state_locked() {
8637                let _ = self.insert_record(removed);
8638                return Err(persistence_failure(error));
8639            }
8640            drop(transaction);
8641        }
8642        self.publish(NodeEvent::SessionRecordRemoved {
8643            record_id: record_id.clone(),
8644        });
8645        if let Some(lease_id) = managed_lease_id {
8646            if let Some(snapshot) = self.managed_worktrees.lock()
8647                .unwrap_or_else(|poisoned| poisoned.into_inner())
8648                .get(&lease_id)
8649                .map(ManagedWorktreeLeaseRecord::snapshot)
8650            {
8651                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
8652            }
8653            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
8654        }
8655        Ok(())
8656    }
8657
8658    /// Retires `ManagedSessionState::Unavailable` records against the
8659    /// node's configured `session_record_retention` age/keep-N policy.
8660    /// Never touches `Live`, `IdentityPending`, or `Dormant` records --
8661    /// `Dormant` is resumable and folding it into this sweep would delete
8662    /// records a caller can still act on. Both policy fields default to
8663    /// `0` (disabled); when both are `0` this is a no-op. When both are
8664    /// set, a record is retired only if it fails BOTH tests (older than
8665    /// `age_ms` AND beyond the newest `keep_per_workspace` in its
8666    /// workspace); when only one is set, that one alone decides. Called
8667    /// once at startup (alongside `reconcile_managed_worktrees`) and then
8668    /// on a `SESSION_RECORD_RETENTION_SWEEP_PERIOD` cadence from the drive
8669    /// loop -- see both call sites.
8670    async fn retire_unavailable_session_records(&self) {
8671        let policy = self.session_record_retention;
8672        let age_enabled = policy.age_ms > 0;
8673        let keep_enabled = policy.keep_per_workspace > 0;
8674        if !age_enabled && !keep_enabled {
8675            return;
8676        }
8677        let now = unix_time_ms();
8678        let candidates: Vec<(SessionRecordId, WorkspaceId, AgentId, u64)> = {
8679            self.session_records
8680                .lock()
8681                .unwrap_or_else(|poisoned| poisoned.into_inner())
8682                .records
8683                .values()
8684                .filter(|record| record.state == ManagedSessionState::Unavailable)
8685                .map(|record| {
8686                    (
8687                        record.record_id.clone(),
8688                        record.workspace_id.clone(),
8689                        record.provider.clone(),
8690                        record.updated_at_unix_ms,
8691                    )
8692                })
8693                .collect()
8694        };
8695        if candidates.is_empty() {
8696            return;
8697        }
8698        let mut by_workspace: BTreeMap<WorkspaceId, Vec<(SessionRecordId, AgentId, u64)>> =
8699            BTreeMap::new();
8700        for (record_id, workspace_id, provider, updated_at_unix_ms) in candidates {
8701            by_workspace
8702                .entry(workspace_id)
8703                .or_default()
8704                .push((record_id, provider, updated_at_unix_ms));
8705        }
8706        let reason = if age_enabled && keep_enabled {
8707            "age+keep-n"
8708        } else if age_enabled {
8709            "age"
8710        } else {
8711            "keep-n"
8712        };
8713        let mut retired: u64 = 0;
8714        for (workspace_id, mut records) in by_workspace {
8715            // Newest `updated_at_unix_ms` first, so index `>=
8716            // keep_per_workspace` names exactly "beyond the newest N".
8717            records.sort_by(|left, right| right.2.cmp(&left.2));
8718            for (index, (record_id, provider, updated_at_unix_ms)) in
8719                records.into_iter().enumerate()
8720            {
8721                let record_age_ms = now.saturating_sub(updated_at_unix_ms);
8722                let age_hit = age_enabled && record_age_ms >= policy.age_ms;
8723                let keep_hit = keep_enabled && index >= policy.keep_per_workspace as usize;
8724                let should_retire = if age_enabled && keep_enabled {
8725                    age_hit && keep_hit
8726                } else {
8727                    age_hit || keep_hit
8728                };
8729                if !should_retire {
8730                    continue;
8731                }
8732                match self.remove_managed_session_record(&record_id, &workspace_id).await {
8733                    Ok(()) => {
8734                        self.retired_records_total.fetch_add(1, Ordering::Relaxed);
8735                        retired += 1;
8736                        tracing::info!(
8737                            node_id = %self.node_id,
8738                            record_id = %record_id,
8739                            workspace_id = %workspace_id,
8740                            provider = %provider,
8741                            age_ms = record_age_ms,
8742                            reason,
8743                            "managed session record retired"
8744                        );
8745                    }
8746                    Err(error) => tracing::warn!(
8747                        node_id = %self.node_id,
8748                        record_id = %record_id,
8749                        workspace_id = %workspace_id,
8750                        code = ?error.code,
8751                        cause = %error.message,
8752                        "session record retention sweep failed to retire a record"
8753                    ),
8754                }
8755            }
8756        }
8757        if retired > 0 {
8758            let remaining_unavailable = self
8759                .session_records
8760                .lock()
8761                .unwrap_or_else(|poisoned| poisoned.into_inner())
8762                .records
8763                .values()
8764                .filter(|record| record.state == ManagedSessionState::Unavailable)
8765                .count();
8766            tracing::info!(
8767                node_id = %self.node_id,
8768                retired,
8769                remaining_unavailable,
8770                "session record retention sweep completed"
8771            );
8772        }
8773    }
8774
8775    async fn resume_session_record(
8776        &self,
8777        record_id: &SessionRecordId,
8778        terminal_size: gate4agent_types::TerminalSize,
8779        initial_prompt: Option<String>,
8780    ) -> Result<(ManagedSessionRecord, SessionAddress), NodeFailure> {
8781        let mut record = self.record(record_id)?;
8782        // claude's ACP agent has no session/load: an ACP claude conversation whose transcript
8783        // is on disk is resumed as a PTY `claude --resume <id>` of that same transcript.
8784        if record.mode == SessionMode::Acp
8785            && record.provider.as_str() == "claude"
8786            && record.provider_session.as_ref().is_some_and(|i| provider_session_exists("claude", &i.id))
8787        {
8788            record.mode = SessionMode::Pty;
8789            if let Some(r) = self.session_records.lock().unwrap_or_else(|p| p.into_inner()).records.get_mut(record_id) {
8790                r.mode = SessionMode::Pty;
8791            }
8792            tracing::info!(record = %record_id, "acp claude record resumed over pty (--resume of its transcript)");
8793        }
8794        if record.active_session.is_some()
8795            || matches!(
8796                record.state,
8797                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8798            )
8799        {
8800            return Err(failure(
8801                NodeFailureCode::SessionRecordBusy,
8802                "managed session already has a live runtime binding",
8803            ));
8804        }
8805        let identity = record.provider_session.clone().ok_or_else(|| {
8806            failure(
8807                NodeFailureCode::SessionRecordNotResumable,
8808                "managed session has no verified provider session identity",
8809            )
8810        })?;
8811        let working_directory = self.workspace_root(&record.workspace_id)?;
8812        if !platform::roots_equal(
8813            &working_directory,
8814            windows_path_text(&record.canonical_root),
8815        ) {
8816            return Err(failure(
8817                NodeFailureCode::SessionRecordConflict,
8818                "managed session workspace root changed; refusing to resume in another directory",
8819            ));
8820        }
8821        {
8822            let records = self
8823                .session_records
8824                .lock()
8825                .unwrap_or_else(|poisoned| poisoned.into_inner());
8826            if records.records.values().any(|candidate| {
8827                candidate.record_id != *record_id
8828                    && candidate.provider_session.as_ref().is_some_and(|candidate_identity| {
8829                        session_registry::same_provider_session(
8830                            &candidate.provider,
8831                            candidate_identity,
8832                            &record.provider,
8833                            &identity,
8834                        )
8835                    })
8836                    && (candidate.active_session.is_some()
8837                        || matches!(
8838                            candidate.state,
8839                            ManagedSessionState::Live | ManagedSessionState::IdentityPending
8840                        ))
8841            }) {
8842                return Err(failure(
8843                    NodeFailureCode::SessionRecordConflict,
8844                    "provider session is already active through another managed record",
8845                ));
8846            }
8847        }
8848        let runtime_requirement = if initial_prompt.is_some() {
8849            ProviderRuntimeRequirement::ResumeWithPrompt
8850        } else {
8851            ProviderRuntimeRequirement::Resume
8852        };
8853        let runtime_policy = self.admit_provider_runtime(
8854            &record.provider,
8855            runtime_requirement,
8856        )
8857        .await?;
8858        // The resumed runtime runs in the session folder its record was named after.
8859        let working_directory = match self.m4a_accounts.as_ref() {
8860            Some(_) => {
8861                let dir = std::path::Path::new(&working_directory).join(".hatchery-sessions").join(&record.display_name);
8862                if !record.display_name.contains(' ') && dir.is_dir() { dir.to_string_lossy().into_owned() } else { working_directory }
8863            }
8864            None => working_directory,
8865        };
8866        let request = ResumeLaunchRequest {
8867            working_directory,
8868            terminal_size,
8869            initial_prompt,
8870        };
8871        request
8872            .validate()
8873            .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
8874        self.ensure_binding_capacity()?;
8875        let resolved_materialization = match self.resolve_record_materialization(
8876            record_id,
8877            &record.provider,
8878            record.mode,
8879            record.environment_profile.as_ref(),
8880            record.bundle.as_ref(),
8881            record.context.as_ref(),
8882        ) {
8883            Ok(materialization) => materialization,
8884            Err(error) => {
8885                let label = match error.code {
8886                    NodeFailureCode::UnknownBundle => "bundle-unavailable",
8887                    NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
8888                    NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
8889                    _ => "environment-profile-unavailable",
8890                };
8891                self.mark_record_error(record_id, label)?;
8892                return Err(error);
8893            }
8894        };
8895        let (materialization_id, environment_overlay) = match resolved_materialization {
8896            Some((id, overlay)) => (Some(id), overlay),
8897            None => (None, None),
8898        };
8899        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
8900        // Resumed runtime: same auto-permit flags and launch options (model / effort) as a spawn.
8901        let environment_overlay = {
8902            let mut args: Vec<OsString> = Vec::new();
8903            if let Ok(cwd) = self.workspace_root(&record.workspace_id) {
8904                let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
8905                if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
8906                crate::auto_permit::prepare(record.provider.as_str(), &homes, Some(std::path::Path::new(&cwd)));
8907            }
8908            args.extend(crate::auto_permit::launch_args(record.provider.as_str(), record.mode));
8909            let options = SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten();
8910            if record.mode == SessionMode::Pty && record.provider.as_str() == "claude" {
8911                if let Some(sel) = options.as_ref() {
8912                    args.push("--model".into()); args.push(sel.model.clone().into());
8913                    if let Some(effort) = sel.values.get("effort").and_then(|v| v.as_str()) {
8914                        args.push("--effort".into()); args.push(effort.to_owned().into());
8915                    }
8916                }
8917            }
8918            if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) && crate::pty_resume::supported(record.provider.as_str()) {
8919                if let (Some(file), Some(identity)) = (self.session_resume_journal(), record.provider_session.as_ref()) {
8920                    crate::pty_resume::upsert(&file, crate::pty_resume::Entry {
8921                        instance_id: instance_id.0,
8922                        provider: record.provider.as_str().to_owned(),
8923                        workspace_id: record.workspace_id.as_str().to_owned(),
8924                        provider_session_id: identity.id.clone(),
8925                        session_key: self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&instance_id).cloned().unwrap_or_default(),
8926                        cwd: String::new(),
8927                        acp: record.mode == SessionMode::Acp,
8928                        options,
8929                        cols: 0,
8930                        rows: 0,
8931                        origin: SPAWN_ORIGIN.try_with(|o| o.clone()).ok().flatten(),
8932                        parent_session_id: SPAWN_PARENT.try_with(|o| o.clone()).ok().flatten(),
8933                    });
8934                }
8935            }
8936            overlay_with_extra_args(&record.provider, record.mode, environment_overlay, args)?
8937        };
8938        // The resumed runtime keeps the m4a account of the runtime it replaces.
8939        let environment_overlay = match (self.m4a_accounts.as_ref(), crate::m4a_accounts::m4a_provider(record.provider.as_str())) {
8940            (Some(accounts), Some(m4a_provider)) if matches!(record.mode, SessionMode::Pty | SessionMode::Acp) => {
8941                if let Some(old) = self.bound_address_for_record(record_id) {
8942                    if let Some(key) = self.m4a_keys.lock().unwrap_or_else(|p| p.into_inner()).get(&old.session.instance_id).cloned() {
8943                        self.m4a_reuse_keys.lock().unwrap_or_else(|p| p.into_inner()).insert(instance_id, key);
8944                    }
8945                }
8946                let session_key = self.m4a_session_key(instance_id);
8947                self.m4a_session_overlay(accounts, m4a_provider, &record.provider, instance_id, environment_overlay, &session_key, record.mode)?
8948            }
8949            _ => environment_overlay,
8950        };
8951        let mut environment_selection = match SpawnPreparer::select_environment(self, 
8952            instance_id,
8953            &record.provider,
8954            record.mode,
8955            record.environment_profile.as_ref(),
8956        ) {
8957            Ok(selection) => selection,
8958            Err(error) => {
8959                self.mark_record_error(record_id, "environment-profile-unavailable")?;
8960                return Err(error);
8961            }
8962        };
8963        let mut instance_overlay = environment_overlay
8964            .map(|overlay| SpawnPreparer::install_launch_overlay(self, instance_id, overlay))
8965            .transpose()?
8966            .flatten();
8967        if let Some(stale_address) = self.bound_address_for_record(record_id) {
8968            self.remove_session(&stale_address).await?;
8969        }
8970        let address = SessionAddress {
8971            workspace_id: record.workspace_id.clone(),
8972            session: SessionKey {
8973                instance_id,
8974                generation: SessionGeneration(1),
8975            },
8976        };
8977        let transaction = self
8978            .state_transaction
8979            .lock()
8980            .unwrap_or_else(|poisoned| poisoned.into_inner());
8981        let current = self.record(record_id)?;
8982        if current.active_session.is_some()
8983            || matches!(
8984                current.state,
8985                ManagedSessionState::Live | ManagedSessionState::IdentityPending
8986            )
8987        {
8988            return Err(failure(
8989                NodeFailureCode::SessionRecordBusy,
8990                "managed session became active while resume was being prepared",
8991            ));
8992        }
8993        self.bind_managed_session_with_materialization(
8994            &address,
8995            record_id.clone(),
8996            runtime_policy,
8997            record.environment_profile.clone(),
8998            record.bundle.clone(),
8999            record.context.clone(),
9000            materialization_id,
9001        );
9002        if let Some(selection) = environment_selection.take() {
9003            selection.retain();
9004        }
9005        if let Some(overlay) = instance_overlay.take() {
9006            overlay.retain();
9007        }
9008        drop(transaction);
9009
9010        let transport = match record.mode {
9011            SessionMode::Pty => TransportKind::Pty,
9012            SessionMode::Inline => TransportKind::Pipe,
9013            SessionMode::Acp => TransportKind::Acp,
9014        };
9015        let agent_id = record.provider.clone();
9016        let dispatch_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
9017        if let Err(error) = self
9018            .dispatch_bounded(
9019                ControlCommand::Register {
9020                    instance_id,
9021                    agent_id,
9022                    transport,
9023                },
9024                dispatch_timeout,
9025            )
9026            .await
9027        {
9028            if let Some(binding) = self.remove_binding(&address) {
9029                SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
9030            }
9031            return Err(error);
9032        }
9033        let command = self.prepare_command(ControlCommand::Resume {
9034            instance_id,
9035            target: ResumeTarget::ProviderSession { identity },
9036            runtime_policy,
9037            request,
9038        });
9039        let started_after = self.current_sequence();
9040        if let Err(error) = self.arm_resume(&address, command.id, runtime_policy) {
9041            self.rollback_spawn(&address, dispatch_timeout).await?;
9042            return Err(error);
9043        }
9044        if let Err(error) = self.dispatch_envelope(command) {
9045            self.clear_armed_resume(&address);
9046            self.rollback_spawn(&address, dispatch_timeout).await?;
9047            return Err(error);
9048        }
9049        match self
9050            .wait_for_record_resume(&address, started_after)
9051            .await
9052        {
9053            Ok(settled_address) => {
9054                let managed_lease_id = {
9055                    self.managed_worktrees.lock()
9056                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9057                        .lease_for_workspace(&record.workspace_id)
9058                };
9059                if let Some(lease_id) = managed_lease_id {
9060                    {
9061                        let mut bindings = self.session_bindings.lock()
9062                            .unwrap_or_else(|poisoned| poisoned.into_inner());
9063                        let binding = bindings.get_mut(&settled_address.session.instance_id)
9064                            .ok_or_else(|| failure(
9065                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9066                                "resumed managed session lost its exact binding",
9067                            ))?;
9068                        if binding.generation != settled_address.session.generation {
9069                            return Err(failure(
9070                                NodeFailureCode::ManagedWorktreeRecoveryRequired,
9071                                "resumed managed session generation diverged",
9072                            ));
9073                        }
9074                        binding.managed_worktree_lease_id = Some(lease_id.clone());
9075                    }
9076                    let snapshot = self.managed_worktrees.lock()
9077                        .unwrap_or_else(|poisoned| poisoned.into_inner())
9078                        .bind_session(
9079                            &lease_id,
9080                            ManagedWorktreeSessionHolder {
9081                                incarnation_id: self.incarnation_id,
9082                                instance_id: settled_address.session.instance_id,
9083                                generation: settled_address.session.generation,
9084                            },
9085                            Some(record_id.clone()),
9086                            unix_time_ms(),
9087                        ).map_err(|message| failure(
9088                            NodeFailureCode::ManagedWorktreeRecoveryRequired,
9089                            &message,
9090                        ))?;
9091                    if let Err(error) = self.persist_state() {
9092                        let _ = self.remove_session(&settled_address).await;
9093                        return Err(persistence_failure(error));
9094                    }
9095                    self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
9096                }
9097                Ok((self.record(record_id)?, settled_address))
9098            }
9099            Err(error) => {
9100                if matches!(
9101                    error.code,
9102                    NodeFailureCode::SessionRecordNotResumable
9103                        | NodeFailureCode::BackendOperationFailed
9104                ) {
9105                    if let Some(bound) = self.bound_address_for_record(record_id) {
9106                        let _ = self.remove_session(&bound).await;
9107                    }
9108                }
9109                Err(error)
9110            }
9111        }
9112    }
9113
9114    async fn wait_for_record_resume(
9115        &self,
9116        initial_address: &SessionAddress,
9117        after_sequence: u64,
9118    ) -> Result<SessionAddress, NodeFailure> {
9119        let deadline = Instant::now()
9120            + Duration::from_millis(MANAGED_RESUME_SETTLE_TIMEOUT_MS);
9121        let mut scan_after = after_sequence;
9122        loop {
9123            let events = self
9124                .history
9125                .lock()
9126                .unwrap_or_else(|poisoned| poisoned.into_inner())
9127                .events
9128                .iter()
9129                .filter(|event| event.sequence > scan_after)
9130                .cloned()
9131                .collect::<Vec<_>>();
9132            for envelope in events {
9133                scan_after = scan_after.max(envelope.sequence);
9134                let NodeEvent::Control { address, event } = envelope.event else {
9135                    continue;
9136                };
9137                if address.session.instance_id != initial_address.session.instance_id {
9138                    continue;
9139                }
9140                match event.event {
9141                    ControlEventKind::Resumed { .. } => return Ok(address),
9142                    ControlEventKind::ResumeDenied { reason } => {
9143                        return Err(failure(
9144                            NodeFailureCode::SessionRecordNotResumable,
9145                            &reason,
9146                        ));
9147                    }
9148                    ControlEventKind::ResumeFailed { message }
9149                    | ControlEventKind::CommandRejected { message } => {
9150                        return Err(failure(
9151                            NodeFailureCode::BackendOperationFailed,
9152                            &message,
9153                        ));
9154                    }
9155                    _ => {}
9156                }
9157            }
9158            if Instant::now() >= deadline {
9159                return Err(failure(
9160                    NodeFailureCode::BackendBusy,
9161                    "managed resume did not settle before the bounded deadline",
9162                ));
9163            }
9164            sleep(Duration::from_millis(5)).await;
9165        }
9166    }
9167
9168    fn workspace_root(&self, workspace_id: &WorkspaceId) -> Result<String, NodeFailure> {
9169        self.workspaces
9170            .read()
9171            .unwrap_or_else(|poisoned| poisoned.into_inner())
9172            .get(workspace_id)
9173            .cloned()
9174            .ok_or_else(|| failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"))
9175    }
9176
9177    fn workspace_roots(&self) -> Vec<PathBuf> {
9178        self.workspaces
9179            .read()
9180            .unwrap_or_else(|poisoned| poisoned.into_inner())
9181            .values()
9182            .map(PathBuf::from)
9183            .collect()
9184    }
9185
9186    async fn inspect_workspace(
9187        &self,
9188        workspace_id: WorkspaceId,
9189    ) -> Result<WorkspaceInspection, NodeFailure> {
9190        let _permit = self
9191            .inspection_slots
9192            .clone()
9193            .try_acquire_owned()
9194            .map_err(|_| {
9195                tracing::warn!(
9196                    workspace_id = %workspace_id,
9197                    "workspace inspection rejected: inspection capacity is busy",
9198                );
9199                failure(
9200                    NodeFailureCode::BackendBusy,
9201                    "workspace inspection capacity is busy",
9202                )
9203            })?;
9204        let canonical_root = self.workspace_root(&workspace_id).map_err(|error| {
9205            tracing::warn!(
9206                workspace_id = %workspace_id,
9207                code = ?error.code,
9208                "workspace inspection rejected: workspace is not registered",
9209            );
9210            error
9211        })?;
9212        let tree_root = canonical_root.clone();
9213        let started_at = Instant::now();
9214        let time_budget_ms = workspace_inspection_time_budget_ms();
9215        let entry_cap = workspace_inspection_entry_cap();
9216        let deadline = started_at + Duration::from_millis(time_budget_ms);
9217        let (entries, tree_truncated, walk_budget) = tokio::task::spawn_blocking(move || {
9218            collect_workspace_entries(Path::new(&tree_root), deadline, entry_cap)
9219        })
9220        .await
9221        .map_err(|error| {
9222            tracing::warn!(
9223                workspace_id = %workspace_id,
9224                error = %error,
9225                "workspace inspection rejected: walk task failed",
9226            );
9227            failure(
9228                NodeFailureCode::BackendOperationFailed,
9229                &format!("workspace inspection task failed: {error}"),
9230            )
9231        })?;
9232        let (mut git, git_time_budget_exceeded) =
9233            inspect_git_workspace(&canonical_root, deadline).await;
9234        if !git.worktrees.is_empty() {
9235            let registered = self
9236                .workspaces
9237                .read()
9238                .unwrap_or_else(|poisoned| poisoned.into_inner())
9239                .iter()
9240                .map(|(workspace_id, root)| (workspace_id.clone(), root.clone()))
9241                .collect::<Vec<_>>();
9242            for worktree in &mut git.worktrees {
9243                worktree.workspace_id = registered
9244                    .iter()
9245                    .find(|(_, root)| {
9246                        worktree_paths_equal(root, windows_path_text(&worktree.path))
9247                    })
9248                    .map(|(workspace_id, _)| workspace_id.clone());
9249            }
9250        }
9251        if git.is_repository {
9252            git.managed_worktree = self
9253                .managed_worktrees
9254                .lock()
9255                .unwrap_or_else(|poisoned| poisoned.into_inner())
9256                .git_scope(&workspace_id, git.branch.as_deref());
9257        }
9258        let truncation = workspace_inspection_truncation(
9259            &walk_budget,
9260            git_time_budget_exceeded,
9261            started_at.elapsed(),
9262        );
9263        if let Some(truncation) = &truncation {
9264            tracing::info!(
9265                workspace_id = %workspace_id,
9266                walk_time_budget_exceeded = truncation.walk_time_budget_exceeded,
9267                walk_entry_cap_exceeded = truncation.walk_entry_cap_exceeded,
9268                git_time_budget_exceeded = truncation.git_time_budget_exceeded,
9269                entries_visited = truncation.entries_visited,
9270                elapsed_ms = truncation.elapsed_ms,
9271                "workspace inspection truncated by its inner budget",
9272            );
9273        }
9274        Ok(WorkspaceInspection {
9275            workspace_id,
9276            entries,
9277            tree_truncated,
9278            git,
9279            truncation,
9280        })
9281    }
9282
9283    async fn browse_host_directories(
9284        &self,
9285        directory: Option<OpaqueHostPath>,
9286        after: Option<OpaqueHostPath>,
9287    ) -> Result<HostDirectoryListing, NodeFailure> {
9288        let permit = self
9289            .inspection_slots
9290            .clone()
9291            .try_acquire_owned()
9292            .map_err(|_| {
9293                failure(
9294                    NodeFailureCode::BackendBusy,
9295                    "host directory browse capacity is busy",
9296                )
9297            })?;
9298        let task = tokio::task::spawn_blocking(move || {
9299            let _permit = permit;
9300            browse_host_directories(directory, after)
9301        });
9302        timeout(Duration::from_millis(HOST_DIRECTORY_BROWSE_TIMEOUT_MS), task)
9303            .await
9304            .map_err(|_| {
9305                failure(
9306                    NodeFailureCode::HostDirectoryReadTimedOut,
9307                    "host directory browse exceeded its bounded deadline",
9308                )
9309            })?
9310            .map_err(|_| {
9311                failure(
9312                    NodeFailureCode::HostDirectoryReadFailed,
9313                    "host directory browse task failed",
9314                )
9315            })?
9316            .map_err(host_directory_failure)
9317    }
9318
9319    async fn read_workspace_file(
9320        &self,
9321        workspace_id: WorkspaceId,
9322        path: RepositoryPath,
9323    ) -> Result<WorkspaceFileRead, NodeFailure> {
9324        let canonical_root = self.workspace_root(&workspace_id)?;
9325        let permit = self
9326            .inspection_slots
9327            .clone()
9328            .try_acquire_owned()
9329            .map_err(|_| {
9330                failure(
9331                    NodeFailureCode::BackendBusy,
9332                    "workspace file read capacity is busy",
9333                )
9334            })?;
9335        let path_to_read = path.clone();
9336        let task = tokio::task::spawn_blocking(move || {
9337            let _permit = permit;
9338            read_workspace_file_from_disk(Path::new(&canonical_root), &path_to_read)
9339        });
9340        let content = timeout(
9341            Duration::from_millis(WORKSPACE_FILE_READ_TIMEOUT_MS),
9342            task,
9343        )
9344        .await
9345        .map_err(|_| {
9346            failure(
9347                NodeFailureCode::RepositoryFileReadTimedOut,
9348                "repository file read exceeded its bounded deadline",
9349            )
9350        })?
9351        .map_err(|_| {
9352            failure(
9353                NodeFailureCode::RepositoryFileReadFailed,
9354                "repository file read task failed",
9355            )
9356        })?
9357        .map_err(workspace_file_failure)?;
9358        let content = match content {
9359            WorkspaceFileBytes::Utf8(text) => {
9360                let revision = Some(workspace_file_revision(text.as_bytes()));
9361                return Ok(WorkspaceFileRead {
9362                    workspace_id,
9363                    path,
9364                    content: WorkspaceFileContent::Utf8 {
9365                        byte_len: u32::try_from(text.len())
9366                            .expect("bounded workspace text length must fit u32"),
9367                        text,
9368                    },
9369                    revision,
9370                });
9371            }
9372            WorkspaceFileBytes::NonUtf8 { byte_length } => {
9373                WorkspaceFileContent::NonUtf8 {
9374                    byte_len: u32::try_from(byte_length)
9375                        .expect("bounded workspace file length must fit u32"),
9376                }
9377            }
9378            WorkspaceFileBytes::TooLarge => WorkspaceFileContent::TooLarge {
9379                limit_bytes: MAX_WORKSPACE_FILE_BYTES as u32,
9380            },
9381        };
9382        Ok(WorkspaceFileRead {
9383            workspace_id,
9384            path,
9385            content,
9386            revision: None,
9387        })
9388    }
9389
9390    async fn write_workspace_file(
9391        &self,
9392        workspace_id: WorkspaceId,
9393        path: RepositoryPath,
9394        expected_revision: WorkspaceFileRevision,
9395        text: String,
9396    ) -> Result<WorkspaceFileRead, NodeFailure> {
9397        let canonical_root = self.workspace_root(&workspace_id)?;
9398        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9399            failure(NodeFailureCode::BackendBusy, "workspace file write capacity is busy")
9400        })?;
9401        let path_to_write = path.clone();
9402        let expected = expected_revision.as_str().to_owned();
9403        let text_to_write = text.clone();
9404        let task = tokio::task::spawn_blocking(move || {
9405            let _permit = permit;
9406            write_workspace_file_to_disk(
9407                Path::new(&canonical_root),
9408                &path_to_write,
9409                &expected,
9410                &text_to_write,
9411            )
9412        });
9413        let revision = timeout(Duration::from_millis(WORKSPACE_FILE_WRITE_TIMEOUT_MS), task)
9414            .await
9415            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteTimedOut, "repository file write exceeded its bounded deadline"))?
9416            .map_err(|_| failure(NodeFailureCode::RepositoryFileWriteFailed, "repository file write task failed"))?
9417            .map_err(workspace_file_write_failure)?;
9418        Ok(WorkspaceFileRead {
9419            workspace_id,
9420            path,
9421            content: WorkspaceFileContent::Utf8 {
9422                byte_len: u32::try_from(text.len()).expect("bounded workspace text length must fit u32"),
9423                text,
9424            },
9425            revision: Some(WorkspaceFileRevision::new(revision)
9426                .expect("workspace writer returns a valid SHA-256 revision")),
9427        })
9428    }
9429
9430    async fn create_workspace_file(
9431        &self,
9432        workspace_id: WorkspaceId,
9433        path: RepositoryPath,
9434    ) -> Result<WorkspaceFileRead, NodeFailure> {
9435        let canonical_root = self.workspace_root(&workspace_id)?;
9436        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9437            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9438        })?;
9439        let path_to_create = path.clone();
9440        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9441        let task_commit_state = Arc::clone(&commit_state);
9442        let task = tokio::task::spawn_blocking(move || {
9443            let _permit = permit;
9444            create_workspace_file_on_disk(
9445                Path::new(&canonical_root),
9446                &path_to_create,
9447                &task_commit_state,
9448            )
9449        });
9450        let revision = settle_workspace_entry_create(
9451            task,
9452            commit_state,
9453            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9454            "repository file create exceeded its bounded deadline",
9455            "repository file create task failed",
9456        )
9457        .await?;
9458        Ok(WorkspaceFileRead {
9459            workspace_id,
9460            path,
9461            content: WorkspaceFileContent::Utf8 {
9462                text: String::new(),
9463                byte_len: 0,
9464            },
9465            revision: Some(WorkspaceFileRevision::new(revision)
9466                .expect("workspace creator returns a valid SHA-256 revision")),
9467        })
9468    }
9469
9470    async fn create_workspace_directory(
9471        &self,
9472        workspace_id: WorkspaceId,
9473        path: RepositoryPath,
9474    ) -> Result<WorkspaceEntry, NodeFailure> {
9475        let canonical_root = self.workspace_root(&workspace_id)?;
9476        let permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9477            failure(NodeFailureCode::BackendBusy, "workspace entry create capacity is busy")
9478        })?;
9479        let path_to_create = path.clone();
9480        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
9481        let task_commit_state = Arc::clone(&commit_state);
9482        let task = tokio::task::spawn_blocking(move || {
9483            let _permit = permit;
9484            create_workspace_directory_on_disk(
9485                Path::new(&canonical_root),
9486                &path_to_create,
9487                &task_commit_state,
9488            )
9489        });
9490        settle_workspace_entry_create(
9491            task,
9492            commit_state,
9493            Duration::from_millis(WORKSPACE_ENTRY_CREATE_TIMEOUT_MS),
9494            "repository directory create exceeded its bounded deadline",
9495            "repository directory create task failed",
9496        )
9497        .await?;
9498        Ok(WorkspaceEntry {
9499            relative_path: path,
9500            kind: WorkspaceEntryKind::Directory,
9501        })
9502    }
9503
9504    async fn read_git_history(
9505        &self,
9506        workspace_id: WorkspaceId,
9507        path: Option<RepositoryPath>,
9508        before: Option<GitObjectId>,
9509        limit: u16,
9510    ) -> Result<GitHistoryPage, NodeFailure> {
9511        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9512            failure(NodeFailureCode::BackendBusy, "git history capacity is busy")
9513        })?;
9514        let root = self.workspace_root(&workspace_id)?;
9515        read_git_history_bounded(&root, path.as_ref(), before.as_ref(), limit).await
9516    }
9517
9518    async fn read_git_diff(
9519        &self,
9520        workspace_id: WorkspaceId,
9521        request: GitDiffRequest,
9522    ) -> Result<GitDiff, NodeFailure> {
9523        let _permit = self.inspection_slots.clone().try_acquire_owned().map_err(|_| {
9524            failure(NodeFailureCode::BackendBusy, "git diff capacity is busy")
9525        })?;
9526        let root = self.workspace_root(&workspace_id)?;
9527        read_git_diff_bounded(&root, request).await
9528    }
9529
9530    fn native_session_route_root(
9531        &self,
9532        route: &NativeSessionCatalogRoute,
9533    ) -> Result<Option<String>, NodeFailure> {
9534        route
9535            .validate()
9536            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9537        match route.scope {
9538            gate4agent_types::NativeSessionCatalogScope::Workspace => route
9539                .workspace_id
9540                .as_ref()
9541                .map(|workspace_id| self.workspace_root(workspace_id))
9542                .transpose(),
9543            gate4agent_types::NativeSessionCatalogScope::Unregistered => Ok(None),
9544        }
9545    }
9546
9547    fn project_native_session_entries(
9548        &self,
9549        route: &NativeSessionCatalogRoute,
9550        entries: Vec<ScopedNativeSessionCatalogEntry>,
9551    ) -> Vec<crate::protocol::NativeSessionCatalogEntry> {
9552        let current_root = route
9553            .workspace_id
9554            .as_ref()
9555            .and_then(|workspace_id| self.workspace_root(workspace_id).ok());
9556        let records = self
9557            .session_records
9558            .lock()
9559            .unwrap_or_else(|poisoned| poisoned.into_inner());
9560        let mut seen_records = std::collections::HashSet::new();
9561        entries
9562            .into_iter()
9563            .map(|entry| {
9564                let record_id = route.workspace_id.as_ref().and_then(|workspace_id| {
9565                    let matches = records
9566                        .records
9567                        .values()
9568                        .filter(|record| {
9569                            record.provider == route.provider
9570                                && record.workspace_id == *workspace_id
9571                                && current_root.as_deref().is_some_and(|root| {
9572                                    platform::roots_equal(
9573                                        root,
9574                                        windows_path_text(&record.canonical_root),
9575                                    )
9576                                })
9577                                && record.provider_session.as_ref().is_some_and(|identity| {
9578                                    session_registry::same_provider_session(
9579                                        &record.provider,
9580                                        identity,
9581                                        &route.provider,
9582                                        &entry.provider_session,
9583                                    )
9584                                })
9585                        })
9586                        .map(|record| record.record_id.clone())
9587                        .collect::<Vec<_>>();
9588                    (matches.len() == 1
9589                        && seen_records.insert(matches[0].clone()))
9590                        .then(|| matches[0].clone())
9591                });
9592                let metadata = entry.metadata;
9593                crate::protocol::NativeSessionCatalogEntry {
9594                    selection_id: metadata.selection_id,
9595                    title: metadata.title,
9596                    modified_at_unix_ms: metadata.modified_at_unix_ms,
9597                    model: metadata.model,
9598                    message_count: metadata.message_count,
9599                    completed_turn_count: metadata.completed_turn_count,
9600                    external_group: entry.external_group,
9601                    record_id,
9602                }
9603            })
9604            .collect()
9605    }
9606
9607    async fn catalog_native_sessions(
9608        &self,
9609        route: NativeSessionCatalogRoute,
9610        limit: u16,
9611    ) -> Result<(
9612        Vec<crate::protocol::NativeSessionCatalogEntry>,
9613        crate::protocol::NativeSessionCatalogSummary,
9614    ), NodeFailure> {
9615        let canonical_root = self.native_session_route_root(&route)?;
9616        let registered_workspace_roots = self.workspace_roots();
9617        let provider = route.provider.clone();
9618        let scope = route.scope;
9619        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9620            failure(
9621                NodeFailureCode::InvalidRequest,
9622                "native session catalog is not configured",
9623            )
9624        })?;
9625        let _permit = Arc::clone(&self.inspection_slots)
9626            .try_acquire_owned()
9627            .map_err(|_| {
9628                failure(
9629                    NodeFailureCode::BackendBusy,
9630                    "native session catalog capacity is busy",
9631                )
9632            })?;
9633        let page = run_native_session_catalog_operation(
9634            catalog,
9635            "native session catalog",
9636            move |catalog| {
9637                catalog.catalog_initial_for_scope(
9638                    &provider,
9639                    scope,
9640                    canonical_root.as_deref().map(Path::new),
9641                    &registered_workspace_roots,
9642                    limit,
9643                )
9644            },
9645        )
9646        .await?;
9647        let summary = crate::protocol::NativeSessionCatalogSummary {
9648            catalog_revision: page.revision,
9649            recent_cutoff_unix_ms: page.cutoff_unix_ms,
9650            recent_total_count: page.recent_total_count,
9651            older_total_count: page.older_total_count,
9652            recent_next_after_selection_id: page.next_after_selection_id,
9653            recent_has_more: page.remaining_count > 0,
9654        };
9655        Ok((self.project_native_session_entries(&route, page.entries), summary))
9656    }
9657
9658    async fn page_native_sessions(
9659        &self,
9660        route: NativeSessionCatalogRoute,
9661        window: crate::protocol::NativeSessionCatalogWindow,
9662        catalog_revision: u64,
9663        recent_cutoff_unix_ms: u64,
9664        after_selection_id: Option<String>,
9665        limit: u16,
9666    ) -> Result<crate::protocol::NativeSessionCatalogPage, NodeFailure> {
9667        let canonical_root = self.native_session_route_root(&route)?;
9668        let registered_workspace_roots = self.workspace_roots();
9669        let provider = route.provider.clone();
9670        let scope = route.scope;
9671        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9672            failure(
9673                NodeFailureCode::InvalidRequest,
9674                "native session catalog is not configured",
9675            )
9676        })?;
9677        let _permit = Arc::clone(&self.inspection_slots)
9678            .try_acquire_owned()
9679            .map_err(|_| {
9680                failure(
9681                    NodeFailureCode::BackendBusy,
9682                    "native session catalog capacity is busy",
9683                )
9684            })?;
9685        let page = run_native_session_catalog_operation(
9686            catalog,
9687            "native session catalog page",
9688            move |catalog| {
9689                Ok::<_, std::convert::Infallible>(catalog.catalog_page_for_scope(
9690                    &provider,
9691                    scope,
9692                    canonical_root.as_deref().map(Path::new),
9693                    &registered_workspace_roots,
9694                    window,
9695                    catalog_revision,
9696                    recent_cutoff_unix_ms,
9697                    after_selection_id.as_deref(),
9698                    limit,
9699                ))
9700            },
9701        )
9702        .await?;
9703        let page = page.map_err(|error| match error {
9704            NativeSessionCatalogError::StaleCatalog => failure(
9705                NodeFailureCode::StaleNativeSessionCatalog,
9706                "native session catalog revision or cursor is stale",
9707            ),
9708            _ => failure(
9709                NodeFailureCode::BackendOperationFailed,
9710                "native session catalog page failed",
9711            ),
9712        })?;
9713        let entries = self.project_native_session_entries(&route, page.entries);
9714        Ok(crate::protocol::NativeSessionCatalogPage {
9715            window: page.window,
9716            revision: page.revision,
9717            entries,
9718            next_after_selection_id: page.next_after_selection_id,
9719            remaining_count: page.remaining_count,
9720            has_more: page.remaining_count > 0,
9721        })
9722    }
9723
9724    async fn preview_native_session(
9725        &self,
9726        selection: NativeSessionSelection,
9727        message_limit: u16,
9728    ) -> Result<crate::protocol::NativeSessionPreview, NodeFailure> {
9729        selection
9730            .validate()
9731            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9732        let canonical_root = self.native_session_route_root(&selection.route)?;
9733        let registered_workspace_roots = self.workspace_roots();
9734        let provider = selection.route.provider.clone();
9735        let scope = selection.route.scope;
9736        let catalog_revision = selection.catalog_revision;
9737        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9738        let selection_id = selection.selection_id.clone();
9739        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9740            failure(
9741                NodeFailureCode::InvalidRequest,
9742                "native session preview is not configured",
9743            )
9744        })?;
9745        let _permit = Arc::clone(&self.inspection_slots)
9746            .try_acquire_owned()
9747            .map_err(|_| {
9748                failure(
9749                    NodeFailureCode::BackendBusy,
9750                    "native session preview capacity is busy",
9751                )
9752            })?;
9753        let preview = run_native_session_catalog_operation(
9754            catalog,
9755            "native session preview",
9756            move |catalog| {
9757                Ok::<_, std::convert::Infallible>(catalog.preview_for_scope(
9758                    &provider,
9759                    scope,
9760                    canonical_root.as_deref().map(Path::new),
9761                    &registered_workspace_roots,
9762                    catalog_revision,
9763                    recent_cutoff_unix_ms,
9764                    &selection_id,
9765                    message_limit,
9766                ))
9767            },
9768        )
9769        .await?;
9770        preview
9771            .map(Into::into)
9772            .map_err(native_session_preview_failure)
9773    }
9774
9775    async fn index_native_session(
9776        &self,
9777        selection: NativeSessionSelection,
9778        display_name: String,
9779    ) -> Result<ManagedSessionRecord, NodeFailure> {
9780        selection
9781            .validate()
9782            .map_err(|message| failure(NodeFailureCode::InvalidRequest, message))?;
9783        if selection.route.scope
9784            != gate4agent_types::NativeSessionCatalogScope::Workspace
9785        {
9786            return Err(failure(
9787                NodeFailureCode::WorkspaceRegistrationRequired,
9788                "register the external project as a workspace before indexing or resuming it",
9789            ));
9790        }
9791        let workspace_id = selection
9792            .route
9793            .workspace_id
9794            .clone()
9795            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9796        let canonical_root = self
9797            .native_session_route_root(&selection.route)?
9798            .ok_or_else(|| failure(NodeFailureCode::InvalidRequest, "workspace route is incomplete"))?;
9799        let registered_workspace_roots = self.workspace_roots();
9800        let provider = selection.route.provider.clone();
9801        let catalog_revision = selection.catalog_revision;
9802        let recent_cutoff_unix_ms = selection.recent_cutoff_unix_ms;
9803        let selection_id = selection.selection_id.clone();
9804        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9805            failure(
9806                NodeFailureCode::InvalidRequest,
9807                "native session index is not configured",
9808            )
9809        })?;
9810        let _permit = Arc::clone(&self.inspection_slots)
9811            .try_acquire_owned()
9812            .map_err(|_| {
9813                failure(
9814                    NodeFailureCode::BackendBusy,
9815                    "native session index capacity is busy",
9816                )
9817            })?;
9818        let resolved = run_native_session_catalog_operation(
9819            catalog,
9820            "native session index resolution",
9821            move |catalog| {
9822                Ok::<_, std::convert::Infallible>(catalog.resolve_selection_for_scope(
9823                    &provider,
9824                    gate4agent_types::NativeSessionCatalogScope::Workspace,
9825                    Some(Path::new(&canonical_root)),
9826                    &registered_workspace_roots,
9827                    catalog_revision,
9828                    recent_cutoff_unix_ms,
9829                    &selection_id,
9830                ))
9831            },
9832        )
9833        .await?
9834        .map_err(native_session_preview_failure)?;
9835        self.index_provider_session_with_policy(
9836            workspace_id,
9837            selection.route.provider,
9838            resolved.identity,
9839            display_name,
9840            true,
9841        )
9842    }
9843
9844    async fn preview_native_session_by_session_id(
9845        &self,
9846        workspace_id: WorkspaceId,
9847        provider: AgentId,
9848        session_id: String,
9849        message_limit: u16,
9850    ) -> Result<gate4agent_types::NativeSessionPreview, NodeFailure> {
9851        let canonical_root = self.workspace_root(&workspace_id)?;
9852        let registered_workspace_roots = self.workspace_roots();
9853        let catalog = self.native_session_catalog.clone().ok_or_else(|| {
9854            failure(
9855                NodeFailureCode::InvalidRequest,
9856                "native session preview is not configured",
9857            )
9858        })?;
9859        let _permit = Arc::clone(&self.inspection_slots)
9860            .try_acquire_owned()
9861            .map_err(|_| {
9862                failure(
9863                    NodeFailureCode::BackendBusy,
9864                    "native session preview capacity is busy",
9865                )
9866            })?;
9867        run_native_session_catalog_operation(
9868            catalog,
9869            "native session preview",
9870            move |catalog| {
9871                catalog.preview_session_id_for_workspace(
9872                    &provider,
9873                    Path::new(&canonical_root),
9874                    &registered_workspace_roots,
9875                    &session_id,
9876                    message_limit,
9877                )
9878            },
9879        )
9880        .await
9881    }
9882
9883    async fn register_workspace(
9884        &self,
9885        workspace_id: WorkspaceId,
9886        root: String,
9887    ) -> Result<WorkspaceSnapshot, NodeFailure> {
9888        validate_workspace_request_root(&root)?;
9889        let canonicalize_workspace_id = workspace_id.clone();
9890        let workspace = tokio::task::spawn_blocking(move || {
9891            WorkspaceConfig::new(canonicalize_workspace_id, root)
9892        })
9893        .await
9894        .map_err(|error| {
9895            failure(
9896                NodeFailureCode::BackendOperationFailed,
9897                &format!("workspace canonicalization task failed: {error}"),
9898            )
9899        })?
9900        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
9901        let transaction = self
9902            .state_transaction
9903            .lock()
9904            .unwrap_or_else(|poisoned| poisoned.into_inner());
9905        let mut workspaces = self
9906            .workspaces
9907            .write()
9908            .unwrap_or_else(|poisoned| poisoned.into_inner());
9909        if workspaces.contains_key(&workspace_id) {
9910            return Err(failure(
9911                NodeFailureCode::DuplicateWorkspaceId,
9912                "workspace ID is already registered",
9913            ));
9914        }
9915        if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
9916            platform::roots_equal(existing_root, workspace.canonical_root())
9917        }) {
9918            return Err(failure(
9919                NodeFailureCode::DuplicateWorkspaceRoot,
9920                &format!("workspace root is already registered as '{existing_id}'"),
9921            ));
9922        }
9923        {
9924            let records = self
9925                .session_records
9926                .lock()
9927                .unwrap_or_else(|poisoned| poisoned.into_inner());
9928            if let Some(record) = records.records.values().find(|record| {
9929                record.workspace_id == workspace_id
9930                    && !record
9931                        .canonical_root
9932                        .as_utf8()
9933                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9934            }) {
9935                return Err(failure(
9936                    NodeFailureCode::SessionRecordConflict,
9937                    &format!(
9938                        "workspace ID is retained by session record '{}' at another root",
9939                        record.record_id,
9940                    ),
9941                ));
9942            }
9943        }
9944        let snapshot = WorkspaceSnapshot {
9945            workspace_id: workspace_id.clone(),
9946            canonical_root: opaque_windows_path(workspace.canonical_root().to_owned()),
9947            sessions: Vec::new(),
9948            worktree_service_mode: Some(WorktreeServiceMode::Manual),
9949            managed_worktree_profiles: Some(WorktreeProfileInventory {
9950                profiles: Vec::new(),
9951            }),
9952        };
9953        workspaces.insert(workspace_id.clone(), workspace.canonical_root().to_owned());
9954        drop(workspaces);
9955        let previous_records = {
9956            let mut records = self
9957                .session_records
9958                .lock()
9959                .unwrap_or_else(|poisoned| poisoned.into_inner());
9960            let previous = records.records.clone();
9961            for record in records.records.values_mut().filter(|record| {
9962                record.workspace_id == workspace_id
9963                    && record
9964                        .canonical_root
9965                        .as_utf8()
9966                        .is_some_and(|root| platform::roots_equal(root, workspace.canonical_root()))
9967            }) {
9968                if record.active_session.is_none() {
9969                    record.state = detached_record_state(record);
9970                }
9971                if record.last_error.as_deref() == Some(WORKSPACE_UNAVAILABLE_ERROR) {
9972                    record.last_error = None;
9973                }
9974                record.updated_at_unix_ms = unix_time_ms();
9975            }
9976            previous
9977        };
9978        if let Err(error) = self.persist_state_locked() {
9979            self.workspaces
9980                .write()
9981                .unwrap_or_else(|poisoned| poisoned.into_inner())
9982                .remove(&workspace_id);
9983            self.session_records
9984                .lock()
9985                .unwrap_or_else(|poisoned| poisoned.into_inner())
9986                .records = previous_records;
9987            return Err(persistence_failure(error));
9988        }
9989        let restored = self
9990            .session_records
9991            .lock()
9992            .unwrap_or_else(|poisoned| poisoned.into_inner())
9993            .records
9994            .values()
9995            .filter(|record| record.workspace_id == workspace_id)
9996            .cloned()
9997            .collect::<Vec<_>>();
9998        drop(transaction);
9999        self.publish(NodeEvent::WorkspaceAdded {
10000            workspace: snapshot.clone(),
10001        });
10002        for record in restored {
10003            self.publish_record(record);
10004        }
10005        Ok(snapshot)
10006    }
10007
10008    async fn create_standalone_workspace(
10009        &self,
10010        workspace_id: WorkspaceId,
10011        root: String,
10012        initial_branch: Option<String>,
10013    ) -> Result<WorkspaceSnapshot, NodeFailure> {
10014        validate_workspace_request_root(&root)?;
10015        validate_git_revision("initial branch", initial_branch.as_deref())?;
10016        let preflight_workspace_id = workspace_id.clone();
10017        let preflight_root = root.clone();
10018        let canonical_candidate = tokio::task::spawn_blocking(move || {
10019            canonical_standalone_workspace_candidate(&preflight_workspace_id, &preflight_root)
10020        })
10021        .await
10022        .map_err(|_| failure(
10023            NodeFailureCode::BackendOperationFailed,
10024            "standalone workspace root preflight task failed",
10025        ))?
10026        .map_err(|error| failure(NodeFailureCode::InvalidWorkspaceRoot, &error.to_string()))?;
10027        {
10028            let workspaces = self
10029                .workspaces
10030                .read()
10031                .unwrap_or_else(|poisoned| poisoned.into_inner());
10032            if workspaces.contains_key(&workspace_id) {
10033                return Err(failure(
10034                    NodeFailureCode::DuplicateWorkspaceId,
10035                    "workspace ID is already registered",
10036                ));
10037            }
10038            if let Some((existing_id, _)) = workspaces.iter().find(|(_, existing_root)| {
10039                platform::roots_equal(existing_root, &canonical_candidate)
10040            }) {
10041                return Err(failure(
10042                    NodeFailureCode::DuplicateWorkspaceRoot,
10043                    &format!("workspace root is already registered as '{existing_id}'"),
10044                ));
10045            }
10046        }
10047        let prepared = prepare_standalone_workspace(root, initial_branch.as_deref())
10048            .await
10049            .map_err(standalone_workspace_failure)?;
10050        prepared
10051            .verify_for_registration()
10052            .await
10053            .map_err(standalone_workspace_failure)?;
10054        match self
10055            .register_workspace(workspace_id, prepared.root().to_owned())
10056            .await
10057        {
10058            Ok(workspace) => Ok(workspace),
10059            Err(registration_error) => {
10060                if prepared.compensate_registration_failure().await {
10061                    Err(registration_error)
10062                } else {
10063                    Err(failure(
10064                        NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
10065                        "standalone workspace initialization succeeded but registration failed; recovery is required",
10066                    ))
10067                }
10068            }
10069        }
10070    }
10071
10072    fn unregister_workspace(&self, workspace_id: &WorkspaceId) -> Result<(), NodeFailure> {
10073        let bindings = self
10074            .session_bindings
10075            .lock()
10076            .unwrap_or_else(|poisoned| poisoned.into_inner());
10077        if bindings.values().any(|binding| &binding.workspace_id == workspace_id) {
10078            return Err(failure(
10079                NodeFailureCode::WorkspaceBusy,
10080                "workspace retains a bound session; remove every session before unregistering it",
10081            ));
10082        }
10083        drop(bindings);
10084        let transaction = self
10085            .state_transaction
10086            .lock()
10087            .unwrap_or_else(|poisoned| poisoned.into_inner());
10088        let mut workspaces = self
10089            .workspaces
10090            .write()
10091            .unwrap_or_else(|poisoned| poisoned.into_inner());
10092        if !workspaces.contains_key(workspace_id) {
10093            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
10094        }
10095        if workspaces.len() == 1 {
10096            return Err(failure(
10097                NodeFailureCode::LastWorkspace,
10098                "node must retain at least one workspace",
10099            ));
10100        }
10101        let removed_root = workspaces
10102            .remove(workspace_id)
10103            .expect("workspace presence was validated");
10104        drop(workspaces);
10105        let previous_records = {
10106            let mut records = self
10107                .session_records
10108                .lock()
10109                .unwrap_or_else(|poisoned| poisoned.into_inner());
10110            let previous = records.records.clone();
10111            for record in records
10112                .records
10113                .values_mut()
10114                .filter(|record| &record.workspace_id == workspace_id)
10115            {
10116                record.active_session = None;
10117                record.state = ManagedSessionState::Unavailable;
10118                record.last_error = Some(WORKSPACE_UNAVAILABLE_ERROR.to_owned());
10119                record.updated_at_unix_ms = unix_time_ms();
10120            }
10121            previous
10122        };
10123        if let Err(error) = self.persist_state_locked() {
10124            self.workspaces
10125                .write()
10126                .unwrap_or_else(|poisoned| poisoned.into_inner())
10127                .insert(workspace_id.clone(), removed_root);
10128            self.session_records
10129                .lock()
10130                .unwrap_or_else(|poisoned| poisoned.into_inner())
10131                .records = previous_records;
10132            return Err(persistence_failure(error));
10133        }
10134        let unavailable = self
10135            .session_records
10136            .lock()
10137            .unwrap_or_else(|poisoned| poisoned.into_inner())
10138            .records
10139            .values()
10140            .filter(|record| &record.workspace_id == workspace_id)
10141            .cloned()
10142            .collect::<Vec<_>>();
10143        drop(transaction);
10144        self.publish(NodeEvent::WorkspaceRemoved {
10145            workspace_id: workspace_id.clone(),
10146        });
10147        for record in unavailable {
10148            self.publish_record(record);
10149        }
10150        Ok(())
10151    }
10152
10153    async fn create_worktree(
10154        &self,
10155        source_workspace_id: WorkspaceId,
10156        workspace_id: WorkspaceId,
10157        target_root: String,
10158        branch: String,
10159        base: Option<String>,
10160    ) -> Result<(GitWorktreeSnapshot, WorkspaceSnapshot), NodeFailure> {
10161        self.require_worktree_service(&source_workspace_id)?;
10162        validate_workspace_request_root(&target_root)?;
10163        validate_git_revision("worktree base", base.as_deref())?;
10164        let source_root = self.workspace_root(&source_workspace_id)?;
10165        {
10166            let workspaces = self
10167                .workspaces
10168                .read()
10169                .unwrap_or_else(|poisoned| poisoned.into_inner());
10170            if workspaces.contains_key(&workspace_id) {
10171                return Err(failure(
10172                    NodeFailureCode::DuplicateWorkspaceId,
10173                    "workspace ID is already registered",
10174                ));
10175            }
10176            if let Some((existing_id, _)) = workspaces
10177                .iter()
10178                .find(|(_, root)| worktree_paths_equal(root, &target_root))
10179            {
10180                return Err(failure(
10181                    NodeFailureCode::DuplicateWorkspaceRoot,
10182                    &format!("workspace root is already registered as '{existing_id}'"),
10183                ));
10184            }
10185        }
10186        let mut worktree = create_git_worktree(
10187            &source_root,
10188            &target_root,
10189            &branch,
10190            base.as_deref(),
10191        )
10192        .await
10193        .map_err(git_worktree_failure)?;
10194        let workspace = match self
10195            .register_workspace(workspace_id, worktree.path.clone())
10196            .await
10197        {
10198            Ok(workspace) => workspace,
10199            Err(registration_error) => {
10200                if remove_git_worktree(&source_root, &worktree.path).await.is_err() {
10201                    return Err(failure(
10202                        NodeFailureCode::ManagedWorktreeRecoveryRequired,
10203                        "worktree registration failed and compensation requires recovery",
10204                    ));
10205                }
10206                return Err(failure(
10207                    registration_error.code,
10208                    &registration_error.message,
10209                ));
10210            }
10211        };
10212        worktree.workspace_id = Some(workspace.workspace_id.clone());
10213        Ok((protocol_worktree(worktree), workspace))
10214    }
10215
10216    async fn remove_worktree(
10217        &self,
10218        source_workspace_id: WorkspaceId,
10219        target_root: String,
10220    ) -> Result<Option<WorkspaceId>, NodeFailure> {
10221        self.require_worktree_service(&source_workspace_id)?;
10222        validate_workspace_request_root(&target_root)?;
10223        let source_root = self.workspace_root(&source_workspace_id)?;
10224        let target_root = normalize_worktree_removal_target(&target_root)
10225            .map_err(git_worktree_failure)?;
10226        let target = list_git_worktrees(&source_root)
10227            .await
10228            .map_err(git_worktree_failure)?
10229            .into_iter()
10230            .find(|worktree| worktree_paths_equal(&worktree.path, &target_root))
10231            .ok_or_else(|| {
10232                failure(
10233                    NodeFailureCode::WorktreeProtected,
10234                    "refusing to remove a path that is not in Git's worktree listing",
10235                )
10236            })?;
10237        let registered_workspace_id = {
10238            let workspaces = self
10239                .workspaces
10240                .read()
10241                .unwrap_or_else(|poisoned| poisoned.into_inner());
10242            let matched = workspaces
10243                .iter()
10244                .find(|(_, root)| worktree_paths_equal(root, &target.path))
10245                .map(|(workspace_id, _)| workspace_id.clone());
10246            if matched.is_some() && workspaces.len() == 1 {
10247                return Err(failure(
10248                    NodeFailureCode::LastWorkspace,
10249                    "node must retain at least one workspace",
10250                ));
10251            }
10252            matched
10253        };
10254        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10255            let bindings = self
10256                .session_bindings
10257                .lock()
10258                .unwrap_or_else(|poisoned| poisoned.into_inner());
10259            if bindings
10260                .values()
10261                .any(|binding| &binding.workspace_id == workspace_id)
10262            {
10263                return Err(failure(
10264                    NodeFailureCode::WorkspaceBusy,
10265                    "worktree workspace retains a bound session; remove every session first",
10266                ));
10267            }
10268        }
10269        remove_git_worktree(&source_root, &target.path)
10270            .await
10271            .map_err(git_worktree_failure)?;
10272        if let Some(workspace_id) = registered_workspace_id.as_ref() {
10273            self.unregister_workspace(workspace_id)
10274                .map_err(|error| failure(error.code, &error.message))?;
10275        }
10276        Ok(registered_workspace_id)
10277    }
10278
10279    async fn cleanup_managed_worktree(
10280        &self,
10281        lease_id: &ManagedWorktreeLeaseId,
10282        explicit: bool,
10283    ) -> Result<ManagedWorktreeLeaseSnapshot, NodeFailure> {
10284        let lease = self.managed_worktrees.lock()
10285            .unwrap_or_else(|poisoned| poisoned.into_inner())
10286            .get(lease_id)
10287            .cloned()
10288            .ok_or_else(|| failure(
10289                NodeFailureCode::UnknownManagedWorktreeLease,
10290                "managed worktree lease does not exist",
10291            ))?;
10292        if lease.state == ManagedWorktreeLeaseState::Removed {
10293            return Ok(lease.snapshot());
10294        }
10295        if lease.has_holders() {
10296            return Err(failure(
10297                NodeFailureCode::ManagedWorktreeBusy,
10298                "managed worktree lease still has a session or durable-record holder",
10299            ));
10300        }
10301        if self
10302            .materializations
10303            .lock()
10304            .unwrap_or_else(|poisoned| poisoned.into_inner())
10305            .values()
10306            .any(|record| record.managed_lease_id() == Some(lease_id))
10307        {
10308            return Err(failure(
10309                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10310                "managed worktree cleanup is blocked by session environment ownership",
10311            ));
10312        }
10313        if !explicit && lease.retention == ManagedWorktreeRetention::Retain {
10314            let snapshot = {
10315                let mut registry = self.managed_worktrees.lock()
10316                    .unwrap_or_else(|poisoned| poisoned.into_inner());
10317                let current = registry.get_mut(lease_id)
10318                    .expect("managed worktree lease remains present");
10319                current.state = ManagedWorktreeLeaseState::Retained;
10320                current.cleanup_failure = None;
10321                current.updated_at_unix_ms = unix_time_ms();
10322                current.snapshot()
10323            };
10324            if self.persist_state().is_err() {
10325                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
10326            }
10327            self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot.clone() });
10328            return Ok(snapshot);
10329        }
10330        let profile = self.managed_profile(&lease.source_workspace_id, &lease.profile_id)?;
10331        if profile.revision() != &lease.profile_revision {
10332            self.mark_managed_recovery_required(
10333                lease_id,
10334                ManagedWorktreeCleanupFailure::OwnershipConflict,
10335            );
10336            return Err(failure(
10337                NodeFailureCode::ManagedWorktreeRecoveryRequired,
10338                "managed worktree profile revision changed",
10339            ));
10340        }
10341        profile.validate_target_authority(&lease.lease_id, &lease.target_root).map_err(|message| {
10342            self.mark_managed_recovery_required(
10343                lease_id,
10344                ManagedWorktreeCleanupFailure::OwnershipConflict,
10345            );
10346            failure(NodeFailureCode::ManagedWorktreeOwnershipConflict, &message)
10347        })?;
10348        let source_root = self.workspace_root(&lease.source_workspace_id)?;
10349        let listed = list_git_worktrees(&source_root).await.map_err(|error| {
10350            self.mark_managed_cleanup_blocked(lease_id, ManagedWorktreeCleanupFailure::Backend);
10351            managed_git_worktree_failure(error)
10352        })?;
10353        let target = listed.iter()
10354            .find(|item| worktree_paths_equal(&item.path, &lease.target_root));
10355        if let Some(target) = target {
10356            if target.is_main || target.is_bare || !exact_owned_worktree(
10357                &lease,
10358                &target.path,
10359                target.branch.as_deref(),
10360                &target.head,
10361            ) {
10362                self.mark_managed_recovery_required(
10363                    lease_id,
10364                    ManagedWorktreeCleanupFailure::OwnershipConflict,
10365                );
10366                return Err(failure(
10367                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
10368                    "Git worktree identity no longer matches the managed lease",
10369                ));
10370            }
10371            if target.prunable {
10372                self.mark_managed_cleanup_blocked(
10373                    lease_id,
10374                    ManagedWorktreeCleanupFailure::Prunable,
10375                );
10376                return Err(failure(
10377                    NodeFailureCode::ManagedWorktreeRecoveryRequired,
10378                    "managed worktree is prunable and requires recovery",
10379                ));
10380            }
10381            if let Err(error) = remove_git_worktree(&source_root, &target.path).await {
10382                let failure_kind = match error.kind {
10383                    GitWorktreeErrorKind::Dirty => ManagedWorktreeCleanupFailure::Dirty,
10384                    GitWorktreeErrorKind::Locked => ManagedWorktreeCleanupFailure::Locked,
10385                    GitWorktreeErrorKind::Protected => ManagedWorktreeCleanupFailure::OwnershipConflict,
10386                    GitWorktreeErrorKind::Conflict => ManagedWorktreeCleanupFailure::Busy,
10387                    GitWorktreeErrorKind::Invalid | GitWorktreeErrorKind::NotRepository
10388                    | GitWorktreeErrorKind::Failed => ManagedWorktreeCleanupFailure::Backend,
10389                };
10390                if failure_kind == ManagedWorktreeCleanupFailure::OwnershipConflict {
10391                    self.mark_managed_recovery_required(lease_id, failure_kind);
10392                } else {
10393                    self.mark_managed_cleanup_blocked(lease_id, failure_kind);
10394                }
10395                return Err(managed_git_worktree_failure(error));
10396            }
10397        } else {
10398            match std::fs::symlink_metadata(&lease.target_root) {
10399                Err(error) if error.kind() == io::ErrorKind::NotFound => {}
10400                Ok(_) => {
10401                    self.mark_managed_recovery_required(
10402                        lease_id,
10403                        ManagedWorktreeCleanupFailure::OwnershipConflict,
10404                    );
10405                    return Err(failure(
10406                        NodeFailureCode::ManagedWorktreeOwnershipConflict,
10407                        "managed target exists but is not owned by Git",
10408                    ));
10409                }
10410                Err(_) => {
10411                    self.mark_managed_cleanup_blocked(
10412                        lease_id,
10413                        ManagedWorktreeCleanupFailure::Backend,
10414                    );
10415                    return Err(failure(
10416                        NodeFailureCode::BackendOperationFailed,
10417                        "managed target identity could not be inspected",
10418                    ));
10419                }
10420            }
10421        }
10422        let registered = self.workspaces.read()
10423            .unwrap_or_else(|poisoned| poisoned.into_inner())
10424            .contains_key(&lease.workspace_id);
10425        if registered {
10426            self.unregister_workspace(&lease.workspace_id)?;
10427        }
10428        let removed = {
10429            self.managed_worktrees.lock()
10430                .unwrap_or_else(|poisoned| poisoned.into_inner())
10431                .tombstone(lease_id, unix_time_ms())
10432                .expect("managed worktree lease remains present during cleanup")
10433        };
10434        self.persist_state().map_err(persistence_failure)?;
10435        self.publish(NodeEvent::ManagedWorktreeRemoved { lease_id: lease_id.clone() });
10436        Ok(removed.snapshot())
10437    }
10438
10439    fn mark_managed_cleanup_blocked(
10440        &self,
10441        lease_id: &ManagedWorktreeLeaseId,
10442        failure_kind: ManagedWorktreeCleanupFailure,
10443    ) {
10444        let snapshot = {
10445            let mut registry = self.managed_worktrees.lock()
10446                .unwrap_or_else(|poisoned| poisoned.into_inner());
10447            let Some(lease) = registry.get_mut(lease_id) else { return };
10448            lease.state = ManagedWorktreeLeaseState::CleanupBlocked;
10449            lease.cleanup_failure = Some(failure_kind);
10450            lease.updated_at_unix_ms = unix_time_ms();
10451            lease.snapshot()
10452        };
10453        let _ = self.persist_state();
10454        self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
10455    }
10456
10457    fn require_worktree_service(
10458        &self,
10459        source_workspace_id: &WorkspaceId,
10460    ) -> Result<(), NodeFailure> {
10461        if self.managed_worktrees.lock()
10462            .unwrap_or_else(|poisoned| poisoned.into_inner())
10463            .lease_for_workspace(source_workspace_id)
10464            .is_some()
10465        {
10466            return Err(failure(
10467                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10468                "managed worktree targets are internal-only workspaces",
10469            ));
10470        }
10471        match self
10472            .worktree_service_modes
10473            .get(source_workspace_id)
10474            .copied()
10475            .unwrap_or(WorktreeServiceMode::Manual)
10476        {
10477            WorktreeServiceMode::Manual => Ok(()),
10478            WorktreeServiceMode::Managed | WorktreeServiceMode::Off => Err(failure(
10479                NodeFailureCode::UnsupportedSpawnCapability,
10480                "Git worktree service mode is unavailable in this node build",
10481            )),
10482        }
10483    }
10484
10485    fn managed_reservation_conflict(
10486        &self,
10487        workspace_id: Option<&WorkspaceId>,
10488        root: Option<&str>,
10489    ) -> bool {
10490        self.managed_worktrees.lock()
10491            .unwrap_or_else(|poisoned| poisoned.into_inner())
10492            .active_records()
10493            .any(|lease| {
10494                workspace_id.is_some_and(|workspace_id| {
10495                    workspace_id == &lease.source_workspace_id
10496                        || workspace_id == &lease.workspace_id
10497                }) || root.is_some_and(|root| worktree_paths_equal(root, &lease.target_root))
10498            })
10499    }
10500
10501    fn reject_managed_reservation(
10502        &self,
10503        workspace_id: Option<&WorkspaceId>,
10504        root: Option<&str>,
10505    ) -> Result<(), NodeFailure> {
10506        if self.managed_reservation_conflict(workspace_id, root) {
10507            Err(failure(
10508                NodeFailureCode::ManagedWorktreeOwnershipConflict,
10509                "managed worktree ownership may only be mutated through its lease cleanup",
10510            ))
10511        } else {
10512            Ok(())
10513        }
10514    }
10515
10516    fn bound_workspace_root(&self, address: &SessionAddress) -> Result<String, NodeFailure> {
10517        self.validate_address(address)?;
10518        self.workspace_root(&address.workspace_id)
10519    }
10520
10521    /// `Resume`/`ResumeWithPrompt` are always a raw-PTY relaunch (see
10522    /// `require_policy`'s own doc on those two requirements), so they keep
10523    /// gating on `binding.runtime_policy`'s PTY-terminal-text-inference
10524    /// flags exactly as before; `pipe_transport`/`acp_transport` stay
10525    /// `false` on the `require_policy` call below for them because a PTY
10526    /// resume never needs those facts, not because this method cannot look
10527    /// them up.
10528    ///
10529    /// `SemanticPrompt` -- the requirement `Prompt`/`Paste` share -- reads
10530    /// the SAME `binding.runtime_policy` every other requirement reads here
10531    /// (one computed policy, read by every gate). That policy is no longer
10532    /// PTY-terminal-verification-shaped for an ACP session --
10533    /// `spawn_session_with_deadline` computes it BY TRANSPORT now, through
10534    /// `provider_runtime::policy_for_transport` -- so consulting it for
10535    /// `Prompt`/`Paste` on ACP is sound where it used to gate the wrong fact
10536    /// (this is what made an ACP `Prompt` come back `UnsupportedCapability`
10537    /// even though a prompt is an ACP session's only interface). Pipe keeps
10538    /// reading the same PTY-probe-derived value it always has --
10539    /// `policy_for_transport` passes Pipe through unchanged this pass (see
10540    /// its own doc for why).
10541    ///
10542    /// What this method still adds on top, for ACP/Pipe only, is a
10543    /// turn-in-flight PRECONDITION ahead of that policy check: a session
10544    /// takes one turn at a time, so a `Prompt`/`Paste` is refused by name
10545    /// (`NodeFailureCode::TurnInFlight`) while a turn is already running,
10546    /// read off the same live `SessionSnapshot` state `require_acp_session`
10547    /// already reads for the ACP control verbs -- `TurnStarted` observed
10548    /// with no matching `TurnCompleted` yet is exactly `ProviderSnapshot::
10549    /// lead_activity != Idle`. Sending a second prompt mid-turn would put
10550    /// its fate in vendor-specific behaviour this node cannot observe (one
10551    /// agent may queue it, another drop it, another interleave it), so this
10552    /// refuses it outright rather than gambling on that -- a rule with no
10553    /// PTY analogue, which is why PTY does not take this extra precondition.
10554    fn require_session_runtime_policy(
10555        &self,
10556        address: &SessionAddress,
10557        requirement: ProviderRuntimeRequirement,
10558    ) -> Result<ProviderRuntimePolicy, NodeFailure> {
10559        let runtime_policy = {
10560            let bindings = self
10561                .session_bindings
10562                .lock()
10563                .unwrap_or_else(|poisoned| poisoned.into_inner());
10564            let binding = bindings
10565                .get(&address.session.instance_id)
10566                .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10567            if binding.workspace_id != address.workspace_id {
10568                return Err(failure(
10569                    NodeFailureCode::SessionWorkspaceMismatch,
10570                    "session belongs to another workspace",
10571                ));
10572            }
10573            if binding.generation != address.session.generation {
10574                return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10575            }
10576            binding.runtime_policy
10577        };
10578        if requirement == ProviderRuntimeRequirement::SemanticPrompt {
10579            let snapshot = self.internal_session_snapshot(address)?;
10580            if matches!(snapshot.transport, TransportKind::Acp | TransportKind::Pipe)
10581                && snapshot.provider.lead_activity != ProviderActivity::Idle
10582            {
10583                return Err(failure(
10584                    NodeFailureCode::TurnInFlight,
10585                    "session already has a turn in flight; wait for it to complete before sending another prompt",
10586                ));
10587            }
10588        }
10589        require_policy(runtime_policy, false, false, requirement)
10590            .map(|()| runtime_policy)
10591            .map_err(|_| failure(
10592                NodeFailureCode::UnsupportedCapability,
10593                "session runtime policy does not admit this semantic operation",
10594            ))
10595    }
10596
10597    fn remove_binding(&self, address: &SessionAddress) -> Option<SessionBinding> {
10598        let mut bindings = self
10599            .session_bindings
10600            .lock()
10601            .unwrap_or_else(|poisoned| poisoned.into_inner());
10602        if bindings.get(&address.session.instance_id).is_some_and(|binding| {
10603            binding.workspace_id == address.workspace_id
10604                && binding.generation == address.session.generation
10605        }) {
10606            let removed = bindings.remove(&address.session.instance_id);
10607            self.clear_terminal_frame_watermark(address);
10608            SessionLifecycleHook::on_binding_removed(self, address, removed.as_ref());
10609            return removed;
10610        }
10611        None
10612    }
10613
10614    fn cleanup_session_owned_materialization(
10615        &self,
10616        address: &SessionAddress,
10617        binding: &SessionBinding,
10618    ) -> Result<(), NodeFailure> {
10619        let Some(id) = binding.materialization_id.as_ref() else {
10620            return Ok(());
10621        };
10622        let owner = self
10623            .materializations
10624            .lock()
10625            .unwrap_or_else(|poisoned| poisoned.into_inner())
10626            .get(id)
10627            .map(|record| record.owner().clone());
10628        match owner {
10629            None | Some(MaterializationOwner::Record { .. }) => Ok(()),
10630            Some(MaterializationOwner::Session {
10631                incarnation_id,
10632                instance_id,
10633                generation,
10634            }) if incarnation_id == self.incarnation_id
10635                && instance_id == address.session.instance_id
10636                && generation == address.session.generation =>
10637            {
10638                self.cleanup_materialization(id)
10639            }
10640            Some(MaterializationOwner::Session { .. }) => Err(failure(
10641                NodeFailureCode::BackendOperationFailed,
10642                "session environment ownership requires recovery",
10643            )),
10644        }
10645    }
10646
10647    fn clear_terminal_frame_watermark(&self, address: &SessionAddress) {
10648        let mut watermarks = self
10649            .terminal_frame_watermarks
10650            .lock()
10651            .unwrap_or_else(|poisoned| poisoned.into_inner());
10652        if watermarks
10653            .get(&address.session.instance_id)
10654            .is_some_and(|(current, _)| current == address)
10655        {
10656            watermarks.remove(&address.session.instance_id);
10657        }
10658    }
10659
10660    #[cfg(test)]
10661    fn address_for(
10662        &self,
10663        instance_id: AgentInstanceId,
10664        generation: SessionGeneration,
10665    ) -> Option<SessionAddress> {
10666        let binding = self.session_bindings
10667            .lock()
10668            .unwrap_or_else(|poisoned| poisoned.into_inner())
10669            .get(&instance_id)
10670            .cloned()?;
10671        (binding.generation == generation).then_some(SessionAddress {
10672            workspace_id: binding.workspace_id,
10673            session: SessionKey { instance_id, generation },
10674        })
10675    }
10676
10677    fn arm_resume(
10678        &self,
10679        address: &SessionAddress,
10680        command_id: CommandId,
10681        runtime_policy: ProviderRuntimePolicy,
10682    ) -> Result<(), NodeFailure> {
10683        let mut bindings = self
10684            .session_bindings
10685            .lock()
10686            .unwrap_or_else(|poisoned| poisoned.into_inner());
10687        let binding = bindings
10688            .get_mut(&address.session.instance_id)
10689            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
10690        if binding.workspace_id != address.workspace_id {
10691            return Err(failure(
10692                NodeFailureCode::SessionWorkspaceMismatch,
10693                "session belongs to another workspace",
10694            ));
10695        }
10696        if binding.generation != address.session.generation {
10697            return Err(failure(NodeFailureCode::StaleGeneration, "session generation is stale"));
10698        }
10699        binding.pending_resume = Some((binding.generation, command_id, runtime_policy));
10700        Ok(())
10701    }
10702
10703    fn clear_armed_resume(&self, address: &SessionAddress) {
10704        let mut bindings = self
10705            .session_bindings
10706            .lock()
10707            .unwrap_or_else(|poisoned| poisoned.into_inner());
10708        if let Some(binding) = bindings.get_mut(&address.session.instance_id) {
10709            if binding.workspace_id == address.workspace_id
10710                && binding.generation == address.session.generation
10711            {
10712                binding.pending_resume = None;
10713            }
10714        }
10715    }
10716
10717    fn publish_control(&self, event: ControlEvent) {
10718        let revokes_harness_mcp = matches!(
10719            &event.event,
10720            ControlEventKind::Exited { .. }
10721                | ControlEventKind::Failed { .. }
10722                | ControlEventKind::Removed
10723        );
10724        let (address, previous_address, record_id, identity_admitted, resume_command_rejected) = {
10725            let mut bindings = self
10726                .session_bindings
10727                .lock()
10728                .unwrap_or_else(|poisoned| poisoned.into_inner());
10729            let Some(binding) = bindings.get_mut(&event.instance_id) else {
10730                return;
10731            };
10732            let previous_address = SessionAddress {
10733                workspace_id: binding.workspace_id.clone(),
10734                session: SessionKey {
10735                    instance_id: event.instance_id,
10736                    generation: binding.generation,
10737                },
10738            };
10739            let mut resume_command_rejected = false;
10740            if binding.generation == event.generation {
10741                if let Some((pending_generation, pending_command_id, _)) = binding.pending_resume {
10742                    let rejected_resume = matches!(
10743                        &event.event,
10744                        ControlEventKind::CommandRejected { .. }
10745                    ) && event.command_id == Some(pending_command_id);
10746                    let resume_failed = matches!(
10747                        &event.event,
10748                        ControlEventKind::ResumeDenied { .. }
10749                            | ControlEventKind::ResumeFailed { .. }
10750                    );
10751                    if pending_generation == binding.generation
10752                        && (rejected_resume || resume_failed)
10753                    {
10754                        resume_command_rejected = rejected_resume;
10755                        binding.pending_resume = None;
10756                    }
10757                }
10758            } else {
10759                let expected = binding.generation.0.checked_add(1);
10760                let authorized = matches!(&event.event, ControlEventKind::ResumeAuthorized { .. });
10761                if !binding.pending_resume.is_some_and(|(generation, _, _)| {
10762                    generation == binding.generation
10763                })
10764                    || expected != Some(event.generation.0)
10765                    || !authorized
10766                {
10767                    return;
10768                }
10769                let (_, _, runtime_policy) = binding
10770                    .pending_resume
10771                    .expect("authorized resume retains its pending runtime policy");
10772                binding.generation = event.generation;
10773                binding.runtime_policy = runtime_policy;
10774                binding.pending_resume = None;
10775            }
10776            (
10777                SessionAddress {
10778                    workspace_id: binding.workspace_id.clone(),
10779                    session: SessionKey {
10780                        instance_id: event.instance_id,
10781                        generation: event.generation,
10782                    },
10783                },
10784                (previous_address.session.generation != event.generation)
10785                    .then_some(previous_address),
10786                binding.record_id.clone(),
10787                binding.runtime_policy.provider_session_identity,
10788                resume_command_rejected,
10789            )
10790        };
10791        if let Some(previous_address) = previous_address.as_ref() {
10792            self.clear_terminal_frame_watermark(previous_address);
10793        }
10794        if let Some(record_id) = record_id {
10795            self.reconcile_managed_record(
10796                &record_id,
10797                &address,
10798                &event,
10799                identity_admitted,
10800                resume_command_rejected,
10801            );
10802        }
10803        // Every state change a session's process lifecycle can reach gets a
10804        // line here: this is the single chokepoint every `ControlEventKind`
10805        // passes through regardless of transport (PTY/pipe/ACP) or whether it
10806        // came from a fresh spawn or a resume. Per-message provider chatter
10807        // (`ProviderEvent`/`ProviderGap`/terminal frames) is deliberately not
10808        // matched here -- that is exactly the per-frame noise this crate
10809        // family's level discipline forbids at INFO.
10810        match &event.event {
10811            ControlEventKind::Running { process_id } => {
10812                if let Some(pid) = process_id {
10813                    crate::git_watch::running(address.workspace_id.as_str(), address.session.instance_id.0, "", *pid);
10814                }
10815                #[cfg(unix)]
10816                if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10817                    proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10818                }
10819                tracing::info!(
10820                    node_id = %self.node_id,
10821                    workspace_id = %address.workspace_id,
10822                    session = ?address.session,
10823                    pid = ?process_id,
10824                    "session process is running",
10825                );
10826            }
10827            ControlEventKind::Exited { exit_code, forced } => {
10828                crate::git_watch::exited(address.workspace_id.as_str(), address.session.instance_id.0);
10829                #[cfg(unix)]
10830                if let Some(proxy) = self.signer_proxy.get().cloned() {
10831                    let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10832                    if proxy.has_session(&key) {
10833                        // Revoke at core off the event path (blocking uplink call).
10834                        std::thread::spawn(move || proxy.release(&key));
10835                    }
10836                }
10837                tracing::info!(
10838                    node_id = %self.node_id,
10839                    workspace_id = %address.workspace_id,
10840                    session = ?address.session,
10841                    exit_code = ?exit_code,
10842                    forced = forced,
10843                    "session process exited",
10844                );
10845            }
10846            ControlEventKind::Failed { message } => {
10847                tracing::warn!(
10848                    node_id = %self.node_id,
10849                    workspace_id = %address.workspace_id,
10850                    session = ?address.session,
10851                    cause = %message,
10852                    "session failed",
10853                );
10854            }
10855            ControlEventKind::CommandRejected { message } => {
10856                tracing::warn!(
10857                    node_id = %self.node_id,
10858                    workspace_id = %address.workspace_id,
10859                    session = ?address.session,
10860                    cause = %message,
10861                    "session command rejected",
10862                );
10863            }
10864            ControlEventKind::Removed => {
10865                tracing::info!(
10866                    node_id = %self.node_id,
10867                    workspace_id = %address.workspace_id,
10868                    session = ?address.session,
10869                    "session removed",
10870                );
10871            }
10872            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10873                tracing::info!(
10874                    node_id = %self.node_id,
10875                    workspace_id = %address.workspace_id,
10876                    session = ?address.session,
10877                    provider_session = ?provider_session,
10878                    "session resume authorized",
10879                );
10880            }
10881            ControlEventKind::Resumed { session: provider_session, process_id } => {
10882                tracing::info!(
10883                    node_id = %self.node_id,
10884                    workspace_id = %address.workspace_id,
10885                    session = ?address.session,
10886                    provider_session = ?provider_session,
10887                    pid = ?process_id,
10888                    "session resumed",
10889                );
10890            }
10891            ControlEventKind::ResumeDenied { reason } => {
10892                tracing::warn!(
10893                    node_id = %self.node_id,
10894                    workspace_id = %address.workspace_id,
10895                    session = ?address.session,
10896                    cause = %reason,
10897                    "session resume denied",
10898                );
10899            }
10900            ControlEventKind::ResumeFailed { message } => {
10901                tracing::warn!(
10902                    node_id = %self.node_id,
10903                    workspace_id = %address.workspace_id,
10904                    session = ?address.session,
10905                    cause = %message,
10906                    "session resume failed",
10907                );
10908            }
10909            _ => {}
10910        }
10911        self.apply_pending_acp_options(&address, &event);
10912        self.apply_acp_auto_permit(&address, &event);
10913        let stream_chunk = agent_stream_chunk(&event);
10914        self.publish(NodeEvent::Control {
10915            address: address.clone(),
10916            event,
10917        });
10918        if let Some(chunk) = stream_chunk {
10919            self.publish_agent_stream_chunk(address.clone(), chunk);
10920        }
10921        if revokes_harness_mcp {
10922            SessionLifecycleHook::on_session_ended(self, &address);
10923        }
10924    }
10925
10926    fn reconcile_managed_record(
10927        &self,
10928        record_id: &SessionRecordId,
10929        address: &SessionAddress,
10930        event: &ControlEvent,
10931        identity_admitted: bool,
10932        resume_command_rejected: bool,
10933    ) {
10934        let _transaction = self
10935            .state_transaction
10936            .lock()
10937            .unwrap_or_else(|poisoned| poisoned.into_inner());
10938        let observed_identity = match &event.event {
10939            ControlEventKind::ProviderEvent {
10940                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10941                ..
10942            } if identity_admitted => Some(identity.clone()),
10943            _ => None,
10944        };
10945        let identity_was_observed = observed_identity.is_some();
10946        let replacement_id = observed_identity
10947            .as_ref()
10948            .and_then(|_| self.allocate_record_id().ok());
10949        let mut upserts = Vec::new();
10950        let mut removals = Vec::new();
10951        let mut rebind = None;
10952        let original_records;
10953        let original_materializations = self
10954            .materializations
10955            .lock()
10956            .unwrap_or_else(|poisoned| poisoned.into_inner())
10957            .clone();
10958        {
10959            let mut records = self
10960                .session_records
10961                .lock()
10962                .unwrap_or_else(|poisoned| poisoned.into_inner());
10963            original_records = records.records.clone();
10964            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10965                return;
10966            };
10967            if let Some(identity) = observed_identity {
10968                let existing_id = records
10969                    .records
10970                    .values()
10971                    .find(|record| {
10972                        record.record_id != *record_id
10973                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10974                                session_registry::same_provider_session(
10975                                    &record.provider,
10976                                    record_identity,
10977                                    &current_snapshot.provider,
10978                                    &identity,
10979                                )
10980                            })
10981                    })
10982                    .map(|record| record.record_id.clone());
10983                if let Some(existing_id) = existing_id {
10984                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10985                        record.mode == current_snapshot.mode
10986                            && record.workspace_id == current_snapshot.workspace_id
10987                            && record.environment_profile == current_snapshot.environment_profile
10988                            && record.bundle == current_snapshot.bundle
10989                            && record.context_id == current_snapshot.context_id
10990                            && record.context == current_snapshot.context
10991                            && record
10992                                .canonical_root
10993                                .as_utf8()
10994                                .zip(current_snapshot.canonical_root.as_utf8())
10995                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
10996                    });
10997                    if !scope_matches {
10998                        if let Some(current) = records.records.get_mut(record_id) {
10999                            current.state = ManagedSessionState::Unavailable;
11000                            current.last_error =
11001                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
11002                            current.updated_at_unix_ms = unix_time_ms();
11003                            upserts.push(current.clone());
11004                        }
11005                    } else {
11006                        let existing_is_active = records
11007                            .records
11008                            .get(&existing_id)
11009                            .is_some_and(|record| record.active_session.is_some());
11010                        if existing_is_active {
11011                            if let Some(current) = records.records.get_mut(record_id) {
11012                                current.state = ManagedSessionState::Unavailable;
11013                                current.last_error =
11014                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
11015                                current.updated_at_unix_ms = unix_time_ms();
11016                                upserts.push(current.clone());
11017                            }
11018                        } else {
11019                            if current_snapshot.provider_session.is_some() {
11020                                if let Some(current) = records.records.get_mut(record_id) {
11021                                    current.active_session = None;
11022                                    current.state = ManagedSessionState::Dormant;
11023                                    current.updated_at_unix_ms = unix_time_ms();
11024                                    upserts.push(current.clone());
11025                                }
11026                            } else {
11027                                records.records.remove(record_id);
11028                                removals.push(record_id.clone());
11029                            }
11030                            if let Some(existing) = records.records.get_mut(&existing_id) {
11031                                existing.provider_session = Some(identity.clone());
11032                                existing.active_session = Some(address.clone());
11033                                existing.state = ManagedSessionState::Live;
11034                                existing.updated_at_unix_ms = unix_time_ms();
11035                                existing.last_error = None;
11036                                upserts.push(existing.clone());
11037                            }
11038                            rebind = Some(existing_id);
11039                        }
11040                    }
11041                } else if current_snapshot
11042                    .provider_session
11043                    .as_ref()
11044                    .is_some_and(|current| {
11045                        !session_registry::same_provider_session(
11046                            &current_snapshot.provider,
11047                            current,
11048                            &current_snapshot.provider,
11049                            &identity,
11050                        )
11051                    })
11052                {
11053                    if let Some(current) = records.records.get_mut(record_id) {
11054                        current.active_session = None;
11055                        if current.bundle.is_some() || current.context.is_some() {
11056                            let had_bundle = current.bundle.is_some();
11057                            current.provider_session = None;
11058                            current.bundle = None;
11059                            current.context_id = None;
11060                            current.context = None;
11061                            current.state = ManagedSessionState::Unavailable;
11062                            current.last_error = Some(if had_bundle {
11063                                "bundle-unavailable".to_owned()
11064                            } else {
11065                                "context-unavailable".to_owned()
11066                            });
11067                        } else {
11068                            current.state = ManagedSessionState::Dormant;
11069                        }
11070                        current.updated_at_unix_ms = unix_time_ms();
11071                        upserts.push(current.clone());
11072                    }
11073                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11074                        if let Some(current) = records.records.get_mut(record_id) {
11075                            current.state = ManagedSessionState::Unavailable;
11076                            current.last_error =
11077                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11078                            current.updated_at_unix_ms = unix_time_ms();
11079                            upserts.push(current.clone());
11080                        }
11081                    } else if let Some(new_record_id) = replacement_id {
11082                        let now = unix_time_ms();
11083                        let replacement = ManagedSessionRecord {
11084                            record_id: new_record_id.clone(),
11085                            display_name: format!(
11086                                "{} #{}",
11087                                current_snapshot.provider.as_str(),
11088                                records
11089                                    .records
11090                                    .values()
11091                                    .filter(|record| {
11092                                        record.provider == current_snapshot.provider
11093                                    })
11094                                    .count()
11095                                    .saturating_add(1),
11096                            ),
11097                            provider: current_snapshot.provider.clone(),
11098                            mode: current_snapshot.mode,
11099                            state: ManagedSessionState::Live,
11100                            workspace_id: current_snapshot.workspace_id,
11101                            canonical_root: current_snapshot.canonical_root,
11102                            provider_session: Some(identity),
11103                            active_session: Some(address.clone()),
11104                            environment_profile: current_snapshot.environment_profile,
11105                            bundle: current_snapshot.bundle,
11106                            context_id: current_snapshot.context_id,
11107                            context: current_snapshot.context,
11108                            // This is a fresh record_id representing the session under its
11109                            // newly-observed identity; it has not yet produced any clean
11110                            // exit of its own, so it starts with no exported pack (the old
11111                            // record, still addressable under its own record_id, keeps
11112                            // whatever exported_context it already had).
11113                            exported_context: None,
11114                            task_binding: current_snapshot.task_binding,
11115                            created_at_unix_ms: now,
11116                            updated_at_unix_ms: now,
11117                            last_error: None,
11118                        };
11119                        records
11120                            .records
11121                            .insert(new_record_id.clone(), replacement.clone());
11122                        upserts.push(replacement);
11123                        rebind = Some(new_record_id);
11124                    } else if let Some(current) = records.records.get_mut(record_id) {
11125                        current.state = ManagedSessionState::Unavailable;
11126                        current.last_error =
11127                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11128                        upserts.push(current.clone());
11129                    }
11130                } else if let Some(current) = records.records.get_mut(record_id) {
11131                    current.provider_session = Some(identity);
11132                    current.active_session = Some(address.clone());
11133                    current.state = ManagedSessionState::Live;
11134                    current.updated_at_unix_ms = unix_time_ms();
11135                    current.last_error = None;
11136                    upserts.push(current.clone());
11137                }
11138            } else if let Some(current) = records.records.get_mut(record_id) {
11139                if resume_command_rejected {
11140                    current.active_session = None;
11141                    current.state = detached_record_state(current);
11142                    current.updated_at_unix_ms = unix_time_ms();
11143                    current.last_error =
11144                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11145                    upserts.push(current.clone());
11146                } else {
11147                    match &event.event {
11148                    ControlEventKind::Running { .. }
11149                    | ControlEventKind::ResumeAuthorized { .. }
11150                    | ControlEventKind::Resumed { .. } => {
11151                        current.active_session = Some(address.clone());
11152                        current.state = active_record_state(
11153                            identity_admitted,
11154                            current.provider_session.is_some(),
11155                        );
11156                        current.updated_at_unix_ms = unix_time_ms();
11157                        current.last_error = None;
11158                        upserts.push(current.clone());
11159                    }
11160                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11161                        current.active_session = None;
11162                        current.state = detached_record_state(current);
11163                        current.updated_at_unix_ms = unix_time_ms();
11164                        upserts.push(current.clone());
11165                    }
11166                    ControlEventKind::Failed { message }
11167                    | ControlEventKind::ResumeFailed { message } => {
11168                        current.active_session = None;
11169                        current.state = detached_record_state(current);
11170                        current.updated_at_unix_ms = unix_time_ms();
11171                        current.last_error = Some(
11172                            session_registry::sanitized_record_error_summary(message),
11173                        );
11174                        upserts.push(current.clone());
11175                    }
11176                    ControlEventKind::ResumeDenied { reason } => {
11177                        current.active_session = None;
11178                        current.state = detached_record_state(current);
11179                        current.updated_at_unix_ms = unix_time_ms();
11180                        current.last_error = Some(
11181                            session_registry::sanitized_record_error_summary(reason),
11182                        );
11183                        upserts.push(current.clone());
11184                    }
11185                        _ => {}
11186                    }
11187                }
11188            }
11189        }
11190        let materialization_owner_record = identity_was_observed.then(|| {
11191            rebind.clone().unwrap_or_else(|| record_id.clone())
11192        }).and_then(|candidate| {
11193            self.session_records
11194                .lock()
11195                .unwrap_or_else(|poisoned| poisoned.into_inner())
11196                .records
11197                .get(&candidate)
11198                .filter(|record| {
11199                    record.provider_session.is_some()
11200                        && record.active_session.as_ref() == Some(address)
11201                })
11202                .map(|_| candidate)
11203        });
11204        if let Some(owner_record_id) = materialization_owner_record {
11205            let materialization_id = self
11206                .session_bindings
11207                .lock()
11208                .unwrap_or_else(|poisoned| poisoned.into_inner())
11209                .get(&address.session.instance_id)
11210                .and_then(|binding| binding.materialization_id.clone());
11211            if let Some(materialization_id) = materialization_id {
11212                let mut transfer_failed = false;
11213                {
11214                    let mut materializations = self
11215                        .materializations
11216                        .lock()
11217                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11218                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
11219                        match ownership.owner() {
11220                            MaterializationOwner::Session {
11221                                incarnation_id,
11222                                instance_id,
11223                                generation,
11224                            } if *incarnation_id == self.incarnation_id
11225                                && *instance_id == address.session.instance_id
11226                                && *generation == address.session.generation =>
11227                            {
11228                                transfer_failed = ownership
11229                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11230                                    .is_err();
11231                            }
11232                            MaterializationOwner::Record { record_id }
11233                                if record_id == &owner_record_id => {}
11234                            MaterializationOwner::Record { record_id } => {
11235                                let expected = record_id.clone();
11236                                transfer_failed = ownership
11237                                    .transfer_record_owner(
11238                                        &expected,
11239                                        owner_record_id.clone(),
11240                                        unix_time_ms(),
11241                                    )
11242                                    .is_err();
11243                            }
11244                            _ => transfer_failed = true,
11245                        }
11246                        if transfer_failed {
11247                            let _ = ownership.mark_recovery_required(unix_time_ms());
11248                        }
11249                    } else {
11250                        transfer_failed = true;
11251                    }
11252                }
11253                if transfer_failed {
11254                    if let Some(record) = self
11255                        .session_records
11256                        .lock()
11257                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11258                        .records
11259                        .get_mut(&owner_record_id)
11260                    {
11261                        record.state = ManagedSessionState::Unavailable;
11262                        record.last_error = Some("environment-profile-unavailable".to_owned());
11263                        record.updated_at_unix_ms = unix_time_ms();
11264                        upserts.push(record.clone());
11265                    }
11266                }
11267            }
11268        }
11269        if let Some(new_record_id) = rebind.clone() {
11270            if let Some(binding) = self
11271                .session_bindings
11272                .lock()
11273                .unwrap_or_else(|poisoned| poisoned.into_inner())
11274                .get_mut(&address.session.instance_id)
11275            {
11276                binding.record_id = Some(new_record_id);
11277            }
11278        }
11279        if upserts.is_empty() && removals.is_empty() {
11280            return;
11281        }
11282        if self.persist_state_locked().is_err() {
11283            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11284            let mut records = self
11285                .session_records
11286                .lock()
11287                .unwrap_or_else(|poisoned| poisoned.into_inner());
11288            records.records = original_records;
11289            *self
11290                .materializations
11291                .lock()
11292                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11293            if let Some(current) = records.records.get_mut(record_id) {
11294                current.state = ManagedSessionState::Unavailable;
11295                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11296                current.updated_at_unix_ms = unix_time_ms();
11297                upserts = vec![current.clone()];
11298            } else {
11299                upserts.clear();
11300            }
11301            removals.clear();
11302            drop(records);
11303            if let Some(binding) = self
11304                .session_bindings
11305                .lock()
11306                .unwrap_or_else(|poisoned| poisoned.into_inner())
11307                .get_mut(&address.session.instance_id)
11308            {
11309                binding.record_id = Some(record_id.clone());
11310            }
11311            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11312        }
11313        for record in upserts {
11314            self.publish_record(record);
11315        }
11316        for record_id in removals {
11317            self.publish(NodeEvent::SessionRecordRemoved { record_id });
11318        }
11319    }
11320
11321    fn snapshot(&self) -> NodeSnapshot {
11322        let control = self.handle.snapshot();
11323        let bindings = self
11324            .session_bindings
11325            .lock()
11326            .unwrap_or_else(|poisoned| poisoned.into_inner())
11327            .clone();
11328        let workspace_roots = self
11329            .workspaces
11330            .read()
11331            .unwrap_or_else(|poisoned| poisoned.into_inner())
11332            .clone();
11333        let environment_profiles = self
11334            .environment_profiles
11335            .read()
11336            .unwrap_or_else(|poisoned| poisoned.into_inner());
11337        let mut workspaces = workspace_roots
11338            .iter()
11339            .map(|(workspace_id, canonical_root)| {
11340                (
11341                    workspace_id.clone(),
11342                    WorkspaceSnapshot {
11343                        workspace_id: workspace_id.clone(),
11344                        canonical_root: opaque_windows_path(canonical_root.clone()),
11345                        sessions: Vec::new(),
11346                        worktree_service_mode: Some(
11347                            self.worktree_service_modes
11348                                .get(workspace_id)
11349                                .copied()
11350                                .unwrap_or(WorktreeServiceMode::Manual),
11351                        ),
11352                        managed_worktree_profiles: Some(WorktreeProfileInventory {
11353                            profiles: self
11354                                .managed_worktree_profiles
11355                                .get(workspace_id)
11356                                .into_iter()
11357                                .flat_map(BTreeMap::values)
11358                                .map(|profile| ManagedWorktreeProfileSummary {
11359                                    id: profile.profile_id().clone(),
11360                                    revision: profile.revision().clone(),
11361                                    retention: profile.retention(),
11362                                })
11363                                .collect(),
11364                        }),
11365                    },
11366                )
11367            })
11368            .collect::<BTreeMap<_, _>>();
11369        let mut agent_progress = Vec::new();
11370        for session in &control.sessions {
11371            let Some(binding) = bindings.get(&session.instance_id) else {
11372                continue;
11373            };
11374            if binding.generation != session.generation {
11375                continue;
11376            }
11377            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11378                let address = SessionAddress {
11379                    workspace_id: binding.workspace_id.clone(),
11380                    session: SessionKey {
11381                        instance_id: session.instance_id,
11382                        generation: session.generation,
11383                    },
11384                };
11385                if let Some(progress) =
11386                    agent_progress_from_provider_snapshot(address, &session.provider)
11387                {
11388                    agent_progress.push(progress);
11389                }
11390                workspace.sessions.push(session.clone());
11391            }
11392        }
11393        NodeSnapshot {
11394            node_id: self.node_id.clone(),
11395            enabled_providers: self.enabled_providers.clone(),
11396            provider_runtime_statuses: {
11397                let updates = self
11398                    .provider_runtime_status_updates
11399                    .read()
11400                    .unwrap_or_else(|poisoned| poisoned.into_inner());
11401                ProviderRuntimeStatuses::new(
11402                    self.provider_runtime_statuses
11403                        .iter()
11404                        .filter(|status| !updates.contains_key(status.provider()))
11405                        .cloned()
11406                        .chain(updates.values().cloned()),
11407                )
11408                .expect("refreshed provider runtime inventory remains bounded and unique")
11409            },
11410            workspaces: workspaces.into_values().collect(),
11411            session_records: self
11412                .session_records
11413                .lock()
11414                .unwrap_or_else(|poisoned| poisoned.into_inner())
11415                .records
11416                .values()
11417                .cloned()
11418                .collect(),
11419            managed_worktrees: self.managed_worktrees
11420                .lock()
11421                .unwrap_or_else(|poisoned| poisoned.into_inner())
11422                .snapshots(),
11423            launch_inventory: Some(LaunchInventory {
11424                spawn_profiles: Some(
11425                    self.spawn_profiles
11426                        .iter()
11427                        .filter_map(|profile| {
11428                            resolved_spawn_profile_summary(profile, &environment_profiles)
11429                        })
11430                        .collect(),
11431                ),
11432                bundles: Some(
11433                    self.bundle_catalog
11434                        .read()
11435                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11436                        .iter()
11437                        .map(NodeBundle::receipt)
11438                        .collect(),
11439                ),
11440                // Opaque station allowlist ids from empty-default catalog /
11441                // optional startup loader. Never secrets. Dig2 probe stays stub.
11442                network_allowlists: {
11443                    let catalog = self
11444                        .network_allowlist_catalog
11445                        .read()
11446                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11447                    if catalog.is_empty() {
11448                        None
11449                    } else {
11450                        Some(catalog.ids().cloned().collect())
11451                    }
11452                },
11453            }),
11454            agent_progress,
11455        }
11456    }
11457
11458    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11459        assert!(
11460            !matches!(
11461                &event,
11462                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11463            ),
11464            "terminal frame and agent stream events must use their own dedicated live channel",
11465        );
11466        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11467        let sequence = history
11468            .last_sequence
11469            .checked_add(1)
11470            .expect("node event sequence exhausted");
11471        if history.events.len() == NODE_EVENT_HISTORY_MAX {
11472            if let Some(evicted) = history.events.pop_front() {
11473                history.replay_floor_sequence = evicted
11474                    .sequence
11475                    .checked_add(1)
11476                    .expect("node replay floor sequence exhausted");
11477                history.removed_record_providers.remove(&evicted.sequence);
11478            }
11479        }
11480        match &event {
11481            NodeEvent::HarnessMcpReadCall { .. } => {
11482                panic!("transient harness MCP calls must not enter durable event history")
11483            }
11484            NodeEvent::SessionRecordUpserted { record } => {
11485                history
11486                    .record_providers
11487                    .insert(record.record_id.clone(), record.provider.clone());
11488            }
11489            NodeEvent::SessionRecordRemoved { record_id } => {
11490                if let Some(provider) = history.record_providers.remove(record_id) {
11491                    history.removed_record_providers.insert(sequence, provider);
11492                }
11493            }
11494            NodeEvent::ManagedWorktreeUpserted { .. }
11495            | NodeEvent::ManagedWorktreeRemoved { .. }
11496            | NodeEvent::Control { .. }
11497            | NodeEvent::ControllerChanged { .. }
11498            | NodeEvent::SessionRecordHistorySummarized { .. }
11499            | NodeEvent::WorkspaceAdded { .. }
11500            | NodeEvent::WorkspaceRemoved { .. }
11501            | NodeEvent::ResyncRequired { .. }
11502            | NodeEvent::AgentStream { .. }
11503            | NodeEvent::TerminalFrame { .. } => {}
11504        }
11505        let envelope = NodeEventEnvelope { sequence, event };
11506        history.events.push_back(envelope.clone());
11507        history.last_sequence = sequence;
11508        let _ = self.event_tx.send(envelope.clone());
11509        drop(history);
11510        envelope
11511    }
11512
11513    /// Publishes a transient (never durable) `NodeEvent` -- today only
11514    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
11515    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
11516    /// why this must never ride the shared `event_tx` bus). Returns whether
11517    /// the send reached at least one subscriber: `broadcast::Sender::send`
11518    /// fails in exactly one way, no active receiver at that instant, and
11519    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
11520    /// return to fail the pending call immediately rather than leave it to
11521    /// expire silently at `LOCAL_READ_TIMEOUT`/
11522    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
11523    fn publish_transient(&self, event: NodeEvent) -> bool {
11524        assert!(event.requires_harness_mcp_proxy_capability());
11525        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11526        let envelope = NodeEventEnvelope { sequence: 0, event };
11527        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11528            Ok(_) => true,
11529            Err(broadcast::error::SendError(undelivered)) => {
11530                if let Some(NodeEventEnvelope {
11531                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11532                    ..
11533                }) = undelivered.first() {
11534                    // `request.content_type` only, never a per-tool label --
11535                    // this crate does not decode `request.body` and does not
11536                    // hold `hatchery-harness-api` (Nested Control Plane
11537                    // doctrine, Law 3; see this crate's dependency on
11538                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
11539                    tracing::warn!(
11540                        call_id = call_id.as_str(),
11541                        content_type = ?request.content_type,
11542                        cause = "no-subscribers",
11543                        "harness MCP proxy event could not be published",
11544                    );
11545                }
11546                false
11547            }
11548        }
11549    }
11550
11551    fn publish_terminal_frames(&self) {
11552        let snapshot = self.handle.snapshot();
11553        let bindings = self
11554            .session_bindings
11555            .lock()
11556            .unwrap_or_else(|poisoned| poisoned.into_inner());
11557        let candidates = snapshot
11558            .sessions
11559            .iter()
11560            .filter_map(|session| {
11561                let binding = bindings.get(&session.instance_id)?;
11562                if binding.generation != session.generation {
11563                    return None;
11564                }
11565                let frame = session.terminal_frame.clone()?;
11566                crate::session_signals::feed(
11567                    binding.workspace_id.as_str(),
11568                    session.instance_id.0,
11569                    session.agent_id.as_str(),
11570                    &frame.contents,
11571                );
11572                crate::provider_limits::observe_screen(session.agent_id.as_str(), &frame.contents);
11573                crate::git_watch::provider_hint(binding.workspace_id.as_str(), session.instance_id.0, session.agent_id.as_str());
11574                Some((
11575                    SessionAddress {
11576                        workspace_id: binding.workspace_id.clone(),
11577                        session: SessionKey {
11578                            instance_id: session.instance_id,
11579                            generation: session.generation,
11580                        },
11581                    },
11582                    frame,
11583                ))
11584            })
11585            .collect();
11586        drop(bindings);
11587        self.publish_terminal_frame_candidates(candidates);
11588    }
11589
11590    fn publish_terminal_frame_candidates(
11591        &self,
11592        candidates: Vec<(SessionAddress, TerminalFrame)>,
11593    ) {
11594        let mut watermarks = self
11595            .terminal_frame_watermarks
11596            .lock()
11597            .unwrap_or_else(|poisoned| poisoned.into_inner());
11598        let mut events = Vec::with_capacity(candidates.len());
11599        for (address, frame) in candidates {
11600            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11601            let advanced = watermarks
11602                .get(&address.session.instance_id)
11603                .map_or(true, |(current_address, sequence)| {
11604                    current_address != &address || frame.sequence > *sequence
11605                });
11606            if !advanced {
11607                continue;
11608            }
11609            if is_first_frame {
11610                // Do NOT sessionize on first PTY output: a session is a
11611                // provider conversation id, not bytes on the wire. Drafts
11612                // leave the provisional map only when HQ attaches
11613                // (Resize / TerminalBytes). The attach-never-arrived timer
11614                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11615                // disconnect grace starts only on a real client WS close.
11616                // This is the first PTY output this node has observed for
11617                // this session -- a one-time event per spawn, not per-frame
11618                // chatter (the watermark makes sure of that). A session that
11619                // reaches this line with no controller attached anywhere on
11620                // the node is running unobserved: exactly how processes from
11621                // repeated spawn attempts went orphaned for hours without a
11622                // single line about it.
11623                if self.controller_state().is_none() {
11624                    tracing::warn!(
11625                        node_id = %self.node_id,
11626                        workspace_id = %address.workspace_id,
11627                        session = ?address.session,
11628                        terminal_size = ?frame.size,
11629                        "session producing output with no controller attached",
11630                    );
11631                } else {
11632                    tracing::info!(
11633                        node_id = %self.node_id,
11634                        workspace_id = %address.workspace_id,
11635                        session = ?address.session,
11636                        terminal_size = ?frame.size,
11637                        "session produced its first output",
11638                    );
11639                }
11640            }
11641            watermarks.insert(
11642                address.session.instance_id,
11643                (address.clone(), frame.sequence),
11644            );
11645            if let Some((at, reported)) = self
11646                .pty_input_marks
11647                .lock()
11648                .unwrap_or_else(|poisoned| poisoned.into_inner())
11649                .get_mut(&address.session.instance_id)
11650            {
11651                // First frame after an input: PTY write -> output published.
11652                if !*reported && at.elapsed() < Duration::from_secs(5) {
11653                    *reported = true;
11654                    tracing::debug!(target: "pty_lat", instance_id = address.session.instance_id.0, node_us = at.elapsed().as_micros() as u64, "pty input to frame published");
11655                }
11656            }
11657            events.push(NodeEvent::TerminalFrame { address, frame });
11658        }
11659        drop(watermarks);
11660        if events.is_empty() {
11661            return;
11662        }
11663        let mut history = self
11664            .history
11665            .lock()
11666            .unwrap_or_else(|poisoned| poisoned.into_inner());
11667        let mut envelopes = Vec::with_capacity(events.len());
11668        for event in events {
11669            let sequence = history
11670                .last_sequence
11671                .checked_add(1)
11672                .expect("node event sequence exhausted");
11673            history.last_sequence = sequence;
11674            envelopes.push(NodeEventEnvelope { sequence, event });
11675        }
11676        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11677        drop(history);
11678    }
11679
11680    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11681    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11682    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11683    /// counter every other event kind shares, never a counter of its own --
11684    /// `queue_connection_event`/`queue_connection_event_batch` compare
11685    /// `envelope.sequence` against one `discard_events_through` watermark
11686    /// regardless of which broadcast channel it arrived on, so a
11687    /// per-channel counter would desynchronize that comparison.
11688    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11689        let mut history = self
11690            .history
11691            .lock()
11692            .unwrap_or_else(|poisoned| poisoned.into_inner());
11693        let sequence = history
11694            .last_sequence
11695            .checked_add(1)
11696            .expect("node event sequence exhausted");
11697        history.last_sequence = sequence;
11698        drop(history);
11699        // Debug, not info/warn -- this fires on every single chunk, but it
11700        // is exactly the counter the next drop-measurement pass needs: what
11701        // the node actually emitted (`source_sequence`, `kind`) versus what
11702        // a harness subscriber's own forwarder later logs having received
11703        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11704        // event"), so a gap between the two logs names itself instead of
11705        // needing a new counter added ad hoc.
11706        tracing::debug!(
11707            source_sequence = chunk.source_sequence,
11708            kind = agent_stream_chunk_kind_label(&chunk.kind),
11709            envelope_sequence = sequence,
11710            "publishing agent stream chunk",
11711        );
11712        let envelope = NodeEventEnvelope {
11713            sequence,
11714            event: NodeEvent::AgentStream { address, chunk },
11715        };
11716        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11717    }
11718
11719    fn current_sequence(&self) -> u64 {
11720        self.history
11721            .lock()
11722            .unwrap_or_else(|poisoned| poisoned.into_inner())
11723            .last_sequence
11724    }
11725
11726    fn controller_state(&self) -> Option<ControllerState> {
11727        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11728        let lease = controller.as_ref().copied()?;
11729        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11730        if remaining.is_zero() {
11731            *controller = None;
11732            return None;
11733        }
11734        Some(ControllerState {
11735            connection_id: lease.connection_id,
11736            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11737        })
11738    }
11739
11740    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11741        if role != ClientRole::Operator {
11742            tracing::warn!(
11743                node_id = %self.node_id,
11744                connection_id,
11745                "controller lease rejected: observer connections cannot acquire control",
11746            );
11747            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11748        }
11749        let lease_ms = if lease_ms == 0 {
11750            DEFAULT_CONTROLLER_LEASE_MS
11751        } else {
11752            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11753        };
11754        let now = Instant::now();
11755        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11756        if let Some(active) = controller.as_ref() {
11757            if active.connection_id != connection_id && active.expires_at > now {
11758                tracing::warn!(
11759                    node_id = %self.node_id,
11760                    connection_id,
11761                    holder_connection_id = active.connection_id,
11762                    "controller lease rejected: another operator holds it",
11763                );
11764                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11765            }
11766        }
11767        // A reconnect (this connection already holds the lease, renewing
11768        // before expiry) happens on a periodic cadence; logging it at INFO
11769        // every renewal would be exactly the heartbeat chatter this crate
11770        // family's level discipline forbids. A genuine attach -- the
11771        // controller identity actually changing -- is a state change and
11772        // gets INFO; a same-connection renewal gets DEBUG.
11773        let is_new_attach = match controller.as_ref() {
11774            Some(active) => active.connection_id != connection_id,
11775            None => true,
11776        };
11777        *controller = Some(ControllerLease {
11778            connection_id,
11779            expires_at: now + Duration::from_millis(lease_ms),
11780        });
11781        drop(controller);
11782        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11783        if is_new_attach {
11784            tracing::info!(
11785                node_id = %self.node_id,
11786                connection_id,
11787                lease_ms,
11788                "controller lease acquired",
11789            );
11790        } else {
11791            tracing::debug!(
11792                node_id = %self.node_id,
11793                connection_id,
11794                lease_ms,
11795                "controller lease renewed",
11796            );
11797        }
11798        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11799        Ok(state)
11800    }
11801
11802    fn release_controller(&self, connection_id: u64) -> bool {
11803        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11804        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11805        if released {
11806            *controller = None;
11807        }
11808        drop(controller);
11809        if released {
11810            tracing::info!(
11811                node_id = %self.node_id,
11812                connection_id,
11813                "controller lease released",
11814            );
11815            self.publish(NodeEvent::ControllerChanged { controller: None });
11816        }
11817        released
11818    }
11819
11820    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11821    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11822    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11823    /// provider session; it only cancels the orphan timer. Disconnect/reload
11824    /// draft kills are owned by HQ and arm only on a real client WS close.
11825    /// ACP path of launch options: once the agent advertises its config options, write the
11826    /// pending model / effort through session/set_config_option (session/set_model when the
11827    /// agent has no model option). Gaps are logged, never forced.
11828    fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11829        let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11830            return;
11831        };
11832        let id = address.session.instance_id;
11833        let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11834            return;
11835        };
11836        let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11837            .iter()
11838            .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11839            .collect();
11840        let (writes, set_model, effort_gap) =
11841            backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11842        if set_model {
11843            if let Some(model_id) = model.clone() {
11844                if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11845                    tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11846                }
11847            }
11848        }
11849        for (option_id, value_json) in writes {
11850            if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11851                tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11852            }
11853        }
11854        if effort_gap {
11855            tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11856        }
11857        tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11858    }
11859
11860    /// ACP auto-permit: switch to the provider's no-ask mode once modes are advertised, and
11861    /// answer every session/request_permission with approve.
11862    fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11863        let id = address.session.instance_id;
11864        let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11865        if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
11866            crate::auto_permit::forget_acp(id.0);
11867            return;
11868        }
11869        let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11870        if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
11871            tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
11872        }
11873        match provider_event {
11874            ProviderEvent::ModeChanged { mode_id, available } => {
11875                if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11876                    match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11877                        Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11878                        Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11879                    }
11880                }
11881            }
11882            ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11883                match self.dispatch(ControlCommand::ResolveInteraction {
11884                    instance_id: id,
11885                    generation: address.session.generation,
11886                    interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11887                    response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11888                }) {
11889                    Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11890                    Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11891                }
11892            }
11893            // NB: ACP emits SessionEnded at the end of every *turn* (prompt stopReason), so it
11894            // must not drop the auto-permit registration; that happens on process exit below.
11895            _ => {}
11896        }
11897    }
11898
11899    fn mark_spawn_provisional(&self, address: SessionAddress) {
11900        let id = address.session.instance_id;
11901        self.provisional_spawns
11902            .lock()
11903            .unwrap_or_else(|poisoned| poisoned.into_inner())
11904            .insert(id, (address, Instant::now()));
11905        tracing::info!(
11906            node_id = %self.node_id,
11907            instance_id = id.0,
11908            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11909            "draft spawn; waiting for HQ view",
11910        );
11911    }
11912
11913    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11914        let removed = self
11915            .provisional_spawns
11916            .lock()
11917            .unwrap_or_else(|poisoned| poisoned.into_inner())
11918            .remove(&instance_id);
11919        if removed.is_some() {
11920            tracing::info!(
11921                node_id = %self.node_id,
11922                instance_id = instance_id.0,
11923                "draft spawn claimed by HQ view",
11924            );
11925        }
11926    }
11927
11928    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11929        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11930        let mut map = self
11931            .provisional_spawns
11932            .lock()
11933            .unwrap_or_else(|poisoned| poisoned.into_inner());
11934        let due: Vec<AgentInstanceId> = map
11935            .iter()
11936            .filter(|(_, (_, at))| at.elapsed() >= grace)
11937            .map(|(id, _)| *id)
11938            .collect();
11939        due.into_iter()
11940            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11941            .collect()
11942    }
11943
11944    async fn reap_provisional_spawn(&self, address: SessionAddress) {
11945        let instance_id = address.session.instance_id;
11946        tracing::warn!(
11947            node_id = %self.node_id,
11948            instance_id = instance_id.0,
11949            workspace = %address.workspace_id,
11950            reason = "attach_never_arrived",
11951            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11952            "draft spawn never viewed; stopping PTY",
11953        );
11954        let _ = self.dispatch(ControlCommand::Stop {
11955            instance_id,
11956            force: true,
11957        });
11958        let _ = self.remove_session(&address).await;
11959    }
11960
11961    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11962        if role != ClientRole::Operator {
11963            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11964        }
11965        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11966        let now = Instant::now();
11967        match controller.as_ref().copied() {
11968            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11969            Some(active) if active.expires_at <= now => {
11970                *controller = None;
11971                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11972            }
11973            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11974        }
11975    }
11976
11977    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11978        if !self
11979            .workspaces
11980            .read()
11981            .unwrap_or_else(|poisoned| poisoned.into_inner())
11982            .contains_key(&address.workspace_id)
11983        {
11984            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11985        }
11986        let binding = self
11987            .session_bindings
11988            .lock()
11989            .unwrap_or_else(|poisoned| poisoned.into_inner())
11990            .get(&address.session.instance_id)
11991            .cloned()
11992            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11993        if binding.workspace_id != address.workspace_id {
11994            return Err(failure(
11995                NodeFailureCode::SessionWorkspaceMismatch,
11996                "session belongs to another workspace",
11997            ));
11998        }
11999        if binding.generation != address.session.generation {
12000            return Err(failure(
12001                NodeFailureCode::StaleGeneration,
12002                &format!(
12003                    "stale session generation: expected {}, received {}",
12004                    binding.generation.0,
12005                    address.session.generation.0,
12006                ),
12007            ));
12008        }
12009        let snapshot = self.handle.snapshot();
12010        let Some(current) = snapshot
12011            .sessions
12012            .iter()
12013            .find(|item| item.instance_id == address.session.instance_id)
12014        else {
12015            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12016        };
12017        if current.generation != address.session.generation {
12018            return Err(failure(
12019                NodeFailureCode::StaleGeneration,
12020                &format!(
12021                    "stale session generation: expected {}, received {}",
12022                    current.generation.0,
12023                    address.session.generation.0,
12024                ),
12025            ));
12026        }
12027        Ok(current.agent_id.clone())
12028    }
12029
12030    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12031        self.dispatch_envelope(self.prepare_command(command))
12032    }
12033
12034    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12035        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12036        CommandEnvelope {
12037            id: CommandId(id),
12038            command,
12039        }
12040    }
12041
12042    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12043        let command_id = envelope.id;
12044        self.handle
12045            .dispatch(envelope)
12046            .map_err(|error| match error {
12047                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12048                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12049            })?;
12050        Ok(command_id)
12051    }
12052
12053    /// Records the kernel's own reason for asynchronously refusing
12054    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
12055    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
12056    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
12057    /// first, and if the map is still at capacity after that, the single
12058    /// oldest surviving entry is evicted -- `CommandId` is monotonically
12059    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
12060    /// is always the oldest.
12061    fn record_rejected_command(
12062        &self,
12063        command_id: CommandId,
12064        message: String,
12065        unsupported_transport: bool,
12066    ) {
12067        let now = Instant::now();
12068        let mut reasons = self.rejected_command_reasons.lock()
12069            .unwrap_or_else(|poisoned| poisoned.into_inner());
12070        reasons.retain(|_, reason| reason.expires_at > now);
12071        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12072            if let Some(&oldest) = reasons.keys().next() {
12073                reasons.remove(&oldest);
12074            }
12075        }
12076        reasons.insert(
12077            command_id,
12078            RejectedCommandReason {
12079                message,
12080                unsupported_transport,
12081                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12082            },
12083        );
12084    }
12085
12086    /// Removes and returns the kernel's reason for refusing `command_id`,
12087    /// if the drive loop has recorded one and it has not expired -- see
12088    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
12089    /// wait loop calls this instead of blindly polling out its whole
12090    /// deadline when the command it is waiting on turns out to have been
12091    /// refused rather than merely slow.
12092    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12093        let now = Instant::now();
12094        let mut reasons = self.rejected_command_reasons.lock()
12095            .unwrap_or_else(|poisoned| poisoned.into_inner());
12096        match reasons.remove(&command_id) {
12097            Some(reason) if reason.expires_at > now => Some(reason),
12098            _ => None,
12099        }
12100    }
12101
12102    async fn dispatch_bounded(
12103        &self,
12104        command: ControlCommand,
12105        timeout_duration: Duration,
12106    ) -> Result<CommandId, NodeFailure> {
12107        let deadline = Instant::now() + timeout_duration;
12108        loop {
12109            match self.dispatch(command.clone()) {
12110                Ok(command_id) => return Ok(command_id),
12111                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12112                    sleep(Duration::from_millis(2)).await;
12113                }
12114                Err(error) => return Err(error),
12115            }
12116        }
12117    }
12118
12119    fn internal_session_snapshot(
12120        &self,
12121        address: &SessionAddress,
12122    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12123        self.validate_address(address)?;
12124        self.handle
12125            .snapshot()
12126            .sessions
12127            .clone()
12128            .into_iter()
12129            .find(|session| {
12130                session.instance_id == address.session.instance_id
12131                    && session.generation == address.session.generation
12132            })
12133            .ok_or_else(|| {
12134                failure(
12135                    NodeFailureCode::UnknownSession,
12136                    "session snapshot is unavailable",
12137                )
12138            })
12139    }
12140
12141    /// Confirms `address` names a live session spawned over the ACP
12142    /// transport and returns its current snapshot -- the gate every
12143    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
12144    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
12145    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
12146    /// the session's own recorded `TransportKind` does, and that lives on
12147    /// the live snapshot, not the binding.
12148    fn require_acp_session(
12149        &self,
12150        address: &SessionAddress,
12151    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12152        let snapshot = self.internal_session_snapshot(address)?;
12153        if snapshot.transport != TransportKind::Acp {
12154            return Err(failure(
12155                NodeFailureCode::UnsupportedTransport,
12156                "this verb requires a session spawned over the ACP transport",
12157            ));
12158        }
12159        Ok(snapshot)
12160    }
12161
12162    async fn dispatch_history_bounded(
12163        &self,
12164        address: &SessionAddress,
12165        command: ControlCommand,
12166        operation: HistoryOperation,
12167    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12168        self.validate_address(address)?;
12169        let started_after = self.current_sequence();
12170        let command_id = self
12171            .dispatch_bounded(
12172                command,
12173                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12174            )
12175            .await?;
12176        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12177        let mut scan_after = started_after;
12178        let mut accepted_after = None;
12179        loop {
12180            let events = self
12181                .history
12182                .lock()
12183                .unwrap_or_else(|poisoned| poisoned.into_inner())
12184                .events
12185                .iter()
12186                .filter(|envelope| envelope.sequence > scan_after)
12187                .cloned()
12188                .collect::<Vec<_>>();
12189            for envelope in events {
12190                scan_after = scan_after.max(envelope.sequence);
12191                let NodeEvent::Control {
12192                    address: event_address,
12193                    event,
12194                } = envelope.event
12195                else {
12196                    continue;
12197                };
12198                if event_address != *address {
12199                    continue;
12200                }
12201                if event.command_id == Some(command_id) {
12202                    match &event.event {
12203                        ControlEventKind::CommandRejected { .. } => {
12204                            return Err(failure(
12205                                NodeFailureCode::InvalidRequest,
12206                                "history request was rejected",
12207                            ));
12208                        }
12209                        ControlEventKind::HistoryRequested {
12210                            operation: requested,
12211                            ..
12212                        } if requested == &operation => {
12213                            accepted_after = Some(envelope.sequence);
12214                            continue;
12215                        }
12216                        _ => {}
12217                    }
12218                }
12219                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12220                    match (&operation, &event.event) {
12221                        (
12222                            HistoryOperation::Discover { .. },
12223                            ControlEventKind::HistoryDiscovered { .. },
12224                        )
12225                        | (
12226                            HistoryOperation::Load { .. },
12227                            ControlEventKind::HistoryLoaded { .. },
12228                        ) => return self.internal_session_snapshot(address),
12229                        (_, ControlEventKind::HistoryFailed { .. }) => {
12230                            return Err(failure(
12231                                NodeFailureCode::BackendOperationFailed,
12232                                "history operation failed",
12233                            ));
12234                        }
12235                        _ => {}
12236                    }
12237                }
12238            }
12239            if Instant::now() >= deadline {
12240                return Err(failure(
12241                    NodeFailureCode::BackendBusy,
12242                    if accepted_after.is_some() {
12243                        "history operation did not settle before the bounded deadline"
12244                    } else {
12245                        "history operation was not accepted before the bounded deadline"
12246                    },
12247                ));
12248            }
12249            sleep(Duration::from_millis(2)).await;
12250        }
12251    }
12252
12253    async fn discover_history(
12254        &self,
12255        address: &SessionAddress,
12256        limit: u16,
12257    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12258        let query = HistoryQuery {
12259            working_directory: Some(self.workspace_root(&address.workspace_id)?),
12260            limit,
12261        };
12262        query
12263            .validate()
12264            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12265        let snapshot = self
12266            .dispatch_history_bounded(
12267                address,
12268                ControlCommand::DiscoverHistory {
12269                    instance_id: address.session.instance_id,
12270                    query: query.clone(),
12271                },
12272                HistoryOperation::Discover { query },
12273            )
12274            .await?;
12275        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12276            return Err(failure(
12277                NodeFailureCode::BackendOperationFailed,
12278                "history discovery settled into an invalid state",
12279            ));
12280        }
12281        Ok(snapshot.history.candidates)
12282    }
12283
12284    async fn load_history(
12285        &self,
12286        address: &SessionAddress,
12287        candidate_id: String,
12288    ) -> Result<HistorySessionRecord, NodeFailure> {
12289        validate_candidate_id(&candidate_id)
12290            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12291        let snapshot = self
12292            .dispatch_history_bounded(
12293                address,
12294                ControlCommand::LoadHistory {
12295                    instance_id: address.session.instance_id,
12296                    candidate_id: candidate_id.clone(),
12297                },
12298                HistoryOperation::Load {
12299                    candidate_id: candidate_id.clone(),
12300                },
12301            )
12302            .await?;
12303        if snapshot.history.pending.is_some()
12304            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12305        {
12306            return Err(failure(
12307                NodeFailureCode::BackendOperationFailed,
12308                "history load settled into an invalid state",
12309            ));
12310        }
12311        snapshot.history.loaded.ok_or_else(|| {
12312            failure(
12313                NodeFailureCode::BackendOperationFailed,
12314                "history load produced no bounded session",
12315            )
12316        })
12317    }
12318
12319    /// `allow_clean_detachment`: `false` for the on-demand
12320    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
12321    /// managed record must stay untouched, still live, for the whole call);
12322    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
12323    /// reacts to the very clean-exit event that detaches the record out from
12324    /// under this same call — see `session_record_export_target_matches`.
12325    async fn export_context_pack_for_session_record(
12326        &self,
12327        record_id: &SessionRecordId,
12328        session: &SessionAddress,
12329        allow_clean_detachment: bool,
12330    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12331        // C2 replaces a relayed failure's prose with a canned per-code
12332        // string (`C2NodeFailure`'s own `From`), so this path's dozen
12333        // distinct refusals are indistinguishable to anyone downstream --
12334        // and a refusal here expires a continuation, which keeps no field
12335        // for why. The prose stays on the node, where it was written.
12336        let outcome = self
12337            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12338            .await;
12339        if let Err(failure) = &outcome {
12340            tracing::warn!(
12341                record = %record_id,
12342                code = ?failure.code,
12343                reason = %failure.message,
12344                "session-record context-pack export refused",
12345            );
12346        }
12347        outcome
12348    }
12349
12350    async fn export_context_pack_for_session_record_inner(
12351        &self,
12352        record_id: &SessionRecordId,
12353        session: &SessionAddress,
12354        allow_clean_detachment: bool,
12355    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12356        // Several steps here refuse with the same BackendOperationFailed
12357        // code and no prose of their own, so which step failed is
12358        // otherwise unrecoverable -- and a refusal here expires a
12359        // continuation.
12360        let step = |name: &'static str| move |failure: NodeFailure| {
12361            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12362            failure
12363        };
12364        let record = self.record(record_id).map_err(step("record-lookup"))?;
12365        // `provider_session` is `Some` only for a provider whose resolved
12366        // runtime policy admits live identity verification (raw PTY plus a
12367        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
12368        // The PTY-sidecar-observation shape (no fleet provider today, but
12369        // capability-keyed rather than vendor-locked) hardcodes
12370        // `provider_session_identity: false` in `provider_runtime::evaluate`,
12371        // so its records go straight to `Live` with `provider_session: None`
12372        // and stay that way for the record's entire life -- that is this
12373        // shape's permanent resting state, not a race with observation, and
12374        // `discover_history`/`load_history` below do not need an identity to
12375        // do their job for it. `IdentityPending` is the actual race (an
12376        // identity-admitted provider whose observation has not landed yet):
12377        // it keeps refusing here rather than falling back, because that
12378        // provider's own history the fallback would otherwise trust is not
12379        // guaranteed to be exact without the identity it is still waiting on.
12380        let identity = match record.provider_session.clone() {
12381            Some(identity) => {
12382                if identity.key != ProviderSessionKey::SessionId {
12383                    return Err(step("provider-identity-key")(failure(
12384                        NodeFailureCode::BackendOperationFailed,
12385                        "managed session provider identity cannot bind history exactly",
12386                    )));
12387                }
12388                Some(identity)
12389            }
12390            None if record.state == ManagedSessionState::IdentityPending => {
12391                return Err(step("provider-session-identity")(failure(
12392                    NodeFailureCode::BackendOperationFailed,
12393                    "managed session has not yet observed its provider session identity",
12394                )));
12395            }
12396            None => None,
12397        };
12398        self.revalidate_session_record_context_export(
12399            &record,
12400            identity.as_ref(),
12401            session,
12402            allow_clean_detachment,
12403        ).map_err(step("revalidate-before-discovery"))?;
12404        let candidates = self
12405            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12406            .await
12407            .map_err(step("discover-history"))?;
12408        // With no verified identity to filter by, the discovered set must
12409        // still resolve to exactly one candidate: the same ambiguity refusal
12410        // an identity filter would otherwise produce is the only safety
12411        // property left available for this shape.
12412        let candidate_id = select_history_candidate(
12413            &candidates,
12414            identity.as_ref().map(|identity| identity.id.as_str()),
12415        ).map_err(step("unique-candidate"))?;
12416        let loaded = self.load_history(session, candidate_id.clone()).await
12417            .map_err(step("load-history"))?;
12418        if let Some(identity) = identity.as_ref() {
12419            if loaded.session_id != identity.id {
12420                return Err(failure(
12421                    NodeFailureCode::SessionRecordConflict,
12422                    "loaded history identity does not match the managed session record",
12423                ));
12424            }
12425        }
12426        self.revalidate_session_record_context_export(
12427            &record,
12428            identity.as_ref(),
12429            session,
12430            allow_clean_detachment,
12431        )?;
12432        self.revalidate_loaded_history(session, &candidate_id, &loaded)
12433            .map_err(step("revalidate-loaded-history"))?;
12434        let pack = self.materialize_context_pack(session).await
12435            .map_err(step("materialize-context-pack"))?;
12436        self.commit_context_pack_for_session_record(
12437            &record,
12438            identity.as_ref(),
12439            session,
12440            &candidate_id,
12441            &loaded,
12442            pack,
12443            allow_clean_detachment,
12444        )
12445    }
12446
12447    async fn export_context_pack(
12448        &self,
12449        address: &SessionAddress,
12450    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12451        let pack = self.materialize_context_pack(address).await?;
12452        self.commit_context_pack(pack)
12453    }
12454
12455    async fn materialize_context_pack(
12456        &self,
12457        address: &SessionAddress,
12458    ) -> Result<NodeContextPack, NodeFailure> {
12459        let snapshot = self.internal_session_snapshot(address)?;
12460        if !context_pack_source_status_is_usable(&snapshot.status) {
12461            return Err(failure(
12462                NodeFailureCode::InvalidRequest,
12463                "context export requires a running or successfully exited source session",
12464            ));
12465        }
12466        if snapshot.history.pending.is_some()
12467            || snapshot.history.loaded_candidate_id.is_none()
12468        {
12469            return Err(failure(
12470                NodeFailureCode::InvalidRequest,
12471                "context export requires settled loaded history",
12472            ));
12473        }
12474        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12475            failure(
12476                NodeFailureCode::InvalidRequest,
12477                "context export requires settled loaded history",
12478            )
12479        })?;
12480        let source_provider = snapshot.agent_id.clone();
12481        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12482        let repository = self
12483            .context_pack_repository(&address.workspace_id)
12484            .await?;
12485        let current = self.internal_session_snapshot(address)?;
12486        if !context_pack_source_status_is_usable(&current.status)
12487            || current.agent_id != source_provider
12488            || current.history.pending.is_some()
12489            || current.history.loaded_candidate_id != loaded_candidate_id
12490            || current.history.loaded.as_ref() != Some(&loaded)
12491        {
12492            return Err(failure(
12493                NodeFailureCode::ContextPackMaterializationFailed,
12494                "context source changed during bounded repository capture",
12495            ));
12496        }
12497        NodeContextPack::export_with_repository(
12498            ContextPackLineageReceipt {
12499                source_node_id: self.node_id.clone(),
12500                source_session: address.clone(),
12501                source_provider,
12502            },
12503            &loaded,
12504            Some(repository),
12505        )
12506        .map_err(|_| {
12507            failure(
12508                NodeFailureCode::ContextPackMaterializationFailed,
12509                "bounded context pack export failed",
12510            )
12511        })
12512    }
12513
12514    /// Write-through to the durable [`ContextPackStore`], if one is
12515    /// configured (`state_path` is `Some`). A node with no durable state
12516    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
12517    /// open at all and this is a no-op, matching `persist_state`'s own
12518    /// "durability optional, degrade gracefully when unconfigured" idiom.
12519    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12520        let mut store = self
12521            .context_pack_store
12522            .lock()
12523            .unwrap_or_else(|poisoned| poisoned.into_inner());
12524        let Some(store) = store.as_mut() else {
12525            return Ok(());
12526        };
12527        store.commit(pack).map_err(|_| {
12528            failure(
12529                NodeFailureCode::ContextPackMaterializationFailed,
12530                "durable context pack commit failed",
12531            )
12532        })
12533    }
12534
12535    fn commit_context_pack(
12536        &self,
12537        pack: NodeContextPack,
12538    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12539        self.commit_context_pack_durably(&pack)?;
12540        self.context_catalog
12541            .write()
12542            .unwrap_or_else(|poisoned| poisoned.into_inner())
12543            .insert(pack)
12544            .map_err(|_| {
12545                failure(
12546                    NodeFailureCode::ContextPackMaterializationFailed,
12547                    "context pack catalog is full",
12548                )
12549            })
12550    }
12551
12552    fn commit_context_pack_for_session_record(
12553        &self,
12554        expected: &ManagedSessionRecord,
12555        identity: Option<&ProviderSessionIdentity>,
12556        session: &SessionAddress,
12557        candidate_id: &str,
12558        loaded: &HistorySessionRecord,
12559        pack: NodeContextPack,
12560        allow_clean_detachment: bool,
12561    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12562        let current_root = self.workspace_root(&expected.workspace_id)?;
12563        let _transaction = self
12564            .state_transaction
12565            .lock()
12566            .unwrap_or_else(|poisoned| poisoned.into_inner());
12567        let records = self
12568            .session_records
12569            .lock()
12570            .unwrap_or_else(|poisoned| poisoned.into_inner());
12571        let current = records.records.get(&expected.record_id).ok_or_else(|| {
12572            failure(
12573                NodeFailureCode::SessionRecordConflict,
12574                "managed session record disappeared during context export",
12575            )
12576        })?;
12577        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12578            return Err(failure(
12579                NodeFailureCode::SessionRecordConflict,
12580                "managed session record changed during context export",
12581            ));
12582        }
12583        let bindings = self
12584            .session_bindings
12585            .lock()
12586            .unwrap_or_else(|poisoned| poisoned.into_inner());
12587        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12588            failure(
12589                NodeFailureCode::SessionRecordConflict,
12590                "managed session runtime binding disappeared during context export",
12591            )
12592        })?;
12593        let runtime = self
12594            .handle
12595            .snapshot()
12596            .sessions
12597            .iter()
12598            .find(|snapshot| {
12599                snapshot.instance_id == session.session.instance_id
12600                    && snapshot.generation == session.session.generation
12601            })
12602            .cloned()
12603            .ok_or_else(|| {
12604                failure(
12605                    NodeFailureCode::SessionRecordConflict,
12606                    "managed session runtime disappeared during context export",
12607                )
12608            })?;
12609        let receipt = pack.receipt();
12610        if !session_record_context_export_binding_is_exact(
12611            expected,
12612            current,
12613            identity,
12614            session,
12615            binding,
12616            &runtime.agent_id,
12617            &current_root,
12618            allow_clean_detachment,
12619        ) || !session_record_context_export_source_is_usable(
12620            current,
12621            &runtime.status,
12622            allow_clean_detachment,
12623        ) || runtime.history.pending.is_some()
12624            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12625            || runtime.history.loaded.as_ref() != Some(loaded)
12626            || receipt.lineage.source_node_id != self.node_id
12627            || receipt.lineage.source_session != *session
12628            || receipt.lineage.source_provider != current.provider
12629            || receipt.source_message_count != loaded.message_count
12630        {
12631            return Err(failure(
12632                NodeFailureCode::SessionRecordConflict,
12633                "managed session source changed before context catalog commit",
12634            ));
12635        }
12636        self.commit_context_pack_durably(&pack)?;
12637        self.context_catalog
12638            .write()
12639            .unwrap_or_else(|poisoned| poisoned.into_inner())
12640            .insert(pack)
12641            .map_err(|_| {
12642                failure(
12643                    NodeFailureCode::ContextPackMaterializationFailed,
12644                    "context pack catalog is full",
12645                )
12646            })
12647    }
12648
12649    async fn context_pack_repository(
12650        &self,
12651        workspace_id: &WorkspaceId,
12652    ) -> Result<ContextPackRepository, NodeFailure> {
12653        let first = self
12654            .context_pack_repository_observation(workspace_id)
12655            .await?;
12656        let second = self
12657            .context_pack_repository_observation(workspace_id)
12658            .await?;
12659        stable_context_pack_repository(first, second)
12660    }
12661
12662    async fn context_pack_repository_observation(
12663        &self,
12664        workspace_id: &WorkspaceId,
12665    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12666        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12667        let head = if git.is_repository {
12668            let root = self.workspace_root(workspace_id)?;
12669            match resolve_base_commit_with_timeout(
12670                &root,
12671                "HEAD",
12672                GIT_COMMAND_TIMEOUT_MS,
12673            )
12674            .await
12675            {
12676                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12677                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12678                    ContextPackRepositoryHead::Unborn
12679                }
12680                Err(_) => {
12681                    return Err(failure(
12682                        NodeFailureCode::ContextPackMaterializationFailed,
12683                        "context pack HEAD observation failed",
12684                    ));
12685                }
12686            }
12687        } else {
12688            ContextPackRepositoryHead::NotRepository
12689        };
12690        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12691        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12692            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12693                failure(
12694                    NodeFailureCode::InvalidRepositoryPath,
12695                    "context pack selected file path is invalid",
12696                )
12697            })?;
12698            match self.read_workspace_file(workspace_id.clone(), path).await {
12699                Ok(WorkspaceFileRead {
12700                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12701                    ..
12702                }) => files.push(ContextPackRepositoryFileSource::utf8(
12703                    selected_path,
12704                    text,
12705                    byte_len,
12706                )),
12707                Ok(WorkspaceFileRead {
12708                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12709                    ..
12710                }) => files.push(ContextPackRepositoryFileSource::skipped(
12711                    selected_path,
12712                    ContextPackSelectedFileSkipReason::NonUtf8,
12713                    Some(byte_len),
12714                )),
12715                Ok(WorkspaceFileRead {
12716                    content: WorkspaceFileContent::TooLarge { .. },
12717                    ..
12718                }) => files.push(ContextPackRepositoryFileSource::skipped(
12719                    selected_path,
12720                    ContextPackSelectedFileSkipReason::TooLarge,
12721                    None,
12722                )),
12723                Err(error) => {
12724                    let reason = if matches!(
12725                        error.code,
12726                        NodeFailureCode::InvalidRepositoryPath
12727                            | NodeFailureCode::RepositoryFileNotRegular
12728                            | NodeFailureCode::RepositoryPathUnsafe
12729                    ) {
12730                        ContextPackSelectedFileSkipReason::Unsafe
12731                    } else {
12732                        ContextPackSelectedFileSkipReason::Unavailable
12733                    };
12734                    files.push(ContextPackRepositoryFileSource::skipped(
12735                        selected_path,
12736                        reason,
12737                        None,
12738                    ));
12739                }
12740            }
12741        }
12742        Ok(ContextPackRepositoryObservation { head, git, files })
12743    }
12744
12745    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12746        let referenced_by_binding = self
12747            .session_bindings
12748            .lock()
12749            .unwrap_or_else(|poisoned| poisoned.into_inner())
12750            .values()
12751            .any(|binding| {
12752                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12753            });
12754        let referenced_by_record = self
12755            .session_records
12756            .lock()
12757            .unwrap_or_else(|poisoned| poisoned.into_inner())
12758            .records
12759            .values()
12760            .any(|record| record.context_id.as_ref() == Some(context_id));
12761        let referenced_by_materialization = self
12762            .materializations
12763            .lock()
12764            .unwrap_or_else(|poisoned| poisoned.into_inner())
12765            .values()
12766            .any(|ownership| {
12767                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12768            });
12769        if referenced_by_binding
12770            || referenced_by_record
12771            || referenced_by_materialization
12772        {
12773            return Err(failure(
12774                NodeFailureCode::ContextPackBusy,
12775                "context pack is still referenced",
12776            ));
12777        }
12778        let mut catalog = self.context_catalog
12779            .write()
12780            .unwrap_or_else(|poisoned| poisoned.into_inner());
12781        if catalog.get(context_id).is_none() {
12782            return Err(failure(
12783                NodeFailureCode::UnknownContextPack,
12784                "context pack is unavailable",
12785            ));
12786        }
12787        self.spawn_idempotency
12788            .lock()
12789            .unwrap_or_else(|poisoned| poisoned.into_inner())
12790            .entries
12791            .retain(|_, entry| !entry.value.references_context(context_id));
12792        let removed = catalog.remove(context_id);
12793        debug_assert!(removed.is_some(), "validated context pack remains present");
12794        Ok(())
12795    }
12796
12797    async fn dispatch_input_bounded(
12798        &self,
12799        address: &SessionAddress,
12800        action: InputAction,
12801    ) -> Result<(), NodeFailure> {
12802        let started_after = self.current_sequence();
12803        self.pty_input_marks
12804            .lock()
12805            .unwrap_or_else(|poisoned| poisoned.into_inner())
12806            .insert(address.session.instance_id, (Instant::now(), false));
12807        self.pty_input_wake.notify_one();
12808        let command_id = self
12809            .dispatch_bounded(
12810                ControlCommand::SendInput {
12811                    instance_id: address.session.instance_id,
12812                    action,
12813                },
12814                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12815            )
12816            .await?;
12817        // A semantic prompt may spend the provider's full readiness budget before
12818        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12819        // but strictly beyond every readiness policy in this node's catalog.
12820        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12821        let mut scan_after = started_after;
12822        let mut accepted_after = None;
12823        loop {
12824            let events = self
12825                .history
12826                .lock()
12827                .unwrap_or_else(|poisoned| poisoned.into_inner())
12828                .events
12829                .iter()
12830                .filter(|envelope| envelope.sequence > scan_after)
12831                .cloned()
12832                .collect::<Vec<_>>();
12833            for envelope in events {
12834                scan_after = scan_after.max(envelope.sequence);
12835                let NodeEvent::Control {
12836                    address: event_address,
12837                    event,
12838                } = envelope.event
12839                else {
12840                    continue;
12841                };
12842                if event_address != *address {
12843                    continue;
12844                }
12845                if event.command_id == Some(command_id) {
12846                    match &event.event {
12847                        ControlEventKind::CommandRejected { message } => {
12848                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12849                        }
12850                        ControlEventKind::InputRequested { .. } => {
12851                            accepted_after = Some(envelope.sequence);
12852                            continue;
12853                        }
12854                        _ => {}
12855                    }
12856                }
12857                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12858                    match &event.event {
12859                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12860                        ControlEventKind::InputFailed { message, .. } => {
12861                            return Err(failure(
12862                                NodeFailureCode::BackendOperationFailed,
12863                                message,
12864                            ));
12865                        }
12866                        _ => {}
12867                    }
12868                }
12869            }
12870            if Instant::now() >= deadline {
12871                return Err(failure(
12872                    NodeFailureCode::BackendBusy,
12873                    if accepted_after.is_some() {
12874                        "input was accepted but did not settle before the bounded deadline"
12875                    } else {
12876                        "input was not accepted before the bounded deadline"
12877                    },
12878                ));
12879            }
12880            sleep(Duration::from_millis(2)).await;
12881        }
12882    }
12883
12884    async fn dispatch_resize_bounded(
12885        &self,
12886        address: &SessionAddress,
12887        size: gate4agent_types::TerminalSize,
12888    ) -> Result<(), NodeFailure> {
12889        let started_after = self.current_sequence();
12890        let command_id = self
12891            .dispatch_bounded(
12892                ControlCommand::Resize {
12893                    instance_id: address.session.instance_id,
12894                    size,
12895                },
12896                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12897            )
12898            .await?;
12899        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12900        let mut scan_after = started_after;
12901        let mut accepted_after = None;
12902        loop {
12903            let events = self
12904                .history
12905                .lock()
12906                .unwrap_or_else(|poisoned| poisoned.into_inner())
12907                .events
12908                .iter()
12909                .filter(|envelope| envelope.sequence > scan_after)
12910                .cloned()
12911                .collect::<Vec<_>>();
12912            for envelope in events {
12913                scan_after = scan_after.max(envelope.sequence);
12914                let NodeEvent::Control {
12915                    address: event_address,
12916                    event,
12917                } = envelope.event
12918                else {
12919                    continue;
12920                };
12921                if event_address != *address {
12922                    continue;
12923                }
12924                if event.command_id == Some(command_id) {
12925                    match &event.event {
12926                        ControlEventKind::CommandRejected { message } => {
12927                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12928                        }
12929                        ControlEventKind::ResizeRequested {
12930                            size: requested_size,
12931                            ..
12932                        } if *requested_size == size => {
12933                            accepted_after = Some(envelope.sequence);
12934                            continue;
12935                        }
12936                        _ => {}
12937                    }
12938                }
12939                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12940                    match &event.event {
12941                        ControlEventKind::Resized { size: settled_size }
12942                            if *settled_size == size =>
12943                        {
12944                            return Ok(());
12945                        }
12946                        ControlEventKind::ResizeFailed { message } => {
12947                            return Err(failure(
12948                                NodeFailureCode::BackendOperationFailed,
12949                                message,
12950                            ));
12951                        }
12952                        _ => {}
12953                    }
12954                }
12955            }
12956            if Instant::now() >= deadline {
12957                return Err(failure(
12958                    NodeFailureCode::BackendBusy,
12959                    if accepted_after.is_some() {
12960                        "resize was accepted but did not settle before the bounded deadline"
12961                    } else {
12962                        "resize was not accepted before the bounded deadline"
12963                    },
12964                ));
12965            }
12966            sleep(Duration::from_millis(2)).await;
12967        }
12968    }
12969
12970    async fn spawn_session(
12971        &self,
12972        workspace_id: WorkspaceId,
12973        provider: AgentId,
12974        mode: SessionMode,
12975        terminal_size: gate4agent_types::TerminalSize,
12976        initial_prompt: Option<String>,
12977    ) -> Result<SessionAddress, NodeFailure> {
12978        if self.managed_worktrees.lock()
12979            .unwrap_or_else(|poisoned| poisoned.into_inner())
12980            .lease_for_workspace(&workspace_id)
12981            .is_some()
12982        {
12983            return Err(failure(
12984                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12985                "managed worktree targets may only be spawned through their lease request",
12986            ));
12987        }
12988        self
12989            .spawn_session_with_deadline(
12990                workspace_id,
12991                provider.clone(),
12992                mode,
12993                terminal_size,
12994                initial_prompt,
12995                None,
12996                None,
12997                None,
12998                None,
12999                None,
13000                SpawnRecordPolicy::ProviderIdentityOnly,
13001                None,
13002                &[],
13003                None,
13004                // `NodeRequest::Spawn` (this method's only caller) carries no
13005                // approval-level selection of its own -- see
13006                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
13007                // the one operator-wire path that can choose something other
13008                // than the axis default. Auto-permit may lower it where the agent
13009                // cannot offer its full-auto mode (claude ACP under root).
13010                crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13011                None,
13012                None,
13013            )
13014            .await
13015            .map(|(session, _runtime_policy)| session)
13016    }
13017
13018    async fn spawn_session_with_deadline(
13019        &self,
13020        workspace_id: WorkspaceId,
13021        provider: AgentId,
13022        mode: SessionMode,
13023        terminal_size: gate4agent_types::TerminalSize,
13024        initial_prompt: Option<String>,
13025        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13026        bundle: Option<ResolvedBundleReceipt>,
13027        context: Option<ResolvedContextPackReceipt>,
13028        managed_authority: Option<ManagedWorktreeLeaseId>,
13029        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13030        record_policy: SpawnRecordPolicy,
13031        deadline: Option<Instant>,
13032        required_capabilities: &[ProviderRuntimeCapability],
13033        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13034        approval_level: ApprovalLevel,
13035        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13036        // Opaque dig2browser station profile id (ids only). Feature
13037        // `dig2-station-probe`: after probe at resolve, spawn takes an
13038        // exclusive node-local lease; cleared on session end. Never
13039        // ImportSession / cookies on C2. Feature off: ignored (stub echo
13040        // already happened on the receipt path).
13041        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13042    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13043        let reserved_lease = self.managed_worktrees.lock()
13044            .unwrap_or_else(|poisoned| poisoned.into_inner())
13045            .lease_for_workspace(&workspace_id);
13046        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13047            (Some(expected), Some(provided)) if expected == provided => {}
13048            (None, None) => {}
13049            _ => {
13050                return Err(failure(
13051                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
13052                    "spawn lacks exact managed worktree lease authority",
13053                ));
13054            }
13055        }
13056        // Refuse concurrent distinct environment profiles (incl. ProviderHome
13057        // bindings) on the same workspace cwd. Parallel path: managed worktree
13058        // spawn so each profile gets its own workspace_id.
13059        self.ensure_workspace_environment_profile_compatible(
13060            &workspace_id,
13061            environment_profile.as_ref(),
13062        )?;
13063        let runtime_requirement = match (mode, initial_prompt.is_some()) {
13064            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13065            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13066            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13067            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13068        };
13069        // This session's own transport -- computed once, here, from `mode`,
13070        // and reused both to admit spawn (below) and, further down, to
13071        // compute the final `runtime_policy` this session actually operates
13072        // under (`provider_runtime::policy_for_transport`). Registration
13073        // reuses this exact value rather than recomputing it a second time.
13074        let transport = match mode {
13075            SessionMode::Pty => TransportKind::Pty,
13076            SessionMode::Inline => TransportKind::Pipe,
13077            SessionMode::Acp => TransportKind::Acp,
13078        };
13079        let pipe_transport = self.provider_supports_pipe_transport(&provider);
13080        let acp_transport = self.provider_supports_acp_transport(&provider);
13081        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13082            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13083                NodeFailureCode::UnsupportedSpawnCapability,
13084                "pre-admitted provider runtime no longer satisfies spawn requirements",
13085            ))?;
13086            runtime_policy
13087        } else if let Some(deadline) = deadline {
13088            let remaining = spawn_deadline_remaining(deadline)?;
13089            timeout(
13090                remaining,
13091                self.admit_provider_runtime(&provider, runtime_requirement),
13092            )
13093            .await
13094            .map_err(|_| {
13095                failure(
13096                    NodeFailureCode::SpawnDeadlineExceeded,
13097                    "provider admission exceeded the spawn deadline",
13098                )
13099            })??
13100        } else {
13101            self.admit_provider_runtime(&provider, runtime_requirement).await?
13102        };
13103        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13104        // The single point where this session's `runtime_policy` becomes
13105        // transport-correct: `admit_provider_runtime` (and the pre-admitted
13106        // branch above) only ever compute a PTY-terminal-verification-shaped
13107        // value, sound outright only for `TransportKind::Pty` (Pipe still
13108        // relies on that same value downstream -- see `policy_for_transport`'s
13109        // own doc for why). `policy_for_transport` is the explicit,
13110        // exhaustive match Item 1 asks for -- this is the ONE place its
13111        // output becomes the session's `runtime_policy`, so every later
13112        // reader (`ControlCommand::Start`, `session_bindings`, and every
13113        // engine gate that reads `state.runtime_policy`) sees the same,
13114        // correctly-computed value.
13115        let runtime_policy =
13116            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13117        if required_capabilities
13118            .iter()
13119            .any(|capability| !runtime_policy.admits(*capability))
13120        {
13121            return Err(failure(
13122                NodeFailureCode::UnsupportedSpawnCapability,
13123                "provider runtime does not admit a required spawn capability",
13124            ));
13125        }
13126        if let Some(deadline) = deadline {
13127            spawn_deadline_remaining(deadline)?;
13128        }
13129        self.ensure_binding_capacity()?;
13130        let mut working_directory = self.workspace_root(&workspace_id)?;
13131        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13132        let session = SessionKey {
13133            instance_id,
13134            generation: SessionGeneration(1),
13135        };
13136        let address = SessionAddress {
13137            workspace_id: workspace_id.clone(),
13138            session,
13139        };
13140        // This is the request-accepted checkpoint: admission (managed
13141        // worktree authority, runtime capability, dispatch deadline) already
13142        // passed and a concrete session identity is minted. It is a distinct
13143        // state from the process actually running (`ControlEventKind::Running`,
13144        // logged in `publish_control`) or the terminal outcome (`Exited`/
13145        // `Failed`, logged there too) -- this line exists so that gap is no
13146        // longer silent.
13147        tracing::info!(
13148            node_id = %self.node_id,
13149            workspace_id = %workspace_id,
13150            provider = %provider,
13151            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13152            session = ?address.session,
13153            "session spawn request accepted",
13154        );
13155        let mut prepared_spawn = SpawnPreparer::prepare(
13156            self,
13157            SpawnPrepareRequest {
13158                instance_id,
13159                address: &address,
13160                provider: &provider,
13161                mode,
13162                environment_profile: environment_profile.as_ref(),
13163                bundle: bundle.as_ref(),
13164                context: context.as_ref(),
13165                managed_authority: managed_authority.as_ref(),
13166                harness_mcp,
13167                approval_level,
13168                network_allowlist: network_allowlist.as_ref(),
13169                browser_profile_id,
13170            },
13171        )?;
13172        let materialization_id = prepared_spawn.materialization_id.take();
13173        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13174        let record_id = self.bind_spawn_session_with_materialization(
13175            &address,
13176            provider.clone(),
13177            mode,
13178            runtime_policy,
13179            record_policy,
13180            environment_profile,
13181            bundle,
13182            context,
13183            materialization_id,
13184        )?;
13185        prepared_spawn.retain_session_guards();
13186        // `transport` was already computed above, alongside this session's
13187        // `runtime_policy`; reused here for `Register` rather than derived a
13188        // second time.
13189        let session_provider = provider.clone();
13190        let agent_id = provider;
13191        let register_command_id = match self.dispatch_bounded(
13192            ControlCommand::Register {
13193                instance_id,
13194                agent_id,
13195                transport,
13196            },
13197            dispatch_timeout,
13198        )
13199        .await {
13200            Ok(command_id) => command_id,
13201            Err(error) => {
13202                if let Some(binding) = self.remove_binding(&address) {
13203                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13204                }
13205                if let Some(record_id) = record_id.as_ref() {
13206                    self.discard_record(record_id)?;
13207                }
13208                return Err(spawn_dispatch_error(error, deadline));
13209            }
13210        };
13211        let start_timeout = match spawn_dispatch_timeout(deadline) {
13212            Ok(timeout) => timeout,
13213            Err(error) => {
13214                let recovery = self
13215                    .rollback_spawn(
13216                        &address,
13217                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13218                    )
13219                    .await;
13220                return match recovery {
13221                    Ok(()) => {
13222                        if let Some(record_id) = record_id.as_ref() {
13223                            self.discard_record(record_id)?;
13224                        }
13225                        Err(error)
13226                    }
13227                    Err(recovery_error) => {
13228                        if let Some(record_id) = record_id.as_ref() {
13229                            self.mark_record_error(
13230                                record_id,
13231                                "spawn deadline elapsed and registration recovery failed",
13232                            )?;
13233                        }
13234                        Err(recovery_error)
13235                    }
13236                };
13237            }
13238        };
13239        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13240            if let Err(recovery_error) = self
13241                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13242                .await
13243            {
13244                return Err(recovery_error);
13245            }
13246            if let Some(record_id) = record_id.as_ref() {
13247                self.discard_record(record_id)?;
13248            }
13249            return Err(start_error);
13250        }
13251        // A session with its own m4a account runs in its own folder inside the workspace.
13252        if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13253            working_directory = dir.to_string_lossy().into_owned();
13254        }
13255        let start_result = self
13256            .dispatch_bounded(
13257                ControlCommand::Start {
13258                    instance_id,
13259                    runtime_policy,
13260                    request: StartRequest {
13261                        working_directory,
13262                        terminal_size,
13263                        initial_prompt,
13264                        session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13265                        approval_level,
13266                    },
13267                },
13268                start_timeout,
13269            )
13270            .await;
13271        let start_command_id = match start_result {
13272            Ok(command_id) => command_id,
13273            Err(start_error) => {
13274                let start_error = spawn_dispatch_error(start_error, deadline);
13275                let recovery = self
13276                    .rollback_spawn(
13277                        &address,
13278                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13279                    )
13280                    .await;
13281                return match recovery {
13282                    Ok(()) => {
13283                        if let Some(record_id) = record_id.as_ref() {
13284                            self.discard_record(record_id)?;
13285                        }
13286                        Err(start_error)
13287                    }
13288                    Err(recovery_error) => {
13289                        let diagnostic = format!(
13290                            "start failed and registration recovery failed: {}",
13291                            recovery_error.message,
13292                        );
13293                        if let Some(record_id) = record_id.as_ref() {
13294                            self.mark_record_error(record_id, &diagnostic)?;
13295                        }
13296                        Err(failure(recovery_error.code, &diagnostic))
13297                    }
13298                };
13299            }
13300        };
13301
13302        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13303        loop {
13304            // `Register`/`Start` only fail synchronously for a port-level
13305            // ingress problem (see `dispatch_bounded`); the kernel refusing
13306            // either command's content -- an unsupported transport, an
13307            // unknown agent -- lands one drive-loop tick later, and no
13308            // session ever appears in `self.handle.snapshot()` for it to
13309            // notice below. Check for that named reason before falling
13310            // through to the blind presence poll, so a rejected spawn
13311            // reports why instead of just timing out.
13312            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13313                .or_else(|| self.take_rejected_command_reason(start_command_id))
13314            {
13315                self.rollback_spawn(
13316                    &address,
13317                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13318                ).await.map_err(|error| {
13319                        failure(
13320                            error.code,
13321                            &format!(
13322                                "spawn was rejected ({}) and registration recovery failed: {}",
13323                                reason.message, error.message
13324                            ),
13325                        )
13326                    })?;
13327                if let Some(record_id) = record_id.as_ref() {
13328                    self.discard_record(record_id)?;
13329                }
13330                let code = if reason.unsupported_transport {
13331                    NodeFailureCode::UnsupportedTransport
13332                } else {
13333                    NodeFailureCode::BackendOperationFailed
13334                };
13335                return Err(failure(code, &reason.message));
13336            }
13337            if let Some(current) = self
13338                .handle
13339                .snapshot()
13340                .sessions
13341                .iter()
13342                .find(|current| current.instance_id == instance_id)
13343            {
13344                if current.generation != session.generation {
13345                    let divergence = failure(
13346                        NodeFailureCode::StaleGeneration,
13347                        "spawn generation diverged",
13348                    );
13349                    return match self
13350                        .rollback_spawn(
13351                            &address,
13352                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13353                        )
13354                        .await
13355                    {
13356                        Ok(()) => {
13357                            if let Some(record_id) = record_id.as_ref() {
13358                                self.discard_record(record_id)?;
13359                            }
13360                            Err(divergence)
13361                        }
13362                        Err(recovery_error) => {
13363                            if let Some(record_id) = record_id.as_ref() {
13364                                self.mark_record_error(
13365                                    record_id,
13366                                    "spawn generation diverged and recovery failed",
13367                                )?;
13368                            }
13369                            Err(recovery_error)
13370                        }
13371                    };
13372                }
13373                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13374                    prepared_spawn.retain_launch_guards();
13375                    return Ok((address, runtime_policy));
13376                }
13377            }
13378            if Instant::now() >= commit_deadline {
13379                self.rollback_spawn(
13380                    &address,
13381                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13382                ).await.map_err(|error| {
13383                        failure(
13384                            error.code,
13385                            &format!(
13386                                "start did not commit and registration recovery failed: {}",
13387                                error.message
13388                            ),
13389                        )
13390                    })?;
13391                if let Some(record_id) = record_id.as_ref() {
13392                    self.discard_record(record_id)?;
13393                }
13394                return Err(failure(
13395                    if deadline.is_some() {
13396                        NodeFailureCode::SpawnDeadlineExceeded
13397                    } else {
13398                        NodeFailureCode::BackendBusy
13399                    },
13400                    "start did not commit before the bounded deadline; registration was removed",
13401                ));
13402            }
13403            sleep(Duration::from_millis(2)).await;
13404        }
13405    }
13406
13407    fn effective_spawn_runtime_policy(
13408        &self,
13409        runtime_policy: ProviderRuntimePolicy,
13410    ) -> ProviderRuntimePolicy {
13411        #[cfg(feature = "fixture")]
13412        let runtime_policy = if self.fixture_semantic_hook_policy {
13413            ProviderRuntimePolicy::new(true, true, true, true, false, true)
13414                .expect("monitoring Hook fixture policy is internally valid")
13415        } else {
13416            runtime_policy
13417        };
13418        runtime_policy
13419    }
13420
13421    fn verify_harness_mcp_before_start(
13422        &self,
13423        prepared: Option<&PreparedHarnessMcpSpawn>,
13424    ) -> Result<(), NodeFailure> {
13425        prepared
13426            .map(PreparedHarnessMcpSpawn::verify_helper_program)
13427            .transpose()
13428            .map(|_| ())
13429            .map_err(harness_mcp_failure)
13430    }
13431
13432    async fn rollback_spawn(
13433        &self,
13434        address: &SessionAddress,
13435        commit_timeout: Duration,
13436    ) -> Result<(), NodeFailure> {
13437        self.dispatch_bounded(
13438            ControlCommand::Remove {
13439                instance_id: address.session.instance_id,
13440            },
13441            commit_timeout,
13442        )
13443        .await?;
13444        self.wait_until_removed(address, commit_timeout).await?;
13445        if let Some(binding) = self.remove_binding(address) {
13446            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13447        }
13448        Ok(())
13449    }
13450
13451    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13452        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13453        self.dispatch_bounded(
13454            ControlCommand::Remove {
13455                instance_id: address.session.instance_id,
13456            },
13457            commit_timeout,
13458        )
13459            .await?;
13460        self.wait_until_removed(address, commit_timeout).await?;
13461        let removed = self.remove_binding(address);
13462        if let Some(binding) = removed.as_ref() {
13463            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13464        }
13465        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13466            let snapshot = self.managed_worktrees.lock()
13467                .unwrap_or_else(|poisoned| poisoned.into_inner())
13468                .release_session(
13469                    &lease_id,
13470                    self.incarnation_id,
13471                    address.session.instance_id,
13472                    address.session.generation,
13473                    unix_time_ms(),
13474                );
13475            if self.persist_state().is_err() {
13476                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13477            }
13478            if let Some(snapshot) = snapshot {
13479                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13480            }
13481            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13482        }
13483        Ok(())
13484    }
13485
13486    /// Polls until `address`'s session leaves the engine's own session map,
13487    /// or the bounded deadline expires.
13488    ///
13489    /// A `Remove` sent while the session is not yet in a terminal status
13490    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
13491    /// once by the engine (`SessionStatus::allows_remove`) and
13492    /// then discarded -- nothing re-issues it on its own, so the removal
13493    /// that would satisfy this wait never arrives and the caller was
13494    /// guaranteed to time out even though the session settles microseconds
13495    /// later. So this re-dispatches on every poll tick: a session that
13496    /// settles mid-wait -- the common case right after a `Stop` -- still
13497    /// gets removed inside the same bounded deadline.
13498    ///
13499    /// It re-dispatches only once the status will ACCEPT the command. A
13500    /// rejected re-dispatch is not the harmless no-op this loop first
13501    /// assumed: every rejection publishes a `ControlEventKind::Command
13502    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
13503    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
13504    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
13505    /// out to the harness and every attached client. Gated, the same
13506    /// teardown sends exactly one `Remove`, at the first tick the engine
13507    /// can act on it.
13508    ///
13509    /// The status is read from the snapshot this loop already takes, so
13510    /// the gate costs nothing extra. It races -- the status can move
13511    /// between the read and the dispatch -- and that is fine: a rare lost
13512    /// race is one rejection, and the next tick retries.
13513    ///
13514    /// Re-dispatch is separately rate-limited to
13515    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
13516    /// leaves a second burst behind it. Once the session settles, every
13517    /// 2ms tick sent another `Remove` while the accepted one was still in
13518    /// flight, and each of those landed on an instance the engine had
13519    /// already dropped -- `agent instance … is not registered`, observed
13520    /// live as a tail of rejections immediately after the `session
13521    /// removed` line. The presence poll stays at 2ms so the wait still
13522    /// exits promptly; only the sending slows down.
13523    ///
13524    /// Rate-limited rather than once-only on purpose: the status gate
13525    /// covers the status precondition, but `Gate4AgentEngine::remove` has
13526    /// others (a pending operation, an in-flight capability probe or
13527    /// history request) that clear on their own. A single shot would turn
13528    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
13529    /// get their retries, just twenty of them across the deadline instead
13530    /// of a thousand.
13531    async fn wait_until_removed(
13532        &self,
13533        address: &SessionAddress,
13534        commit_timeout: Duration,
13535    ) -> Result<(), NodeFailure> {
13536        let deadline = Instant::now() + commit_timeout;
13537        let mut next_dispatch = Instant::now();
13538        loop {
13539            let snapshot = self.handle.snapshot();
13540            let status = snapshot
13541                .sessions
13542                .iter()
13543                .find(|session| session.instance_id == address.session.instance_id)
13544                .map(|session| session.status.clone());
13545            let Some(status) = status else {
13546                return Ok(());
13547            };
13548            let now = Instant::now();
13549            if now >= deadline {
13550                return Err(failure(
13551                    NodeFailureCode::BackendBusy,
13552                    "remove did not commit before the bounded deadline; session binding was retained",
13553                ));
13554            }
13555            if status.allows_remove() && now >= next_dispatch {
13556                let _ = self.dispatch(ControlCommand::Remove {
13557                    instance_id: address.session.instance_id,
13558                });
13559                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13560            }
13561            sleep(Duration::from_millis(2)).await;
13562        }
13563    }
13564
13565    fn resync(&self, after_sequence: u64) -> NodeResponse {
13566        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13567        let event_sequence = history.last_sequence;
13568        let oldest_available_sequence = history.replay_floor_sequence;
13569        let events = history
13570            .events
13571            .iter()
13572            .filter(|event| event.sequence > after_sequence)
13573            .cloned()
13574            .collect();
13575        drop(history);
13576        NodeResponse::Resync {
13577            event_sequence,
13578            oldest_available_sequence,
13579            snapshot: self.snapshot(),
13580            events,
13581        }
13582    }
13583}
13584
13585/// Coarse label for one `AgentStreamChunkKindV1`, used only by
13586/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
13587/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
13588/// "name what crossed this boundary" reasoning, one layer upstream of it).
13589fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13590    match kind {
13591        AgentStreamChunkKindV1::Text { .. } => "text",
13592        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13593        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13594        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13595        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13596        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13597        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13598    }
13599}
13600
13601/// Serves this wire on the local endpoint, and -- when `call_home` names a
13602/// relay -- over an outbound connection to it as well.
13603///
13604/// The two live in one future rather than a fourth arm of `run`'s own
13605/// `select!` because they are one concern: this is where the node's wire
13606/// is served, and the direction a given connection was opened in does not
13607/// change what happens on it. It also means the existing graceful drain
13608/// below covers both without being duplicated.
13609///
13610/// Either side finishing ends the pair, which is correct: the listener
13611/// only returns on shutdown or a bind failure, and `call_home_loop` only
13612/// returns on shutdown -- a failed relay connection is retried inside it,
13613/// never propagated out.
13614async fn accept_connections(
13615    endpoint: &str,
13616    call_home: Option<std::net::SocketAddr>,
13617    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13618    shared: Arc<NodeShared>,
13619) -> Result<(), NodeServerError> {
13620    let mut connections = JoinSet::new();
13621    let result = match call_home {
13622        None => accept_connections_inner(endpoint, shared, &mut connections).await,
13623        Some(relay) => {
13624            let listener =
13625                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13626            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13627            tokio::pin!(listener);
13628            tokio::pin!(dialler);
13629            tokio::select! {
13630                result = &mut listener => result,
13631                result = &mut dialler => result,
13632            }
13633        }
13634    };
13635    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13636    while !connections.is_empty() {
13637        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13638        if remaining.is_zero()
13639            || timeout(remaining, connections.join_next()).await.is_err()
13640        {
13641            break;
13642        }
13643    }
13644    connections.abort_all();
13645    while connections.join_next().await.is_some() {}
13646    result
13647}
13648
13649/// Backoff ladder for a relay that will not answer, capped rather than
13650/// unbounded: a node whose relay is down is not broken and should keep
13651/// trying forever, but it should not spend a core doing it.
13652const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13653
13654/// Dials the relay, serves this wire over the connection, and does it
13655/// again when the connection ends.
13656///
13657/// The node is the SERVER on the socket it just opened. That inversion is
13658/// the whole point: `serve_connection` is the same function the local
13659/// listener feeds, and it neither knows nor cares which end placed the
13660/// call. The one thing this direction adds is the preface -- the relay
13661/// accepted a socket and cannot know whose it is, so the node says its
13662/// name before the handshake it would otherwise begin with.
13663///
13664/// Never returns an error. A relay that is down, refusing, or speaking a
13665/// different protocol is a condition to retry, not a reason to take the
13666/// node's local endpoint down with it; the only way out is shutdown.
13667async fn call_home_loop(
13668    relay: std::net::SocketAddr,
13669    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13670    shared: Arc<NodeShared>,
13671) -> Result<(), NodeServerError> {
13672    let mut failures = 0_usize;
13673    loop {
13674        if shared.shutdown.load(Ordering::Acquire) {
13675            return Ok(());
13676        }
13677        let permit = tokio::select! {
13678            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13679                permit.map_err(|_| NodeServerError::PreauthClosed)?
13680            }
13681            _ = shared.shutdown_notify.notified() => return Ok(()),
13682        };
13683        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13684            Ok(()) => {
13685                // A clean end is the relay hanging up, which it does on
13686                // its own restart. Reconnect immediately rather than
13687                // serving the backoff a peer that is plainly there.
13688                failures = 0;
13689                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13690            }
13691            Err(error) => {
13692                failures = failures.saturating_add(1);
13693                tracing::warn!(
13694                    node_id = %shared.node_id,
13695                    %relay,
13696                    cause = %error,
13697                    attempt = failures,
13698                    "call-home connection failed",
13699                );
13700                let backoff = call_home_backoff(failures);
13701                tokio::select! {
13702                    _ = sleep(backoff) => {}
13703                    _ = shared.shutdown_notify.notified() => return Ok(()),
13704                }
13705            }
13706        }
13707    }
13708}
13709
13710/// Idle time before the first keepalive probe on a call-home socket.
13711const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13712/// Interval between keepalive probes once the link has gone quiet.
13713const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13714
13715fn call_home_keepalive() -> socket2::TcpKeepalive {
13716    let keepalive = socket2::TcpKeepalive::new()
13717        .with_time(CALL_HOME_KEEPALIVE_IDLE)
13718        .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13719    #[cfg(not(windows))]
13720    let keepalive = keepalive.with_retries(3);
13721    keepalive
13722}
13723
13724fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13725    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13726}
13727
13728/// Same keepalive profile for any node->relay socket (restream uplink).
13729pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13730    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13731}
13732
13733/// Backoff for the `attempt`-th consecutive failure (1-based), capped.
13734fn call_home_backoff(attempt: usize) -> Duration {
13735    Duration::from_millis(
13736        CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13737    )
13738}
13739
13740
13741/// Dial the call-home relay. Tunnel addresses go through the in-process
13742/// WireGuard dialer when the userspace session is up.
13743async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13744    #[cfg(feature = "wireguard")]
13745    {
13746        let std_stream = tokio::task::spawn_blocking(move || {
13747            gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13748        })
13749        .await
13750        .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13751        .map_err(NodeServerError::CallHomeTransport)?;
13752        std_stream
13753            .set_nonblocking(true)
13754            .map_err(NodeServerError::CallHomeTransport)?;
13755        return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13756    }
13757    #[cfg(not(feature = "wireguard"))]
13758    {
13759        tokio::net::TcpStream::connect(relay)
13760            .await
13761            .map_err(NodeServerError::CallHomeTransport)
13762    }
13763}
13764
13765/// One call-home attempt: connect, name this node, then serve.
13766async fn dial_relay_and_serve(
13767    relay: std::net::SocketAddr,
13768    identity: Option<&hatchery_enroll::NodeIdentity>,
13769    shared: Arc<NodeShared>,
13770    preauth_permit: OwnedSemaphorePermit,
13771) -> Result<(), NodeServerError> {
13772    let mut stream = dial_call_home(relay).await?;
13773    // Nagle off for the same reason the rest of this wire wants it off:
13774    // the traffic is small request/response frames, and coalescing them
13775    // adds latency to every one.
13776    let _ = stream.set_nodelay(true);
13777    // A relay that vanishes without a FIN/RST reaching us (tunnel drop,
13778    // relay-side socket kill whose RST was lost) would otherwise leave this
13779    // socket ESTABLISHED forever: the node mostly reads, so nothing ever
13780    // fails and the reconnect loop never runs. Kernel keepalive turns that
13781    // half-open link into a read error within a bounded time.
13782    if let Err(error) = enable_call_home_keepalive(&stream) {
13783        tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13784    }
13785    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13786        .await
13787        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13788    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13789    let mut session_token = None;
13790    if let Some(identity) = identity {
13791        // An enrolled relay answers the announce with a signed-call-home challenge; a
13792        // token-mode relay sends its wire hello instead. Peek to tell them apart
13793        // without consuming either.
13794        let mut magic = [0_u8; 4];
13795        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13796            .await
13797            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13798            .map_err(NodeServerError::CallHomeTransport)?;
13799        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13800            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13801            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13802                .await
13803                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13804                .map_err(NodeServerError::CallHomeTransport)?;
13805            let (answer, token) = identity
13806                .answer_challenge(shared.node_id.as_str(), &frame)
13807                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13808            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13809            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13810            session_token = Some(token);
13811        }
13812    }
13813    serve_connection(stream, shared, session_token, preauth_permit).await
13814}
13815
13816async fn accept_connections_inner(
13817    endpoint: &str,
13818    shared: Arc<NodeShared>,
13819    connections: &mut JoinSet<Result<(), NodeServerError>>,
13820) -> Result<(), NodeServerError> {
13821    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13822    loop {
13823        while connections.try_join_next().is_some() {}
13824        if shared.shutdown.load(Ordering::Acquire) {
13825            return Ok(());
13826        }
13827        let preauth_permit = tokio::select! {
13828            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13829                permit.map_err(|_| NodeServerError::PreauthClosed)?
13830            }
13831            _ = shared.shutdown_notify.notified() => return Ok(()),
13832        };
13833        let server = tokio::select! {
13834            result = listener.accept() => result?,
13835            _ = shared.shutdown_notify.notified() => return Ok(()),
13836        };
13837        let connection_shared = Arc::clone(&shared);
13838        connections.spawn(async move {
13839            serve_connection(server, connection_shared, None, preauth_permit).await
13840        });
13841        if shared.shutdown.load(Ordering::Acquire) {
13842            return Ok(());
13843        }
13844    }
13845}
13846
13847/// Serves one connection, whichever end opened it.
13848///
13849/// Generic over the stream rather than typed to `LocalServerStream`
13850/// because a call-home connection arrives on a `TcpStream` the node itself
13851/// dialled. Nothing in the body ever depended on the concrete type -- it
13852/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13853/// the whole of what welding "who dials" to "who serves" was costing.
13854async fn serve_connection<S>(
13855    mut pipe: S,
13856    shared: Arc<NodeShared>,
13857    session_token: Option<String>,
13858    preauth_permit: OwnedSemaphorePermit,
13859) -> Result<(), NodeServerError>
13860where
13861    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13862{
13863    // A signed call-home connection authenticates with its own per-connection token.
13864    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13865    let hello_frame = timeout(
13866        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13867        read_json_frame_limited_body_timeout(
13868            &mut pipe,
13869            MAX_NODE_HELLO_FRAME_BYTES,
13870            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13871        ),
13872    )
13873    .await
13874    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13875    let ClientFrame::Hello(hello) = hello_frame else {
13876        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13877    };
13878    if hello.build_stamp != BUILD_STAMP {
13879        return Err(NodeServerError::Handshake(format!(
13880            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13881            hello.build_stamp,
13882        )));
13883    }
13884    let compatibility = match hello.compatibility.as_ref() {
13885        Some(offer) => {
13886            let mut selected = node_compatibility_support(&shared)?
13887                .negotiate(offer)
13888                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13889            project_negotiated_provider_ids(&mut selected);
13890            Some(selected)
13891        }
13892        None => None,
13893    };
13894    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13895    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13896        (Some(offer), Some(selected)) => negotiated_auth_proof(
13897            auth_token,
13898            AuthDirection::Server,
13899            hello.role,
13900            &hello.client_nonce,
13901            &server_nonce,
13902            offer,
13903            selected,
13904        ),
13905        (None, None) => auth_proof(
13906            auth_token,
13907            AuthDirection::Server,
13908            hello.role,
13909            &hello.client_nonce,
13910            &server_nonce,
13911        ),
13912        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13913    }
13914    .map_err(NodeServerError::Authentication)?;
13915    write_json_frame_limited(
13916        &mut pipe,
13917        &ServerFrame::Challenge(ServerChallenge {
13918            build_stamp: BUILD_STAMP.to_owned(),
13919            server_nonce,
13920            server_proof,
13921            compatibility: compatibility.clone(),
13922        }),
13923        MAX_NODE_HELLO_FRAME_BYTES,
13924    )
13925    .await?;
13926    let authentication = timeout(
13927        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13928        read_json_frame_limited_body_timeout(
13929            &mut pipe,
13930            MAX_NODE_HELLO_FRAME_BYTES,
13931            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13932        ),
13933    )
13934    .await
13935    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13936    let ClientFrame::Authenticate(authentication) = authentication else {
13937        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13938    };
13939    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13940        (Some(offer), Some(selected)) => negotiated_auth_proof(
13941            auth_token,
13942            AuthDirection::Client,
13943            hello.role,
13944            &hello.client_nonce,
13945            &server_nonce,
13946            offer,
13947            selected,
13948        ),
13949        (None, None) => auth_proof(
13950            auth_token,
13951            AuthDirection::Client,
13952            hello.role,
13953            &hello.client_nonce,
13954            &server_nonce,
13955        ),
13956        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13957    }
13958    .map_err(NodeServerError::Authentication)?;
13959    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13960        return Err(NodeServerError::Handshake("access denied".to_owned()));
13961    }
13962    let selected_capabilities = compatibility
13963        .as_ref()
13964        .map(|selected| selected.capabilities.clone())
13965        .unwrap_or_default();
13966    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13967        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13968    });
13969    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13970        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13971    });
13972    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13973        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13974    });
13975    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13976        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13977    });
13978    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13979        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13980    });
13981    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13982        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13983    }) && selected_capabilities.iter().any(|capability| {
13984        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13985    });
13986    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13987        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13988    });
13989    let include_session_bundles = selected_capabilities.iter().any(|capability| {
13990        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13991    });
13992    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13993        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13994    });
13995    let include_agent_progress = selected_capabilities.iter().any(|capability| {
13996        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13997    });
13998    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13999        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
14000    });
14001    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
14002        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
14003    });
14004    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
14005        .try_acquire_owned()
14006        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
14007    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14008    let _release_guard = ControllerReleaseGuard {
14009        shared: Arc::clone(&shared),
14010        connection_id,
14011    };
14012    drop(preauth_permit);
14013    let _authenticated_permit = authenticated_permit;
14014    let mut event_rx = shared.event_tx.subscribe();
14015    let mut terminal_event_rx = terminal_event_subscription(
14016        &shared,
14017        include_terminal_frame_events,
14018    );
14019    let mut agent_stream_event_rx = agent_stream_event_subscription(
14020        &shared,
14021        include_agent_stream_events,
14022    );
14023    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14024        &shared,
14025        include_harness_mcp_proxy,
14026    );
14027    write_json_frame(
14028        &mut pipe,
14029        &ServerFrame::Hello(NodeHello {
14030            build_stamp: BUILD_STAMP.to_owned(),
14031            incarnation_id: shared.incarnation_id,
14032            connection_id,
14033            role: hello.role,
14034            event_sequence: shared.current_sequence(),
14035            controller: shared.controller_state(),
14036            snapshot: snapshot_for_wire(
14037                &shared,
14038                include_provider_runtime_status,
14039                include_open_provider_ids,
14040                include_spawn_profiles,
14041                include_managed_worktrees,
14042                include_child_environment_profiles,
14043                include_session_bundles,
14044                include_history_context_packs,
14045                include_agent_progress,
14046                include_session_task_correlation,
14047            ),
14048            compatibility,
14049        }),
14050    )
14051    .await?;
14052    let (mut reader, mut writer) = tokio::io::split(pipe);
14053    let (frame_tx, mut frame_rx) = mpsc::channel(16);
14054    let reader_task = tokio::spawn(async move {
14055        loop {
14056            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14057                &mut reader,
14058                MAX_NODE_CLIENT_FRAME_BYTES,
14059                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14060            )
14061            .await;
14062            let terminal = frame.is_err();
14063            if frame_tx.send(frame).await.is_err() || terminal {
14064                break;
14065            }
14066        }
14067    });
14068    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14069    let mut pending_events = Vec::new();
14070    let mut resync_required = false;
14071    let mut discard_events_through = 0_u64;
14072    loop {
14073        // One tick of the serve loop, whether or not it had anything to do.
14074        // Against `connection_events_sent` this separates "busy" from
14075        // "spinning": a loop turning over thousands of times a second while
14076        // sending nothing is not serving, it is burning a core.
14077        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14078        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14079            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14080        for _ in 0..durable_drain_budget {
14081            match event_rx.try_recv() {
14082                Ok(event) => queue_connection_event(
14083                    &mut pending_events,
14084                    event,
14085                    discard_events_through,
14086                ),
14087                Err(broadcast::error::TryRecvError::Empty) => break,
14088                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14089                    resync_required = true;
14090                    break;
14091                }
14092                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14093            }
14094        }
14095        if let Some(receiver) = terminal_event_rx.as_mut() {
14096            match receiver.try_recv() {
14097                Ok(events) => queue_connection_event_batch(
14098                    &mut pending_events,
14099                    &events,
14100                    discard_events_through,
14101                ),
14102                Err(broadcast::error::TryRecvError::Empty) => {}
14103                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14104                    resync_required = true;
14105                }
14106                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14107            }
14108        }
14109        if let Some(receiver) = agent_stream_event_rx.as_mut() {
14110            // Loops up to the same remaining-room budget the durable
14111            // `event_rx` drain above uses, rather than one `try_recv` per
14112            // tick: each successful receive here is itself only ONE
14113            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
14114            // sends `vec![envelope]`, never more), so a single-shot drain
14115            // caps this connection's agent-stream throughput at one chunk
14116            // per outer-loop tick regardless of how many are already queued
14117            // -- exactly the kind of self-inflicted stall this crate's own
14118            // durable-event budget exists to avoid.
14119            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14120                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14121            for _ in 0..agent_stream_drain_budget {
14122                match receiver.try_recv() {
14123                    Ok(events) => queue_connection_event_batch(
14124                        &mut pending_events,
14125                        &events,
14126                        discard_events_through,
14127                    ),
14128                    Err(broadcast::error::TryRecvError::Empty) => break,
14129                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14130                        tracing::warn!(
14131                            connection_id,
14132                            dropped,
14133                            "agent stream event subscription lagged; forcing a resync",
14134                        );
14135                        resync_required = true;
14136                        break;
14137                    }
14138                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14139                }
14140            }
14141        }
14142        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14143            // Mirrors the agent-stream drain immediately above: each
14144            // successful receive is one single-envelope batch
14145            // (`publish_transient` always sends `vec![envelope]`), so this
14146            // loops up to the same remaining-room budget rather than
14147            // stopping after one `try_recv`.
14148            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14149                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14150            for _ in 0..harness_mcp_proxy_drain_budget {
14151                match receiver.try_recv() {
14152                    Ok(events) => queue_connection_event_batch(
14153                        &mut pending_events,
14154                        &events,
14155                        discard_events_through,
14156                    ),
14157                    Err(broadcast::error::TryRecvError::Empty) => break,
14158                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14159                        tracing::warn!(
14160                            connection_id,
14161                            dropped,
14162                            "harness MCP proxy event subscription lagged; forcing a resync",
14163                        );
14164                        resync_required = true;
14165                        break;
14166                    }
14167                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14168                }
14169            }
14170        }
14171        if resync_required {
14172            let event = resync_required_event(&shared);
14173            apply_connection_resync_watermark(
14174                &mut pending_events,
14175                &mut discard_events_through,
14176                event.sequence,
14177            );
14178            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14179            resync_required = false;
14180            continue;
14181        }
14182        if !pending_events.is_empty() {
14183            pending_events.sort_unstable_by_key(|event| event.sequence);
14184            let burst_len = connection_event_burst_len(pending_events.len());
14185            shared
14186                .connection_events_sent
14187                .fetch_add(burst_len as u64, Ordering::Relaxed);
14188            for event in pending_events.drain(..burst_len) {
14189                let event = if include_managed_worktrees {
14190                    Some(event)
14191                } else {
14192                    project_event_without_managed_worktrees(event)
14193                };
14194                let event = event.and_then(|event| if include_open_provider_ids {
14195                    Some(event)
14196                } else {
14197                    project_event_legacy_provider_ids(&shared, event)
14198                });
14199                let event = event.map(|event| if include_child_environment_profiles {
14200                    event
14201                } else {
14202                    project_event_without_child_environment_profile(event)
14203                });
14204                let event = event.map(|event| if include_session_bundles {
14205                    event
14206                } else {
14207                    project_event_without_session_bundle(event)
14208                });
14209                let event = event.map(|event| {
14210                    project_event_history_for_wire(event, include_history_context_packs)
14211                });
14212                let event = event.map(|event| if include_history_context_packs {
14213                    event
14214                } else {
14215                    project_event_without_context_pack(event)
14216                });
14217                let event = event.map(|event| if include_session_task_correlation {
14218                    event
14219                } else {
14220                    project_event_without_session_task_binding(event)
14221                });
14222                let exact_harness_controller = hello.role == ClientRole::Operator
14223                    && shared.controller_state().is_some_and(|controller| {
14224                        controller.connection_id == connection_id
14225                    });
14226                let event = event.and_then(|event| {
14227                    if include_harness_mcp_proxy && exact_harness_controller {
14228                        Some(event)
14229                    } else {
14230                        project_event_without_harness_mcp_proxy(event)
14231                    }
14232                });
14233                if let Some(event) = event {
14234                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14235                }
14236            }
14237        }
14238        tokio::select! {
14239            biased;
14240            frame = frame_rx.recv() => {
14241                let frame = match frame {
14242                    Some(frame) => frame,
14243                    None => break,
14244                };
14245                let frame = match frame {
14246                    Ok(frame) => frame,
14247                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14248                    Err(error) => return Err(error.into()),
14249                };
14250                let ClientFrame::Request(request) = frame else {
14251                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14252                };
14253                #[cfg(feature = "fixture")]
14254                let probe_spawn_managed_worktree_v2 = matches!(
14255                    &request.request,
14256                    NodeRequest::SpawnManagedWorktreeV2 { .. }
14257                );
14258                let requires_open_provider_ids =
14259                    request_requires_open_provider_ids(&shared, &request.request);
14260                let requires_child_environment_profile =
14261                    request_requires_child_environment_profile(&shared, &request.request);
14262                let requires_session_bundle =
14263                    request_requires_session_bundle(&shared, &request.request);
14264                let requires_history_context_pack =
14265                    request_requires_history_context_pack(&shared, &request.request);
14266                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14267                    ResponseEnvelope {
14268                        request_id: request.request_id,
14269                        result: Err(failure(
14270                            NodeFailureCode::UnsupportedCapability,
14271                            "open provider IDs were not negotiated",
14272                        )),
14273                    }
14274                } else if requires_child_environment_profile
14275                    && !include_child_environment_profiles
14276                {
14277                    ResponseEnvelope {
14278                        request_id: request.request_id,
14279                        result: Err(failure(
14280                            NodeFailureCode::UnsupportedCapability,
14281                            "child environment profiles were not negotiated",
14282                        )),
14283                    }
14284                } else if requires_session_bundle && !include_session_bundles {
14285                    ResponseEnvelope {
14286                        request_id: request.request_id,
14287                        result: Err(failure(
14288                            NodeFailureCode::UnsupportedCapability,
14289                            "session bundles were not negotiated",
14290                        )),
14291                    }
14292                } else if requires_history_context_pack && !include_history_context_packs {
14293                    ResponseEnvelope {
14294                        request_id: request.request_id,
14295                        result: Err(failure(
14296                            NodeFailureCode::UnsupportedCapability,
14297                            "history context packs were not negotiated",
14298                        )),
14299                    }
14300                } else if request_uses_unnegotiated_capability(
14301                    &request.request,
14302                    &selected_capabilities,
14303                )
14304                {
14305                    ResponseEnvelope {
14306                        request_id: request.request_id,
14307                        result: Err(failure(
14308                            NodeFailureCode::UnsupportedCapability,
14309                            "request capability was not negotiated",
14310                        )),
14311                    }
14312                } else {
14313                    process_request(&shared, connection_id, hello.role, request).await
14314                };
14315                #[cfg(feature = "fixture")]
14316                if probe_spawn_managed_worktree_v2 {
14317                    shared
14318                        .fixture_spawn_managed_worktree_v2_failure_probe
14319                        .record(reply.result.as_ref().err().map(|failure| failure.code));
14320                }
14321                if !include_provider_runtime_status {
14322                    clear_response_provider_runtime_status(&mut reply);
14323                }
14324                if !include_managed_worktrees {
14325                    project_response_without_managed_worktrees(&mut reply);
14326                }
14327                if !include_open_provider_ids {
14328                    project_response_legacy_provider_ids(&shared, &mut reply);
14329                }
14330                if !include_child_environment_profiles {
14331                    project_response_without_child_environment_profile(&mut reply);
14332                }
14333                if !include_session_bundles {
14334                    project_response_without_session_bundle(&mut reply);
14335                }
14336                project_response_history_for_wire(
14337                    &mut reply,
14338                    include_history_context_packs,
14339                );
14340                if !include_history_context_packs {
14341                    project_response_without_context_pack(&mut reply);
14342                }
14343                if !include_agent_progress {
14344                    project_response_without_agent_progress(&mut reply);
14345                }
14346                if !include_session_task_correlation {
14347                    project_response_without_session_task_binding(&mut reply);
14348                }
14349                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14350            }
14351            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14352            event = event_rx.recv() => {
14353                match event {
14354                    Ok(event) => queue_connection_event(
14355                        &mut pending_events,
14356                        event,
14357                        discard_events_through,
14358                    ),
14359                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14360                    Err(broadcast::error::RecvError::Closed) => break,
14361                }
14362            }
14363            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14364                match events {
14365                    Ok(events) => queue_connection_event_batch(
14366                        &mut pending_events,
14367                        &events,
14368                        discard_events_through,
14369                    ),
14370                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14371                    Err(broadcast::error::RecvError::Closed) => break,
14372                }
14373            }
14374            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14375                match events {
14376                    Ok(events) => queue_connection_event_batch(
14377                        &mut pending_events,
14378                        &events,
14379                        discard_events_through,
14380                    ),
14381                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14382                    Err(broadcast::error::RecvError::Closed) => break,
14383                }
14384            }
14385            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14386                match events {
14387                    Ok(events) => queue_connection_event_batch(
14388                        &mut pending_events,
14389                        &events,
14390                        discard_events_through,
14391                    ),
14392                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14393                    Err(broadcast::error::RecvError::Closed) => break,
14394                }
14395            }
14396            _ = shared.shutdown_notify.notified() => break,
14397        }
14398    }
14399    Ok(())
14400}
14401
14402async fn receive_terminal_event_batch(
14403    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14404) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14405    match receiver {
14406        Some(receiver) => receiver.recv().await,
14407        None => std::future::pending().await,
14408    }
14409}
14410
14411fn terminal_event_subscription(
14412    shared: &NodeShared,
14413    enabled: bool,
14414) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14415    enabled.then(|| shared.terminal_event_tx.subscribe())
14416}
14417
14418/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
14419/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
14420/// this needs a dedicated channel rather than a flag on the terminal one.
14421fn agent_stream_event_subscription(
14422    shared: &NodeShared,
14423    enabled: bool,
14424) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14425    enabled.then(|| shared.agent_stream_event_tx.subscribe())
14426}
14427
14428/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
14429/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
14430/// own doc for why this needs a dedicated channel rather than a ride on
14431/// the durable `event_tx`.
14432fn harness_mcp_proxy_event_subscription(
14433    shared: &NodeShared,
14434    enabled: bool,
14435) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14436    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14437}
14438
14439fn queue_connection_event(
14440    pending: &mut Vec<NodeEventEnvelope>,
14441    event: NodeEventEnvelope,
14442    discard_through: u64,
14443) {
14444    if event.sequence <= discard_through
14445        && !event.event.requires_harness_mcp_proxy_capability()
14446    {
14447        return;
14448    }
14449    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14450        if let Some(index) = pending.iter().position(|current| {
14451            matches!(
14452                &current.event,
14453                NodeEvent::TerminalFrame {
14454                    address: current_address,
14455                    ..
14456                } if current_address == address
14457            )
14458        }) {
14459            pending.remove(index);
14460        }
14461    }
14462    pending.push(event);
14463}
14464
14465fn project_event_without_harness_mcp_proxy(
14466    envelope: NodeEventEnvelope,
14467) -> Option<NodeEventEnvelope> {
14468    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14469}
14470
14471fn queue_connection_event_batch(
14472    pending: &mut Vec<NodeEventEnvelope>,
14473    events: &[NodeEventEnvelope],
14474    discard_through: u64,
14475) {
14476    for event in events {
14477        queue_connection_event(pending, event.clone(), discard_through);
14478    }
14479}
14480
14481fn apply_connection_resync_watermark(
14482    pending: &mut Vec<NodeEventEnvelope>,
14483    discard_through: &mut u64,
14484    marker_sequence: u64,
14485) {
14486    *discard_through = (*discard_through).max(marker_sequence);
14487    pending.retain(|event| event.sequence > *discard_through);
14488}
14489
14490fn connection_event_burst_len(pending_len: usize) -> usize {
14491    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14492}
14493
14494fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14495    let history = shared
14496        .history
14497        .lock()
14498        .unwrap_or_else(|poisoned| poisoned.into_inner());
14499    let sequence = history.last_sequence;
14500    let oldest_available_sequence = history.replay_floor_sequence;
14501    NodeEventEnvelope {
14502        sequence,
14503        event: NodeEvent::ResyncRequired {
14504            oldest_available_sequence,
14505        },
14506    }
14507}
14508
14509fn node_compatibility_support(
14510    shared: &NodeShared,
14511) -> Result<NodeCompatibilitySupport, NodeServerError> {
14512    let mut support = node_compatibility_support_for_manifest(
14513        &shared.provider_contracts,
14514        &shared.provider_adapter_contracts,
14515    )?;
14516    if shared
14517        .delivery_store
14518        .lock()
14519        .unwrap_or_else(|poisoned| poisoned.into_inner())
14520        .is_some()
14521    {
14522        support.capabilities.push(
14523            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14524                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14525        );
14526    }
14527    if shared.harness_mcp_registry.is_some() {
14528        support.capabilities.push(
14529            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14530                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14531        );
14532    }
14533    Ok(support)
14534}
14535
14536fn node_compatibility_support_for_manifest(
14537    provider_contracts: &[ProviderContractSupport],
14538    provider_adapter_contracts: &[ProviderAdapterContractSupport],
14539) -> Result<NodeCompatibilitySupport, NodeServerError> {
14540    Ok(NodeCompatibilitySupport {
14541        build_stamp: BUILD_STAMP.to_owned(),
14542        capabilities: baseline_capabilities()?,
14543        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14544        path_semantics: platform::path_semantics(),
14545        local_transport: platform::local_transport(),
14546        state_schema: StateSchemaSupport {
14547            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14548                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14549        },
14550        provider_contracts: provider_contracts.to_vec(),
14551        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14552    })
14553}
14554
14555fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14556    let capabilities = [
14557        NODE_COMPATIBILITY_METADATA_CAPABILITY,
14558        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14559        NODE_REPOSITORY_PATH_CAPABILITY,
14560        NODE_WORKSPACE_FILE_READ_CAPABILITY,
14561        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14562        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14563        NODE_GIT_READ_CAPABILITY,
14564        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14565        NODE_PROVIDER_ID_OPEN_CAPABILITY,
14566        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14567        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14568        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14569        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14570        NODE_ACP_CONTROL_CAPABILITY,
14571        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14572        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14573        NODE_WORKTREE_SELECTION_CAPABILITY,
14574        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14575        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14576        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14577        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14578        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14579        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14580        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14581        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14582        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14583        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14584        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14585        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14586    ]
14587        .into_iter()
14588        .map(|capability| {
14589            CapabilityId::new(capability)
14590                .map_err(|error| NodeServerError::Handshake(error.to_string()))
14591        })
14592        .collect::<Result<Vec<_>, _>>()?;
14593    #[cfg(windows)]
14594    let capabilities = {
14595        let mut capabilities = capabilities;
14596        capabilities.push(
14597            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14598                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14599        );
14600        capabilities
14601    };
14602    Ok(capabilities)
14603}
14604
14605fn request_uses_unnegotiated_capability(
14606    request: &NodeRequest,
14607    selected_capabilities: &[CapabilityId],
14608) -> bool {
14609    let selected = |required: &str| {
14610        selected_capabilities
14611            .iter()
14612            .any(|capability| capability.as_str() == required)
14613    };
14614    request
14615        .required_capability()
14616        .is_some_and(|required| !selected(required))
14617        || (request.requires_worktree_selection_capability()
14618            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14619        || (request.requires_spawn_spec_defaults_overrides_capability()
14620            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14621        || (request.requires_spawn_profile_revision_capability()
14622            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14623        || (request.requires_child_environment_profile_capability()
14624            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14625        || (request.requires_session_bundle_materialization_capability()
14626            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14627        || (request.requires_history_context_pack_capability()
14628            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14629}
14630
14631fn snapshot_for_wire(
14632    shared: &NodeShared,
14633    include_provider_runtime_status: bool,
14634    include_open_provider_ids: bool,
14635    include_spawn_profiles: bool,
14636    include_managed_worktrees: bool,
14637    include_child_environment_profiles: bool,
14638    include_session_bundles: bool,
14639    include_history_context_packs: bool,
14640    include_agent_progress: bool,
14641    include_session_task_correlation: bool,
14642) -> NodeSnapshot {
14643    let mut snapshot = shared.snapshot();
14644    if !include_provider_runtime_status {
14645        snapshot.provider_runtime_statuses.clear();
14646    }
14647    if !include_open_provider_ids {
14648        project_snapshot_legacy_provider_ids(&mut snapshot);
14649    }
14650    if !include_managed_worktrees {
14651        snapshot.managed_worktrees.clear();
14652        for workspace in &mut snapshot.workspaces {
14653            workspace.managed_worktree_profiles = None;
14654        }
14655    }
14656    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14657        if !include_spawn_profiles {
14658            inventory.spawn_profiles = None;
14659        }
14660        if !include_session_bundles {
14661            inventory.bundles = None;
14662        }
14663        if inventory.spawn_profiles.is_none()
14664            && inventory.bundles.is_none()
14665            && inventory.network_allowlists.is_none()
14666        {
14667            snapshot.launch_inventory = None;
14668        }
14669    }
14670    if !include_child_environment_profiles {
14671        clear_snapshot_child_environment_profiles(&mut snapshot);
14672    }
14673    if !include_session_bundles {
14674        clear_snapshot_session_bundles(&mut snapshot);
14675    }
14676    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14677    if !include_history_context_packs {
14678        clear_snapshot_context_packs(&mut snapshot);
14679    }
14680    if !include_agent_progress {
14681        snapshot.agent_progress.clear();
14682    }
14683    if !include_session_task_correlation {
14684        clear_snapshot_session_task_bindings(&mut snapshot);
14685    }
14686    snapshot
14687}
14688
14689fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14690    for record in &mut snapshot.session_records {
14691        record.task_binding = None;
14692    }
14693}
14694
14695fn project_event_without_session_task_binding(
14696    mut envelope: NodeEventEnvelope,
14697) -> NodeEventEnvelope {
14698    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14699        record.task_binding = None;
14700    }
14701    envelope
14702}
14703
14704fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14705    let Ok(response) = reply.result.as_mut() else { return };
14706    match response {
14707        NodeResponse::Snapshot { snapshot, .. } => {
14708            clear_snapshot_session_task_bindings(snapshot);
14709        }
14710        NodeResponse::Resync { snapshot, events, .. } => {
14711            clear_snapshot_session_task_bindings(snapshot);
14712            for event in events {
14713                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14714                    record.task_binding = None;
14715                }
14716            }
14717        }
14718        NodeResponse::SessionRecordUpdated { record }
14719        | NodeResponse::ProviderSessionIndexed { record }
14720        | NodeResponse::NativeSessionIndexed { record, .. }
14721        | NodeResponse::SessionRecordResumed { record, .. } => {
14722            record.task_binding = None;
14723        }
14724        _ => {}
14725    }
14726}
14727
14728fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14729    let Ok(response) = reply.result.as_mut() else { return };
14730    match response {
14731        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14732            snapshot.agent_progress.clear();
14733        }
14734        _ => {}
14735    }
14736}
14737
14738fn project_event_without_managed_worktrees(
14739    mut envelope: NodeEventEnvelope,
14740) -> Option<NodeEventEnvelope> {
14741    if matches!(
14742        envelope.event,
14743        NodeEvent::ManagedWorktreeUpserted { .. }
14744            | NodeEvent::ManagedWorktreeRemoved { .. }
14745    ) {
14746        return None;
14747    }
14748    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14749        workspace.managed_worktree_profiles = None;
14750    }
14751    Some(envelope)
14752}
14753
14754fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14755    if matches!(
14756        reply.result,
14757        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14758            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14759    ) {
14760        reply.result = Err(failure(
14761            NodeFailureCode::UnsupportedCapability,
14762            "managed worktree capability was not negotiated",
14763        ));
14764        return;
14765    }
14766    let Ok(response) = reply.result.as_mut() else { return };
14767    match response {
14768        NodeResponse::Snapshot { snapshot, .. } => {
14769            snapshot.managed_worktrees.clear();
14770            for workspace in &mut snapshot.workspaces {
14771                workspace.managed_worktree_profiles = None;
14772            }
14773        }
14774        NodeResponse::Resync { snapshot, events, .. } => {
14775            snapshot.managed_worktrees.clear();
14776            for workspace in &mut snapshot.workspaces {
14777                workspace.managed_worktree_profiles = None;
14778            }
14779            *events = std::mem::take(events)
14780                .into_iter()
14781                .filter_map(project_event_without_managed_worktrees)
14782                .collect();
14783        }
14784        NodeResponse::WorkspaceRegistered { workspace }
14785        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14786        | NodeResponse::WorktreeCreated { workspace, .. } => {
14787            workspace.managed_worktree_profiles = None;
14788        }
14789        NodeResponse::WorkspaceInspected { inspection } => {
14790            inspection.git.managed_worktree = None;
14791        }
14792        _ => {}
14793    }
14794}
14795
14796fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14797    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14798        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14799    });
14800    if includes_open_provider_ids {
14801        return;
14802    }
14803    compatibility
14804        .provider_contracts
14805        .retain(|contract| provider_id_is_legacy(&contract.provider));
14806    compatibility
14807        .provider_adapter_contracts
14808        .retain(|contract| provider_id_is_legacy(&contract.provider));
14809}
14810
14811fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14812    if let Some(spec) = match request {
14813        NodeRequest::SpawnSpec { spec }
14814        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14815        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14816        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14817        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14818        _ => None,
14819    } {
14820        return shared
14821            .resolve_spawn_spec(spec)
14822            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14823    }
14824    request_requires_open_provider_ids_with(
14825        request,
14826        |session| shared.validate_address(session).ok(),
14827        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14828    )
14829}
14830
14831fn request_requires_child_environment_profile(
14832    shared: &NodeShared,
14833    request: &NodeRequest,
14834) -> bool {
14835    if let Some(spec) = match request {
14836        NodeRequest::SpawnSpec { spec }
14837        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14838        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14839        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14840        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14841        _ => None,
14842    } {
14843        return shared
14844            .resolve_spawn_spec(spec)
14845            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14846    }
14847    match request {
14848        NodeRequest::Resume { session, .. }
14849        | NodeRequest::Prompt { session, .. }
14850        | NodeRequest::Paste { session, .. }
14851        | NodeRequest::Input { session, .. }
14852        | NodeRequest::TerminalBytes { session, .. }
14853        | NodeRequest::TerminalControl { session, .. }
14854        | NodeRequest::Resize { session, .. }
14855        | NodeRequest::Interrupt { session }
14856        | NodeRequest::Stop { session, .. }
14857        | NodeRequest::Remove { session }
14858        | NodeRequest::ResolveInteraction { session, .. }
14859        | NodeRequest::SetSessionMode { session, .. }
14860        | NodeRequest::SetSessionConfigOption { session, .. }
14861        | NodeRequest::SetSessionModel { session, .. }
14862        | NodeRequest::DiscoverHistory { session, .. }
14863        | NodeRequest::LoadHistory { session, .. }
14864        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14865        | NodeRequest::ExportContextPack { session } => shared
14866            .session_bindings
14867            .lock()
14868            .unwrap_or_else(|poisoned| poisoned.into_inner())
14869            .get(&session.session.instance_id)
14870            .filter(|binding| {
14871                binding.workspace_id == session.workspace_id
14872                    && binding.generation == session.session.generation
14873            })
14874            .map_or(true, |binding| binding.environment_profile.is_some()),
14875        NodeRequest::RenameSessionRecord { record_id, .. }
14876        | NodeRequest::SetSessionTask { record_id, .. }
14877        | NodeRequest::ResumeSessionRecord { record_id, .. }
14878        | NodeRequest::ForgetSessionRecord { record_id } => shared
14879            .record(record_id)
14880            .map_or(true, |record| record.environment_profile.is_some()),
14881        NodeRequest::ArmHarnessMcpReservation { .. }
14882        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14883        | NodeRequest::ActivateHarnessMcpReservation { .. }
14884        | NodeRequest::AbortHarnessMcpReservation { .. }
14885        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14886        | NodeRequest::RejectHarnessMcpCall { .. }
14887        | NodeRequest::Snapshot
14888        | NodeRequest::Resync { .. }
14889        | NodeRequest::BeginDeliveryStage { .. }
14890        | NodeRequest::PutDeliveryBlobChunk { .. }
14891        | NodeRequest::CommitDeliveryStage { .. }
14892        | NodeRequest::AbortDeliveryStage { .. }
14893        | NodeRequest::BrowseHostDirectories { .. }
14894        | NodeRequest::InspectWorkspace { .. }
14895        | NodeRequest::ReadWorkspaceFile { .. }
14896        | NodeRequest::WriteWorkspaceFile { .. }
14897        | NodeRequest::CreateWorkspaceFile { .. }
14898        | NodeRequest::CreateWorkspaceDirectory { .. }
14899        | NodeRequest::ReadGitHistory { .. }
14900        | NodeRequest::ReadGitDiff { .. }
14901        | NodeRequest::AcquireController { .. }
14902        | NodeRequest::ReleaseController
14903        | NodeRequest::Spawn { .. }
14904        | NodeRequest::SpawnWithOptions { .. }
14905        | NodeRequest::SpawnSpec { .. }
14906        | NodeRequest::RegisterWorkspace { .. }
14907        | NodeRequest::CreateStandaloneWorkspace { .. }
14908        | NodeRequest::UnregisterWorkspace { .. }
14909        | NodeRequest::CreateWorktree { .. }
14910        | NodeRequest::RemoveWorktree { .. }
14911        | NodeRequest::SpawnManagedWorktree { .. }
14912        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14913        | NodeRequest::CleanupManagedWorktree { .. }
14914        | NodeRequest::IndexProviderSession { .. }
14915        | NodeRequest::IndexNativeSession { .. }
14916        | NodeRequest::CatalogNativeSessions { .. }
14917        | NodeRequest::PageNativeSessions { .. }
14918        | NodeRequest::PreviewNativeSession { .. }
14919        | NodeRequest::PreviewSessionRecord { .. }
14920        | NodeRequest::ForgetContextPack { .. }
14921        | NodeRequest::ResolveDurableContextPack { .. }
14922        | NodeRequest::ReadContextPack { .. }
14923        | NodeRequest::Shutdown => false,
14924    }
14925}
14926
14927fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14928    if let Some(spec) = match request {
14929        NodeRequest::SpawnSpec { spec }
14930        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14931        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14932        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14933        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14934        _ => None,
14935    } {
14936        return shared
14937            .resolve_spawn_spec(spec)
14938            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14939    }
14940    match request {
14941        NodeRequest::Resume { session, .. }
14942        | NodeRequest::Prompt { session, .. }
14943        | NodeRequest::Paste { session, .. }
14944        | NodeRequest::Input { session, .. }
14945        | NodeRequest::TerminalBytes { session, .. }
14946        | NodeRequest::TerminalControl { session, .. }
14947        | NodeRequest::Resize { session, .. }
14948        | NodeRequest::Interrupt { session }
14949        | NodeRequest::Stop { session, .. }
14950        | NodeRequest::Remove { session }
14951        | NodeRequest::ResolveInteraction { session, .. }
14952        | NodeRequest::SetSessionMode { session, .. }
14953        | NodeRequest::SetSessionConfigOption { session, .. }
14954        | NodeRequest::SetSessionModel { session, .. }
14955        | NodeRequest::DiscoverHistory { session, .. }
14956        | NodeRequest::LoadHistory { session, .. }
14957        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14958        | NodeRequest::ExportContextPack { session } => shared
14959            .session_bindings
14960            .lock()
14961            .unwrap_or_else(|poisoned| poisoned.into_inner())
14962            .get(&session.session.instance_id)
14963            .filter(|binding| {
14964                binding.workspace_id == session.workspace_id
14965                    && binding.generation == session.session.generation
14966            })
14967            .map_or(true, |binding| binding.bundle.is_some()),
14968        NodeRequest::RenameSessionRecord { record_id, .. }
14969        | NodeRequest::SetSessionTask { record_id, .. }
14970        | NodeRequest::ResumeSessionRecord { record_id, .. }
14971        | NodeRequest::ForgetSessionRecord { record_id } => shared
14972            .record(record_id)
14973            .map_or(true, |record| record.bundle.is_some()),
14974        NodeRequest::ArmHarnessMcpReservation { .. }
14975        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14976        | NodeRequest::ActivateHarnessMcpReservation { .. }
14977        | NodeRequest::AbortHarnessMcpReservation { .. }
14978        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14979        | NodeRequest::RejectHarnessMcpCall { .. }
14980        | NodeRequest::Snapshot
14981        | NodeRequest::Resync { .. }
14982        | NodeRequest::BeginDeliveryStage { .. }
14983        | NodeRequest::PutDeliveryBlobChunk { .. }
14984        | NodeRequest::CommitDeliveryStage { .. }
14985        | NodeRequest::AbortDeliveryStage { .. }
14986        | NodeRequest::BrowseHostDirectories { .. }
14987        | NodeRequest::InspectWorkspace { .. }
14988        | NodeRequest::ReadWorkspaceFile { .. }
14989        | NodeRequest::WriteWorkspaceFile { .. }
14990        | NodeRequest::CreateWorkspaceFile { .. }
14991        | NodeRequest::CreateWorkspaceDirectory { .. }
14992        | NodeRequest::ReadGitHistory { .. }
14993        | NodeRequest::ReadGitDiff { .. }
14994        | NodeRequest::AcquireController { .. }
14995        | NodeRequest::ReleaseController
14996        | NodeRequest::Spawn { .. }
14997        | NodeRequest::SpawnWithOptions { .. }
14998        | NodeRequest::SpawnSpec { .. }
14999        | NodeRequest::RegisterWorkspace { .. }
15000        | NodeRequest::CreateStandaloneWorkspace { .. }
15001        | NodeRequest::UnregisterWorkspace { .. }
15002        | NodeRequest::CreateWorktree { .. }
15003        | NodeRequest::RemoveWorktree { .. }
15004        | NodeRequest::SpawnManagedWorktree { .. }
15005        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15006        | NodeRequest::CleanupManagedWorktree { .. }
15007        | NodeRequest::IndexProviderSession { .. }
15008        | NodeRequest::IndexNativeSession { .. }
15009        | NodeRequest::CatalogNativeSessions { .. }
15010        | NodeRequest::PageNativeSessions { .. }
15011        | NodeRequest::PreviewNativeSession { .. }
15012        | NodeRequest::PreviewSessionRecord { .. }
15013        | NodeRequest::ForgetContextPack { .. }
15014        | NodeRequest::ResolveDurableContextPack { .. }
15015        | NodeRequest::ReadContextPack { .. }
15016        | NodeRequest::Shutdown => false,
15017    }
15018}
15019
15020fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15021    if request.requires_history_context_pack_capability() {
15022        return true;
15023    }
15024    if let Some(spec) = match request {
15025        NodeRequest::SpawnSpec { spec }
15026        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15027        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15028        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15029        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15030        _ => None,
15031    } {
15032        return shared
15033            .resolve_spawn_spec(spec)
15034            .map_or(true, |resolved| resolved.context_id.is_some());
15035    }
15036    match request {
15037        NodeRequest::Resume { session, .. }
15038        | NodeRequest::Prompt { session, .. }
15039        | NodeRequest::Paste { session, .. }
15040        | NodeRequest::Input { session, .. }
15041        | NodeRequest::TerminalBytes { session, .. }
15042        | NodeRequest::TerminalControl { session, .. }
15043        | NodeRequest::Resize { session, .. }
15044        | NodeRequest::Interrupt { session }
15045        | NodeRequest::Stop { session, .. }
15046        | NodeRequest::Remove { session }
15047        | NodeRequest::ResolveInteraction { session, .. }
15048        | NodeRequest::SetSessionMode { session, .. }
15049        | NodeRequest::SetSessionConfigOption { session, .. }
15050        | NodeRequest::SetSessionModel { session, .. } => shared
15051            .session_bindings
15052            .lock()
15053            .unwrap_or_else(|poisoned| poisoned.into_inner())
15054            .get(&session.session.instance_id)
15055            .filter(|binding| {
15056                binding.workspace_id == session.workspace_id
15057                    && binding.generation == session.session.generation
15058            })
15059            .map_or(true, |binding| binding.context.is_some()),
15060        NodeRequest::RenameSessionRecord { record_id, .. }
15061        | NodeRequest::SetSessionTask { record_id, .. }
15062        | NodeRequest::ResumeSessionRecord { record_id, .. }
15063        | NodeRequest::ForgetSessionRecord { record_id } => shared
15064            .record(record_id)
15065            .map_or(true, |record| record.context.is_some()),
15066        NodeRequest::ArmHarnessMcpReservation { .. }
15067        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15068        | NodeRequest::ActivateHarnessMcpReservation { .. }
15069        | NodeRequest::AbortHarnessMcpReservation { .. }
15070        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15071        | NodeRequest::RejectHarnessMcpCall { .. }
15072        | NodeRequest::Snapshot
15073        | NodeRequest::Resync { .. }
15074        | NodeRequest::BeginDeliveryStage { .. }
15075        | NodeRequest::PutDeliveryBlobChunk { .. }
15076        | NodeRequest::CommitDeliveryStage { .. }
15077        | NodeRequest::AbortDeliveryStage { .. }
15078        | NodeRequest::BrowseHostDirectories { .. }
15079        | NodeRequest::InspectWorkspace { .. }
15080        | NodeRequest::ReadWorkspaceFile { .. }
15081        | NodeRequest::WriteWorkspaceFile { .. }
15082        | NodeRequest::CreateWorkspaceFile { .. }
15083        | NodeRequest::CreateWorkspaceDirectory { .. }
15084        | NodeRequest::ReadGitHistory { .. }
15085        | NodeRequest::ReadGitDiff { .. }
15086        | NodeRequest::AcquireController { .. }
15087        | NodeRequest::ReleaseController
15088        | NodeRequest::Spawn { .. }
15089        | NodeRequest::SpawnWithOptions { .. }
15090        | NodeRequest::SpawnSpec { .. }
15091        | NodeRequest::RegisterWorkspace { .. }
15092        | NodeRequest::CreateStandaloneWorkspace { .. }
15093        | NodeRequest::UnregisterWorkspace { .. }
15094        | NodeRequest::CreateWorktree { .. }
15095        | NodeRequest::RemoveWorktree { .. }
15096        | NodeRequest::SpawnManagedWorktree { .. }
15097        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15098        | NodeRequest::CleanupManagedWorktree { .. }
15099        | NodeRequest::IndexProviderSession { .. }
15100        | NodeRequest::IndexNativeSession { .. }
15101        | NodeRequest::CatalogNativeSessions { .. }
15102        | NodeRequest::PageNativeSessions { .. }
15103        | NodeRequest::PreviewNativeSession { .. }
15104        | NodeRequest::PreviewSessionRecord { .. }
15105        | NodeRequest::DiscoverHistory { .. }
15106        | NodeRequest::LoadHistory { .. }
15107        | NodeRequest::ExportContextPackForSessionRecord { .. }
15108        | NodeRequest::ExportContextPack { .. }
15109        | NodeRequest::ForgetContextPack { .. }
15110        | NodeRequest::ResolveDurableContextPack { .. }
15111        | NodeRequest::ReadContextPack { .. }
15112        | NodeRequest::Shutdown => false,
15113    }
15114}
15115
15116fn request_requires_open_provider_ids_with(
15117    request: &NodeRequest,
15118    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15119    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15120) -> bool {
15121    match request {
15122        NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15123        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15124        NodeRequest::CatalogNativeSessions { route, .. }
15125        | NodeRequest::PageNativeSessions { route, .. } => {
15126            !provider_id_is_legacy(&route.provider)
15127        }
15128        NodeRequest::PreviewNativeSession { selection, .. }
15129        | NodeRequest::IndexNativeSession { selection, .. } => {
15130            !provider_id_is_legacy(&selection.route.provider)
15131        }
15132        NodeRequest::SpawnSpec { .. }
15133        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15134        NodeRequest::SpawnManagedWorktree { .. }
15135        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15136        NodeRequest::Resume { session, .. }
15137        | NodeRequest::Prompt { session, .. }
15138        | NodeRequest::Paste { session, .. }
15139        | NodeRequest::Input { session, .. }
15140        | NodeRequest::TerminalBytes { session, .. }
15141        | NodeRequest::TerminalControl { session, .. }
15142        | NodeRequest::Resize { session, .. }
15143        | NodeRequest::Interrupt { session }
15144        | NodeRequest::Stop { session, .. }
15145        | NodeRequest::Remove { session }
15146        | NodeRequest::ResolveInteraction { session, .. }
15147        | NodeRequest::SetSessionMode { session, .. }
15148        | NodeRequest::SetSessionConfigOption { session, .. }
15149        | NodeRequest::SetSessionModel { session, .. }
15150        | NodeRequest::DiscoverHistory { session, .. }
15151        | NodeRequest::LoadHistory { session, .. }
15152        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15153        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15154            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15155        NodeRequest::RenameSessionRecord { record_id, .. }
15156        | NodeRequest::SetSessionTask { record_id, .. }
15157        | NodeRequest::ResumeSessionRecord { record_id, .. }
15158        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15159            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15160        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15161            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15162        NodeRequest::ArmHarnessMcpReservation { .. }
15163        | NodeRequest::ActivateHarnessMcpReservation { .. }
15164        | NodeRequest::AbortHarnessMcpReservation { .. }
15165        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15166        | NodeRequest::RejectHarnessMcpCall { .. }
15167        | NodeRequest::Snapshot
15168        | NodeRequest::Resync { .. }
15169        | NodeRequest::BeginDeliveryStage { .. }
15170        | NodeRequest::PutDeliveryBlobChunk { .. }
15171        | NodeRequest::CommitDeliveryStage { .. }
15172        | NodeRequest::AbortDeliveryStage { .. }
15173        | NodeRequest::BrowseHostDirectories { .. }
15174        | NodeRequest::InspectWorkspace { .. }
15175        | NodeRequest::ReadWorkspaceFile { .. }
15176        | NodeRequest::WriteWorkspaceFile { .. }
15177        | NodeRequest::CreateWorkspaceFile { .. }
15178        | NodeRequest::CreateWorkspaceDirectory { .. }
15179        | NodeRequest::ReadGitHistory { .. }
15180        | NodeRequest::ReadGitDiff { .. }
15181        | NodeRequest::AcquireController { .. }
15182        | NodeRequest::ReleaseController
15183        | NodeRequest::RegisterWorkspace { .. }
15184        | NodeRequest::CreateStandaloneWorkspace { .. }
15185        | NodeRequest::UnregisterWorkspace { .. }
15186        | NodeRequest::CreateWorktree { .. }
15187        | NodeRequest::RemoveWorktree { .. }
15188        | NodeRequest::CleanupManagedWorktree { .. }
15189        | NodeRequest::Shutdown => false,
15190        NodeRequest::ForgetContextPack { .. } => true,
15191        // No session/record to cheaply resolve a provider from here either;
15192        // the resolved receipt can carry an open-id provider, so require the
15193        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
15194        // is the same digest-keyed lookup one step further into the same
15195        // catalog, so it inherits the same conservative answer.
15196        NodeRequest::ResolveDurableContextPack { .. }
15197        | NodeRequest::ReadContextPack { .. } => true,
15198    }
15199}
15200
15201fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15202    snapshot.enabled_providers.retain(provider_id_is_legacy);
15203    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15204        snapshot
15205            .provider_runtime_statuses
15206            .as_slice()
15207            .iter()
15208            .filter(|status| provider_id_is_legacy(status.provider()))
15209            .cloned(),
15210    )
15211    .expect("a filtered provider runtime projection remains valid");
15212    snapshot
15213        .session_records
15214        .retain(|record| provider_id_is_legacy(&record.provider));
15215    for workspace in &mut snapshot.workspaces {
15216        workspace
15217            .sessions
15218            .retain(|session| provider_id_is_legacy(&session.agent_id));
15219    }
15220}
15221
15222fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15223    for record in &mut snapshot.session_records {
15224        record.environment_profile = None;
15225    }
15226    if let Some(profiles) = snapshot
15227        .launch_inventory
15228        .as_mut()
15229        .and_then(|inventory| inventory.spawn_profiles.as_mut())
15230    {
15231        for profile in profiles {
15232            profile.environment_profile = None;
15233        }
15234    }
15235}
15236
15237fn project_event_without_child_environment_profile(
15238    mut envelope: NodeEventEnvelope,
15239) -> NodeEventEnvelope {
15240    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15241        record.environment_profile = None;
15242    }
15243    envelope
15244}
15245
15246fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15247    let contains_environment_profile = match reply.result.as_ref() {
15248        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15249            receipt.environment_profile.is_some()
15250        }
15251        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15252            receipt.spawn.environment_profile.is_some()
15253        }
15254        _ => false,
15255    };
15256    if contains_environment_profile {
15257        reply.result = Err(failure(
15258            NodeFailureCode::UnsupportedCapability,
15259            "child environment profiles were not negotiated",
15260        ));
15261        return;
15262    }
15263    let Ok(response) = reply.result.as_mut() else {
15264        return;
15265    };
15266    match response {
15267        NodeResponse::Snapshot { snapshot, .. } => {
15268            clear_snapshot_child_environment_profiles(snapshot);
15269        }
15270        NodeResponse::Resync { snapshot, events, .. } => {
15271            clear_snapshot_child_environment_profiles(snapshot);
15272            for event in events {
15273                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15274                    record.environment_profile = None;
15275                }
15276            }
15277        }
15278        NodeResponse::SessionRecordUpdated { record }
15279        | NodeResponse::ProviderSessionIndexed { record }
15280        | NodeResponse::NativeSessionIndexed { record, .. }
15281        | NodeResponse::SessionRecordResumed { record, .. } => {
15282            record.environment_profile = None;
15283        }
15284        NodeResponse::Armed { .. }
15285        | NodeResponse::Spawned { .. }
15286        | NodeResponse::Activated { .. }
15287        | NodeResponse::Aborted { .. }
15288        | NodeResponse::ReplyChunkAccepted { .. }
15289        | NodeResponse::CallRejected { .. }
15290        | NodeResponse::DeliveryStageBegun { .. }
15291        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15292        | NodeResponse::DeliveryCommitted { .. }
15293        | NodeResponse::DeliveryStageAborted { .. }
15294        | NodeResponse::WorkspaceInspected { .. }
15295        | NodeResponse::HostDirectoriesBrowsed { .. }
15296        | NodeResponse::WorkspaceFileRead { .. }
15297        | NodeResponse::WorkspaceFileWritten { .. }
15298        | NodeResponse::WorkspaceFileCreated { .. }
15299        | NodeResponse::WorkspaceDirectoryCreated { .. }
15300        | NodeResponse::GitHistoryRead { .. }
15301        | NodeResponse::GitDiffRead { .. }
15302        | NodeResponse::Controller { .. }
15303        | NodeResponse::SpawnAccepted { .. }
15304        | NodeResponse::SpawnSpecAccepted { .. }
15305        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15306        | NodeResponse::ManagedWorktreeCleanup { .. }
15307        | NodeResponse::NativeSessionsCataloged { .. }
15308        | NodeResponse::NativeSessionsPaged { .. }
15309        | NodeResponse::NativeSessionPreviewed { .. }
15310        | NodeResponse::SessionRecordPreviewed { .. }
15311        | NodeResponse::HistoryDiscovered { .. }
15312        | NodeResponse::HistoryLoaded { .. }
15313        | NodeResponse::ContextPackForSessionRecordExported { .. }
15314        | NodeResponse::ContextPackExported { .. }
15315        | NodeResponse::ContextPackForgotten { .. }
15316        | NodeResponse::DurableContextPackResolved { .. }
15317        | NodeResponse::ContextPackBytesRead { .. }
15318        | NodeResponse::SessionRecordForgotten { .. }
15319        | NodeResponse::WorkspaceRegistered { .. }
15320        | NodeResponse::StandaloneWorkspaceCreated { .. }
15321        | NodeResponse::WorkspaceUnregistered { .. }
15322        | NodeResponse::WorktreeCreated { .. }
15323        | NodeResponse::WorktreeRemoved { .. }
15324        | NodeResponse::Accepted
15325        | NodeResponse::ShuttingDown => {}
15326    }
15327}
15328
15329fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15330    for record in &mut snapshot.session_records {
15331        record.bundle = None;
15332    }
15333}
15334
15335fn project_event_without_session_bundle(
15336    mut envelope: NodeEventEnvelope,
15337) -> NodeEventEnvelope {
15338    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15339        record.bundle = None;
15340    }
15341    envelope
15342}
15343
15344fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15345    let contains_bundle = match reply.result.as_ref() {
15346        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15347        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15348            receipt.spawn.bundle.is_some()
15349        }
15350        _ => false,
15351    };
15352    if contains_bundle {
15353        reply.result = Err(failure(
15354            NodeFailureCode::UnsupportedCapability,
15355            "session bundles were not negotiated",
15356        ));
15357        return;
15358    }
15359    let Ok(response) = reply.result.as_mut() else {
15360        return;
15361    };
15362    match response {
15363        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15364        NodeResponse::Resync { snapshot, events, .. } => {
15365            clear_snapshot_session_bundles(snapshot);
15366            for event in events {
15367                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15368                    record.bundle = None;
15369                }
15370            }
15371        }
15372        NodeResponse::SessionRecordUpdated { record }
15373        | NodeResponse::ProviderSessionIndexed { record }
15374        | NodeResponse::NativeSessionIndexed { record, .. }
15375        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15376        NodeResponse::Armed { .. }
15377        | NodeResponse::Spawned { .. }
15378        | NodeResponse::Activated { .. }
15379        | NodeResponse::Aborted { .. }
15380        | NodeResponse::ReplyChunkAccepted { .. }
15381        | NodeResponse::CallRejected { .. }
15382        | NodeResponse::DeliveryStageBegun { .. }
15383        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15384        | NodeResponse::DeliveryCommitted { .. }
15385        | NodeResponse::DeliveryStageAborted { .. }
15386        | NodeResponse::WorkspaceInspected { .. }
15387        | NodeResponse::HostDirectoriesBrowsed { .. }
15388        | NodeResponse::WorkspaceFileRead { .. }
15389        | NodeResponse::WorkspaceFileWritten { .. }
15390        | NodeResponse::WorkspaceFileCreated { .. }
15391        | NodeResponse::WorkspaceDirectoryCreated { .. }
15392        | NodeResponse::GitHistoryRead { .. }
15393        | NodeResponse::GitDiffRead { .. }
15394        | NodeResponse::Controller { .. }
15395        | NodeResponse::SpawnAccepted { .. }
15396        | NodeResponse::SpawnSpecAccepted { .. }
15397        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15398        | NodeResponse::ManagedWorktreeCleanup { .. }
15399        | NodeResponse::NativeSessionsCataloged { .. }
15400        | NodeResponse::NativeSessionsPaged { .. }
15401        | NodeResponse::NativeSessionPreviewed { .. }
15402        | NodeResponse::SessionRecordPreviewed { .. }
15403        | NodeResponse::HistoryDiscovered { .. }
15404        | NodeResponse::HistoryLoaded { .. }
15405        | NodeResponse::ContextPackForSessionRecordExported { .. }
15406        | NodeResponse::ContextPackExported { .. }
15407        | NodeResponse::ContextPackForgotten { .. }
15408        | NodeResponse::DurableContextPackResolved { .. }
15409        | NodeResponse::ContextPackBytesRead { .. }
15410        | NodeResponse::SessionRecordForgotten { .. }
15411        | NodeResponse::WorkspaceRegistered { .. }
15412        | NodeResponse::StandaloneWorkspaceCreated { .. }
15413        | NodeResponse::WorkspaceUnregistered { .. }
15414        | NodeResponse::WorktreeCreated { .. }
15415        | NodeResponse::WorktreeRemoved { .. }
15416        | NodeResponse::Accepted
15417        | NodeResponse::ShuttingDown => {}
15418    }
15419}
15420
15421fn project_snapshot_history_for_wire(
15422    snapshot: &mut NodeSnapshot,
15423    include_history_metadata: bool,
15424) {
15425    for workspace in &mut snapshot.workspaces {
15426        for session in &mut workspace.sessions {
15427            if include_history_metadata {
15428                session.history.loaded = None;
15429            } else {
15430                session.history = Default::default();
15431            }
15432        }
15433    }
15434}
15435
15436fn project_event_history_for_wire(
15437    mut envelope: NodeEventEnvelope,
15438    include_history_metadata: bool,
15439) -> NodeEventEnvelope {
15440    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15441        for session in &mut workspace.sessions {
15442            if include_history_metadata {
15443                session.history.loaded = None;
15444            } else {
15445                session.history = Default::default();
15446            }
15447        }
15448    }
15449    envelope
15450}
15451
15452fn project_response_history_for_wire(
15453    reply: &mut ResponseEnvelope,
15454    include_history_metadata: bool,
15455) {
15456    let Ok(response) = reply.result.as_mut() else {
15457        return;
15458    };
15459    match response {
15460        NodeResponse::Snapshot { snapshot, .. } => {
15461            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15462        }
15463        NodeResponse::Resync {
15464            snapshot, events, ..
15465        } => {
15466            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15467            for event in events {
15468                *event = project_event_history_for_wire(
15469                    event.clone(),
15470                    include_history_metadata,
15471                );
15472            }
15473        }
15474        NodeResponse::WorkspaceRegistered { workspace }
15475        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15476        | NodeResponse::WorktreeCreated { workspace, .. } => {
15477            for session in &mut workspace.sessions {
15478                if include_history_metadata {
15479                    session.history.loaded = None;
15480                } else {
15481                    session.history = Default::default();
15482                }
15483            }
15484        }
15485        _ => {}
15486    }
15487}
15488
15489fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15490    for record in &mut snapshot.session_records {
15491        record.context_id = None;
15492        record.context = None;
15493        record.exported_context = None;
15494    }
15495}
15496
15497fn project_event_without_context_pack(
15498    mut envelope: NodeEventEnvelope,
15499) -> NodeEventEnvelope {
15500    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15501        record.context_id = None;
15502        record.context = None;
15503        record.exported_context = None;
15504    }
15505    envelope
15506}
15507
15508fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15509    let contains_context = match reply.result.as_ref() {
15510        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15511        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15512            receipt.spawn.context.is_some()
15513        }
15514        Ok(NodeResponse::HistoryDiscovered { .. })
15515        | Ok(NodeResponse::HistoryLoaded { .. })
15516        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15517        | Ok(NodeResponse::ContextPackExported { .. })
15518        | Ok(NodeResponse::ContextPackForgotten { .. })
15519        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15520        _ => false,
15521    };
15522    if contains_context {
15523        reply.result = Err(failure(
15524            NodeFailureCode::UnsupportedCapability,
15525            "history context packs were not negotiated",
15526        ));
15527        return;
15528    }
15529    let Ok(response) = reply.result.as_mut() else {
15530        return;
15531    };
15532    match response {
15533        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15534        NodeResponse::Resync {
15535            snapshot, events, ..
15536        } => {
15537            clear_snapshot_context_packs(snapshot);
15538            for event in events {
15539                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15540                    record.context_id = None;
15541                    record.context = None;
15542                    record.exported_context = None;
15543                }
15544            }
15545        }
15546        NodeResponse::SessionRecordUpdated { record }
15547        | NodeResponse::ProviderSessionIndexed { record }
15548        | NodeResponse::NativeSessionIndexed { record, .. }
15549        | NodeResponse::SessionRecordResumed { record, .. } => {
15550            record.context_id = None;
15551            record.context = None;
15552            record.exported_context = None;
15553        }
15554        _ => {}
15555    }
15556}
15557
15558fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15559    let contains_open_record = match reply.result.as_ref() {
15560        Ok(NodeResponse::SessionRecordUpdated { record })
15561        | Ok(NodeResponse::ProviderSessionIndexed { record })
15562        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15563            !provider_id_is_legacy(&record.provider)
15564        }
15565        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15566            !provider_id_is_legacy(&selection.route.provider)
15567                || !provider_id_is_legacy(&record.provider)
15568        }
15569        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15570            !provider_id_is_legacy(&receipt.provider)
15571        }
15572        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15573            !provider_id_is_legacy(&receipt.spawn.provider)
15574        }
15575        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15576        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15577            !provider_id_is_legacy(&route.provider)
15578        }
15579        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15580            !provider_id_is_legacy(&selection.route.provider)
15581        }
15582        _ => false,
15583    };
15584    if contains_open_record {
15585        reply.result = Err(failure(
15586            NodeFailureCode::UnsupportedCapability,
15587            "open provider IDs were not negotiated",
15588        ));
15589        return;
15590    }
15591    let Ok(response) = reply.result.as_mut() else {
15592        return;
15593    };
15594    match response {
15595        NodeResponse::Snapshot { snapshot, .. } => {
15596            project_snapshot_legacy_provider_ids(snapshot);
15597        }
15598        NodeResponse::Resync { snapshot, events, .. } => {
15599            project_snapshot_legacy_provider_ids(snapshot);
15600            *events = std::mem::take(events)
15601                .into_iter()
15602                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15603                .collect();
15604        }
15605        NodeResponse::WorkspaceRegistered { workspace }
15606        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15607        | NodeResponse::WorktreeCreated { workspace, .. } => {
15608            workspace
15609                .sessions
15610                .retain(|session| provider_id_is_legacy(&session.agent_id));
15611        }
15612        NodeResponse::Armed { .. }
15613        | NodeResponse::Spawned { .. }
15614        | NodeResponse::Activated { .. }
15615        | NodeResponse::Aborted { .. }
15616        | NodeResponse::ReplyChunkAccepted { .. }
15617        | NodeResponse::CallRejected { .. }
15618        | NodeResponse::DeliveryStageBegun { .. }
15619        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15620        | NodeResponse::DeliveryCommitted { .. }
15621        | NodeResponse::DeliveryStageAborted { .. }
15622        | NodeResponse::WorkspaceInspected { .. }
15623        | NodeResponse::HostDirectoriesBrowsed { .. }
15624        | NodeResponse::WorkspaceFileRead { .. }
15625        | NodeResponse::WorkspaceFileWritten { .. }
15626        | NodeResponse::WorkspaceFileCreated { .. }
15627        | NodeResponse::WorkspaceDirectoryCreated { .. }
15628        | NodeResponse::GitHistoryRead { .. }
15629        | NodeResponse::GitDiffRead { .. }
15630        | NodeResponse::Controller { .. }
15631        | NodeResponse::SpawnAccepted { .. }
15632        | NodeResponse::SpawnSpecAccepted { .. }
15633        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15634        | NodeResponse::ManagedWorktreeCleanup { .. }
15635        | NodeResponse::NativeSessionsCataloged { .. }
15636        | NodeResponse::NativeSessionsPaged { .. }
15637        | NodeResponse::NativeSessionPreviewed { .. }
15638        | NodeResponse::SessionRecordPreviewed { .. }
15639        | NodeResponse::HistoryDiscovered { .. }
15640        | NodeResponse::HistoryLoaded { .. }
15641        | NodeResponse::ContextPackForSessionRecordExported { .. }
15642        | NodeResponse::ContextPackExported { .. }
15643        | NodeResponse::ContextPackForgotten { .. }
15644        | NodeResponse::DurableContextPackResolved { .. }
15645        | NodeResponse::ContextPackBytesRead { .. }
15646        | NodeResponse::SessionRecordUpdated { .. }
15647        | NodeResponse::ProviderSessionIndexed { .. }
15648        | NodeResponse::NativeSessionIndexed { .. }
15649        | NodeResponse::SessionRecordResumed { .. }
15650        | NodeResponse::SessionRecordForgotten { .. }
15651        | NodeResponse::WorkspaceUnregistered { .. }
15652        | NodeResponse::WorktreeRemoved { .. }
15653        | NodeResponse::Accepted
15654        | NodeResponse::ShuttingDown => {}
15655    }
15656}
15657
15658fn project_event_legacy_provider_ids(
15659    shared: &NodeShared,
15660    mut envelope: NodeEventEnvelope,
15661) -> Option<NodeEventEnvelope> {
15662    let include = match &mut envelope.event {
15663        NodeEvent::HarnessMcpReadCall { .. } => true,
15664        NodeEvent::Control { address, .. }
15665        | NodeEvent::AgentStream { address, .. }
15666        | NodeEvent::TerminalFrame { address, .. } => shared
15667            .validate_address(address)
15668            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15669        NodeEvent::WorkspaceAdded { workspace } => {
15670            workspace
15671                .sessions
15672                .retain(|session| provider_id_is_legacy(&session.agent_id));
15673            true
15674        }
15675        NodeEvent::SessionRecordUpserted { record } => {
15676            provider_id_is_legacy(&record.provider)
15677        }
15678        NodeEvent::SessionRecordHistorySummarized { .. } => false,
15679        NodeEvent::SessionRecordRemoved { .. } => shared
15680            .history
15681            .lock()
15682            .unwrap_or_else(|poisoned| poisoned.into_inner())
15683            .removed_record_providers
15684            .get(&envelope.sequence)
15685            .is_some_and(provider_id_is_legacy),
15686        NodeEvent::ControllerChanged { .. }
15687        | NodeEvent::ManagedWorktreeUpserted { .. }
15688        | NodeEvent::ManagedWorktreeRemoved { .. }
15689        | NodeEvent::WorkspaceRemoved { .. }
15690        | NodeEvent::ResyncRequired { .. } => true,
15691    };
15692    include.then_some(envelope)
15693}
15694
15695fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15696    let Ok(response) = reply.result.as_mut() else {
15697        return;
15698    };
15699    match response {
15700        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15701            snapshot.provider_runtime_statuses.clear();
15702        }
15703        NodeResponse::Armed { .. }
15704        | NodeResponse::Spawned { .. }
15705        | NodeResponse::Activated { .. }
15706        | NodeResponse::Aborted { .. }
15707        | NodeResponse::ReplyChunkAccepted { .. }
15708        | NodeResponse::CallRejected { .. }
15709        | NodeResponse::DeliveryStageBegun { .. }
15710        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15711        | NodeResponse::DeliveryCommitted { .. }
15712        | NodeResponse::DeliveryStageAborted { .. }
15713        | NodeResponse::WorkspaceInspected { .. }
15714        | NodeResponse::HostDirectoriesBrowsed { .. }
15715        | NodeResponse::WorkspaceFileRead { .. }
15716        | NodeResponse::WorkspaceFileWritten { .. }
15717        | NodeResponse::WorkspaceFileCreated { .. }
15718        | NodeResponse::WorkspaceDirectoryCreated { .. }
15719        | NodeResponse::GitHistoryRead { .. }
15720        | NodeResponse::GitDiffRead { .. }
15721        | NodeResponse::Controller { .. }
15722        | NodeResponse::SpawnAccepted { .. }
15723        | NodeResponse::SpawnSpecAccepted { .. }
15724        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15725        | NodeResponse::ManagedWorktreeCleanup { .. }
15726        | NodeResponse::NativeSessionsCataloged { .. }
15727        | NodeResponse::NativeSessionsPaged { .. }
15728        | NodeResponse::NativeSessionPreviewed { .. }
15729        | NodeResponse::SessionRecordPreviewed { .. }
15730        | NodeResponse::HistoryDiscovered { .. }
15731        | NodeResponse::HistoryLoaded { .. }
15732        | NodeResponse::ContextPackForSessionRecordExported { .. }
15733        | NodeResponse::ContextPackExported { .. }
15734        | NodeResponse::ContextPackForgotten { .. }
15735        | NodeResponse::DurableContextPackResolved { .. }
15736        | NodeResponse::ContextPackBytesRead { .. }
15737        | NodeResponse::SessionRecordUpdated { .. }
15738        | NodeResponse::ProviderSessionIndexed { .. }
15739        | NodeResponse::NativeSessionIndexed { .. }
15740        | NodeResponse::SessionRecordResumed { .. }
15741        | NodeResponse::SessionRecordForgotten { .. }
15742        | NodeResponse::WorkspaceRegistered { .. }
15743        | NodeResponse::StandaloneWorkspaceCreated { .. }
15744        | NodeResponse::WorkspaceUnregistered { .. }
15745        | NodeResponse::WorktreeCreated { .. }
15746        | NodeResponse::WorktreeRemoved { .. }
15747        | NodeResponse::Accepted
15748        | NodeResponse::ShuttingDown => {}
15749    }
15750}
15751
15752struct ControllerReleaseGuard {
15753    shared: Arc<NodeShared>,
15754    connection_id: u64,
15755}
15756
15757impl Drop for ControllerReleaseGuard {
15758    fn drop(&mut self) {
15759        self.shared.release_controller(self.connection_id);
15760    }
15761}
15762
15763struct AbortTaskOnDrop(AbortHandle);
15764
15765impl Drop for AbortTaskOnDrop {
15766    fn drop(&mut self) {
15767        self.0.abort();
15768    }
15769}
15770
15771/// Reads an unsigned env-config override, following the pattern of every
15772/// other bounded knob in this module: missing/unparsable/zero falls back to
15773/// `default`, and the resolved value is always clamped to `max` so a bad
15774/// override cannot reopen a bound this budget exists to enforce.
15775fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15776    std::env::var(name)
15777        .ok()
15778        .and_then(|value| value.parse::<u64>().ok())
15779        .filter(|value| *value > 0)
15780        .unwrap_or(default)
15781        .min(max)
15782}
15783
15784fn workspace_inspection_time_budget_ms() -> u64 {
15785    env_config_bounded(
15786        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15787        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15788        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15789    )
15790}
15791
15792fn workspace_inspection_entry_cap() -> usize {
15793    env_config_bounded(
15794        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15795        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15796        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15797    ) as usize
15798}
15799
15800/// Tracks the shared walk-side budget across the whole recursive walk: a
15801/// wall-clock deadline (shared with the git phase that runs after the walk)
15802/// and a cap on directory entries visited, whether or not they were pushed
15803/// to the response. Cheap to check per entry — one `Instant::now()` and one
15804/// integer compare.
15805struct WorkspaceWalkBudget {
15806    deadline: Instant,
15807    entry_cap: usize,
15808    entries_visited: u64,
15809    time_budget_exceeded: bool,
15810    entry_cap_exceeded: bool,
15811}
15812
15813impl WorkspaceWalkBudget {
15814    fn new(deadline: Instant, entry_cap: usize) -> Self {
15815        Self {
15816            deadline,
15817            entry_cap,
15818            entries_visited: 0,
15819            time_budget_exceeded: false,
15820            entry_cap_exceeded: false,
15821        }
15822    }
15823
15824    fn is_exhausted(&self) -> bool {
15825        self.time_budget_exceeded || self.entry_cap_exceeded
15826    }
15827
15828    /// Records one visited directory entry (pushed to the response or not)
15829    /// and reports whether the walk must stop now.
15830    fn record_visit(&mut self) -> bool {
15831        self.entries_visited += 1;
15832        if self.entries_visited as usize >= self.entry_cap {
15833            self.entry_cap_exceeded = true;
15834        }
15835        if Instant::now() >= self.deadline {
15836            self.time_budget_exceeded = true;
15837        }
15838        self.is_exhausted()
15839    }
15840}
15841
15842fn collect_workspace_entries(
15843    root: &Path,
15844    deadline: Instant,
15845    entry_cap: usize,
15846) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15847    let mut entries = Vec::new();
15848    let mut truncated = false;
15849    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15850    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15851    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15852    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15853    // require strict global byte order over relative paths.
15854    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15855    (entries, truncated, budget)
15856}
15857
15858/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15859/// `inspect_workspace` response — `None` once the walk and git phase both
15860/// completed inside their shared budget.
15861fn workspace_inspection_truncation(
15862    walk_budget: &WorkspaceWalkBudget,
15863    git_time_budget_exceeded: bool,
15864    elapsed: Duration,
15865) -> Option<WorkspaceInspectionTruncationV1> {
15866    if !walk_budget.time_budget_exceeded
15867        && !walk_budget.entry_cap_exceeded
15868        && !git_time_budget_exceeded
15869    {
15870        return None;
15871    }
15872    Some(WorkspaceInspectionTruncationV1 {
15873        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15874        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15875        git_time_budget_exceeded,
15876        entries_visited: walk_budget.entries_visited,
15877        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15878    })
15879}
15880
15881fn walk_workspace_directory(
15882    directory: &Path,
15883    relative_directory: Option<&RepositoryPath>,
15884    depth: usize,
15885    entries: &mut Vec<WorkspaceEntry>,
15886    truncated: &mut bool,
15887    budget: &mut WorkspaceWalkBudget,
15888) {
15889    if budget.is_exhausted() {
15890        *truncated = true;
15891        return;
15892    }
15893    let read_dir = match std::fs::read_dir(directory) {
15894        Ok(read_dir) => read_dir,
15895        Err(_) => {
15896            *truncated = true;
15897            return;
15898        }
15899    };
15900    let mut children = Vec::new();
15901    for child in read_dir {
15902        match child {
15903            Ok(child) => children.push(child),
15904            Err(_) => *truncated = true,
15905        }
15906    }
15907    children.sort_by_key(|child| child.file_name());
15908    for child in children {
15909        if budget.record_visit() {
15910            *truncated = true;
15911            return;
15912        }
15913        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15914            *truncated = true;
15915            return;
15916        }
15917        let file_type = match child.file_type() {
15918            Ok(file_type) => file_type,
15919            Err(_) => {
15920                *truncated = true;
15921                continue;
15922            }
15923        };
15924        if file_type.is_symlink() {
15925            continue;
15926        }
15927        let name = match child.file_name().into_string() {
15928            Ok(name) => name,
15929            Err(_) => {
15930                *truncated = true;
15931                continue;
15932            }
15933        };
15934        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15935            continue;
15936        }
15937        let relative_path = match relative_directory {
15938            None => windows_repository_path(name),
15939            Some(parent) => parent
15940                .as_utf8()
15941                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15942        };
15943        let Some(relative_path) = relative_path else {
15944            *truncated = true;
15945            continue;
15946        };
15947        let kind = if file_type.is_dir() {
15948            WorkspaceEntryKind::Directory
15949        } else if file_type.is_file() {
15950            WorkspaceEntryKind::File
15951        } else {
15952            continue;
15953        };
15954        entries.push(WorkspaceEntry {
15955            relative_path: relative_path.clone(),
15956            kind,
15957        });
15958        if file_type.is_dir() {
15959            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15960                walk_workspace_directory(
15961                    &child.path(),
15962                    Some(&relative_path),
15963                    depth + 1,
15964                    entries,
15965                    truncated,
15966                    budget,
15967                );
15968            } else {
15969                *truncated = true;
15970            }
15971        }
15972    }
15973}
15974
15975fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15976    if value.contains('\\') {
15977        return None;
15978    }
15979    RepositoryPath::utf8(value).ok()
15980}
15981
15982fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15983    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15984}
15985
15986/// Excludes a directory from the workspace tree walk. The three literal
15987/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15988/// beneath them catches every build-output directory regardless of name
15989/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
15990/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
15991/// directory without maintaining a name heuristic list). The name check
15992/// runs first and returns early so a directory rejected by name never pays
15993/// for the extra `CACHEDIR.TAG` stat.
15994fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15995    if name.eq_ignore_ascii_case(".git")
15996        || name.eq_ignore_ascii_case("target")
15997        || name.eq_ignore_ascii_case("node_modules")
15998    {
15999        return true;
16000    }
16001    path.join("CACHEDIR.TAG").is_file()
16002}
16003
16004fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
16005    matches!(
16006        status,
16007        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16008    )
16009}
16010
16011#[derive(Clone, Debug, Eq, PartialEq)]
16012enum ContextPackRepositoryHead {
16013    NotRepository,
16014    Unborn,
16015    Commit(String),
16016}
16017
16018#[derive(Clone, Debug, Eq, PartialEq)]
16019struct ContextPackRepositoryObservation {
16020    head: ContextPackRepositoryHead,
16021    git: GitSnapshot,
16022    files: Vec<ContextPackRepositoryFileSource>,
16023}
16024
16025fn stable_context_pack_repository(
16026    first: ContextPackRepositoryObservation,
16027    second: ContextPackRepositoryObservation,
16028) -> Result<ContextPackRepository, NodeFailure> {
16029    if first != second {
16030        return Err(failure(
16031            NodeFailureCode::ContextPackMaterializationFailed,
16032            "context repository changed during bounded capture",
16033        ));
16034    }
16035    Ok(ContextPackRepository::from_sources(
16036        &second.git,
16037        second.files,
16038    ))
16039}
16040
16041/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
16042/// (and only once) the shared walk+git `deadline` has already passed. Every
16043/// remaining phase in `inspect_git_workspace` checks this immediately
16044/// before issuing its own git call, so a budget spent by the walk (or by an
16045/// earlier git call) skips the rest of the chain instead of paying for it
16046/// anyway.
16047fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16048    if Instant::now() < deadline {
16049        return false;
16050    }
16051    append_git_diagnostic(
16052        snapshot,
16053        &format!("{stage} skipped: workspace inspection time budget exhausted"),
16054    );
16055    true
16056}
16057
16058/// Deadline-aware sibling of `run_git_bounded`, used only by
16059/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
16060/// whatever remains of the shared `deadline` (never more than
16061/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
16062/// of the budget cannot still hand a full per-call timeout to every git
16063/// probe behind it. `inspect_git_workspace` already guards each call site
16064/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
16065/// passed yet here.
16066async fn run_git_bounded_with_deadline(
16067    root: &str,
16068    arguments: &[&str],
16069    output_limit: usize,
16070    deadline: Instant,
16071) -> io::Result<GitCommandOutput> {
16072    let remaining = deadline.checked_duration_since(Instant::now());
16073    let timeout_ms = remaining
16074        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16075        .unwrap_or(0)
16076        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16077    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16078}
16079
16080/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
16081/// `deadline` is the same absolute instant the preceding directory walk was
16082/// bounded by, so time already spent walking counts against what remains
16083/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
16084/// shared budget (not an individual per-call timeout) is what cut the git
16085/// phase short.
16086async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16087    let mut snapshot = GitSnapshot {
16088        is_repository: false,
16089        branch: None,
16090        status: Vec::new(),
16091        recent_commits: Vec::new(),
16092        worktrees: Vec::new(),
16093        managed_worktree: None,
16094        truncated: false,
16095        diagnostic: None,
16096    };
16097    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16098        return (snapshot, true);
16099    }
16100    let repository = match run_git_bounded_with_deadline(
16101        root,
16102        &["rev-parse", "--is-inside-work-tree"],
16103        4 * 1_024,
16104        deadline,
16105    )
16106    .await
16107    {
16108        Ok(output) => output,
16109        Err(error) => {
16110            append_git_diagnostic(
16111                &mut snapshot,
16112                &format!("git inspection unavailable: {error}"),
16113            );
16114            return (snapshot, false);
16115        }
16116    };
16117    snapshot.truncated |= repository.truncated;
16118    if repository.timed_out {
16119        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16120        return (snapshot, false);
16121    }
16122    if !repository.success
16123        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16124    {
16125        let stderr = String::from_utf8_lossy(&repository.stderr);
16126        if !stderr.to_ascii_lowercase().contains("not a git repository") {
16127            append_git_diagnostic(&mut snapshot, stderr.trim());
16128        }
16129        return (snapshot, false);
16130    }
16131    snapshot.is_repository = true;
16132
16133    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16134        return (snapshot, true);
16135    }
16136    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16137        Ok(output) => {
16138            snapshot.truncated |= output.truncated;
16139            if output.timed_out {
16140                append_git_diagnostic(&mut snapshot, "git branch query timed out");
16141            } else if output.success {
16142                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16143                if !branch.is_empty() {
16144                    snapshot.branch = Some(truncate_git_field(
16145                        &branch,
16146                        MAX_REPOSITORY_PATH_BYTES,
16147                        &mut snapshot.truncated,
16148                    ));
16149                }
16150            }
16151        }
16152        Err(error) => append_git_diagnostic(
16153            &mut snapshot,
16154            &format!("git branch query failed: {error}"),
16155        ),
16156    }
16157    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16158        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16159            snapshot.truncated |= output.truncated;
16160            if output.success && !output.timed_out {
16161                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16162                if !head.is_empty() {
16163                    snapshot.branch = Some(format!("detached:{head}"));
16164                }
16165            }
16166        }
16167    }
16168
16169    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16170        return (snapshot, true);
16171    }
16172    match run_git_bounded_with_deadline(
16173        root,
16174        &[
16175            "status",
16176            "--porcelain=v1",
16177            "-z",
16178            "--renames",
16179            "--untracked-files=normal",
16180            "--",
16181            ".",
16182        ],
16183        GIT_OUTPUT_MAX_BYTES,
16184        deadline,
16185    )
16186    .await
16187    {
16188        Ok(output) => {
16189            snapshot.truncated |= output.truncated;
16190            if output.timed_out {
16191                append_git_diagnostic(&mut snapshot, "git status timed out");
16192            } else if output.success {
16193                parse_git_status(&output.stdout, &mut snapshot);
16194            } else {
16195                append_git_diagnostic(
16196                    &mut snapshot,
16197                    String::from_utf8_lossy(&output.stderr).trim(),
16198                );
16199            }
16200        }
16201        Err(error) => append_git_diagnostic(
16202            &mut snapshot,
16203            &format!("git status failed: {error}"),
16204        ),
16205    }
16206
16207    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16208        return (snapshot, true);
16209    }
16210    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16211    match run_git_bounded_with_deadline(
16212        root,
16213        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16214        16 * 1_024,
16215        deadline,
16216    )
16217    .await
16218    {
16219        Ok(output) => {
16220            snapshot.truncated |= output.truncated;
16221            if output.timed_out {
16222                append_git_diagnostic(&mut snapshot, "git log timed out");
16223            } else if output.success {
16224                parse_git_commits(&output.stdout, &mut snapshot);
16225            } else {
16226                let stderr = String::from_utf8_lossy(&output.stderr);
16227                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16228                    append_git_diagnostic(&mut snapshot, stderr.trim());
16229                }
16230            }
16231        }
16232        Err(error) => append_git_diagnostic(
16233            &mut snapshot,
16234            &format!("git log failed: {error}"),
16235        ),
16236    }
16237
16238    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16239        return (snapshot, true);
16240    }
16241    match list_git_worktrees_with_deadline(root, deadline).await {
16242        Ok(worktrees) => {
16243            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16244        }
16245        Err(error) => append_git_diagnostic(
16246            &mut snapshot,
16247            &format!("git worktree list failed: {}", error.message),
16248        ),
16249    }
16250    (snapshot, false)
16251}
16252
16253fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16254    let mut remaining = output;
16255    while !remaining.is_empty() {
16256        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16257            snapshot.truncated = true;
16258            break;
16259        }
16260        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16261            snapshot.truncated = true;
16262            break;
16263        };
16264        let record = &remaining[..record_end];
16265        remaining = &remaining[record_end + 1..];
16266        let has_previous_path = record
16267            .get(..2)
16268            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16269        let previous_path_bytes = if has_previous_path {
16270            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16271                snapshot.truncated = true;
16272                break;
16273            };
16274            let previous = &remaining[..previous_end];
16275            remaining = &remaining[previous_end + 1..];
16276            Some(previous)
16277        } else {
16278            None
16279        };
16280        if record.len() < 4
16281            || record[2] != b' '
16282            || !is_git_status_code(record[0])
16283            || !is_git_status_code(record[1])
16284        {
16285            snapshot.truncated = true;
16286            continue;
16287        }
16288        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16289            snapshot.truncated = true;
16290            continue;
16291        };
16292        let previous_path = match previous_path_bytes {
16293            Some(previous) => {
16294                let Some(previous) = windows_repository_path_from_bytes(previous) else {
16295                    snapshot.truncated = true;
16296                    continue;
16297                };
16298                Some(previous)
16299            }
16300            None => None,
16301        };
16302        snapshot.status.push(GitStatusEntry {
16303            index_status: (record[0] as char).to_string(),
16304            worktree_status: (record[1] as char).to_string(),
16305            path,
16306            previous_path,
16307        });
16308    }
16309}
16310
16311fn is_git_status_code(byte: u8) -> bool {
16312    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16313}
16314
16315fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16316    for line in String::from_utf8_lossy(output).lines() {
16317        let Some((id, summary)) = line.split_once('\u{1f}') else {
16318            continue;
16319        };
16320        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16321            snapshot.truncated = true;
16322            break;
16323        }
16324        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16325        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16326        snapshot.recent_commits.push(GitCommitSummary { id, summary });
16327    }
16328}
16329
16330fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16331    if value.len() <= max_bytes {
16332        return value.to_owned();
16333    }
16334    *truncated = true;
16335    let mut boundary = max_bytes;
16336    while boundary > 0 && !value.is_char_boundary(boundary) {
16337        boundary -= 1;
16338    }
16339    value[..boundary].to_owned()
16340}
16341
16342fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16343    if message.is_empty() {
16344        return;
16345    }
16346    let normalized = message
16347        .chars()
16348        .map(|character| if character.is_control() { ' ' } else { character })
16349        .collect::<String>();
16350    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16351    if !diagnostic.is_empty() {
16352        diagnostic.push_str("; ");
16353    }
16354    diagnostic.push_str(normalized.trim());
16355    snapshot.diagnostic = Some(truncate_git_field(
16356        &diagnostic,
16357        GIT_DIAGNOSTIC_MAX_BYTES,
16358        &mut snapshot.truncated,
16359    ));
16360}
16361
16362async fn run_git_bounded(
16363    root: &str,
16364    arguments: &[&str],
16365    output_limit: usize,
16366) -> io::Result<GitCommandOutput> {
16367    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16368}
16369
16370async fn read_git_history_bounded(
16371    root: &str,
16372    path: Option<&RepositoryPath>,
16373    before: Option<&GitObjectId>,
16374    limit: u16,
16375) -> Result<GitHistoryPage, NodeFailure> {
16376    let path = path
16377        .map(|path| {
16378            path.as_utf8().ok_or_else(|| {
16379                failure(
16380                    NodeFailureCode::InvalidRepositoryPath,
16381                    "git history path must use UTF-8",
16382                )
16383            })
16384        })
16385        .transpose()?
16386        .unwrap_or(".");
16387    let count = (usize::from(limit) + 1).to_string();
16388    let start = before
16389        .map(|revision| format!("{}^", revision.as_str()))
16390        .unwrap_or_else(|| "HEAD".to_owned());
16391    let arguments = [
16392        "--no-pager",
16393        "log",
16394        "--no-decorate",
16395        "--date=iso-strict",
16396        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16397        "-n",
16398        count.as_str(),
16399        start.as_str(),
16400        "--",
16401        path,
16402    ];
16403    let output = run_git_bounded(root, &arguments, 256 * 1_024)
16404        .await
16405        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16406    if output.timed_out {
16407        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16408    }
16409    if !output.success {
16410        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16411    }
16412    let mut commits = parse_git_history(&output.stdout);
16413    let has_more = commits.len() > usize::from(limit) || output.truncated;
16414    commits.truncate(usize::from(limit));
16415    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16416    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16417}
16418
16419fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16420    String::from_utf8_lossy(output)
16421        .split('\u{1e}')
16422        .filter_map(|record| {
16423            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16424            if fields.len() != 9 {
16425                return None;
16426            }
16427            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16428            let parents = if fields[1].is_empty() {
16429                Vec::new()
16430            } else {
16431                fields[1]
16432                    .split(' ')
16433                    .map(|value| GitObjectId::new(value.to_owned()))
16434                    .collect::<Result<Vec<_>, _>>()
16435                    .ok()?
16436            };
16437            Some(GitCommitDetails {
16438                id,
16439                parents,
16440                subject: bounded_git_text(fields[2], 512),
16441                author_name: bounded_git_text(fields[3], 256),
16442                author_email: bounded_git_text(fields[4], 320),
16443                authored_at: bounded_git_text(fields[5], 64),
16444                committer_name: bounded_git_text(fields[6], 256),
16445                committer_email: bounded_git_text(fields[7], 320),
16446                committed_at: bounded_git_text(fields[8], 64),
16447                signature_status: GitSignatureStatus::NoSignature,
16448                signer: None,
16449            })
16450        })
16451        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16452        .collect()
16453}
16454
16455fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16456    let normalized = value
16457        .chars()
16458        .map(|character| if character.is_control() { ' ' } else { character })
16459        .collect::<String>();
16460    if normalized.len() <= max_bytes {
16461        return normalized;
16462    }
16463    let mut boundary = max_bytes;
16464    while boundary > 0 && !normalized.is_char_boundary(boundary) {
16465        boundary -= 1;
16466    }
16467    normalized[..boundary].to_owned()
16468}
16469
16470async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16471    let path = request.path.as_ref().map(|path| {
16472        path.as_utf8()
16473            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16474    }).transpose()?;
16475    let mut arguments = match &request.mode {
16476        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16477        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16478        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16479    };
16480    if let GitDiffMode::Commit { revision } = &request.mode {
16481        arguments.push(revision.as_str());
16482    }
16483    arguments.push("--");
16484    if let Some(path) = path {
16485        arguments.push(path);
16486    }
16487    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16488        .await
16489        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16490    if output.timed_out {
16491        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16492    }
16493    if !output.success {
16494        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16495    }
16496    Ok(GitDiff {
16497        mode: request.mode,
16498        path: request.path,
16499        text: String::from_utf8_lossy(&output.stdout).into_owned(),
16500        truncated: output.truncated,
16501    })
16502}
16503
16504async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16505    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16506    ResponseEnvelope { request_id: envelope.request_id, result }
16507}
16508
16509async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16510    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16511        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16512    }
16513    let read_only = matches!(
16514        &request,
16515        NodeRequest::Snapshot
16516            | NodeRequest::Resync { .. }
16517            | NodeRequest::BrowseHostDirectories { .. }
16518            | NodeRequest::InspectWorkspace { .. }
16519            | NodeRequest::ReadWorkspaceFile { .. }
16520            | NodeRequest::ReadGitHistory { .. }
16521            | NodeRequest::ReadGitDiff { .. }
16522            | NodeRequest::CatalogNativeSessions { .. }
16523            | NodeRequest::PageNativeSessions { .. }
16524            | NodeRequest::PreviewNativeSession { .. }
16525            | NodeRequest::PreviewSessionRecord { .. }
16526    );
16527    let _mutation_guard = if read_only {
16528        None
16529    } else {
16530        let guard = shared.mutation_gate.lock().await;
16531        if shared.shutdown.load(Ordering::Acquire) {
16532            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16533        }
16534        Some(guard)
16535    };
16536    match request {
16537        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16538            event_sequence: shared.current_sequence(),
16539            controller: shared.controller_state(),
16540            snapshot: shared.snapshot(),
16541        }),
16542        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16543        NodeRequest::ArmHarnessMcpReservation {
16544            reservation_id,
16545            activation_digest,
16546            spawn_spec,
16547            launch,
16548            expires_at_unix_ms,
16549        } => {
16550            shared.require_controller(connection_id, role)?;
16551            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16552                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16553            })?;
16554            let expires_at_unix_ms = registry
16555                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16556                .await
16557                .map_err(harness_mcp_failure)?;
16558            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16559        }
16560        NodeRequest::SpawnSpecWithHarnessMcp {
16561            reservation_id,
16562            activation_digest,
16563            spec,
16564            deadline_unix_ms,
16565        } => {
16566            shared.require_controller(connection_id, role)?;
16567            let receipt = shared
16568                .spawn_from_spec_with_harness_mcp(
16569                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16570                )
16571                .await?;
16572            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16573        }
16574        NodeRequest::ActivateHarnessMcpReservation {
16575            reservation_id,
16576            activation_digest,
16577            record_id,
16578            session,
16579        } => {
16580            shared.require_controller(connection_id, role)?;
16581            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16582                current.instance_id == session.session.instance_id
16583                    && current.generation == session.session.generation
16584            }).and_then(|current| current.process_id).ok_or_else(|| {
16585                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16586            })?;
16587            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16588                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16589            })?.activate(
16590                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16591            ).map_err(harness_mcp_failure)?;
16592            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16593        }
16594        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16595            shared.require_controller(connection_id, role)?;
16596            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16597                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16598            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16599            if let Some(instance_id) = instance_id {
16600                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16601            }
16602            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16603        }
16604        NodeRequest::PutHarnessMcpReplyChunk {
16605            reservation_id, activation_digest, record_id, session, call_id,
16606            offset, final_chunk, chunk_hex,
16607        } => {
16608            shared.require_controller(connection_id, role)?;
16609            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16610                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16611            })?.put_reply_chunk(
16612                &reservation_id, &activation_digest, &record_id, &session, &call_id,
16613                offset, final_chunk, &chunk_hex,
16614            ).map_err(harness_mcp_failure)?;
16615            Ok(NodeResponse::ReplyChunkAccepted {
16616                reservation_id, activation_digest, record_id, session, call_id,
16617                next_offset, completed,
16618            })
16619        }
16620        NodeRequest::RejectHarnessMcpCall {
16621            reservation_id, activation_digest, record_id, session, call_id, reason,
16622        } => {
16623            shared.require_controller(connection_id, role)?;
16624            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16625                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16626            })?.reject_call(
16627                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16628            ).map_err(harness_mcp_failure)?;
16629            Ok(NodeResponse::CallRejected {
16630                reservation_id, activation_digest, record_id, session, call_id,
16631            })
16632        }
16633        NodeRequest::BeginDeliveryStage { manifest } => {
16634            shared.require_controller(connection_id, role)?;
16635            shared.begin_delivery_stage(manifest)
16636        }
16637        NodeRequest::PutDeliveryBlobChunk {
16638            stage_id,
16639            blob_digest,
16640            offset,
16641            chunk_hex,
16642        } => {
16643            shared.require_controller(connection_id, role)?;
16644            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16645        }
16646        NodeRequest::CommitDeliveryStage { stage_id } => {
16647            shared.require_controller(connection_id, role)?;
16648            let receipt = shared.commit_delivery_stage(stage_id)?;
16649            Ok(NodeResponse::DeliveryCommitted { receipt })
16650        }
16651        NodeRequest::AbortDeliveryStage { stage_id } => {
16652            shared.require_controller(connection_id, role)?;
16653            shared.abort_delivery_stage(&stage_id)?;
16654            Ok(NodeResponse::DeliveryStageAborted { stage_id })
16655        }
16656        NodeRequest::BrowseHostDirectories { directory, after } => {
16657            let listing = shared.browse_host_directories(directory, after).await?;
16658            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16659        }
16660        NodeRequest::InspectWorkspace { workspace_id } => {
16661            let inspection = shared.inspect_workspace(workspace_id).await?;
16662            Ok(NodeResponse::WorkspaceInspected { inspection })
16663        }
16664        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16665            let file = shared.read_workspace_file(workspace_id, path).await?;
16666            Ok(NodeResponse::WorkspaceFileRead { file })
16667        }
16668        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16669            shared.require_controller(connection_id, role)?;
16670            let file = shared
16671                .write_workspace_file(workspace_id, path, expected_revision, text)
16672                .await?;
16673            Ok(NodeResponse::WorkspaceFileWritten { file })
16674        }
16675        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16676            shared.require_controller(connection_id, role)?;
16677            let file = shared.create_workspace_file(workspace_id, path).await?;
16678            Ok(NodeResponse::WorkspaceFileCreated { file })
16679        }
16680        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16681            shared.require_controller(connection_id, role)?;
16682            let entry = shared
16683                .create_workspace_directory(workspace_id.clone(), path)
16684                .await?;
16685            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16686        }
16687        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16688            if role != ClientRole::Operator {
16689                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16690            }
16691            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16692                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16693            }
16694            let page = shared
16695                .read_git_history(workspace_id.clone(), path, before, limit)
16696                .await?;
16697            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16698        }
16699        NodeRequest::ReadGitDiff { workspace_id, request } => {
16700            if role != ClientRole::Operator {
16701                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16702            }
16703            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16704            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16705        }
16706        NodeRequest::CatalogNativeSessions { route, limit } => {
16707            if role != ClientRole::Operator {
16708                return Err(failure(
16709                    NodeFailureCode::ObserverReadOnly,
16710                    "native session catalog requires an operator connection",
16711                ));
16712            }
16713            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16714                return Err(failure(
16715                    NodeFailureCode::InvalidRequest,
16716                    "native session catalog limit is invalid",
16717                ));
16718            }
16719            let (entries, summary) = shared
16720                .catalog_native_sessions(route.clone(), limit)
16721                .await?;
16722            Ok(NodeResponse::NativeSessionsCataloged {
16723                route,
16724                entries,
16725                summary: Some(summary),
16726            })
16727        }
16728        NodeRequest::PageNativeSessions {
16729            route,
16730            window,
16731            catalog_revision,
16732            recent_cutoff_unix_ms,
16733            after_selection_id,
16734            limit,
16735        } => {
16736            if role != ClientRole::Operator {
16737                return Err(failure(
16738                    NodeFailureCode::ObserverReadOnly,
16739                    "native session catalog paging requires an operator connection",
16740                ));
16741            }
16742            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16743                || after_selection_id
16744                    .as_deref()
16745                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16746            {
16747                return Err(failure(
16748                    NodeFailureCode::InvalidRequest,
16749                    "native session catalog page request is invalid",
16750                ));
16751            }
16752            let page = shared
16753                .page_native_sessions(
16754                    route.clone(),
16755                    window,
16756                    catalog_revision,
16757                    recent_cutoff_unix_ms,
16758                    after_selection_id,
16759                    limit,
16760                )
16761                .await?;
16762            Ok(NodeResponse::NativeSessionsPaged {
16763                route,
16764                page,
16765            })
16766        }
16767        NodeRequest::PreviewNativeSession {
16768            selection,
16769            message_limit,
16770        } => {
16771            if role != ClientRole::Operator {
16772                return Err(failure(
16773                    NodeFailureCode::ObserverReadOnly,
16774                    "native session preview requires an operator connection",
16775                ));
16776            }
16777            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16778                .contains(&message_limit)
16779                || selection.validate().is_err()
16780            {
16781                return Err(failure(
16782                    NodeFailureCode::InvalidRequest,
16783                    "native session preview request is invalid",
16784                ));
16785            }
16786            let preview = shared
16787                .preview_native_session(selection.clone(), message_limit)
16788                .await?;
16789            Ok(NodeResponse::NativeSessionPreviewed {
16790                selection,
16791                preview,
16792            })
16793        }
16794        NodeRequest::IndexNativeSession {
16795            selection,
16796            display_name,
16797        } => {
16798            shared.require_controller(connection_id, role)?;
16799            let record = shared
16800                .index_native_session(selection.clone(), display_name)
16801                .await?;
16802            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16803        }
16804        NodeRequest::PreviewSessionRecord {
16805            record_id,
16806            message_limit,
16807        } => {
16808            if role != ClientRole::Operator {
16809                return Err(failure(
16810                    NodeFailureCode::ObserverReadOnly,
16811                    "native session preview requires an operator connection",
16812                ));
16813            }
16814            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16815                .contains(&message_limit)
16816            {
16817                return Err(failure(
16818                    NodeFailureCode::InvalidRequest,
16819                    "native session preview request is invalid",
16820                ));
16821            }
16822            let record = shared.record(&record_id)?;
16823            let working_directory = shared.workspace_root(&record.workspace_id)?;
16824            if !platform::roots_equal(
16825                &working_directory,
16826                windows_path_text(&record.canonical_root),
16827            ) {
16828                return Err(failure(
16829                    NodeFailureCode::SessionRecordConflict,
16830                    "managed session workspace root changed; refusing to preview another directory",
16831                ));
16832            }
16833            let identity = record.provider_session.clone().ok_or_else(|| {
16834                failure(
16835                    NodeFailureCode::BackendOperationFailed,
16836                    "native session preview is unavailable for this session record",
16837                )
16838            })?;
16839            if identity.key != ProviderSessionKey::SessionId {
16840                return Err(failure(
16841                    NodeFailureCode::BackendOperationFailed,
16842                    "native session preview is unavailable for this session identity",
16843                ));
16844            }
16845            let preview = shared
16846                .preview_native_session_by_session_id(
16847                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16848                )
16849                .await?;
16850            if preview.session_id != identity.id {
16851                return Err(failure(
16852                    NodeFailureCode::SessionRecordConflict,
16853                    "native session preview identity changed while loading",
16854                ));
16855            }
16856            shared.revalidate_session_record_preview(&record, &identity)?;
16857            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16858            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16859                record_id: record_id.clone(),
16860                summary: SessionHistorySummaryV1::from(&preview),
16861            });
16862            Ok(NodeResponse::SessionRecordPreviewed {
16863                record_id,
16864                preview,
16865            })
16866        }
16867        NodeRequest::AcquireController { lease_ms } => {
16868            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16869            Ok(NodeResponse::Controller { controller: Some(controller) })
16870        }
16871        NodeRequest::ReleaseController => {
16872            shared.release_controller(connection_id);
16873            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16874        }
16875        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16876            shared.require_controller(connection_id, role)?;
16877            let root = require_windows_path(root)?;
16878            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16879            let workspace = shared.register_workspace(workspace_id, root).await?;
16880            Ok(NodeResponse::WorkspaceRegistered { workspace })
16881        }
16882        NodeRequest::CreateStandaloneWorkspace {
16883            workspace_id,
16884            root,
16885            initial_branch,
16886        } => {
16887            shared.require_controller(connection_id, role)?;
16888            let root = require_windows_path(root)?;
16889            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16890            let workspace = shared
16891                .create_standalone_workspace(workspace_id, root, initial_branch)
16892                .await?;
16893            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16894        }
16895        NodeRequest::UnregisterWorkspace { workspace_id } => {
16896            shared.require_controller(connection_id, role)?;
16897            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16898            shared.unregister_workspace(&workspace_id)?;
16899            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16900        }
16901        NodeRequest::CreateWorktree {
16902            source_workspace_id,
16903            workspace_id,
16904            target_root,
16905            branch,
16906            base,
16907        } => {
16908            shared.require_controller(connection_id, role)?;
16909            let target_root = require_windows_path(target_root)?;
16910            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16911            let (worktree, workspace) = shared
16912                .create_worktree(
16913                    source_workspace_id,
16914                    workspace_id,
16915                    target_root,
16916                    branch,
16917                    base,
16918                )
16919                .await?;
16920            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16921        }
16922        NodeRequest::RemoveWorktree {
16923            source_workspace_id,
16924            target_root,
16925        } => {
16926            shared.require_controller(connection_id, role)?;
16927            let native_target_root = require_windows_path(target_root.clone())?;
16928            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16929            let workspace_id = shared
16930                .remove_worktree(source_workspace_id, native_target_root)
16931                .await?;
16932            Ok(NodeResponse::WorktreeRemoved {
16933                target_root,
16934                workspace_id,
16935            })
16936        }
16937        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16938            shared.require_controller(connection_id, role)?;
16939            if !terminal_size.is_valid() {
16940                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16941            }
16942            if let Some(prompt) = initial_prompt.as_deref() {
16943                validate_node_text("spawn initial prompt", prompt)?;
16944            }
16945            match shared
16946                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16947                .await
16948            {
16949                Ok(session) => {
16950                    shared.mark_spawn_provisional(session.clone());
16951                    Ok(NodeResponse::SpawnAccepted { session })
16952                }
16953                Err(err) => {
16954                    tracing::warn!(
16955                        %provider,
16956                        %workspace_id,
16957                        ?mode,
16958                        code = ?err.code,
16959                        message = %err.message,
16960                        "session spawn refused"
16961                    );
16962                    Err(err)
16963                }
16964            }
16965        }
16966        NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16967            shared.require_controller(connection_id, role)?;
16968            if !terminal_size.is_valid() {
16969                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16970            }
16971            if let Some(prompt) = initial_prompt.as_deref() {
16972                validate_node_text("spawn initial prompt", prompt)?;
16973            }
16974            let plan = backend_paths::plan_launch_options(
16975                provider.as_str(),
16976                mode,
16977                options.model.is_some(),
16978                options.effort.is_some(),
16979            )
16980            .map_err(|r| {
16981                tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16982                failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16983            })?;
16984            let selection = spawn_selection(&options)?;
16985            let pty_selection = match plan {
16986                backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16987                _ => None,
16988            };
16989            let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
16990                SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
16991                    SPAWN_SESSION_OPTIONS
16992                        .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16993                        .await
16994                }).await
16995            }).await;
16996            match spawned {
16997                Ok(session) => {
16998                    if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16999                        shared
17000                            .pending_acp_options
17001                            .lock()
17002                            .unwrap_or_else(|p| p.into_inner())
17003                            .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
17004                    }
17005                    shared.mark_spawn_provisional(session.clone());
17006                    tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
17007                    Ok(NodeResponse::SpawnAccepted { session })
17008                }
17009                Err(err) => {
17010                    tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17011                    Err(err)
17012                }
17013            }
17014        }
17015        NodeRequest::SpawnSpec { spec } => {
17016            shared.require_controller(connection_id, role)?;
17017            let receipt = shared.spawn_from_spec(spec).await?;
17018            Ok(NodeResponse::SpawnSpecAccepted { receipt })
17019        }
17020        NodeRequest::SpawnManagedWorktree { request } => {
17021            shared.require_controller(connection_id, role)?;
17022            let receipt = shared.spawn_managed_worktree(request).await?;
17023            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17024        }
17025        NodeRequest::SpawnManagedWorktreeV2 { request } => {
17026            shared.require_controller(connection_id, role)?;
17027            let receipt = shared.spawn_managed_worktree_v2(request).await?;
17028            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17029        }
17030        NodeRequest::CleanupManagedWorktree { lease_id } => {
17031            shared.require_controller(connection_id, role)?;
17032            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17033            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17034        }
17035        NodeRequest::DiscoverHistory { session, limit } => {
17036            controlled_session(shared, connection_id, role, &session)?;
17037            let candidates = shared.discover_history(&session, limit).await?;
17038            Ok(NodeResponse::HistoryDiscovered {
17039                session,
17040                candidates,
17041            })
17042        }
17043        NodeRequest::LoadHistory {
17044            session,
17045            candidate_id,
17046        } => {
17047            controlled_session(shared, connection_id, role, &session)?;
17048            let loaded = shared
17049                .load_history(&session, candidate_id)
17050                .await?;
17051            Ok(NodeResponse::HistoryLoaded {
17052                session,
17053                session_id: loaded.session_id,
17054                message_count: loaded.message_count,
17055                completed_turn_count: loaded.completed_turn_count,
17056            })
17057        }
17058        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17059            shared.require_controller(connection_id, role)?;
17060            let context = shared
17061                .export_context_pack_for_session_record(&record_id, &session, false)
17062                .await?;
17063            Ok(NodeResponse::ContextPackForSessionRecordExported {
17064                record_id,
17065                session,
17066                context,
17067            })
17068        }
17069        NodeRequest::ExportContextPack { session } => {
17070            controlled_session(shared, connection_id, role, &session)?;
17071            let context = shared.export_context_pack(&session).await?;
17072            Ok(NodeResponse::ContextPackExported { context })
17073        }
17074        NodeRequest::ForgetContextPack { context_id } => {
17075            shared.require_controller(connection_id, role)?;
17076            shared.forget_context_pack(&context_id)?;
17077            Ok(NodeResponse::ContextPackForgotten { context_id })
17078        }
17079        NodeRequest::ResolveDurableContextPack { context_id } => {
17080            // Read-only, idempotent, no controller lease required: this lets
17081            // a caller preflight "is this durably exported pack still here"
17082            // without holding the Node's mutation controller.
17083            let context = shared
17084                .context_catalog
17085                .read()
17086                .unwrap_or_else(|poisoned| poisoned.into_inner())
17087                .get(&context_id)
17088                .map(|pack| pack.receipt().clone())
17089                .ok_or_else(|| {
17090                    failure(
17091                        NodeFailureCode::UnknownContextPack,
17092                        "context pack is not present",
17093                    )
17094                })?;
17095            Ok(NodeResponse::DurableContextPackResolved { context })
17096        }
17097        NodeRequest::ReadContextPack { digest } => {
17098            // Read-only fetch of the pack's own bytes, keyed by content digest
17099            // rather than the caller's `SpawnContextId`: same no-controller-lease
17100            // rationale as `ResolveDurableContextPack` above, one step further
17101            // down the same catalog.
17102            let (id, byte_len, bytes) = shared
17103                .context_catalog
17104                .read()
17105                .unwrap_or_else(|poisoned| poisoned.into_inner())
17106                .get_by_digest(&digest)
17107                .map(|pack| {
17108                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17109                })
17110                .ok_or_else(|| {
17111                    failure(
17112                        NodeFailureCode::UnknownContextPack,
17113                        &format!("context pack {digest} not on this node"),
17114                    )
17115                })?;
17116            tracing::debug!(
17117                digest = digest.as_str(),
17118                byte_len,
17119                "read context pack bytes",
17120            );
17121            Ok(NodeResponse::ContextPackBytesRead {
17122                pack: ContextPackBytesRead {
17123                    digest,
17124                    id,
17125                    byte_len,
17126                    bytes,
17127                },
17128            })
17129        }
17130        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17131            let provider = controlled_session(shared, connection_id, role, &session)?;
17132            if let Some(prompt) = initial_prompt.as_deref() {
17133                validate_node_text("resume initial prompt", prompt)?;
17134            }
17135            let runtime_requirement = if initial_prompt.is_some() {
17136                ProviderRuntimeRequirement::ResumeWithPrompt
17137            } else {
17138                ProviderRuntimeRequirement::Resume
17139            };
17140            shared.require_session_runtime_policy(&session, runtime_requirement)?;
17141            let runtime_policy = shared
17142                .admit_provider_runtime(&provider, runtime_requirement)
17143                .await?;
17144            let working_directory = shared.bound_workspace_root(&session)?;
17145            let request = ResumeLaunchRequest {
17146                working_directory,
17147                terminal_size,
17148                initial_prompt,
17149            };
17150            request
17151                .validate()
17152                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17153            let command = shared.prepare_command(ControlCommand::Resume {
17154                instance_id: session.session.instance_id,
17155                target: ResumeTarget::CurrentProvider,
17156                runtime_policy,
17157                request,
17158            });
17159            shared.arm_resume(&session, command.id, runtime_policy)?;
17160            let dispatch = shared.dispatch_envelope(command);
17161            if let Err(error) = dispatch {
17162                shared.clear_armed_resume(&session);
17163                return Err(error);
17164            }
17165            Ok(NodeResponse::Accepted)
17166        }
17167        NodeRequest::RenameSessionRecord {
17168            record_id,
17169            display_name,
17170        } => {
17171            shared.require_controller(connection_id, role)?;
17172            let record = shared.rename_session_record(&record_id, display_name)?;
17173            Ok(NodeResponse::SessionRecordUpdated { record })
17174        }
17175        NodeRequest::SetSessionTask {
17176            record_id,
17177            expected_revision,
17178            target,
17179        } => {
17180            shared.require_controller(connection_id, role)?;
17181            let record = shared.set_session_task(&record_id, expected_revision, target)?;
17182            Ok(NodeResponse::SessionRecordUpdated { record })
17183        }
17184        NodeRequest::IndexProviderSession {
17185            workspace_id,
17186            provider,
17187            identity,
17188            display_name,
17189        } => {
17190            shared.require_controller(connection_id, role)?;
17191            let record = shared.index_provider_session(
17192                workspace_id,
17193                provider,
17194                identity,
17195                display_name,
17196            )?;
17197            Ok(NodeResponse::ProviderSessionIndexed { record })
17198        }
17199        NodeRequest::ResumeSessionRecord {
17200            record_id,
17201            terminal_size,
17202            initial_prompt,
17203        } => {
17204            shared.require_controller(connection_id, role)?;
17205            if let Some(prompt) = initial_prompt.as_deref() {
17206                validate_node_text("managed resume initial prompt", prompt)?;
17207            }
17208            let (record, session) = shared
17209                .resume_session_record(
17210                    &record_id,
17211                    terminal_size,
17212                    initial_prompt,
17213                )
17214                .await?;
17215            Ok(NodeResponse::SessionRecordResumed { record, session })
17216        }
17217        NodeRequest::ForgetSessionRecord { record_id } => {
17218            shared.require_controller(connection_id, role)?;
17219            shared.forget_session_record(&record_id).await?;
17220            Ok(NodeResponse::SessionRecordForgotten { record_id })
17221        }
17222        NodeRequest::Prompt { session, text } => {
17223            let agent_id = controlled_session(shared, connection_id, role, &session)?;
17224            validate_node_text("prompt", &text)?;
17225            shared.require_session_runtime_policy(
17226                &session,
17227                ProviderRuntimeRequirement::SemanticPrompt,
17228            )?;
17229            let framing = prompt_framing(&agent_id);
17230            shared
17231                .dispatch_input_bounded(
17232                    &session,
17233                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
17234                )
17235                .await?;
17236            Ok(NodeResponse::Accepted)
17237        }
17238        NodeRequest::Paste { session, text } => {
17239            controlled_session(shared, connection_id, role, &session)?;
17240            validate_node_text("paste", &text)?;
17241            shared.require_session_runtime_policy(
17242                &session,
17243                ProviderRuntimeRequirement::SemanticPrompt,
17244            )?;
17245            shared
17246                .dispatch_input_bounded(
17247                    &session,
17248                    InputAction::InsertDraft(PromptPayload {
17249                        text,
17250                        framing: PromptFraming::BracketedPaste,
17251                    }),
17252                )
17253                .await?;
17254            Ok(NodeResponse::Accepted)
17255        }
17256        NodeRequest::Input { session, text } => {
17257            controlled_session(shared, connection_id, role, &session)?;
17258            validate_node_text("terminal input", &text)?;
17259            shared
17260                .dispatch_input_bounded(
17261                    &session,
17262                    InputAction::TerminalText(TerminalText { text }),
17263                )
17264                .await?;
17265            Ok(NodeResponse::Accepted)
17266        }
17267        NodeRequest::TerminalBytes { session, bytes } => {
17268            controlled_session(shared, connection_id, role, &session)?;
17269            shared.sessionize_spawn(session.session.instance_id);
17270            let action = terminal_bytes_action(bytes)?;
17271            shared
17272                .dispatch_input_bounded(&session, action)
17273                .await?;
17274            Ok(NodeResponse::Accepted)
17275        }
17276        NodeRequest::TerminalControl { session, control } => {
17277            controlled_session(shared, connection_id, role, &session)?;
17278            shared
17279                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17280                .await?;
17281            Ok(NodeResponse::Accepted)
17282        }
17283        NodeRequest::Resize { session, size } => {
17284            controlled_session(shared, connection_id, role, &session)?;
17285            shared.sessionize_spawn(session.session.instance_id);
17286            if !size.is_valid() {
17287                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17288            }
17289            shared.dispatch_resize_bounded(&session, size).await?;
17290            Ok(NodeResponse::Accepted)
17291        }
17292        NodeRequest::Interrupt { session } => {
17293            controlled_session(shared, connection_id, role, &session)?;
17294            shared
17295                .dispatch_input_bounded(
17296                    &session,
17297                    InputAction::TerminalControl(TerminalControl::Interrupt),
17298                )
17299                .await?;
17300            Ok(NodeResponse::Accepted)
17301        }
17302        NodeRequest::Stop { session, force } => {
17303            controlled_session(shared, connection_id, role, &session)?;
17304            shared.sessionize_spawn(session.session.instance_id);
17305            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17306            shared.retire_session_grok_leader(session.session.instance_id);
17307            Ok(NodeResponse::Accepted)
17308        }
17309        NodeRequest::Remove { session } => {
17310            controlled_session(shared, connection_id, role, &session)?;
17311            shared.remove_session(&session).await?;
17312            Ok(NodeResponse::Accepted)
17313        }
17314        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17315            shared.require_controller(connection_id, role)?;
17316            let snapshot = shared.require_acp_session(&session)?;
17317            let interaction = snapshot
17318                .provider
17319                .interactions
17320                .iter()
17321                .find(|interaction| {
17322                    interaction_correlation(
17323                        session.session.instance_id,
17324                        session.session.generation,
17325                        interaction.id.0,
17326                    ) == correlation_id
17327                })
17328                .ok_or_else(|| failure(
17329                    NodeFailureCode::InvalidRequest,
17330                    "unknown interaction correlation",
17331                ))?;
17332            if interaction.status != ProviderInteractionStatus::Pending {
17333                return Err(failure(
17334                    NodeFailureCode::BackendOperationFailed,
17335                    "interaction has already been resolved",
17336                ));
17337            }
17338            response
17339                .validate_for(interaction.interaction_kind)
17340                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17341            let interaction_id = interaction.id;
17342            let response_kind = response.kind();
17343            let response_outcome = response.outcome();
17344            shared.dispatch(ControlCommand::ResolveInteraction {
17345                instance_id: session.session.instance_id,
17346                generation: session.session.generation,
17347                interaction_id,
17348                response,
17349            })?;
17350            // The one line that lets an operator compare what THIS node
17351            // recorded against what the provider itself reports doing --
17352            // measured live on a kimi run where our side resolved `true`
17353            // while the agent reported "rejected" and neither layer had a
17354            // line to line up against the other.
17355            tracing::info!(
17356                node_id = %shared.node_id,
17357                session = ?session.session,
17358                correlation_id = %correlation_id,
17359                interaction_id = interaction_id.0,
17360                response_kind = ?response_kind,
17361                outcome = ?response_outcome,
17362                "interaction resolved",
17363            );
17364            Ok(NodeResponse::Accepted)
17365        }
17366        NodeRequest::SetSessionMode { session, mode_id } => {
17367            shared.require_controller(connection_id, role)?;
17368            shared.require_acp_session(&session)?;
17369            shared.dispatch(ControlCommand::SetSessionMode {
17370                instance_id: session.session.instance_id,
17371                mode_id,
17372            })?;
17373            Ok(NodeResponse::Accepted)
17374        }
17375        NodeRequest::SetSessionConfigOption {
17376            session,
17377            option_id,
17378            value_json,
17379        } => {
17380            shared.require_controller(connection_id, role)?;
17381            shared.require_acp_session(&session)?;
17382            shared.dispatch(ControlCommand::SetSessionConfigOption {
17383                instance_id: session.session.instance_id,
17384                option_id,
17385                value_json,
17386            })?;
17387            Ok(NodeResponse::Accepted)
17388        }
17389        NodeRequest::SetSessionModel { session, model_id } => {
17390            shared.require_controller(connection_id, role)?;
17391            shared.require_acp_session(&session)?;
17392            shared.dispatch(ControlCommand::SetSessionModel {
17393                instance_id: session.session.instance_id,
17394                model_id,
17395            })?;
17396            Ok(NodeResponse::Accepted)
17397        }
17398        NodeRequest::Shutdown => {
17399            shared.require_controller(connection_id, role)?;
17400            shared.begin_shutdown_locked().await?;
17401            Ok(NodeResponse::ShuttingDown)
17402        }
17403    }
17404}
17405
17406fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17407    shared.require_controller(connection_id, role)?;
17408    shared.validate_address(session)
17409}
17410
17411/// Selects the sole history candidate matching `provider_session_id` when
17412/// the managed record carries a verified identity, or the sole candidate in
17413/// the discovered set at all when it does not (see
17414/// `export_context_pack_for_session_record_inner`'s identity match). Either
17415/// way, more than one match refuses instead of guessing which one is exact.
17416fn select_history_candidate(
17417    candidates: &[HistoryCandidateSummary],
17418    provider_session_id: Option<&str>,
17419) -> Result<String, NodeFailure> {
17420    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17421        Some(id) => candidate.session_id_hint == id,
17422        None => true,
17423    });
17424    let candidate = matches.next().ok_or_else(|| {
17425        failure(
17426            NodeFailureCode::SessionRecordConflict,
17427            if provider_session_id.is_some() {
17428                "no history candidate matches the managed provider session"
17429            } else {
17430                "no history candidate exists for the managed session"
17431            },
17432        )
17433    })?;
17434    if matches.next().is_some() {
17435        return Err(failure(
17436            NodeFailureCode::SessionRecordConflict,
17437            if provider_session_id.is_some() {
17438                "history identity is ambiguous for the managed provider session"
17439            } else {
17440                "history is ambiguous without a verified provider session identity"
17441            },
17442        ));
17443    }
17444    Ok(candidate.id.clone())
17445}
17446
17447fn resolved_spawn_profile_summary(
17448    profile: &SpawnProfileDefaults,
17449    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17450) -> Option<SpawnProfileSummary> {
17451    let environment_profile = match profile.environment_profile_id.as_ref() {
17452        None => None,
17453        Some(profile_id) => {
17454            let binding = environment_profiles.get(profile_id)?;
17455            if binding.provider != profile.provider
17456                || binding.native_profile_id(profile.mode).is_none()
17457            {
17458                return None;
17459            }
17460            Some(ResolvedEnvironmentProfileReceipt {
17461                profile_id: binding.id.clone(),
17462                profile_revision: binding.revision.clone(),
17463                network_allowlist: None,
17464                browser_profile_id: None,
17465            })
17466        }
17467    };
17468    Some(SpawnProfileSummary {
17469        id: profile.profile_id.clone(),
17470        revision: profile.revision.clone(),
17471        environment_profile,
17472    })
17473}
17474
17475/// True if `current` is an acceptable version of `expected` at export
17476/// revalidation/commit time.
17477///
17478/// When `allow_clean_detachment` is `false` (the on-demand,
17479/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
17480/// be byte-identical to `expected` and still actively bound to `session` —
17481/// the original, unchanged invariant.
17482///
17483/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
17484/// ALSO be the exact Live -> Dormant transition that the same clean exit this
17485/// export reacts to produces on its own: `state` Live -> Dormant,
17486/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
17487/// advancing. Every other field — including `provider_session`, `context`,
17488/// `bundle`, `task_binding` — must still match exactly. A record that changed
17489/// in any other way (rebind, a different active session, an identity
17490/// conflict, capacity eviction) is rejected either way; this widens nothing
17491/// about which sessions are eligible, only how the record's own expected
17492/// detachment is tolerated mid-export.
17493fn session_record_export_target_matches(
17494    expected: &ManagedSessionRecord,
17495    current: &ManagedSessionRecord,
17496    session: &SessionAddress,
17497    allow_clean_detachment: bool,
17498) -> bool {
17499    if current == expected && current.active_session.as_ref() == Some(session) {
17500        return current.state == ManagedSessionState::Live;
17501    }
17502    allow_clean_detachment
17503        && current.state == ManagedSessionState::Dormant
17504        && current.active_session.is_none()
17505        && current.record_id == expected.record_id
17506        && current.display_name == expected.display_name
17507        && current.provider == expected.provider
17508        && current.mode == expected.mode
17509        && current.workspace_id == expected.workspace_id
17510        && current.canonical_root == expected.canonical_root
17511        && current.provider_session == expected.provider_session
17512        && current.environment_profile == expected.environment_profile
17513        && current.bundle == expected.bundle
17514        && current.context_id == expected.context_id
17515        && current.context == expected.context
17516        && current.exported_context == expected.exported_context
17517        && current.task_binding == expected.task_binding
17518        && current.created_at_unix_ms == expected.created_at_unix_ms
17519        && current.last_error == expected.last_error
17520}
17521
17522fn session_record_context_export_binding_is_exact(
17523    expected: &ManagedSessionRecord,
17524    current: &ManagedSessionRecord,
17525    identity: Option<&ProviderSessionIdentity>,
17526    session: &SessionAddress,
17527    binding: &SessionBinding,
17528    provider: &AgentId,
17529    current_root: &str,
17530    allow_clean_detachment: bool,
17531) -> bool {
17532    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17533        && current.provider_session.as_ref() == identity
17534        && current.workspace_id == session.workspace_id
17535        && provider == &current.provider
17536        && binding.workspace_id == session.workspace_id
17537        && binding.generation == session.session.generation
17538        && binding.record_id.as_ref() == Some(&current.record_id)
17539        && platform::roots_equal(
17540            current_root,
17541            windows_path_text(&current.canonical_root),
17542        )
17543}
17544
17545fn session_record_context_export_source_is_usable(
17546    record: &ManagedSessionRecord,
17547    status: &SessionStatus,
17548    allow_clean_detachment: bool,
17549) -> bool {
17550    (record.state == ManagedSessionState::Live
17551        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17552        && context_pack_source_status_is_usable(status)
17553}
17554
17555fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17556    PromptFraming::BracketedPaste
17557}
17558
17559fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17560    if text.len() > MAX_NODE_TEXT_BYTES {
17561        return Err(failure(
17562            NodeFailureCode::InvalidRequest,
17563            &format!(
17564                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17565                text.len(),
17566            ),
17567        ));
17568    }
17569    Ok(())
17570}
17571
17572fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17573    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17574        return Err(failure(
17575            NodeFailureCode::InvalidRequest,
17576            &format!(
17577                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17578                bytes.len(),
17579            ),
17580        ));
17581    }
17582    Ok(())
17583}
17584
17585fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17586    validate_terminal_bytes(&bytes)?;
17587    Ok(InputAction::TerminalBytes(bytes))
17588}
17589
17590fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17591    if root.is_empty()
17592        || root.len() > MAX_WORKSPACE_ROOT_BYTES
17593        || root.chars().any(char::is_control)
17594    {
17595        return Err(failure(
17596            NodeFailureCode::InvalidWorkspaceRoot,
17597            &format!(
17598                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17599            ),
17600        ));
17601    }
17602    Ok(())
17603}
17604
17605fn canonical_standalone_workspace_candidate(
17606    workspace_id: &WorkspaceId,
17607    root: &str,
17608) -> Result<String, NodeServerError> {
17609    let path = Path::new(root);
17610    match std::fs::symlink_metadata(path) {
17611        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17612            .map(|workspace| workspace.canonical_root),
17613        Err(error) if error.kind() == io::ErrorKind::NotFound => {
17614            if !path.is_absolute() {
17615                return Err(NodeServerError::InvalidWorkspaceRoot {
17616                    workspace_id: workspace_id.clone(),
17617                    path: root.to_owned(),
17618                    message: "path is not absolute".to_owned(),
17619                });
17620            }
17621            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17622                workspace_id: workspace_id.clone(),
17623                path: root.to_owned(),
17624                message: "path has no standalone directory name".to_owned(),
17625            })?;
17626            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17627                workspace_id: workspace_id.clone(),
17628                path: root.to_owned(),
17629                message: "path has no parent directory".to_owned(),
17630            })?;
17631            let canonical = std::fs::canonicalize(parent)
17632                .map(|parent| parent.join(name))
17633                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17634                    workspace_id: workspace_id.clone(),
17635                    path: root.to_owned(),
17636                    message: error.to_string(),
17637                })?;
17638            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17639                NodeServerError::InvalidWorkspaceRoot {
17640                    workspace_id: workspace_id.clone(),
17641                    path: path.to_string_lossy().into_owned(),
17642                    message: "canonical path is not valid Unicode".to_owned(),
17643                }
17644            })?;
17645            let canonical_root = platform::normalize_canonical_root(canonical_root);
17646            validate_workspace_root(workspace_id, &canonical_root)?;
17647            Ok(canonical_root)
17648        }
17649        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17650            workspace_id: workspace_id.clone(),
17651            path: root.to_owned(),
17652            message: error.to_string(),
17653        }),
17654    }
17655}
17656
17657fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17658    let Some(revision) = revision else {
17659        return Ok(());
17660    };
17661    if revision.is_empty()
17662        || revision.len() > MAX_REPOSITORY_PATH_BYTES
17663        || revision.chars().any(char::is_control)
17664    {
17665        return Err(failure(
17666            NodeFailureCode::InvalidRequest,
17667            &format!(
17668                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17669            ),
17670        ));
17671    }
17672    Ok(())
17673}
17674
17675fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17676    let code = match error.kind {
17677        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17678        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17679        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17680        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17681        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17682        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17683        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17684    };
17685    failure(code, &error.message)
17686}
17687
17688fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17689    let code = match error.kind {
17690        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17691        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17692        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17693            NodeFailureCode::BackendOperationFailed
17694        }
17695        StandaloneWorkspaceErrorKind::RecoveryRequired => {
17696            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17697        }
17698    };
17699    failure(code, error.message)
17700}
17701
17702fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17703    let (code, message) = match error.kind {
17704        GitWorktreeErrorKind::Invalid => (
17705            NodeFailureCode::InvalidRequest,
17706            "managed Git input is invalid",
17707        ),
17708        GitWorktreeErrorKind::NotRepository => (
17709            NodeFailureCode::NotGitRepository,
17710            "managed source is not a Git repository",
17711        ),
17712        GitWorktreeErrorKind::Conflict => (
17713            NodeFailureCode::WorktreeConflict,
17714            "managed Git identity conflicts with existing repository state",
17715        ),
17716        GitWorktreeErrorKind::Protected => (
17717            NodeFailureCode::ManagedWorktreeOwnershipConflict,
17718            "managed worktree ownership validation failed",
17719        ),
17720        GitWorktreeErrorKind::Dirty => (
17721            NodeFailureCode::WorktreeDirty,
17722            "managed worktree contains uncommitted changes",
17723        ),
17724        GitWorktreeErrorKind::Locked => (
17725            NodeFailureCode::WorktreeLocked,
17726            "managed worktree is locked",
17727        ),
17728        GitWorktreeErrorKind::Failed => (
17729            NodeFailureCode::BackendOperationFailed,
17730            "managed Git operation failed",
17731        ),
17732    };
17733    NodeFailure { code, message: message.to_owned() }
17734}
17735
17736fn managed_spawn_receipt(
17737    resolved: &ResolvedSpawnSpec,
17738    incarnation_id: NodeIncarnationId,
17739    session: SessionAddress,
17740    lease: ManagedWorktreeLeaseSnapshot,
17741    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17742    bundle: Option<ResolvedBundleReceipt>,
17743    context: Option<ResolvedContextPackReceipt>,
17744) -> ManagedWorktreeSpawnReceipt {
17745    let mut managed = resolved.clone();
17746    managed.target.worktree_id = Some(lease.workspace_id.clone());
17747    ManagedWorktreeSpawnReceipt {
17748        spawn: managed.receipt_with_materialization(
17749            incarnation_id,
17750            session,
17751            environment_profile,
17752            bundle,
17753            context,
17754        ),
17755        lease,
17756    }
17757}
17758
17759fn validate_selected_worktree(
17760    source_root: &str,
17761    selected_root: &str,
17762    worktrees: &[NativeGitWorktreeSnapshot],
17763) -> Result<(), NodeFailure> {
17764    if !worktrees
17765        .iter()
17766        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17767    {
17768        return Err(failure(
17769            NodeFailureCode::WorktreeProtected,
17770            "source workspace is not an authoritative non-bare Git worktree root",
17771        ));
17772    }
17773    if !worktrees.iter().any(|worktree| {
17774        worktree_paths_equal(&worktree.path, selected_root)
17775            && !worktree.is_main
17776            && !worktree.is_bare
17777    }) {
17778        return Err(failure(
17779            NodeFailureCode::WorktreeProtected,
17780            "selected workspace is not an eligible Git worktree in the source repository",
17781        ));
17782    }
17783    Ok(())
17784}
17785
17786fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17787    let code = match error.kind() {
17788        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17789        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17790            NodeFailureCode::RepositoryPathUnsafe
17791        }
17792        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17793        WorkspaceFileReadErrorKind::NotRegularFile => {
17794            NodeFailureCode::RepositoryFileNotRegular
17795        }
17796        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17797            NodeFailureCode::RepositoryFileReadFailed
17798        }
17799        WorkspaceFileReadErrorKind::RevisionConflict => {
17800            NodeFailureCode::RepositoryFileRevisionConflict
17801        }
17802        WorkspaceFileReadErrorKind::AlreadyExists
17803        | WorkspaceFileReadErrorKind::ParentNotFound
17804        | WorkspaceFileReadErrorKind::ParentNotDirectory
17805        | WorkspaceFileReadErrorKind::Canceled => {
17806            NodeFailureCode::RepositoryFileReadFailed
17807        }
17808    };
17809    failure(code, "repository file read failed")
17810}
17811
17812fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17813    let code = match error.kind() {
17814        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17815        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17816            NodeFailureCode::RepositoryPathUnsafe
17817        }
17818        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17819        WorkspaceFileReadErrorKind::NotRegularFile => {
17820            NodeFailureCode::RepositoryFileNotRegular
17821        }
17822        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17823            NodeFailureCode::RepositoryFileWriteFailed
17824        }
17825        WorkspaceFileReadErrorKind::RevisionConflict => {
17826            NodeFailureCode::RepositoryFileRevisionConflict
17827        }
17828        WorkspaceFileReadErrorKind::AlreadyExists
17829        | WorkspaceFileReadErrorKind::ParentNotFound
17830        | WorkspaceFileReadErrorKind::ParentNotDirectory
17831        | WorkspaceFileReadErrorKind::Canceled => {
17832            NodeFailureCode::RepositoryFileWriteFailed
17833        }
17834    };
17835    failure(code, "repository file write failed")
17836}
17837
17838async fn settle_workspace_entry_create<T>(
17839    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17840    commit_state: Arc<AtomicU8>,
17841    deadline: Duration,
17842    timeout_message: &'static str,
17843    task_failure_message: &'static str,
17844) -> Result<T, NodeFailure> {
17845    let joined = match timeout(deadline, &mut task).await {
17846        Ok(joined) => joined,
17847        Err(_) => {
17848            if commit_state
17849                .compare_exchange(
17850                    WORKSPACE_ENTRY_CREATE_PENDING,
17851                    WORKSPACE_ENTRY_CREATE_CANCELED,
17852                    Ordering::AcqRel,
17853                    Ordering::Acquire,
17854                )
17855                .is_ok()
17856            {
17857                return Err(failure(
17858                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17859                    timeout_message,
17860                ));
17861            }
17862            debug_assert_eq!(
17863                commit_state.load(Ordering::Acquire),
17864                WORKSPACE_ENTRY_CREATE_COMMITTING,
17865            );
17866            task.await
17867        }
17868    };
17869    joined
17870        .map_err(|_| {
17871            failure(
17872                NodeFailureCode::RepositoryEntryCreateFailed,
17873                task_failure_message,
17874            )
17875        })?
17876        .map_err(workspace_entry_create_failure)
17877}
17878
17879fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17880    let code = match error.kind() {
17881        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17882        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17883            NodeFailureCode::RepositoryPathUnsafe
17884        }
17885        WorkspaceFileReadErrorKind::AlreadyExists => {
17886            NodeFailureCode::RepositoryEntryAlreadyExists
17887        }
17888        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17889            NodeFailureCode::RepositoryParentNotFound
17890        }
17891        WorkspaceFileReadErrorKind::ParentNotDirectory
17892        | WorkspaceFileReadErrorKind::NotRegularFile => {
17893            NodeFailureCode::RepositoryParentNotDirectory
17894        }
17895        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17896            NodeFailureCode::RepositoryEntryCreateFailed
17897        }
17898        WorkspaceFileReadErrorKind::RevisionConflict => {
17899            NodeFailureCode::RepositoryEntryCreateFailed
17900        }
17901        WorkspaceFileReadErrorKind::Canceled => {
17902            NodeFailureCode::RepositoryEntryCreateTimedOut
17903        }
17904    };
17905    failure(code, "repository entry create failed")
17906}
17907
17908fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17909    let value = digest(&SHA256, bytes)
17910        .as_ref()
17911        .iter()
17912        .map(|byte| format!("{byte:02x}"))
17913        .collect();
17914    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17915}
17916
17917fn managed_spawn_request_digest_v2(
17918    request: &ManagedWorktreeSpawnRequestV2,
17919) -> Result<String, NodeFailure> {
17920    let encoded = serde_json::to_vec(request).map_err(|_| {
17921        failure(
17922            NodeFailureCode::InvalidRequest,
17923            "managed worktree V2 request could not be canonicalized",
17924        )
17925    })?;
17926    let mut value = String::with_capacity(71);
17927    value.push_str("sha256:");
17928    for byte in digest(&SHA256, &encoded).as_ref() {
17929        use std::fmt::Write as _;
17930        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17931    }
17932    Ok(value)
17933}
17934
17935fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17936    let code = match error {
17937        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17938        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17939        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17940        NativeSessionPreviewError::UnsupportedProvider
17941        | NativeSessionPreviewError::PreviewUnavailable
17942        | NativeSessionPreviewError::SessionNotFound
17943        | NativeSessionPreviewError::AmbiguousSession => {
17944            NodeFailureCode::BackendOperationFailed
17945        }
17946    };
17947    failure(code, "native session preview failed")
17948}
17949
17950fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17951    let code = match error.kind() {
17952        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17953        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17954    };
17955    failure(code, "host directory browse failed")
17956}
17957
17958fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17959    let code = match error {
17960        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17961        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17962        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17963            NodeFailureCode::DeliveryStageConflict
17964        }
17965        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17966        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17967            NodeFailureCode::DeliveryChunkOutOfOrder
17968        }
17969        DeliveryStoreError::BlobDigestMismatch => {
17970            NodeFailureCode::DeliveryBlobDigestMismatch
17971        }
17972        DeliveryStoreError::BundleDigestMismatch => {
17973            NodeFailureCode::DeliveryBundleDigestMismatch
17974        }
17975        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17976        DeliveryStoreError::Unavailable
17977        | DeliveryStoreError::Corrupt
17978        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17979    };
17980    failure(code, "delivery operation failed")
17981}
17982
17983fn validate_workspace_root(
17984    workspace_id: &WorkspaceId,
17985    canonical_root: &str,
17986) -> Result<(), NodeServerError> {
17987    #[cfg(windows)]
17988    if canonical_root.starts_with(r"\\") {
17989        return Err(NodeServerError::InvalidWorkspaceRoot {
17990            workspace_id: workspace_id.clone(),
17991            path: canonical_root.to_owned(),
17992            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17993        });
17994    }
17995    if !platform::workspace_root_supported(canonical_root)
17996        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17997    {
17998        return Err(NodeServerError::InvalidWorkspaceRoot {
17999            workspace_id: workspace_id.clone(),
18000            path: canonical_root.to_owned(),
18001            message: "canonical path must be absolute, bounded, and free of control characters"
18002                .to_owned(),
18003        });
18004    }
18005    Ok(())
18006}
18007
18008fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18009    NodeFailure {
18010        code,
18011        message: node_failure_category(code).to_owned(),
18012    }
18013}
18014
18015fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18016    let code = match error {
18017        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18018        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18019        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18020        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18021        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18022        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18023        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18024        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18025        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18026    };
18027    failure(code, "harness MCP proxy operation failed")
18028}
18029
18030/// Numbers each harness-MCP overlay this process constructs, in
18031/// construction order, so two overlays built back to back in one node process
18032/// still get distinct trace files.
18033static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18034
18035/// Builds the optional trace environment entry of one harness-MCP overlay.
18036/// The caller's launch description names both the entry and the node-process
18037/// variable that opts in: when that variable holds a non-empty directory, the
18038/// entry names a trace file under it. Returns `None` when the launch has no
18039/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
18040/// the directory; that is the operator's job.
18041fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18042    let trace = launch.trace.as_ref()?;
18043    let trace_dir = std::env::var(&trace.dir_env).ok()?;
18044    if trace_dir.is_empty() {
18045        return None;
18046    }
18047    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18048    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18049    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18050}
18051
18052fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18053    let remaining = deadline.saturating_duration_since(Instant::now());
18054    if remaining.is_zero() {
18055        Err(failure(
18056            NodeFailureCode::SpawnDeadlineExceeded,
18057            "spawn deadline elapsed",
18058        ))
18059    } else {
18060        Ok(remaining)
18061    }
18062}
18063
18064fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18065    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18066    match deadline {
18067        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18068        None => Ok(bounded),
18069    }
18070}
18071
18072fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18073    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18074        failure(
18075            NodeFailureCode::SpawnDeadlineExceeded,
18076            "spawn dispatch exceeded the spawn deadline",
18077        )
18078    } else {
18079        error
18080    }
18081}
18082
18083fn spawn_runtime_capabilities(
18084    capabilities: &SpawnRequiredCapabilities,
18085) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18086    capabilities
18087        .iter()
18088        .map(|capability| match capability.as_str() {
18089            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18090            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18091            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18092            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18093                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18094            }
18095            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18096            _ => Err(failure(
18097                NodeFailureCode::UnsupportedSpawnCapability,
18098                "spawn required capability is unknown",
18099            )),
18100        })
18101        .collect()
18102}
18103
18104fn persistence_failure(_error: io::Error) -> NodeFailure {
18105    NodeFailure {
18106        code: NodeFailureCode::BackendOperationFailed,
18107        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18108    }
18109}
18110
18111fn node_failure_category(code: NodeFailureCode) -> &'static str {
18112    match code {
18113        NodeFailureCode::InvalidRequest => "invalid-request",
18114        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18115        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18116        NodeFailureCode::ReservationNotFound => "reservation-not-found",
18117        NodeFailureCode::ReservationConflict => "reservation-conflict",
18118        NodeFailureCode::ReservationExpired => "reservation-expired",
18119        NodeFailureCode::BindingMismatch => "binding-mismatch",
18120        NodeFailureCode::NotActivated => "not-activated",
18121        NodeFailureCode::CallNotFound => "call-not-found",
18122        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18123        NodeFailureCode::ResponseTooLarge => "response-too-large",
18124        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18125        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18126        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18127        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18128        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18129        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18130        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18131        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18132        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18133        NodeFailureCode::Unauthorized => "unauthorized",
18134        NodeFailureCode::ObserverReadOnly => "observer-read-only",
18135        NodeFailureCode::ControllerBusy => "controller-busy",
18136        NodeFailureCode::ControllerRequired => "controller-required",
18137        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18138        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18139        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18140        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18141        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18142        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18143        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18144        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18145        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18146        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18147        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18148        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18149        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18150        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18151        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18152        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18153        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18154        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18155        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18156        NodeFailureCode::GitReadFailed => "git-read-failed",
18157        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18158        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18159        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18160        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18161        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18162        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18163        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18164        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18165        NodeFailureCode::TurnInFlight => "turn-in-flight",
18166        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18167        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18168        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18169            "unsupported-network-allowlist-mapping"
18170        }
18171        NodeFailureCode::BrowserStationProbeUnavailable => {
18172            "browser-station-probe-unavailable"
18173        }
18174        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18175        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18176        NodeFailureCode::EnvironmentProfileBindingMismatch => {
18177            "environment-profile-binding-mismatch"
18178        }
18179        NodeFailureCode::UnknownBundle => "unknown-bundle",
18180        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18181        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18182        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18183        NodeFailureCode::ContextPackBusy => "context-pack-busy",
18184        NodeFailureCode::ContextPackMaterializationFailed => {
18185            "context-pack-materialization-failed"
18186        }
18187        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18188        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18189        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18190        NodeFailureCode::WorkspaceBusy => "workspace-busy",
18191        NodeFailureCode::LastWorkspace => "last-workspace",
18192        NodeFailureCode::NotGitRepository => "not-git-repository",
18193        NodeFailureCode::WorktreeConflict => "worktree-conflict",
18194        NodeFailureCode::WorktreeProtected => "worktree-protected",
18195        NodeFailureCode::WorktreeDirty => "worktree-dirty",
18196        NodeFailureCode::WorktreeLocked => "worktree-locked",
18197        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18198        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18199        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18200        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18201            "managed-worktree-profile-revision-mismatch"
18202        }
18203        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18204        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18205            "standalone-workspace-recovery-required"
18206        }
18207        NodeFailureCode::UnknownSession => "unknown-session",
18208        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18209        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18210        NodeFailureCode::SessionRecordBusy => "session-record-busy",
18211        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18212        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18213        NodeFailureCode::WorkspaceRegistrationRequired => {
18214            "workspace-registration-required"
18215        }
18216        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18217        NodeFailureCode::StaleGeneration => "stale-generation",
18218        NodeFailureCode::BackendBusy => "backend-busy",
18219        NodeFailureCode::BackendDisconnected => "backend-disconnected",
18220        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18221        NodeFailureCode::ShuttingDown => "shutting-down",
18222    }
18223}
18224
18225fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18226    NodeServerError::DurableState(io::Error::new(error.kind(), category))
18227}
18228
18229fn durable_state_load_error(error: io::Error) -> NodeServerError {
18230    let category = match session_registry::state_load_refusal(&error) {
18231        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18232            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18233        }
18234        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18235            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18236        }
18237        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18238            DURABLE_STATE_CONFLICT_ERROR
18239        }
18240        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18241    };
18242    durable_state_server_error(error, category)
18243}
18244
18245fn unix_time_ms() -> u64 {
18246    std::time::SystemTime::now()
18247        .duration_since(std::time::UNIX_EPOCH)
18248        .unwrap_or_default()
18249        .as_millis()
18250        .min(u64::MAX as u128) as u64
18251}
18252
18253fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18254    if record.provider_session.is_some() {
18255        ManagedSessionState::Dormant
18256    } else {
18257        ManagedSessionState::Unavailable
18258    }
18259}
18260
18261fn active_record_state(
18262    identity_admitted: bool,
18263    provider_session_is_known: bool,
18264) -> ManagedSessionState {
18265    if identity_admitted && !provider_session_is_known {
18266        ManagedSessionState::IdentityPending
18267    } else {
18268        ManagedSessionState::Live
18269    }
18270}
18271
18272fn managed_worktree_record_holders(
18273    records: &[ManagedSessionRecord],
18274) -> Vec<(SessionRecordId, WorkspaceId)> {
18275    records
18276        .iter()
18277        .filter(|record| record.provider_session.is_some())
18278        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18279        .collect()
18280}
18281
18282fn managed_spawn_record_ownership(
18283    binding: &SessionBinding,
18284    effective_runtime_policy: ProviderRuntimePolicy,
18285) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18286    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18287    if effective_runtime_policy.provider_session_identity {
18288        (binding.record_id.clone(), None)
18289    } else {
18290        (None, binding.record_id.clone())
18291    }
18292}
18293
18294#[derive(Debug, Error)]
18295pub enum NodeServerError {
18296    #[error("node endpoint must be a bounded absolute local transport path")]
18297    InvalidEndpoint,
18298    #[error("node access token must contain 1..=4096 bytes")]
18299    InvalidAccessToken,
18300    #[error("node HTTP observer must listen on a loopback address: {0}")]
18301    InvalidApiListen(std::net::SocketAddr),
18302    #[error("node browser bridge must listen on a loopback address: {0}")]
18303    InvalidBridgeListen(std::net::SocketAddr),
18304    #[error("node browser bridge token must contain 1..=4096 bytes")]
18305    InvalidBridgeToken,
18306    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18307    BridgeUnderlayRequiresBridgeListen,
18308    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18309    BridgeUnderlayRequiresBridgeToken,
18310    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18311    InvalidBridgeUnderlayToken,
18312    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18313    InvalidBridgeUnderlayKey,
18314    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18315    InvalidCallHome(std::net::SocketAddr),
18316    #[error("node could not reach its call-home relay: {0}")]
18317    CallHomeTransport(#[source] std::io::Error),
18318    #[error("node durable state path must be an absolute file path: {0}")]
18319    InvalidStatePath(String),
18320    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18321    InvalidHarnessMcpHelper,
18322    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18323    InvalidNetworkAllowlistCatalogPath(String),
18324    #[error("network allowlist catalog could not be read: {0}")]
18325    NetworkAllowlistCatalogIo(#[source] std::io::Error),
18326    #[error("network allowlist catalog line {line}: {message}")]
18327    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18328    #[error("network allowlist catalog: {0}")]
18329    InvalidNetworkAllowlistCatalog(String),
18330    #[error("network allowlist catalog exceeds the {max}-entry limit")]
18331    NetworkAllowlistCatalogCapacity { max: usize },
18332    #[error("the local state directory is unavailable; supply an explicit state path")]
18333    LocalStateDirectoryUnavailable,
18334    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18335    LocalRuntimeDirectoryUnavailable,
18336    #[error("node durable state failed: {0}")]
18337    DurableState(io::Error),
18338    #[error("node requires at least one configured workspace")]
18339    NoWorkspaces,
18340    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18341    InvalidWorkspaceRoot {
18342        workspace_id: WorkspaceId,
18343        path: String,
18344        message: String,
18345    },
18346    #[error("duplicate workspace ID: {0}")]
18347    DuplicateWorkspaceId(WorkspaceId),
18348    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18349    DuplicateWorkspaceRoot {
18350        first: WorkspaceId,
18351        second: WorkspaceId,
18352        root: String,
18353    },
18354    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18355    ManagedWorktreeProfileRequired(WorkspaceId),
18356    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18357    ManagedWorktreeProfileModeMismatch(WorkspaceId),
18358    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18359    DuplicateManagedWorktreeProfile {
18360        workspace_id: WorkspaceId,
18361        profile_id: WorktreeProfileId,
18362    },
18363    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18364    ManagedWorktreeProfileCapacity {
18365        workspace_id: WorkspaceId,
18366        max: usize,
18367    },
18368    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18369    InvalidManagedWorktreeProfile {
18370        workspace_id: WorkspaceId,
18371        profile_id: WorktreeProfileId,
18372        message: String,
18373    },
18374    #[error("duplicate node environment profile: {0}")]
18375    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18376    #[error("node environment profile provider is unavailable: {0}")]
18377    EnvironmentProfileProviderUnavailable(AgentId),
18378    #[error("node environment profile capacity is {max}")]
18379    EnvironmentProfileCapacity { max: usize },
18380    #[error("node environment profiles reuse native profile ID '{0}'")]
18381    DuplicateNativeEnvironmentProfile(String),
18382    #[error("node native environment profile is invalid: {0}")]
18383    NativeEnvironmentProfile(String),
18384    #[error("node session-environment materialization root must be absolute")]
18385    InvalidSessionEnvironmentRoot,
18386    #[error("node session-environment materializer is unavailable")]
18387    SessionEnvironmentMaterializerRequired,
18388    #[error("node session-environment materializer failed to initialize")]
18389    SessionEnvironmentMaterializer,
18390    #[error("node bundle catalog is invalid: {0}")]
18391    BundleCatalog(String),
18392    #[error("node delivery store failed to initialize: {0}")]
18393    DeliveryStore(String),
18394    #[error("node context pack store failed to initialize")]
18395    ContextPackStore,
18396    #[error("active agent registry failed: {0}")]
18397    Registry(String),
18398    #[error("node provider contract manifest is invalid: {0}")]
18399    ProviderContractManifest(String),
18400    #[error("named pipe I/O failed: {0}")]
18401    Io(#[from] io::Error),
18402    #[error("node HTTP observer failed: {0}")]
18403    HttpApi(io::Error),
18404    #[error("node browser bridge failed: {0}")]
18405    BridgeApi(io::Error),
18406    #[error(transparent)]
18407    Frame(#[from] FrameError),
18408    #[error("node handshake failed: {0}")]
18409    Handshake(String),
18410    #[error("node authentication frame was not received before the bounded deadline")]
18411    AuthenticationTimedOut,
18412    #[error("node authentication primitive failed: {0}")]
18413    Authentication(String),
18414    #[error("node incarnation identity generation failed: {0}")]
18415    IncarnationIdentity(String),
18416    #[error("node pre-authentication limiter is closed")]
18417    PreauthClosed,
18418    #[error("node authenticated connection limit was reached")]
18419    AuthenticatedConnectionLimit,
18420    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18421    SignalStreamClosed,
18422    #[error("node stop-all dispatch failed: {0:?}")]
18423    ShutdownDispatch(NodeFailure),
18424    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18425    ShutdownTimedOut { active_native_sessions: usize },
18426}
18427
18428impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18429    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18430        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18431        match error {
18432            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18433            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18434            E::InvalidEntry { line, message } => {
18435                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18436            }
18437            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18438            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18439        }
18440    }
18441}
18442
18443#[cfg(test)]
18444mod agent_stream_blocked_tests {
18445    use super::*;
18446    use gate4agent_types::{
18447        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18448        ProviderSource,
18449    };
18450
18451    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18452        ProviderSource {
18453            family,
18454            binding: AdapterBinding::new(
18455                AdapterId::new(adapter).unwrap(),
18456                "fixture/v1",
18457                AdapterVerification::SyntheticFixture,
18458            )
18459            .unwrap(),
18460        }
18461    }
18462
18463    fn provider_control_event_at(
18464        family: AdapterFamily,
18465        adapter: &str,
18466        source_sequence: u64,
18467        provider_event: ProviderEvent,
18468    ) -> ControlEvent {
18469        ControlEvent {
18470            sequence: 41,
18471            command_id: None,
18472            instance_id: AgentInstanceId(7),
18473            generation: SessionGeneration(3),
18474            event: ControlEventKind::ProviderEvent {
18475                sequence: 8,
18476                source: provider_source(family, adapter),
18477                source_sequence,
18478                event: provider_event,
18479            },
18480        }
18481    }
18482
18483    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18484        provider_control_event_at(family, "fixture", 9, provider_event)
18485    }
18486
18487    fn denied_host_request(
18488        by: ProviderHostDecisionAuthority,
18489        reason: Option<String>,
18490    ) -> ControlEvent {
18491        provider_control_event_at(
18492            AdapterFamily::Acp,
18493            "claude-code",
18494            2,
18495            ProviderEvent::HostRequestObserved {
18496                method: "terminal/create".to_owned(),
18497                params_json: String::new(),
18498                decision: ProviderHostRequestDecision::Denied { by },
18499                outcome: ProviderHostRequestOutcome::Executed,
18500                reason,
18501            },
18502        )
18503    }
18504
18505    fn blocked(
18506        event: &ControlEvent,
18507    ) -> (
18508        Option<String>,
18509        String,
18510        BlockAuthorityV1,
18511        Option<String>,
18512        String,
18513        Option<String>,
18514    ) {
18515        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18516        chunk.validate().expect("minted chunk must validate");
18517        let AgentStreamChunkKindV1::Blocked {
18518            correlation_id,
18519            tool_class,
18520            authority,
18521            reason_kind,
18522            reason,
18523            help,
18524        } = chunk.kind
18525        else {
18526            panic!("expected AgentStreamChunkKindV1::Blocked");
18527        };
18528        (correlation_id, tool_class, authority, reason_kind, reason, help)
18529    }
18530
18531    /// Pins the fleet and every adapter each member declares: the fleet is
18532    /// exactly what `active_registry` says, and no member declares a hook
18533    /// adapter (lifecycle hooks are retired).
18534    #[test]
18535    fn the_enabled_provider_matrix_is_exact() {
18536        let registry = active_registry().unwrap();
18537        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
18538        assert_eq!(
18539            providers
18540                .iter()
18541                .map(|contract| contract.provider.as_str())
18542                .collect::<Vec<_>>(),
18543            // `shell` is the plain PTY provider (operator terminals and smoke tests).
18544            vec!["shell", "claude", "codex", "grok", "kimi"],
18545        );
18546        assert!(adapters.iter().all(|contract| {
18547            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18548        }));
18549    }
18550
18551    /// A gate-denied host request, WITH its reason text carried all the way
18552    /// from the ACP host, mints a `Blocked` chunk naming the gate.
18553    #[test]
18554    fn gate_denied_host_request_mints_a_blocked_chunk() {
18555        let gate_text =
18556            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18557        let event = denied_host_request(
18558            ProviderHostDecisionAuthority::Gate,
18559            Some(gate_text.to_owned()),
18560        );
18561        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18562        assert_eq!(chunk.source_sequence, 2);
18563        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18564        assert_eq!(authority, BlockAuthorityV1::HostGate);
18565        assert_eq!(reason, gate_text);
18566        assert_eq!(correlation_id, None);
18567        assert_eq!(reason_kind, None);
18568        assert_eq!(help, None);
18569        assert_eq!(tool_class, "Write");
18570    }
18571
18572    const CLASSIFIER_SAMPLE: &str = "Permission for this action was denied by the Claude Code auto mode classifier. Reason: Blocked by classifier. If you have other tasks that don't depend on this action, continue working on those. IMPORTANT: You *may* attempt to accomplish this action using other tools that might naturally be used to accomplish this goal, e.g. using head instead of cat. But you *should not* attempt to work around this denial in malicious ways, e.g. do not use your ability to run tests to execute non-test actions. You should only try to work around this restriction in reasonable ways that do not attempt to bypass the intent behind this denial. If you believe this capability is essential to complete the user's request, STOP and explain to the user what you were trying to do and why you need this permission. Let the user decide how to proceed. To allow this type of action in the future, the user can add a Bash permission rule to their settings.";
18573
18574    /// The owner's classifier sample, verbatim, arriving as a
18575    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
18576    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
18577    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
18578    /// and the guidance tail as `help`.
18579    #[test]
18580    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18581        let event = provider_control_event(
18582            AdapterFamily::Acp,
18583            ProviderEvent::ToolCompleted {
18584                id: "toolu_9".to_owned(),
18585                output: CLASSIFIER_SAMPLE.to_owned(),
18586                is_error: true,
18587                duration_ms: None,
18588                agent_id: None,
18589                non_execution_kind: Some("permission-rule".to_owned()),
18590            },
18591        );
18592        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18593        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18594        assert_eq!(reason, "Blocked by classifier");
18595        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18596        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18597        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18598    }
18599
18600    /// A `session/prompt` RPC error with `data.codexErrorInfo:
18601    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
18602    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
18603    /// vendor code as `reason_kind`.
18604    #[test]
18605    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18606        let message = "You've hit your usage limit. Upgrade to Pro (https://chatgpt.com/explore/pro), visit https://chatgpt.com/codex/settings/usage to purchase more credits or try again at Sep 7th, 2026 6:19 PM.";
18607        let event = provider_control_event(
18608            AdapterFamily::Acp,
18609            ProviderEvent::SessionEnded {
18610                result: "JSON-RPC error -32603: Internal error".to_owned(),
18611                cost_usd: None,
18612                is_error: true,
18613                stop_reason: Some(ProviderStopReason::ProviderError {
18614                    code: -32603,
18615                    message: message.to_owned(),
18616                    vendor_code: Some("usageLimitExceeded".to_owned()),
18617                }),
18618            },
18619        );
18620        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18621        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18622        assert_eq!(reason, message);
18623        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18624        assert_eq!(tool_class, "Session");
18625    }
18626
18627    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
18628    /// `authority: ProviderRefusal`.
18629    #[test]
18630    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18631        let event = provider_control_event(
18632            AdapterFamily::Acp,
18633            ProviderEvent::SessionEnded {
18634                result: "refusal".to_owned(),
18635                cost_usd: None,
18636                is_error: true,
18637                stop_reason: Some(ProviderStopReason::Refusal),
18638            },
18639        );
18640        let (_, _, authority, _, _, _) = blocked(&event);
18641        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18642    }
18643
18644    /// A failed tool completion with no typed `non_execution_kind` -- or one
18645    /// that only says the turn stopped -- is a plain execution failure, never a
18646    /// block; nor is an agent's own narrative claiming one.
18647    #[test]
18648    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18649        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18650            let event = provider_control_event(
18651                AdapterFamily::Acp,
18652                ProviderEvent::ToolCompleted {
18653                    id: "toolu_1".to_owned(),
18654                    output: "command exited with status 1".to_owned(),
18655                    is_error: true,
18656                    duration_ms: Some(12),
18657                    agent_id: None,
18658                    non_execution_kind,
18659                },
18660            );
18661            assert!(agent_stream_chunk(&event).is_none());
18662        }
18663        let narrative = provider_control_event(
18664            AdapterFamily::Acp,
18665            ProviderEvent::ToolCompleted {
18666                id: "toolu_2".to_owned(),
18667                output: "the harness blocked the write, approval needed".to_owned(),
18668                is_error: false,
18669                duration_ms: Some(3),
18670                agent_id: None,
18671                non_execution_kind: None,
18672            },
18673        );
18674        assert!(agent_stream_chunk(&narrative).is_none());
18675    }
18676
18677    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
18678    /// execution outcome: a granted request that failed while running was
18679    /// still authorized.
18680    #[test]
18681    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18682        for (decision, outcome) in [
18683            (
18684                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18685                ProviderHostRequestOutcome::Executed,
18686            ),
18687            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18688            (
18689                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18690                ProviderHostRequestOutcome::Failed {
18691                    error: "terminal/create spawn failed: os error 3".to_owned(),
18692                },
18693            ),
18694        ] {
18695            let event = provider_control_event_at(
18696                AdapterFamily::Acp,
18697                "claude-code",
18698                3,
18699                ProviderEvent::HostRequestObserved {
18700                    method: "fs/read_text_file".to_owned(),
18701                    params_json: String::new(),
18702                    decision: decision.clone(),
18703                    outcome,
18704                    reason: None,
18705                },
18706            );
18707            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18708        }
18709    }
18710
18711    /// A deferred `session/request_permission` resolved by an operator or an
18712    /// expired deadline carries no reason text -- the chunk falls back to a
18713    /// categorical sentence naming the authority rather than inventing detail.
18714    #[test]
18715    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18716        for (by, expected_authority, expected_reason) in [
18717            (
18718                ProviderHostDecisionAuthority::Operator,
18719                BlockAuthorityV1::Operator,
18720                "declined by the operator",
18721            ),
18722            (
18723                ProviderHostDecisionAuthority::DeadlinePolicy,
18724                BlockAuthorityV1::HostDeadline,
18725                "denied by host policy after the operator deadline expired",
18726            ),
18727        ] {
18728            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18729            assert_eq!(authority, expected_authority);
18730            assert_eq!(reason, expected_reason);
18731        }
18732    }
18733
18734    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
18735    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
18736    /// outright rather than truncating.
18737    #[test]
18738    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18739        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18740        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18741            ProviderHostDecisionAuthority::Policy,
18742            Some(oversized),
18743        ));
18744        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18745    }
18746}
18747
18748#[cfg(test)]
18749mod standalone_capability_platform_tests {
18750    use super::*;
18751
18752    #[test]
18753    fn standalone_workspace_capability_requires_windows_identity_guards() {
18754        let capabilities = baseline_capabilities().unwrap();
18755        let advertised = capabilities.iter().any(|capability| {
18756            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18757        });
18758        assert_eq!(advertised, cfg!(windows));
18759
18760        let request = NodeRequest::CreateStandaloneWorkspace {
18761            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18762            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18763            initial_branch: None,
18764        };
18765        assert_eq!(
18766            request_uses_unnegotiated_capability(&request, &capabilities),
18767            !cfg!(windows),
18768        );
18769    }
18770}
18771
18772
18773#[cfg(test)]
18774mod station_knobs_resolve_tests {
18775    use super::*;
18776    use gate4agent_catalog::EnvMutation;
18777    use gate4agent_runtime_native::{
18778        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18779        NativeLaunchProfileId,
18780    };
18781    use std::ffi::OsString;
18782    use std::sync::Arc;
18783
18784    struct EmptyEnvironmentResolver;
18785
18786    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18787        fn resolve_child_environment(
18788            &self,
18789        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18790            Ok(vec![EnvMutation {
18791                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18792                value: None,
18793            }])
18794        }
18795    }
18796
18797    fn agent(value: &str) -> AgentId {
18798        AgentId::new(value).unwrap()
18799    }
18800
18801    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18802        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18803        let profile_revision =
18804            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18805        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18806            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18807            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18808            provider: agent("claude"),
18809            mode: SessionMode::Pty,
18810            terminal_size: gate4agent_types::TerminalSize {
18811                rows: 24,
18812                columns: 80,
18813            },
18814            prompt: None,
18815            bundle_id: None,
18816            context_id: None,
18817            environment_profile_id: Some(profile_id.clone()),
18818        };
18819        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18820        let catalog = active_registry().unwrap();
18821        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18822        let control = runtime.native_launch_profile_control();
18823        let node_profile = NodeEnvironmentProfile::new(
18824            profile_id.clone(),
18825            profile_revision,
18826            agent("claude"),
18827            [NativeLaunchProfile::new(
18828                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18829                agent("claude"),
18830                TransportKind::Pty,
18831                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18832                Arc::new(EmptyEnvironmentResolver),
18833            )
18834            .unwrap()],
18835        )
18836        .unwrap();
18837        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18838        for native_profile in native_profiles {
18839            runtime.upsert_native_launch_profile(native_profile).unwrap();
18840        }
18841        let workspace = WorkspaceConfig::new(
18842            WorkspaceId::new("primary").unwrap(),
18843            std::env::current_dir().unwrap(),
18844        )
18845        .unwrap();
18846        let shared = NodeShared::new_with_incarnation(
18847            handle,
18848            "fixture-token".to_owned(),
18849            NodeId::new("node-station-knobs-test").unwrap(),
18850            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18851            vec![workspace],
18852            vec![agent("claude")],
18853            ProviderRuntimeStatuses::default(),
18854            None,
18855            Vec::new(),
18856            Vec::new(),
18857            spawn_profiles,
18858            Some(control),
18859            None,
18860            None,
18861            None,
18862            Vec::new(),
18863            Vec::new(),
18864            Vec::new(),
18865            Vec::new(),
18866            None,
18867            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18868        );
18869        shared
18870            .environment_profiles
18871            .write()
18872            .unwrap_or_else(|poisoned| poisoned.into_inner())
18873            .insert(binding.id.clone(), binding);
18874        (shared, spawn_defaults)
18875    }
18876
18877    /// Empty-default catalog refuses unknown network_allowlist ids.
18878    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18879    #[test]
18880    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18881        let (shared, defaults) = station_knobs_fixture();
18882        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18883        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18884        let spec = crate::protocol::SpawnSpec {
18885            target: crate::protocol::SpawnTarget {
18886                node_id: shared.node_id.clone(),
18887                workspace_id: WorkspaceId::new("primary").unwrap(),
18888                worktree_id: None,
18889            },
18890            profile_id: defaults.profile_id.clone(),
18891            expected_profile_revision: defaults.revision.clone(),
18892            overrides: crate::protocol::SpawnOverrides {
18893                network_allowlist: Some(unknown),
18894                browser_profile_id: Some(browser),
18895                ..crate::protocol::SpawnOverrides::default()
18896            },
18897            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18898            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18899                "station-knobs-unknown-allowlist",
18900            )
18901            .unwrap(),
18902            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18903        };
18904        let resolved = spec.resolve(&defaults).unwrap();
18905        let failure = shared
18906            .resolve_environment_profile(&resolved)
18907            .expect_err("unknown allowlist must refuse");
18908        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18909    }
18910
18911    /// Registered network_allowlist (+ browser_profile_id when feature off)
18912    /// echo onto the env-profile receipt. Empty catalog still allows omit
18913    /// (None). Empty/whitespace refuse is type-level. Feature-off
18914    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18915    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18916    #[test]
18917    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18918        let (shared, defaults) = station_knobs_fixture();
18919        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18920        #[cfg(not(feature = "dig2-station-probe"))]
18921        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18922        shared
18923            .network_allowlist_catalog
18924            .write()
18925            .unwrap_or_else(|poisoned| poisoned.into_inner())
18926            .insert_id(allowlist.clone())
18927            .unwrap();
18928        let spec = crate::protocol::SpawnSpec {
18929            target: crate::protocol::SpawnTarget {
18930                node_id: shared.node_id.clone(),
18931                workspace_id: WorkspaceId::new("primary").unwrap(),
18932                worktree_id: None,
18933            },
18934            profile_id: defaults.profile_id.clone(),
18935            expected_profile_revision: defaults.revision.clone(),
18936            overrides: crate::protocol::SpawnOverrides {
18937                network_allowlist: Some(allowlist.clone()),
18938                #[cfg(not(feature = "dig2-station-probe"))]
18939                browser_profile_id: Some(browser.clone()),
18940                ..crate::protocol::SpawnOverrides::default()
18941            },
18942            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18943            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18944                .unwrap(),
18945            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18946        };
18947        let resolved = spec.resolve(&defaults).unwrap();
18948        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18949        #[cfg(not(feature = "dig2-station-probe"))]
18950        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18951        let receipt = shared
18952            .resolve_environment_profile(&resolved)
18953            .expect("resolve ok")
18954            .expect("env profile present");
18955        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18956        #[cfg(not(feature = "dig2-station-probe"))]
18957        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18958        #[cfg(feature = "dig2-station-probe")]
18959        assert_eq!(receipt.browser_profile_id, None);
18960        assert_eq!(receipt.profile_id.as_str(), "local-claude");
18961
18962        let omitted = crate::protocol::SpawnSpec {
18963            overrides: crate::protocol::SpawnOverrides::default(),
18964            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18965                .unwrap(),
18966            ..spec
18967        };
18968        let resolved_omit = omitted.resolve(&defaults).unwrap();
18969        let bare = shared
18970            .resolve_environment_profile(&resolved_omit)
18971            .expect("resolve ok")
18972            .expect("env profile present");
18973        assert_eq!(bare.network_allowlist, None);
18974        assert_eq!(bare.browser_profile_id, None);
18975    }
18976
18977    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
18978    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
18979    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18980    /// unit-tested separately (probe refuses before lease on Linux).
18981    #[cfg(feature = "dig2-station-probe")]
18982    #[test]
18983    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18984        let (shared, defaults) = station_knobs_fixture();
18985        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18986        let spec = crate::protocol::SpawnSpec {
18987            target: crate::protocol::SpawnTarget {
18988                node_id: shared.node_id.clone(),
18989                workspace_id: WorkspaceId::new("primary").unwrap(),
18990                worktree_id: None,
18991            },
18992            profile_id: defaults.profile_id.clone(),
18993            expected_profile_revision: defaults.revision.clone(),
18994            overrides: crate::protocol::SpawnOverrides {
18995                browser_profile_id: Some(browser),
18996                ..crate::protocol::SpawnOverrides::default()
18997            },
18998            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18999            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19000                "station-knobs-probe-refuse",
19001            )
19002            .unwrap(),
19003            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19004        };
19005        let resolved = spec.resolve(&defaults).unwrap();
19006        let failure = shared
19007            .resolve_environment_profile(&resolved)
19008            .expect_err("browser_profile with failing probe must refuse");
19009        #[cfg(windows)]
19010        {
19011            assert!(
19012                matches!(
19013                    failure.code,
19014                    NodeFailureCode::BrowserStationUnreachable
19015                        | NodeFailureCode::BrowserStationProbeUnavailable
19016                ),
19017                "unexpected code {:?}",
19018                failure.code
19019            );
19020        }
19021        #[cfg(not(windows))]
19022        {
19023            assert_eq!(
19024                failure.code,
19025                NodeFailureCode::BrowserStationProbeUnavailable
19026            );
19027        }
19028    }
19029
19030    /// Feature on: omitting browser_profile_id still resolves (no probe).
19031    #[cfg(feature = "dig2-station-probe")]
19032    #[test]
19033    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19034        let (shared, defaults) = station_knobs_fixture();
19035        let spec = crate::protocol::SpawnSpec {
19036            target: crate::protocol::SpawnTarget {
19037                node_id: shared.node_id.clone(),
19038                workspace_id: WorkspaceId::new("primary").unwrap(),
19039                worktree_id: None,
19040            },
19041            profile_id: defaults.profile_id.clone(),
19042            expected_profile_revision: defaults.revision.clone(),
19043            overrides: crate::protocol::SpawnOverrides::default(),
19044            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19045            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19046                "station-knobs-probe-omit",
19047            )
19048            .unwrap(),
19049            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19050        };
19051        let resolved = spec.resolve(&defaults).unwrap();
19052        let receipt = shared
19053            .resolve_environment_profile(&resolved)
19054            .expect("omit browser_profile must not probe")
19055            .expect("env profile present");
19056        assert_eq!(receipt.browser_profile_id, None);
19057    }
19058
19059    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
19060    /// refuses a second holder for the same opaque profile id; release on
19061    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
19062    #[cfg(feature = "dig2-station-probe")]
19063    #[test]
19064    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19065        let (shared, _defaults) = station_knobs_fixture();
19066        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19067        let workspace_id = WorkspaceId::new("primary").unwrap();
19068        let address_a = SessionAddress {
19069            workspace_id: workspace_id.clone(),
19070            session: SessionKey {
19071                instance_id: AgentInstanceId(101),
19072                generation: SessionGeneration(1),
19073            },
19074        };
19075        let address_b = SessionAddress {
19076            workspace_id: workspace_id.clone(),
19077            session: SessionKey {
19078                instance_id: AgentInstanceId(102),
19079                generation: SessionGeneration(1),
19080            },
19081        };
19082        let guard_a = crate::dig2_station_lease::acquire_lease(
19083            &shared.browser_station_leases,
19084            profile.clone(),
19085            address_a.session.instance_id,
19086            1,
19087        )
19088        .expect("first lease");
19089        let busy = crate::dig2_station_lease::acquire_lease(
19090            &shared.browser_station_leases,
19091            profile.clone(),
19092            address_b.session.instance_id,
19093            2,
19094        )
19095        .expect_err("second holder must refuse");
19096        assert!(matches!(
19097            busy,
19098            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19099        ));
19100        // Simulate a bound session so remove_binding clears the lease.
19101        shared.bind_session_with_policy(
19102            &address_a,
19103            ProviderRuntimePolicy::raw_pty(),
19104            None,
19105        );
19106        guard_a.retain();
19107        assert!(shared.remove_binding(&address_a).is_some());
19108        crate::dig2_station_lease::acquire_lease(
19109            &shared.browser_station_leases,
19110            profile,
19111            address_b.session.instance_id,
19112            3,
19113        )
19114        .expect("reacquire after clear")
19115        .retain();
19116        assert!(
19117            crate::dig2_station_lease::release_lease_for_holder(
19118                &shared.browser_station_leases,
19119                address_b.session.instance_id,
19120            )
19121        );
19122    }
19123
19124    /// Feature-off: no browser_station_leases field; stub echo documented in
19125    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
19126    #[cfg(not(feature = "dig2-station-probe"))]
19127    #[test]
19128    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19129        let (shared, defaults) = station_knobs_fixture();
19130        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19131        let spec = crate::protocol::SpawnSpec {
19132            target: crate::protocol::SpawnTarget {
19133                node_id: shared.node_id.clone(),
19134                workspace_id: WorkspaceId::new("primary").unwrap(),
19135                worktree_id: None,
19136            },
19137            profile_id: defaults.profile_id.clone(),
19138            expected_profile_revision: defaults.revision.clone(),
19139            overrides: crate::protocol::SpawnOverrides {
19140                browser_profile_id: Some(browser.clone()),
19141                ..crate::protocol::SpawnOverrides::default()
19142            },
19143            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19144            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19145                .unwrap(),
19146            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19147        };
19148        let resolved = spec.resolve(&defaults).unwrap();
19149        let receipt = shared
19150            .resolve_environment_profile(&resolved)
19151            .expect("stub resolve")
19152            .expect("env profile");
19153        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19154    }
19155
19156
19157    /// Startup file loader registers ids into the empty-default catalog.
19158    /// Feature-off dig2 probe stays stub. Never secrets on C2.
19159    #[test]
19160    fn network_allowlist_catalog_loads_from_temp_file() {
19161        let unique = std::time::SystemTime::now()
19162            .duration_since(std::time::UNIX_EPOCH)
19163            .unwrap()
19164            .as_nanos();
19165        let path = std::env::temp_dir().join(format!(
19166            "gate4agent-node-allowlist-config-{}-{unique}.txt",
19167            std::process::id(),
19168        ));
19169        std::fs::write(
19170            &path,
19171            "# opaque station allowlist ids\negress-default\nlab-egress\n",
19172        )
19173        .unwrap();
19174        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19175            .expect("temp catalog file must load");
19176        let _ = std::fs::remove_file(&path);
19177        assert_eq!(loaded.len(), 2);
19178        let (shared, defaults) = station_knobs_fixture();
19179        {
19180            let mut catalog = shared
19181                .network_allowlist_catalog
19182                .write()
19183                .unwrap_or_else(|poisoned| poisoned.into_inner());
19184            *catalog = loaded;
19185        }
19186        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19187        let spec = crate::protocol::SpawnSpec {
19188            target: crate::protocol::SpawnTarget {
19189                node_id: shared.node_id.clone(),
19190                workspace_id: WorkspaceId::new("primary").unwrap(),
19191                worktree_id: None,
19192            },
19193            profile_id: defaults.profile_id.clone(),
19194            expected_profile_revision: defaults.revision.clone(),
19195            overrides: crate::protocol::SpawnOverrides {
19196                network_allowlist: Some(allowlist.clone()),
19197                ..crate::protocol::SpawnOverrides::default()
19198            },
19199            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19200            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19201                "station-knobs-catalog-file",
19202            )
19203            .unwrap(),
19204            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19205        };
19206        let resolved = spec.resolve(&defaults).unwrap();
19207        let receipt = shared
19208            .resolve_environment_profile(&resolved)
19209            .expect("registered via file load")
19210            .expect("env profile present");
19211        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19212    }
19213
19214    /// Snapshot launch inventory echoes registered catalog ids (opaque).
19215    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
19216    #[test]
19217    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19218        let (shared, _defaults) = station_knobs_fixture();
19219        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19220        shared
19221            .network_allowlist_catalog
19222            .write()
19223            .unwrap_or_else(|poisoned| poisoned.into_inner())
19224            .insert_id(allowlist.clone())
19225            .unwrap();
19226        let snapshot = shared.snapshot();
19227        let inventory = snapshot
19228            .launch_inventory
19229            .expect("launch inventory present");
19230        let ids = inventory
19231            .network_allowlists
19232            .expect("non-empty catalog surfaces ids");
19233        assert_eq!(ids, vec![allowlist]);
19234    }
19235
19236    /// Track B: registered id with provider_native codex_network_access refuses
19237    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
19238    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
19239    #[test]
19240    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19241        let (shared, defaults) = station_knobs_fixture();
19242        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19243        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19244            id: allowlist.clone(),
19245            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19246                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19247                peer: "127.0.0.1:443".to_owned(),
19248            }],
19249            provider_native: Some(
19250                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19251                    codex_network_access: Some(true),
19252                },
19253            ),
19254        };
19255        shared
19256            .network_allowlist_catalog
19257            .write()
19258            .unwrap_or_else(|poisoned| poisoned.into_inner())
19259            .insert(entry)
19260            .unwrap();
19261        let spec = crate::protocol::SpawnSpec {
19262            target: crate::protocol::SpawnTarget {
19263                node_id: shared.node_id.clone(),
19264                workspace_id: WorkspaceId::new("primary").unwrap(),
19265                worktree_id: None,
19266            },
19267            profile_id: defaults.profile_id.clone(),
19268            expected_profile_revision: defaults.revision.clone(),
19269            overrides: crate::protocol::SpawnOverrides {
19270                network_allowlist: Some(allowlist),
19271                ..crate::protocol::SpawnOverrides::default()
19272            },
19273            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19274            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19275                "station-knobs-unsupported-mapping",
19276            )
19277            .unwrap(),
19278            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19279        };
19280        let resolved = spec.resolve(&defaults).unwrap();
19281        assert_eq!(resolved.provider.as_str(), "claude");
19282        let failure = shared
19283            .resolve_environment_profile(&resolved)
19284            .expect_err("claude + codex_network_access must refuse");
19285        assert_eq!(
19286            failure.code,
19287            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19288        );
19289    }
19290
19291    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
19292    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
19293    /// Never secrets on C2.
19294    #[test]
19295    fn resolve_codex_network_access_overlay_moderate_only() {
19296        let (shared, defaults) = station_knobs_fixture();
19297        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19298        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19299            id: allowlist.clone(),
19300            permits: Vec::new(),
19301            provider_native: Some(
19302                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19303                    codex_network_access: Some(true),
19304                },
19305            ),
19306        };
19307        shared
19308            .network_allowlist_catalog
19309            .write()
19310            .unwrap_or_else(|poisoned| poisoned.into_inner())
19311            .insert(entry)
19312            .unwrap();
19313
19314        let overlay = shared
19315            .resolve_codex_network_access_overlay_args(
19316                &agent("codex"),
19317                SessionMode::Pty,
19318                ApprovalLevel::Moderate,
19319                Some(&allowlist),
19320            )
19321            .expect("Moderate Codex maps network_access");
19322        assert_eq!(
19323            overlay,
19324            vec![
19325                std::ffi::OsString::from("-c"),
19326                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19327            ]
19328        );
19329
19330        let acp = shared
19331            .resolve_codex_network_access_overlay_args(
19332                &agent("codex"),
19333                SessionMode::Acp,
19334                ApprovalLevel::Moderate,
19335                Some(&allowlist),
19336            )
19337            .unwrap();
19338        assert_eq!(acp, overlay);
19339
19340        let inline = shared
19341            .resolve_codex_network_access_overlay_args(
19342                &agent("codex"),
19343                SessionMode::Inline,
19344                ApprovalLevel::Moderate,
19345                Some(&allowlist),
19346            )
19347            .unwrap_err();
19348        assert_eq!(
19349            inline.code,
19350            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19351        );
19352
19353        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19354            let spec = crate::protocol::SpawnSpec {
19355                target: crate::protocol::SpawnTarget {
19356                    node_id: shared.node_id.clone(),
19357                    workspace_id: WorkspaceId::new("primary").unwrap(),
19358                    worktree_id: None,
19359                },
19360                profile_id: defaults.profile_id.clone(),
19361                expected_profile_revision: defaults.revision.clone(),
19362                overrides: crate::protocol::SpawnOverrides {
19363                    provider: crate::protocol::SpawnOverride::Set {
19364                        value: agent("codex"),
19365                    },
19366                    approval_level: Some(level),
19367                    network_allowlist: Some(allowlist.clone()),
19368                    ..crate::protocol::SpawnOverrides::default()
19369                },
19370                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19371                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19372                    "codex-net-refuse-{}",
19373                    match level {
19374                        ApprovalLevel::ReadOnly => "ro",
19375                        ApprovalLevel::FullAuto => "fa",
19376                        _ => "other",
19377                    }
19378                ))
19379                .unwrap(),
19380                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19381            };
19382            let resolved = spec.resolve(&defaults).unwrap();
19383            let failure = shared
19384                .resolve_environment_profile(&resolved)
19385                .expect_err("RO/FullAuto must refuse");
19386            assert_eq!(
19387                failure.code,
19388                NodeFailureCode::UnsupportedNetworkAllowlistMapping
19389            );
19390        }
19391    }
19392
19393    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
19394    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
19395    /// Never secrets on C2; does not invent provider_native Claude keys.
19396    #[test]
19397    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19398        let (shared, _defaults) = station_knobs_fixture();
19399        let allowlist =
19400            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19401        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19402            id: allowlist.clone(),
19403            permits: vec![
19404                crate::network_allowlist_catalog::NetworkPermitSketch {
19405                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19406                    peer: "github.com:443".to_owned(),
19407                },
19408                crate::network_allowlist_catalog::NetworkPermitSketch {
19409                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19410                    peer: "*.npmjs.org".to_owned(),
19411                },
19412            ],
19413            provider_native: None,
19414        };
19415        shared
19416            .network_allowlist_catalog
19417            .write()
19418            .unwrap_or_else(|poisoned| poisoned.into_inner())
19419            .insert(entry)
19420            .unwrap();
19421
19422        let unique = std::time::SystemTime::now()
19423            .duration_since(std::time::UNIX_EPOCH)
19424            .unwrap()
19425            .as_nanos();
19426        let provider_home = std::env::temp_dir().join(format!(
19427            "gate4agent-claude-provider-home-{}-{unique}",
19428            std::process::id(),
19429        ));
19430        std::fs::create_dir_all(&provider_home).unwrap();
19431
19432        let overlay = shared
19433            .resolve_network_allowlist_overlay_args(
19434                &agent("claude"),
19435                SessionMode::Pty,
19436                ApprovalLevel::Moderate,
19437                Some(&allowlist),
19438                Some(provider_home.as_path()),
19439            )
19440            .expect("Claude PTY settings overlay on unix");
19441        let settings_path = provider_home.join(
19442            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19443        );
19444        assert_eq!(
19445            overlay,
19446            vec![
19447                std::ffi::OsString::from("--settings"),
19448                std::ffi::OsString::from(settings_path.as_os_str()),
19449            ]
19450        );
19451        let body = std::fs::read_to_string(&settings_path).unwrap();
19452        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19453        assert_eq!(value["sandbox"]["enabled"], true);
19454        assert_eq!(
19455            value["sandbox"]["network"]["allowedDomains"],
19456            serde_json::json!(["github.com", "*.npmjs.org"])
19457        );
19458        assert!(settings_path.starts_with(&provider_home));
19459
19460        let acp = shared
19461            .resolve_network_allowlist_overlay_args(
19462                &agent("claude"),
19463                SessionMode::Acp,
19464                ApprovalLevel::Moderate,
19465                Some(&allowlist),
19466                Some(provider_home.as_path()),
19467            )
19468            .expect_err("Claude ACP must refuse settings overlay");
19469        assert_eq!(
19470            acp.code,
19471            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19472        );
19473
19474        let no_home = shared
19475            .resolve_network_allowlist_overlay_args(
19476                &agent("claude"),
19477                SessionMode::Pty,
19478                ApprovalLevel::Moderate,
19479                Some(&allowlist),
19480                None,
19481            )
19482            .expect_err("Claude overlay requires ProviderHome");
19483        assert_eq!(
19484            no_home.code,
19485            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19486        );
19487
19488        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19489        shared
19490            .network_allowlist_catalog
19491            .write()
19492            .unwrap_or_else(|poisoned| poisoned.into_inner())
19493            .insert_id(bare.clone())
19494            .unwrap();
19495        let empty = shared
19496            .resolve_network_allowlist_overlay_args(
19497                &agent("claude"),
19498                SessionMode::Pty,
19499                ApprovalLevel::Moderate,
19500                Some(&bare),
19501                Some(provider_home.as_path()),
19502            )
19503            .unwrap();
19504        assert!(empty.is_empty());
19505
19506        let _ = std::fs::remove_dir_all(&provider_home);
19507    }
19508
19509    /// Kimi / Grok with permits stay without invented argv overlays.
19510    #[test]
19511    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19512        let (shared, _defaults) = station_knobs_fixture();
19513        let allowlist =
19514            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19515        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19516            id: allowlist.clone(),
19517            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19518                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19519                peer: "example.com:443".to_owned(),
19520            }],
19521            provider_native: None,
19522        };
19523        shared
19524            .network_allowlist_catalog
19525            .write()
19526            .unwrap_or_else(|poisoned| poisoned.into_inner())
19527            .insert(entry)
19528            .unwrap();
19529        for provider in ["kimi", "grok"] {
19530            let overlay = shared
19531                .resolve_network_allowlist_overlay_args(
19532                    &agent(provider),
19533                    SessionMode::Pty,
19534                    ApprovalLevel::Moderate,
19535                    Some(&allowlist),
19536                    None,
19537                )
19538                .unwrap();
19539            assert!(
19540                overlay.is_empty(),
19541                "{provider} must not invent network argv from permits"
19542            );
19543        }
19544    }
19545
19546    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
19547    #[test]
19548    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19549        let unique = std::time::SystemTime::now()
19550            .duration_since(std::time::UNIX_EPOCH)
19551            .unwrap()
19552            .as_nanos();
19553        let path = std::env::temp_dir().join(format!(
19554            "gate4agent-node-allowlist-json-{}-{unique}.json",
19555            std::process::id(),
19556        ));
19557        std::fs::write(
19558            &path,
19559            r#"{
19560              "schema_version": 2,
19561              "entries": [
19562                {
19563                  "id": "egress-default",
19564                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19565                },
19566                { "id": "lab-egress" }
19567              ]
19568            }"#,
19569        )
19570        .unwrap();
19571        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19572            .expect("json v2 catalog must load");
19573        let _ = std::fs::remove_file(&path);
19574        assert_eq!(loaded.len(), 2);
19575        let entry = loaded
19576            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19577            .unwrap();
19578        assert_eq!(entry.permits.len(), 1);
19579        let (shared, defaults) = station_knobs_fixture();
19580        {
19581            let mut catalog = shared
19582                .network_allowlist_catalog
19583                .write()
19584                .unwrap_or_else(|poisoned| poisoned.into_inner());
19585            *catalog = loaded;
19586        }
19587        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19588        let spec = crate::protocol::SpawnSpec {
19589            target: crate::protocol::SpawnTarget {
19590                node_id: shared.node_id.clone(),
19591                workspace_id: WorkspaceId::new("primary").unwrap(),
19592                worktree_id: None,
19593            },
19594            profile_id: defaults.profile_id.clone(),
19595            expected_profile_revision: defaults.revision.clone(),
19596            overrides: crate::protocol::SpawnOverrides {
19597                network_allowlist: Some(allowlist.clone()),
19598                ..crate::protocol::SpawnOverrides::default()
19599            },
19600            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19601            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19602                "station-knobs-catalog-json",
19603            )
19604            .unwrap(),
19605            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19606        };
19607        let resolved = spec.resolve(&defaults).unwrap();
19608        let receipt = shared
19609            .resolve_environment_profile(&resolved)
19610            .expect("permits-only entry ok for claude")
19611            .expect("env profile present");
19612        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19613        let inventory = shared
19614            .snapshot()
19615            .launch_inventory
19616            .expect("launch inventory present");
19617        let ids = inventory.network_allowlists.expect("ids surface");
19618        assert_eq!(
19619            ids,
19620            vec![
19621                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19622                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19623            ]
19624        );
19625    }
19626
19627
19628}
19629
19630
19631#[cfg(test)]
19632mod workspace_environment_collision_tests {
19633    use super::*;
19634    use crate::session_environment::{
19635        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19636    };
19637    use gate4agent_catalog::EnvMutation;
19638    use gate4agent_runtime_native::{
19639        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19640        NativeLaunchProfileId,
19641    };
19642    use std::ffi::OsString;
19643    use std::sync::Arc;
19644
19645    struct EmptyEnvironmentResolver;
19646
19647    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19648        fn resolve_child_environment(
19649            &self,
19650        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19651            Ok(vec![EnvMutation {
19652                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19653                value: None,
19654            }])
19655        }
19656    }
19657
19658    fn agent(value: &str) -> AgentId {
19659        AgentId::new(value).unwrap()
19660    }
19661
19662    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
19663    fn claude_config_dir_collision_fixture() -> (
19664        NodeShared,
19665        ResolvedEnvironmentProfileReceipt,
19666        ResolvedEnvironmentProfileReceipt,
19667    ) {
19668        let catalog = active_registry().unwrap();
19669        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19670        let control = runtime.native_launch_profile_control();
19671        let workspace = WorkspaceConfig::new(
19672            WorkspaceId::new("primary").unwrap(),
19673            std::env::current_dir().unwrap(),
19674        )
19675        .unwrap();
19676        let shared = NodeShared::new_with_incarnation(
19677            handle,
19678            "fixture-token".to_owned(),
19679            NodeId::new("node-env-collision-test").unwrap(),
19680            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19681            vec![workspace],
19682            vec![agent("claude")],
19683            ProviderRuntimeStatuses::default(),
19684            None,
19685            Vec::new(),
19686            Vec::new(),
19687            SpawnProfileRegistry::default(),
19688            Some(control),
19689            None,
19690            None,
19691            None,
19692            Vec::new(),
19693            Vec::new(),
19694            Vec::new(),
19695            Vec::new(),
19696            None,
19697            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19698        );
19699
19700        let mut receipts = Vec::new();
19701        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19702            let profile_id =
19703                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19704            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19705                "claude-config-dir-{suffix}-r1"
19706            ))
19707            .unwrap();
19708            let materialization = NodeSessionMaterializationProfile::new(
19709                Vec::new(),
19710                vec![NodeSessionPathBinding::new(
19711                    "CLAUDE_CONFIG_DIR",
19712                    NodeSessionPathClass::ProviderHome,
19713                )
19714                .unwrap()],
19715                Vec::new(),
19716            )
19717            .unwrap();
19718            let node_profile = NodeEnvironmentProfile::new_with_materialization(
19719                profile_id.clone(),
19720                profile_revision.clone(),
19721                agent("claude"),
19722                [NativeLaunchProfile::new(
19723                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19724                        .unwrap(),
19725                    agent("claude"),
19726                    TransportKind::Pty,
19727                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19728                    Arc::new(EmptyEnvironmentResolver),
19729                )
19730                .unwrap()],
19731                Some(materialization),
19732            )
19733            .unwrap();
19734            let (binding, native_profiles, materialization) = node_profile.into_parts();
19735            for native_profile in native_profiles {
19736                runtime.upsert_native_launch_profile(native_profile).unwrap();
19737            }
19738            shared
19739                .environment_profiles
19740                .write()
19741                .unwrap_or_else(|poisoned| poisoned.into_inner())
19742                .insert(binding.id.clone(), binding);
19743            shared
19744                .environment_materialization_profiles
19745                .write()
19746                .unwrap_or_else(|poisoned| poisoned.into_inner())
19747                .insert(profile_id.clone(), materialization.unwrap());
19748            receipts.push(ResolvedEnvironmentProfileReceipt {
19749                profile_id,
19750                profile_revision,
19751                network_allowlist: None,
19752                browser_profile_id: None,
19753            });
19754        }
19755        let profile_b = receipts.pop().unwrap();
19756        let profile_a = receipts.pop().unwrap();
19757        (shared, profile_a, profile_b)
19758    }
19759
19760    #[test]
19761    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19762        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19763        let primary = WorkspaceId::new("primary").unwrap();
19764        let worktree = WorkspaceId::new("worktree-b").unwrap();
19765        let address = SessionAddress {
19766            workspace_id: primary.clone(),
19767            session: SessionKey {
19768                instance_id: AgentInstanceId(41),
19769                generation: SessionGeneration(1),
19770            },
19771        };
19772        shared.bind_session_with_policy(
19773            &address,
19774            ProviderRuntimePolicy::raw_pty(),
19775            Some(profile_a.clone()),
19776        );
19777
19778        let refused = shared
19779            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19780            .unwrap_err();
19781        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19782
19783        // Some vs None on the same cwd is also a collision.
19784        let refused_none = shared
19785            .ensure_workspace_environment_profile_compatible(&primary, None)
19786            .unwrap_err();
19787        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19788
19789        // Same profile on the same workspace remains allowed.
19790        shared
19791            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19792            .unwrap();
19793
19794        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19795        shared
19796            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19797            .unwrap();
19798    }
19799
19800    #[tokio::test]
19801    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19802        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19803        let primary = WorkspaceId::new("primary").unwrap();
19804        let address = SessionAddress {
19805            workspace_id: primary.clone(),
19806            session: SessionKey {
19807                instance_id: AgentInstanceId(41),
19808                generation: SessionGeneration(1),
19809            },
19810        };
19811        shared.bind_session_with_policy(
19812            &address,
19813            ProviderRuntimePolicy::raw_pty(),
19814            Some(profile_a),
19815        );
19816
19817        let failure = shared
19818            .spawn_session_with_deadline(
19819                primary,
19820                agent("claude"),
19821                SessionMode::Pty,
19822                gate4agent_types::TerminalSize {
19823                    rows: 24,
19824                    columns: 80,
19825                },
19826                None,
19827                Some(profile_b),
19828                None,
19829                None,
19830                None,
19831                None,
19832                SpawnRecordPolicy::ProviderIdentityOnly,
19833                Some(Instant::now() + Duration::from_secs(30)),
19834                &[],
19835                None,
19836                ApprovalLevel::default(),
19837                None,
19838                None,
19839            )
19840            .await
19841            .unwrap_err();
19842        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19843        // Guard runs before instance id mint / bind.
19844        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19845        assert!(shared.handle.snapshot().sessions.is_empty());
19846    }
19847}
19848
19849#[cfg(test)]
19850mod spawn_characterization_tests {
19851    use super::*;
19852    use crate::session_environment::{
19853        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19854        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19855        NodeSessionPathClass,
19856    };
19857    use gate4agent_catalog::EnvMutation;
19858    use gate4agent_runtime_native::{
19859        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19860        NativeLaunchProfile, NativeLaunchProfileId,
19861    };
19862    use std::ffi::OsString;
19863    use std::sync::Arc;
19864
19865    struct EmptyEnvironmentResolver;
19866
19867    struct FixtureSecretResolver {
19868        deny: Arc<AtomicBool>,
19869    }
19870
19871    impl NodeSecretResolver for FixtureSecretResolver {
19872        fn resolve(
19873            &self,
19874            _reference: &NodeSecretReference,
19875        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19876            if self.deny.load(Ordering::Acquire) {
19877                Err(NodeSecretResolveError::Denied)
19878            } else {
19879                NodeSecretValue::text("fixture-secret")
19880                    .map_err(|_| NodeSecretResolveError::Unavailable)
19881            }
19882        }
19883    }
19884
19885    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19886        fn resolve_child_environment(
19887            &self,
19888        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19889            Ok(vec![EnvMutation {
19890                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19891                value: None,
19892            }])
19893        }
19894    }
19895
19896    fn agent(value: &str) -> AgentId {
19897        AgentId::new(value).unwrap()
19898    }
19899
19900    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19901        let unique = std::time::SystemTime::now()
19902            .duration_since(std::time::UNIX_EPOCH)
19903            .unwrap()
19904            .as_nanos();
19905        std::env::temp_dir().join(format!(
19906            "gate4agent-node-{label}-{}-{unique}",
19907            std::process::id(),
19908        ))
19909    }
19910
19911    fn materialization_test_shared(
19912        deny_secret: bool,
19913    ) -> (
19914        NodeShared,
19915        NativeRuntime,
19916        ResolvedEnvironmentProfileReceipt,
19917        PathBuf,
19918        Arc<AtomicBool>,
19919    ) {
19920        let workspace = WorkspaceConfig::new(
19921            WorkspaceId::new("primary").unwrap(),
19922            std::env::current_dir().unwrap(),
19923        )
19924        .unwrap();
19925        materialization_test_shared_with_workspace(deny_secret, workspace)
19926    }
19927
19928    fn materialization_test_shared_with_workspace(
19929        deny_secret: bool,
19930        workspace: WorkspaceConfig,
19931    ) -> (
19932        NodeShared,
19933        NativeRuntime,
19934        ResolvedEnvironmentProfileReceipt,
19935        PathBuf,
19936        Arc<AtomicBool>,
19937    ) {
19938        let root = temporary_workspace_root(if deny_secret {
19939            "materialization-denied"
19940        } else {
19941            "materialization-ready"
19942        });
19943        std::fs::create_dir_all(&root).unwrap();
19944        let materialization_root = root.join("session-environments");
19945        let deny = Arc::new(AtomicBool::new(deny_secret));
19946        let resolver = Arc::new(FixtureSecretResolver {
19947            deny: Arc::clone(&deny),
19948        });
19949        let materializer = SessionEnvironmentMaterializer::new(
19950            materialization_root,
19951            resolver,
19952        )
19953        .unwrap();
19954        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19955        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19956            "materialized-claude-r1",
19957        )
19958        .unwrap();
19959        let materialization = NodeSessionMaterializationProfile::new(
19960            vec![NodeSessionEnvironmentMutation::SetSecret {
19961                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19962                reference: NodeSecretReference::new("fixture-token").unwrap(),
19963            }],
19964            vec![NodeSessionPathBinding::new(
19965                "GATE4AGENT_PROVIDER_HOME",
19966                NodeSessionPathClass::ProviderHome,
19967            )
19968            .unwrap()],
19969            vec![NodeSessionFile::secret(
19970                NodeSessionPathClass::Config,
19971                "auth/token",
19972                NodeSecretReference::new("fixture-token").unwrap(),
19973            )
19974            .unwrap()],
19975        )
19976        .unwrap();
19977        let catalog = active_registry().unwrap();
19978        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19979        let control = runtime.native_launch_profile_control();
19980        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19981            profile_id.clone(),
19982            profile_revision.clone(),
19983            agent("claude"),
19984            [NativeLaunchProfile::new(
19985                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19986                agent("claude"),
19987                TransportKind::Pty,
19988                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19989                Arc::new(EmptyEnvironmentResolver),
19990            )
19991            .unwrap()],
19992            Some(materialization),
19993        )
19994        .unwrap();
19995        let (binding, native_profiles, materialization) = node_profile.into_parts();
19996        for native_profile in native_profiles {
19997            runtime.upsert_native_launch_profile(native_profile).unwrap();
19998        }
19999        let shared = NodeShared::new_with_incarnation(
20000            handle,
20001            "fixture-token".to_owned(),
20002            NodeId::new("node-materialization-test").unwrap(),
20003            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20004            vec![workspace],
20005            vec![agent("claude")],
20006            ProviderRuntimeStatuses::default(),
20007            None,
20008            Vec::new(),
20009            Vec::new(),
20010            SpawnProfileRegistry::default(),
20011            Some(control),
20012            None,
20013            Some(materializer),
20014            None,
20015            Vec::new(),
20016            Vec::new(),
20017            Vec::new(),
20018            Vec::new(),
20019            None,
20020            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20021        );
20022        shared
20023            .environment_profiles
20024            .write()
20025            .unwrap_or_else(|poisoned| poisoned.into_inner())
20026            .insert(binding.id.clone(), binding);
20027        shared
20028            .environment_materialization_profiles
20029            .write()
20030            .unwrap_or_else(|poisoned| poisoned.into_inner())
20031            .insert(profile_id.clone(), materialization.unwrap());
20032        (
20033            shared,
20034            runtime,
20035            ResolvedEnvironmentProfileReceipt {
20036                profile_id,
20037                profile_revision,
20038                network_allowlist: None,
20039                browser_profile_id: None,
20040            },
20041            root,
20042            deny,
20043        )
20044    }
20045
20046
20047    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
20048
20049    async fn characterization_spawn(
20050        shared: &NodeShared,
20051        mode: SessionMode,
20052        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20053        required: &[ProviderRuntimeCapability],
20054        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20055    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20056        shared
20057            .spawn_session_with_deadline(
20058                WorkspaceId::new("primary").unwrap(),
20059                agent("claude"),
20060                mode,
20061                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20062                None,
20063                environment_profile,
20064                None,
20065                None,
20066                None,
20067                Some(ProviderRuntimePolicy::raw_pty()),
20068                SpawnRecordPolicy::ProviderIdentityOnly,
20069                Some(Instant::now() + Duration::from_secs(30)),
20070                required,
20071                harness_mcp,
20072                ApprovalLevel::default(),
20073                None,
20074                None,
20075            )
20076            .await
20077    }
20078
20079    fn assert_no_session_state(shared: &NodeShared) {
20080        assert!(shared.handle.snapshot().sessions.is_empty());
20081        assert!(shared
20082            .session_bindings
20083            .lock()
20084            .unwrap_or_else(|poisoned| poisoned.into_inner())
20085            .is_empty());
20086        assert!(shared
20087            .materializations
20088            .lock()
20089            .unwrap_or_else(|poisoned| poisoned.into_inner())
20090            .is_empty());
20091    }
20092
20093    #[tokio::test]
20094    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20095        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20096        let error = characterization_spawn(
20097            &shared,
20098            SessionMode::Pty,
20099            None,
20100            &[ProviderRuntimeCapability::SemanticReadiness],
20101            None,
20102        )
20103        .await
20104        .unwrap_err();
20105        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20106        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20107        assert_no_session_state(&shared);
20108        drop(shared);
20109        drop(runtime);
20110        std::fs::remove_dir_all(root).unwrap();
20111    }
20112
20113    #[tokio::test]
20114    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20115        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20116        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20117            .await
20118            .unwrap_err();
20119        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20120        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20121        assert_no_session_state(&shared);
20122        drop(shared);
20123        drop(runtime);
20124        std::fs::remove_dir_all(root).unwrap();
20125    }
20126
20127    #[test]
20128    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20129        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20130        let address = SessionAddress {
20131            workspace_id: WorkspaceId::new("primary").unwrap(),
20132            session: SessionKey {
20133                instance_id: AgentInstanceId(77),
20134                generation: SessionGeneration(1),
20135            },
20136        };
20137        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20138        let removed = shared.remove_binding(&address).expect("binding removed");
20139        assert!(removed.environment_profile.is_some());
20140        assert!(shared.remove_binding(&address).is_none());
20141        assert_no_session_state(&shared);
20142        drop(shared);
20143        drop(runtime);
20144        std::fs::remove_dir_all(root).unwrap();
20145    }
20146
20147    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20148        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20149        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20150        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20151            .expect("review current test executable as fixture helper");
20152        let registry = HarnessMcpProxyRegistry::new(helper);
20153        let spec = crate::protocol::SpawnSpec {
20154            target: crate::protocol::SpawnTarget {
20155                node_id: NodeId::new("node-characterization").unwrap(),
20156                workspace_id: WorkspaceId::new("primary").unwrap(),
20157                worktree_id: None,
20158            },
20159            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20160            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20161            overrides: crate::protocol::SpawnOverrides::default(),
20162            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20163            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20164            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20165        };
20166        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20167        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20168        let launch = HarnessMcpLaunchV1 {
20169            server_name: "fixture-harness".to_owned(),
20170            args: vec!["--session-proxy".to_owned()],
20171            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20172            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20173            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20174            trace: None,
20175            scrub_env: Vec::new(),
20176        };
20177        let expires = unix_time_ms().saturating_add(30_000);
20178        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20179        registry
20180            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20181            .unwrap()
20182    }
20183
20184    #[tokio::test]
20185    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20186        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20187        let prepared = characterization_prepared_harness("claude").await;
20188        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20189            .await
20190            .unwrap_err();
20191        // Inline is stopped by runtime admission before the harness check runs.
20192        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20193        let mismatched = characterization_prepared_harness("codex").await;
20194        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20195            .await
20196            .unwrap_err();
20197        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20198        assert_no_session_state(&shared);
20199        drop(shared);
20200        drop(runtime);
20201        std::fs::remove_dir_all(root).unwrap();
20202    }
20203
20204    #[tokio::test]
20205    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20206        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20207        shared.native_launch_profile_control = None;
20208        let prepared = characterization_prepared_harness("claude").await;
20209        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20210            .await
20211            .unwrap_err();
20212        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20213        assert_no_session_state(&shared);
20214        drop(shared);
20215        drop(runtime);
20216        std::fs::remove_dir_all(root).unwrap();
20217    }
20218}
20219
20220#[cfg(all(test, windows))]
20221mod tests {
20222    use super::*;
20223    use gate4agent_types::ProviderActivity;
20224    use crate::session_environment::{
20225        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20226        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20227        NodeSessionPathClass,
20228    };
20229    use gate4agent_catalog::EnvMutation;
20230    use gate4agent_runtime_native::{
20231        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20232        NativeLaunchProfile, NativeLaunchProfileId,
20233    };
20234    use std::ffi::OsString;
20235    use std::sync::Arc;
20236
20237    struct EmptyEnvironmentResolver;
20238
20239    struct FixtureSecretResolver {
20240        deny: Arc<AtomicBool>,
20241    }
20242
20243    impl NodeSecretResolver for FixtureSecretResolver {
20244        fn resolve(
20245            &self,
20246            _reference: &NodeSecretReference,
20247        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20248            if self.deny.load(Ordering::Acquire) {
20249                Err(NodeSecretResolveError::Denied)
20250            } else {
20251                NodeSecretValue::text("fixture-secret")
20252                    .map_err(|_| NodeSecretResolveError::Unavailable)
20253            }
20254        }
20255    }
20256
20257    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20258        fn resolve_child_environment(
20259            &self,
20260        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20261            Ok(vec![EnvMutation {
20262                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20263                value: None,
20264            }])
20265        }
20266    }
20267
20268    fn agent(value: &str) -> AgentId {
20269        AgentId::new(value).unwrap()
20270    }
20271
20272    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20273        let catalog = active_registry().unwrap();
20274        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20275        let workspace = WorkspaceConfig::new(
20276            WorkspaceId::new("primary").unwrap(),
20277            std::env::current_dir().unwrap(),
20278        )
20279        .unwrap()
20280        .with_worktree_service_mode(mode);
20281        NodeShared::new(
20282            handle,
20283            "fixture-token".to_owned(),
20284            NodeId::new("node-terminal-test").unwrap(),
20285            vec![workspace],
20286            vec![agent("claude")],
20287        )
20288    }
20289
20290    fn terminal_test_shared() -> NodeShared {
20291        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20292    }
20293
20294    #[cfg(feature = "fixture")]
20295    #[test]
20296    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20297        let probe = SpawnManagedWorktreeV2FailureProbe::default();
20298        assert_eq!(probe.latest_code(), None);
20299
20300        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20301        assert_eq!(
20302            probe.latest_code(),
20303            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20304        );
20305
20306        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20307        assert_eq!(
20308            probe.latest_code(),
20309            Some(NodeFailureCode::SpawnDeadlineExceeded),
20310        );
20311
20312        probe.record(None);
20313        assert_eq!(probe.latest_code(), None);
20314    }
20315
20316    #[test]
20317    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20318        let source = temporary_workspace_root("launch-inventory-source");
20319        let allocation = temporary_workspace_root("launch-inventory-allocation");
20320        std::fs::create_dir_all(&source).unwrap();
20321        std::fs::create_dir_all(&allocation).unwrap();
20322        let workspace_id = WorkspaceId::new("primary").unwrap();
20323        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20324            .unwrap()
20325            .with_worktree_service_mode(WorktreeServiceMode::Managed);
20326        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20327            workspace = workspace
20328                .with_managed_worktree_profile(
20329                    ManagedWorktreeProfile::new(
20330                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20331                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20332                        &allocation,
20333                        "gate4agent",
20334                        "HEAD",
20335                        ManagedWorktreeRetention::RemoveWhenReleased,
20336                    )
20337                    .unwrap(),
20338                )
20339                .unwrap();
20340        }
20341        let overflow = workspace.clone().with_managed_worktree_profile(
20342            ManagedWorktreeProfile::new(
20343                WorktreeProfileId::new("overflow").unwrap(),
20344                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20345                &allocation,
20346                "gate4agent",
20347                "HEAD",
20348                ManagedWorktreeRetention::Retain,
20349            )
20350            .unwrap(),
20351        );
20352        assert!(matches!(
20353            overflow,
20354            Err(NodeServerError::ManagedWorktreeProfileCapacity {
20355                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20356                ..
20357            }),
20358        ));
20359
20360        let catalog = active_registry().unwrap();
20361        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20362        let shared = NodeShared::new(
20363            handle,
20364            "fixture-token".to_owned(),
20365            NodeId::new("node-launch-inventory").unwrap(),
20366            vec![workspace],
20367            vec![agent("claude")],
20368        );
20369        let snapshot = shared.snapshot();
20370        let launch = snapshot.launch_inventory.as_ref().unwrap();
20371        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20372        assert!(launch.bundles.as_ref().unwrap().is_empty());
20373        let profiles = snapshot.workspaces[0]
20374            .managed_worktree_profiles
20375            .as_ref()
20376            .unwrap();
20377        assert_eq!(
20378            snapshot.workspaces[0].worktree_service_mode,
20379            Some(WorktreeServiceMode::Managed),
20380        );
20381        assert_eq!(
20382            profiles.profiles.len(),
20383            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20384        );
20385        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20386        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20387            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20388        }
20389        let legacy = snapshot_for_wire(
20390            &shared,
20391            true,
20392            true,
20393            false,
20394            false,
20395            true,
20396            false,
20397            true,
20398            false,
20399            true,
20400        );
20401        assert!(legacy.launch_inventory.is_none());
20402        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20403
20404        drop(shared);
20405        std::fs::remove_dir_all(source).unwrap();
20406        std::fs::remove_dir_all(allocation).unwrap();
20407    }
20408
20409    #[test]
20410    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20411        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20412            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20413        }));
20414        let source = gate4agent_types::ProviderSource {
20415            family: AdapterFamily::PtySemantic,
20416            binding: AdapterBinding::new(
20417                gate4agent_types::AdapterId::new("claude").unwrap(),
20418                "fixture/v1",
20419                gate4agent_types::AdapterVerification::SyntheticFixture,
20420            )
20421            .unwrap(),
20422        };
20423        let provider = ProviderSnapshot {
20424            sequence: 17,
20425            session: Some(ProviderSessionIdentity {
20426                key: ProviderSessionKey::SessionId,
20427                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20428                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20429            }),
20430            model: Some("PRIVATE_MODEL".to_owned()),
20431            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20432            completed_turns: 3,
20433            usage: gate4agent_types::TokenUsage {
20434                input_tokens: 10,
20435                output_tokens: 20,
20436                cache_read_tokens: 30,
20437                cache_write_tokens: 40,
20438                reasoning_tokens: 50,
20439                context_window: Some(200_000),
20440            },
20441            lead_activity: ProviderActivity::WaitingForInput,
20442            activity: ProviderActivity::WaitingForInput,
20443            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20444            active_tools: (0..9)
20445                .map(|index| gate4agent_types::ActiveProviderTool {
20446                    id: format!("private-tool-id-{index}"),
20447                    name: if index == 0 {
20448                        format!("  Read\u{0000}{}", "x".repeat(80))
20449                    } else {
20450                        format!("Tool-{index}")
20451                    },
20452                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20453                })
20454                .collect(),
20455            interactions: vec![gate4agent_types::ProviderInteraction {
20456                id: gate4agent_types::ProviderInteractionId(17),
20457                source: source.clone(),
20458                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20459                interaction_kind: ProviderInteractionKind::Approval,
20460                tool_name: " Approve\u{0007}Tool ".to_owned(),
20461                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20462                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20463                resume_lead_activity: Some(ProviderActivity::Working),
20464                status: ProviderInteractionStatus::Pending,
20465            }],
20466            subagents: vec![gate4agent_types::ProviderSubagent {
20467                source: source.clone(),
20468                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20469                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20470                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20471            }],
20472            sources: vec![gate4agent_types::ProviderSourceCursor {
20473                source,
20474                sequence: 17,
20475                gap_count: 2,
20476                stale: true,
20477            }],
20478            last_event: Some(ProviderEvent::Error {
20479                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20480            }),
20481            gap_count: 2,
20482            stale: true,
20483        };
20484        let address = terminal_address(3);
20485        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20486        assert_eq!(entry.address, address);
20487        assert_eq!(entry.progress.provider_sequence, 17);
20488        assert_eq!(entry.progress.active_tool_count, 9);
20489        assert_eq!(
20490            entry.progress.active_tool_labels.len(),
20491            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20492        );
20493        assert_eq!(entry.progress.subagent_count, 1);
20494        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20495        assert!(entry.progress.truncated);
20496        let encoded = serde_json::to_string(&entry).unwrap();
20497        for forbidden in [
20498            "PRIVATE_PROVIDER_SESSION",
20499            "PRIVATE_PROVIDER_PATH",
20500            "PRIVATE_MODEL",
20501            "PRIVATE_TOOL_INVENTORY",
20502            "PRIVATE_CURRENT_PROMPT",
20503            "PRIVATE_TOOL_INPUT_JSON",
20504            "PRIVATE_PROVIDER_REQUEST",
20505            "PRIVATE_INTERACTION_PROMPT",
20506            "PRIVATE_INTERACTION_AGENT",
20507            "PRIVATE_SUBAGENT_ID",
20508            "PRIVATE_SUBAGENT_TYPE",
20509            "PRIVATE_SUBAGENT_DESCRIPTION",
20510            "PRIVATE_PROVIDER_ERROR",
20511            "input_json",
20512            "current_prompt",
20513            "session_id",
20514        ] {
20515            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20516        }
20517
20518        let snapshot = NodeSnapshot {
20519            node_id: NodeId::new("node-progress-test").unwrap(),
20520            enabled_providers: vec![agent("claude")],
20521            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20522            workspaces: Vec::new(),
20523            session_records: Vec::new(),
20524            managed_worktrees: Vec::new(),
20525            launch_inventory: None,
20526            agent_progress: vec![entry],
20527        };
20528        let mut legacy_reply = ResponseEnvelope {
20529            request_id: 1,
20530            result: Ok(NodeResponse::Snapshot {
20531                event_sequence: 0,
20532                controller: None,
20533                snapshot: snapshot.clone(),
20534            }),
20535        };
20536        project_response_without_agent_progress(&mut legacy_reply);
20537        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20538            panic!("agent progress projection changed response kind");
20539        };
20540        assert!(legacy.agent_progress.is_empty());
20541        assert_eq!(snapshot.agent_progress.len(), 1);
20542    }
20543
20544    fn terminal_address(generation: u64) -> SessionAddress {
20545        SessionAddress {
20546            workspace_id: WorkspaceId::new("primary").unwrap(),
20547            session: SessionKey {
20548                instance_id: AgentInstanceId(41),
20549                generation: SessionGeneration(generation),
20550            },
20551        }
20552    }
20553
20554    fn spawn_spec_fixture() -> SpawnSpec {
20555        SpawnSpec {
20556            target: crate::protocol::SpawnTarget {
20557                node_id: NodeId::new("node-terminal-test").unwrap(),
20558                workspace_id: WorkspaceId::new("primary").unwrap(),
20559                worktree_id: None,
20560            },
20561            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20562            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20563                .unwrap(),
20564            overrides: crate::protocol::SpawnOverrides::default(),
20565            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20566            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20567            required_capabilities: SpawnRequiredCapabilities::default(),
20568        }
20569    }
20570
20571    fn environment_profile_test_shared(
20572    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20573        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20574        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20575            "local-claude-r1",
20576        )
20577        .unwrap();
20578        let spawn_profiles = SpawnProfileRegistry::new([
20579            crate::protocol::SpawnProfileDefaults {
20580                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20581                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20582                provider: agent("claude"),
20583                mode: SessionMode::Pty,
20584                terminal_size: gate4agent_types::TerminalSize {
20585                    rows: 24,
20586                    columns: 80,
20587                },
20588                prompt: None,
20589                bundle_id: None,
20590                context_id: None,
20591                environment_profile_id: Some(profile_id.clone()),
20592            },
20593        ])
20594        .unwrap();
20595        let catalog = active_registry().unwrap();
20596        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20597        let control = runtime.native_launch_profile_control();
20598        let node_profile = NodeEnvironmentProfile::new(
20599            profile_id.clone(),
20600            profile_revision.clone(),
20601            agent("claude"),
20602            [NativeLaunchProfile::new(
20603                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20604                agent("claude"),
20605                TransportKind::Pty,
20606                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20607                Arc::new(EmptyEnvironmentResolver),
20608            )
20609            .unwrap()],
20610        )
20611        .unwrap();
20612        let (binding, native_profiles, _materialization) = node_profile.into_parts();
20613        for native_profile in native_profiles {
20614            runtime.upsert_native_launch_profile(native_profile).unwrap();
20615        }
20616        let workspace = WorkspaceConfig::new(
20617            WorkspaceId::new("primary").unwrap(),
20618            std::env::current_dir().unwrap(),
20619        )
20620        .unwrap();
20621        let shared = NodeShared::new_with_incarnation(
20622            handle,
20623            "fixture-token".to_owned(),
20624            NodeId::new("node-terminal-test").unwrap(),
20625            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20626            vec![workspace],
20627            vec![agent("claude")],
20628            ProviderRuntimeStatuses::default(),
20629            None,
20630            Vec::new(),
20631            Vec::new(),
20632            spawn_profiles,
20633            Some(control),
20634            None,
20635            None,
20636            None,
20637            Vec::new(),
20638            Vec::new(),
20639            Vec::new(),
20640            Vec::new(),
20641            None,
20642            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20643        );
20644        shared
20645            .environment_profiles
20646            .write()
20647            .unwrap_or_else(|poisoned| poisoned.into_inner())
20648            .insert(binding.id.clone(), binding);
20649        (
20650            shared,
20651            runtime,
20652            ResolvedEnvironmentProfileReceipt {
20653                profile_id,
20654                profile_revision,
20655                network_allowlist: None,
20656                browser_profile_id: None,
20657            },
20658        )
20659    }
20660
20661    fn materialization_test_shared(
20662        deny_secret: bool,
20663    ) -> (
20664        NodeShared,
20665        NativeRuntime,
20666        ResolvedEnvironmentProfileReceipt,
20667        PathBuf,
20668        Arc<AtomicBool>,
20669    ) {
20670        let workspace = WorkspaceConfig::new(
20671            WorkspaceId::new("primary").unwrap(),
20672            std::env::current_dir().unwrap(),
20673        )
20674        .unwrap();
20675        materialization_test_shared_with_workspace(deny_secret, workspace)
20676    }
20677
20678    fn materialization_test_shared_with_workspace(
20679        deny_secret: bool,
20680        workspace: WorkspaceConfig,
20681    ) -> (
20682        NodeShared,
20683        NativeRuntime,
20684        ResolvedEnvironmentProfileReceipt,
20685        PathBuf,
20686        Arc<AtomicBool>,
20687    ) {
20688        let root = temporary_workspace_root(if deny_secret {
20689            "materialization-denied"
20690        } else {
20691            "materialization-ready"
20692        });
20693        std::fs::create_dir_all(&root).unwrap();
20694        let materialization_root = root.join("session-environments");
20695        let deny = Arc::new(AtomicBool::new(deny_secret));
20696        let resolver = Arc::new(FixtureSecretResolver {
20697            deny: Arc::clone(&deny),
20698        });
20699        let materializer = SessionEnvironmentMaterializer::new(
20700            materialization_root,
20701            resolver,
20702        )
20703        .unwrap();
20704        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20705        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20706            "materialized-claude-r1",
20707        )
20708        .unwrap();
20709        let materialization = NodeSessionMaterializationProfile::new(
20710            vec![NodeSessionEnvironmentMutation::SetSecret {
20711                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20712                reference: NodeSecretReference::new("fixture-token").unwrap(),
20713            }],
20714            vec![NodeSessionPathBinding::new(
20715                "GATE4AGENT_PROVIDER_HOME",
20716                NodeSessionPathClass::ProviderHome,
20717            )
20718            .unwrap()],
20719            vec![NodeSessionFile::secret(
20720                NodeSessionPathClass::Config,
20721                "auth/token",
20722                NodeSecretReference::new("fixture-token").unwrap(),
20723            )
20724            .unwrap()],
20725        )
20726        .unwrap();
20727        let catalog = active_registry().unwrap();
20728        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20729        let control = runtime.native_launch_profile_control();
20730        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20731            profile_id.clone(),
20732            profile_revision.clone(),
20733            agent("claude"),
20734            [NativeLaunchProfile::new(
20735                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20736                agent("claude"),
20737                TransportKind::Pty,
20738                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20739                Arc::new(EmptyEnvironmentResolver),
20740            )
20741            .unwrap()],
20742            Some(materialization),
20743        )
20744        .unwrap();
20745        let (binding, native_profiles, materialization) = node_profile.into_parts();
20746        for native_profile in native_profiles {
20747            runtime.upsert_native_launch_profile(native_profile).unwrap();
20748        }
20749        let shared = NodeShared::new_with_incarnation(
20750            handle,
20751            "fixture-token".to_owned(),
20752            NodeId::new("node-materialization-test").unwrap(),
20753            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20754            vec![workspace],
20755            vec![agent("claude")],
20756            ProviderRuntimeStatuses::default(),
20757            None,
20758            Vec::new(),
20759            Vec::new(),
20760            SpawnProfileRegistry::default(),
20761            Some(control),
20762            None,
20763            Some(materializer),
20764            None,
20765            Vec::new(),
20766            Vec::new(),
20767            Vec::new(),
20768            Vec::new(),
20769            None,
20770            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20771        );
20772        shared
20773            .environment_profiles
20774            .write()
20775            .unwrap_or_else(|poisoned| poisoned.into_inner())
20776            .insert(binding.id.clone(), binding);
20777        shared
20778            .environment_materialization_profiles
20779            .write()
20780            .unwrap_or_else(|poisoned| poisoned.into_inner())
20781            .insert(profile_id.clone(), materialization.unwrap());
20782        (
20783            shared,
20784            runtime,
20785            ResolvedEnvironmentProfileReceipt {
20786                profile_id,
20787                profile_revision,
20788                network_allowlist: None,
20789                browser_profile_id: None,
20790            },
20791            root,
20792            deny,
20793        )
20794    }
20795
20796    #[cfg(feature = "fixture")]
20797    #[derive(Clone, Copy)]
20798    enum CodexHomeProfile {
20799        Missing,
20800        WrongClass,
20801        Exact,
20802    }
20803
20804    #[cfg(feature = "fixture")]
20805    fn codex_bundle_test_shared(
20806        home_profile: CodexHomeProfile,
20807    ) -> (
20808        NodeShared,
20809        NativeRuntime,
20810        ResolvedEnvironmentProfileReceipt,
20811        NodeBundle,
20812        PathBuf,
20813    ) {
20814        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20815        const CLAUDE_MANIFEST: &[u8] =
20816            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20817        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20818        const DIGEST: &str =
20819            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20820
20821        let (shared, mut runtime, _claude_receipt, root, _deny) =
20822            materialization_test_shared(false);
20823        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20824            CodexHomeProfile::Missing => "codex-home-missing",
20825            CodexHomeProfile::WrongClass => "codex-home-wrong",
20826            CodexHomeProfile::Exact => "codex-home-exact",
20827        })
20828        .unwrap();
20829        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20830            "codex-home-r1",
20831        )
20832        .unwrap();
20833        let materialization = match home_profile {
20834            CodexHomeProfile::Missing => None,
20835            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20836                Some(NodeSessionMaterializationProfile::new(
20837                    Vec::new(),
20838                    vec![NodeSessionPathBinding::new(
20839                        "CODEX_HOME",
20840                        if matches!(home_profile, CodexHomeProfile::Exact) {
20841                            NodeSessionPathClass::ProviderHome
20842                        } else {
20843                            NodeSessionPathClass::Config
20844                        },
20845                    )
20846                    .unwrap()],
20847                    Vec::new(),
20848                )
20849                .unwrap())
20850            }
20851        };
20852        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20853            profile_id.clone(),
20854            profile_revision.clone(),
20855            agent("codex"),
20856            [NativeLaunchProfile::new(
20857                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20858                agent("codex"),
20859                TransportKind::Pty,
20860                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20861                Arc::new(EmptyEnvironmentResolver),
20862            )
20863            .unwrap()],
20864            materialization,
20865        )
20866        .unwrap();
20867        let (binding, native_profiles, materialization) = node_profile.into_parts();
20868        for native_profile in native_profiles {
20869            runtime.upsert_native_launch_profile(native_profile).unwrap();
20870        }
20871        shared
20872            .environment_profiles
20873            .write()
20874            .unwrap_or_else(|poisoned| poisoned.into_inner())
20875            .insert(binding.id.clone(), binding);
20876        if let Some(materialization) = materialization {
20877            shared
20878                .environment_materialization_profiles
20879                .write()
20880                .unwrap_or_else(|poisoned| poisoned.into_inner())
20881                .insert(profile_id.clone(), materialization);
20882        }
20883
20884        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20885        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20886        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20887        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20888        std::fs::write(
20889            bundle_source.join(".claude-plugin/plugin.json"),
20890            CLAUDE_MANIFEST,
20891        )
20892        .unwrap();
20893        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20894        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20895        let bundle = NodeBundle::new(
20896            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20897            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20898            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20899            &bundle_source,
20900        )
20901        .unwrap();
20902        *shared
20903            .bundle_catalog
20904            .write()
20905            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20906            BundleCatalog::new([bundle.clone()]).unwrap();
20907        (
20908            shared,
20909            runtime,
20910            ResolvedEnvironmentProfileReceipt {
20911                profile_id,
20912                profile_revision,
20913                network_allowlist: None,
20914                browser_profile_id: None,
20915            },
20916            bundle,
20917            root,
20918        )
20919    }
20920
20921    #[cfg(feature = "fixture")]
20922    #[test]
20923    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20924        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20925            let (shared, runtime, receipt, bundle, root) =
20926                codex_bundle_test_shared(home_profile);
20927            let error = shared
20928                .bundle_layout(
20929                    &agent("codex"),
20930                    SessionMode::Pty,
20931                    Some(&receipt),
20932                    &bundle,
20933                )
20934                .unwrap_err();
20935            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20936            assert!(shared
20937                .materializations
20938                .lock()
20939                .unwrap_or_else(|poisoned| poisoned.into_inner())
20940                .is_empty());
20941            let materialization_root = root.join("session-environments");
20942            assert!(!std::fs::read_dir(&materialization_root)
20943                .unwrap()
20944                .filter_map(Result::ok)
20945                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20946            drop(shared);
20947            drop(runtime);
20948            let _ = std::fs::remove_dir_all(root);
20949        }
20950    }
20951
20952    #[cfg(feature = "fixture")]
20953    #[test]
20954    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20955        let (shared, runtime, receipt, bundle, root) =
20956            codex_bundle_test_shared(CodexHomeProfile::Exact);
20957        let address = SessionAddress {
20958            workspace_id: WorkspaceId::new("primary").unwrap(),
20959            session: SessionKey {
20960                instance_id: AgentInstanceId(310),
20961                generation: SessionGeneration(1),
20962            },
20963        };
20964        let (overlay, guard) = shared
20965            .prepare_session_materialization(
20966                &address,
20967                &agent("codex"),
20968                SessionMode::Pty,
20969                Some(&receipt),
20970                Some(&bundle.receipt()),
20971                None,
20972                None,
20973            )
20974            .unwrap()
20975            .unwrap();
20976        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20977        let ownership = shared
20978            .materializations
20979            .lock()
20980            .unwrap_or_else(|poisoned| poisoned.into_inner())
20981            .get(guard.id().unwrap())
20982            .unwrap()
20983            .clone();
20984        let (profile, _, _) = shared
20985            .materialization_profile(
20986                &agent("codex"),
20987                SessionMode::Pty,
20988                Some(&receipt),
20989                Some(&bundle.receipt()),
20990                None,
20991            )
20992            .unwrap()
20993            .unwrap();
20994        let environment = shared
20995            .session_environment_materializer
20996            .as_ref()
20997            .unwrap()
20998            .resolve_environment(&ownership, &profile)
20999            .unwrap();
21000        assert!(environment.iter().any(|mutation| {
21001            mutation.key == OsString::from("CODEX_HOME")
21002                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
21003        }));
21004        assert_eq!(
21005            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
21006                .unwrap(),
21007            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21008        );
21009        assert!(std::fs::read_dir(ownership.bundle_root())
21010            .unwrap()
21011            .next()
21012            .is_none());
21013        drop(guard);
21014        drop(shared);
21015        drop(runtime);
21016        let _ = std::fs::remove_dir_all(root);
21017    }
21018
21019    #[cfg(feature = "fixture")]
21020    #[test]
21021    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21022        let (shared, runtime, receipt, bundle, root) =
21023            codex_bundle_test_shared(CodexHomeProfile::Exact);
21024        let bundle_receipt = bundle.receipt();
21025        let address = SessionAddress {
21026            workspace_id: WorkspaceId::new("primary").unwrap(),
21027            session: SessionKey {
21028                instance_id: AgentInstanceId(311),
21029                generation: SessionGeneration(1),
21030            },
21031        };
21032        let (_overlay, guard) = shared
21033            .prepare_session_materialization(
21034                &address,
21035                &agent("codex"),
21036                SessionMode::Pty,
21037                Some(&receipt),
21038                Some(&bundle_receipt),
21039                None,
21040                None,
21041            )
21042            .unwrap()
21043            .unwrap();
21044        let materialization_id = guard.id().unwrap().clone();
21045        let skill_path = shared
21046            .materializations
21047            .lock()
21048            .unwrap_or_else(|poisoned| poisoned.into_inner())
21049            .get(&materialization_id)
21050            .unwrap()
21051            .provider_home()
21052            .join("skills/review-code/SKILL.md");
21053        let record_id = shared
21054            .bind_spawn_session_with_materialization(
21055                &address,
21056                agent("codex"),
21057                SessionMode::Pty,
21058                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21059                SpawnRecordPolicy::ProviderIdentityOnly,
21060                Some(receipt.clone()),
21061                Some(bundle_receipt.clone()),
21062                None,
21063                Some(materialization_id.clone()),
21064            )
21065            .unwrap()
21066            .unwrap();
21067        guard.retain();
21068        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21069
21070        let error = match shared.resolve_record_materialization(
21071                &record_id,
21072                &agent("codex"),
21073                SessionMode::Pty,
21074                Some(&receipt),
21075                Some(&bundle_receipt),
21076                None,
21077            ) {
21078                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21079                Err(error) => error,
21080            };
21081        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21082        assert_eq!(
21083            shared
21084                .materializations
21085                .lock()
21086                .unwrap_or_else(|poisoned| poisoned.into_inner())
21087                .get(&materialization_id)
21088                .unwrap()
21089                .state(),
21090            MaterializationState::RecoveryRequired,
21091        );
21092        drop(shared);
21093        drop(runtime);
21094        let _ = std::fs::remove_dir_all(root);
21095    }
21096
21097    fn bind_record_owned_materialization_fixture(
21098        shared: &NodeShared,
21099        receipt: &ResolvedEnvironmentProfileReceipt,
21100        instance_id: u64,
21101    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21102        let address = SessionAddress {
21103            workspace_id: WorkspaceId::new("primary").unwrap(),
21104            session: SessionKey {
21105                instance_id: AgentInstanceId(instance_id),
21106                generation: SessionGeneration(1),
21107            },
21108        };
21109        let (overlay, materialization_guard) = shared
21110            .prepare_session_materialization(
21111                &address,
21112                &agent("claude"),
21113                SessionMode::Pty,
21114                Some(receipt),
21115                None,
21116                None,
21117                None,
21118            )
21119            .unwrap()
21120            .unwrap();
21121        let materialization_id = materialization_guard.id().unwrap().clone();
21122        let materialization_root = shared
21123            .materializations
21124            .lock()
21125            .unwrap_or_else(|poisoned| poisoned.into_inner())
21126            .get(&materialization_id)
21127            .unwrap()
21128            .root()
21129            .to_path_buf();
21130        let selection = shared
21131            .select_environment_profile(
21132                address.session.instance_id,
21133                &agent("claude"),
21134                SessionMode::Pty,
21135                Some(receipt),
21136            )
21137            .unwrap()
21138            .unwrap();
21139        assert!(shared
21140            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21141            .unwrap()
21142            .is_none());
21143        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21144        let record_id = shared
21145            .bind_spawn_session_with_materialization(
21146                &address,
21147                agent("claude"),
21148                SessionMode::Pty,
21149                policy,
21150                SpawnRecordPolicy::ProviderIdentityOnly,
21151                Some(receipt.clone()),
21152                None,
21153                None,
21154                Some(materialization_id.clone()),
21155            )
21156            .unwrap()
21157            .unwrap();
21158        selection.retain();
21159        materialization_guard.retain();
21160        (address, record_id, materialization_id, materialization_root)
21161    }
21162
21163    #[test]
21164    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21165        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21166        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21167            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21168        }));
21169        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21170        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21171        let mut spec = spawn_spec_fixture();
21172        spec.expected_profile_revision =
21173            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21174        assert!(matches!(
21175            &spec.overrides.environment_profile_id,
21176            crate::protocol::SpawnOverride::Inherit,
21177        ));
21178        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21179            .requires_child_environment_profile_capability());
21180        assert!(request_requires_child_environment_profile(
21181            &shared,
21182            &NodeRequest::SpawnSpec { spec: spec.clone() },
21183        ));
21184
21185        let address = terminal_address(1);
21186        shared.bind_session_with_policy(
21187            &address,
21188            ProviderRuntimePolicy::raw_pty(),
21189            Some(environment_profile.clone()),
21190        );
21191        assert!(request_requires_child_environment_profile(
21192            &shared,
21193            &NodeRequest::Input {
21194                session: address.clone(),
21195                text: "x".to_owned(),
21196            },
21197        ));
21198
21199        let mut record = record("claude", "profile-record");
21200        record.active_session = Some(address.clone());
21201        record.environment_profile = Some(environment_profile.clone());
21202        let record_id = record.record_id.clone();
21203        shared.insert_record(record.clone()).unwrap();
21204        assert!(request_requires_child_environment_profile(
21205            &shared,
21206            &NodeRequest::ResumeSessionRecord {
21207                record_id,
21208                terminal_size: gate4agent_types::TerminalSize {
21209                    rows: 24,
21210                    columns: 80,
21211                },
21212                initial_prompt: None,
21213            },
21214        ));
21215
21216        let snapshot = snapshot_for_wire(
21217            &shared, false, true, true, true, false, false, false, false, true,
21218        );
21219        assert_eq!(snapshot.session_records.len(), 1);
21220        assert!(snapshot.session_records[0].environment_profile.is_none());
21221        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21222            sequence: 1,
21223            event: NodeEvent::SessionRecordUpserted { record },
21224        });
21225        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21226            panic!("record projection changed the event kind");
21227        };
21228        assert!(record.environment_profile.is_none());
21229
21230        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21231        let mut reply = ResponseEnvelope {
21232            request_id: 1,
21233            result: Ok(NodeResponse::SpawnSpecAccepted {
21234                receipt: resolved.receipt_with_environment(
21235                    shared.incarnation_id,
21236                    address,
21237                    Some(environment_profile),
21238                ),
21239            }),
21240        };
21241        project_response_without_child_environment_profile(&mut reply);
21242        assert_eq!(
21243            reply.result.unwrap_err().code,
21244            NodeFailureCode::UnsupportedCapability,
21245        );
21246    }
21247
21248    #[test]
21249    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21250        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21251        let snapshot = shared.snapshot();
21252        let profiles = snapshot
21253            .launch_inventory
21254            .as_ref()
21255            .and_then(|inventory| inventory.spawn_profiles.as_ref())
21256            .unwrap();
21257        assert_eq!(profiles.len(), 1);
21258        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21259        assert!(snapshot.requires_child_environment_profile_capability());
21260
21261        let mut legacy = snapshot.clone();
21262        clear_snapshot_child_environment_profiles(&mut legacy);
21263        assert!(legacy
21264            .launch_inventory
21265            .as_ref()
21266            .unwrap()
21267            .spawn_profiles
21268            .as_ref()
21269            .unwrap()[0]
21270            .environment_profile
21271            .is_none());
21272        assert!(!legacy.requires_child_environment_profile_capability());
21273
21274        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21275        let bindings = shared
21276            .environment_profiles
21277            .read()
21278            .unwrap_or_else(|poisoned| poisoned.into_inner())
21279            .clone();
21280        let mut provider_mismatch = bindings.clone();
21281        provider_mismatch
21282            .values_mut()
21283            .next()
21284            .unwrap()
21285            .provider = agent("codex");
21286        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21287        let mut mode_mismatch = profile;
21288        mode_mismatch.mode = SessionMode::Inline;
21289        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21290
21291        shared
21292            .environment_profiles
21293            .write()
21294            .unwrap_or_else(|poisoned| poisoned.into_inner())
21295            .clear();
21296        assert!(shared
21297            .snapshot()
21298            .launch_inventory
21299            .unwrap()
21300            .spawn_profiles
21301            .unwrap()
21302            .is_empty());
21303    }
21304
21305    #[test]
21306    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21307        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21308            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21309        }));
21310        let shared = terminal_test_shared();
21311        let bundle = ResolvedBundleReceipt {
21312            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21313            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21314            digest: crate::protocol::SpawnBundleDigest::new(format!(
21315                "sha256:{}",
21316                "0".repeat(64),
21317            ))
21318            .unwrap(),
21319        };
21320        let mut bundled = record("claude", "bundle-record");
21321        bundled.bundle = Some(bundle.clone());
21322        let record_id = bundled.record_id.clone();
21323        shared.insert_record(bundled.clone()).unwrap();
21324        assert!(request_requires_session_bundle(
21325            &shared,
21326            &NodeRequest::ForgetSessionRecord {
21327                record_id: record_id.clone(),
21328            },
21329        ));
21330
21331        let snapshot = snapshot_for_wire(
21332            &shared, false, true, true, true, true, false, false, false, true,
21333        );
21334        assert!(snapshot.session_records[0].bundle.is_none());
21335        let launch = snapshot.launch_inventory.as_ref().unwrap();
21336        assert!(launch.spawn_profiles.is_some());
21337        assert!(launch.bundles.is_none());
21338        let event = project_event_without_session_bundle(NodeEventEnvelope {
21339            sequence: 1,
21340            event: NodeEvent::SessionRecordUpserted { record: bundled },
21341        });
21342        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21343            panic!("record projection changed the event kind");
21344        };
21345        assert!(record.bundle.is_none());
21346
21347        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21348        let mut reply = ResponseEnvelope {
21349            request_id: 1,
21350            result: Ok(NodeResponse::SpawnSpecAccepted {
21351                receipt: resolved.receipt_with_materialization(
21352                    shared.incarnation_id,
21353                    terminal_address(17),
21354                    None,
21355                    Some(bundle),
21356                    None,
21357                ),
21358            }),
21359        };
21360        project_response_without_session_bundle(&mut reply);
21361        assert_eq!(
21362            reply.result.unwrap_err().code,
21363            NodeFailureCode::UnsupportedCapability,
21364        );
21365    }
21366
21367    #[test]
21368    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21369        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21370            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21371        }));
21372        let context_id = SpawnContextId::new("context-review").unwrap();
21373        let context = ResolvedContextPackReceipt {
21374            id: context_id.clone(),
21375            digest: crate::protocol::SpawnContextDigest::new(format!(
21376                "sha256:{}",
21377                "c".repeat(64),
21378            ))
21379            .unwrap(),
21380            lineage: ContextPackLineageReceipt {
21381                source_node_id: NodeId::new("node-source").unwrap(),
21382                source_session: terminal_address(1),
21383                source_provider: agent("kimi"),
21384            },
21385            source_message_count: 2,
21386            retained_message_count: 2,
21387            byte_len: 256,
21388            truncated: false,
21389        };
21390        assert!(context.is_valid());
21391        let mut bound_record = record("codex", "context-record");
21392        bound_record.context_id = Some(context_id);
21393        bound_record.context = Some(context.clone());
21394        let session = gate4agent_types::SessionSnapshot {
21395            instance_id: AgentInstanceId(91),
21396            agent_id: agent("kimi"),
21397            transport: TransportKind::Pty,
21398            generation: SessionGeneration(3),
21399            status: SessionStatus::Running,
21400            pending_operation: None,
21401            pending_input: None,
21402            process_id: Some(9001),
21403            terminal_size: None,
21404            terminal_frame: None,
21405            terminal_stale: None,
21406            session_options: None,
21407            capabilities: gate4agent_types::CapabilitySnapshot::default(),
21408            history: gate4agent_types::HistorySnapshot {
21409                pending: None,
21410                candidates: vec![HistoryCandidateSummary {
21411                    id: "candidate-opaque".to_owned(),
21412                    session_id_hint: "session-hint".to_owned(),
21413                    modified_at_unix_ms: Some(1),
21414                }],
21415                loaded_candidate_id: Some("candidate-opaque".to_owned()),
21416                loaded: Some(HistorySessionRecord {
21417                    session_id: "private-vendor-session".to_owned(),
21418                    title: Some("private title".to_owned()),
21419                    cwd: Some(r"C:\private\history-root".to_owned()),
21420                    model: Some("private-model".to_owned()),
21421                    message_count: 2,
21422                    completed_turn_count: None,
21423                    total_tokens: 19,
21424                    messages: vec![
21425                        gate4agent_types::HistoryMessageRecord {
21426                            role: gate4agent_types::HistoryMessageRole::User,
21427                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21428                        },
21429                        gate4agent_types::HistoryMessageRecord {
21430                            role: gate4agent_types::HistoryMessageRole::Assistant,
21431                            text: "private answer".to_owned(),
21432                        },
21433                    ],
21434                }),
21435                last_error: None,
21436            },
21437            resume: gate4agent_types::ResumeSnapshot::default(),
21438            foreground: gate4agent_types::ForegroundSnapshot::default(),
21439            provider: gate4agent_types::ProviderSnapshot::default(),
21440            screen_state: gate4agent_types::PtyScreenState::default(),
21441        };
21442        let original = NodeSnapshot {
21443            node_id: NodeId::new("node-terminal-test").unwrap(),
21444            enabled_providers: vec![agent("kimi"), agent("codex")],
21445            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21446            workspaces: vec![WorkspaceSnapshot {
21447                workspace_id: WorkspaceId::new("primary").unwrap(),
21448                canonical_root: opaque_windows_path(
21449                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21450                ),
21451                sessions: vec![session],
21452                worktree_service_mode: None,
21453                managed_worktree_profiles: None,
21454            }],
21455            session_records: vec![bound_record],
21456            managed_worktrees: Vec::new(),
21457            launch_inventory: None,
21458            agent_progress: Vec::new(),
21459        };
21460
21461        let mut negotiated = original.clone();
21462        project_snapshot_history_for_wire(&mut negotiated, true);
21463        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21464        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21465        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21466        assert!(!serde_json::to_string(&negotiated)
21467            .unwrap()
21468            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21469
21470        let mut legacy = original;
21471        project_snapshot_history_for_wire(&mut legacy, false);
21472        clear_snapshot_context_packs(&mut legacy);
21473        assert_eq!(
21474            legacy.workspaces[0].sessions[0].history,
21475            gate4agent_types::HistorySnapshot::default(),
21476        );
21477        assert!(legacy.session_records[0].context_id.is_none());
21478        assert!(legacy.session_records[0].context.is_none());
21479        let encoded = serde_json::to_string(&legacy).unwrap();
21480        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21481        assert!(!encoded.contains("context-review"));
21482
21483        let mut reply = ResponseEnvelope {
21484            request_id: 7,
21485            result: Ok(NodeResponse::ContextPackExported { context }),
21486        };
21487        project_response_without_context_pack(&mut reply);
21488        assert_eq!(
21489            reply.result.unwrap_err().code,
21490            NodeFailureCode::UnsupportedCapability,
21491        );
21492    }
21493
21494    #[test]
21495    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21496        let shared = terminal_test_shared();
21497        let history = HistorySessionRecord {
21498            session_id: "fixture-history".to_owned(),
21499            title: None,
21500            cwd: None,
21501            model: None,
21502            message_count: 1,
21503            completed_turn_count: None,
21504            total_tokens: 0,
21505            messages: vec![gate4agent_types::HistoryMessageRecord {
21506                role: gate4agent_types::HistoryMessageRole::User,
21507                text: "bounded handoff".to_owned(),
21508            }],
21509        };
21510        let pack = NodeContextPack::export(
21511            ContextPackLineageReceipt {
21512                source_node_id: shared.node_id.clone(),
21513                source_session: terminal_address(1),
21514                source_provider: agent("claude"),
21515            },
21516            &history,
21517        )
21518        .unwrap();
21519        let context = shared
21520            .context_catalog
21521            .write()
21522            .unwrap_or_else(|poisoned| poisoned.into_inner())
21523            .insert(pack)
21524            .unwrap();
21525        let mut retained = record("codex", "context-retained-record");
21526        retained.context_id = Some(context.id.clone());
21527        retained.context = Some(context.clone());
21528        let record_id = retained.record_id.clone();
21529        shared.insert_record(retained).unwrap();
21530        assert_eq!(
21531            shared.forget_context_pack(&context.id).unwrap_err().code,
21532            NodeFailureCode::ContextPackBusy,
21533        );
21534        assert!(shared
21535            .context_catalog
21536            .read()
21537            .unwrap_or_else(|poisoned| poisoned.into_inner())
21538            .get(&context.id)
21539            .is_some());
21540        shared
21541            .session_records
21542            .lock()
21543            .unwrap_or_else(|poisoned| poisoned.into_inner())
21544            .records
21545            .remove(&record_id);
21546
21547        let mut spec = spawn_spec_fixture();
21548        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21549            value: context.id.clone(),
21550        };
21551        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21552        let receipt = resolved.receipt_with_materialization(
21553            shared.incarnation_id,
21554            terminal_address(2),
21555            None,
21556            None,
21557            Some(context.clone()),
21558        );
21559        assert!(receipt.context_binding_is_valid());
21560        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21561
21562        shared.forget_context_pack(&context.id).unwrap();
21563        assert!(shared
21564            .context_catalog
21565            .read()
21566            .unwrap_or_else(|poisoned| poisoned.into_inner())
21567            .get(&context.id)
21568            .is_none());
21569        assert!(shared
21570            .replay_spawn_spec(&spec, Instant::now())
21571            .unwrap()
21572            .is_none());
21573    }
21574
21575    #[test]
21576    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21577        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21578        let address = terminal_address(1);
21579        let selection = shared
21580            .select_environment_profile(
21581                address.session.instance_id,
21582                &agent("claude"),
21583                SessionMode::Pty,
21584                Some(&environment_profile),
21585            )
21586            .unwrap()
21587            .unwrap();
21588        shared.bind_session_with_policy(
21589            &address,
21590            ProviderRuntimePolicy::raw_pty(),
21591            Some(environment_profile.clone()),
21592        );
21593        selection.retain();
21594        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21595        assert!(matches!(
21596            runtime.remove_native_launch_profile(&native_id),
21597            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21598        ));
21599        assert!(shared.remove_binding(&address).is_some());
21600        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21601
21602        let mismatched = ResolvedEnvironmentProfileReceipt {
21603            profile_id: environment_profile.profile_id,
21604            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21605                "local-claude-r2",
21606            )
21607            .unwrap(),
21608            network_allowlist: None,
21609            browser_profile_id: None,
21610        };
21611        let error = match shared.select_environment_profile(
21612                AgentInstanceId(42),
21613                &agent("claude"),
21614                SessionMode::Pty,
21615                Some(&mismatched),
21616            ) {
21617            Ok(_) => panic!("mismatched environment profile revision was selected"),
21618            Err(error) => error,
21619        };
21620        assert_eq!(
21621            error.code,
21622            NodeFailureCode::EnvironmentProfileBindingMismatch,
21623        );
21624        assert!(!shared
21625            .session_bindings
21626            .lock()
21627            .unwrap_or_else(|poisoned| poisoned.into_inner())
21628            .contains_key(&AgentInstanceId(42)));
21629    }
21630
21631    #[test]
21632    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21633        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21634        let address = terminal_address(1);
21635        let selection = shared
21636            .select_environment_profile(
21637                address.session.instance_id,
21638                &agent("claude"),
21639                SessionMode::Pty,
21640                Some(&environment_profile),
21641            )
21642            .unwrap()
21643            .unwrap();
21644        let overlay = NativeInstanceLaunchOverlay::new(
21645            agent("claude"),
21646            TransportKind::Pty,
21647            vec![EnvMutation {
21648                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21649                value: Some(OsString::from("fixture-secret")),
21650            }],
21651            vec![
21652                OsString::from("--plugin-dir"),
21653                std::env::current_dir().unwrap().into_os_string(),
21654            ],
21655        )
21656        .unwrap();
21657        let instance_overlay = shared
21658            .install_prepared_launch_overlay(
21659                address.session.instance_id,
21660                PreparedNativeLaunchOverlay::Instance(overlay),
21661            )
21662            .unwrap()
21663            .unwrap();
21664        let bundle = ResolvedBundleReceipt {
21665            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21666            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21667            digest: crate::protocol::SpawnBundleDigest::new(format!(
21668                "sha256:{}",
21669                "0".repeat(64),
21670            ))
21671            .unwrap(),
21672        };
21673        shared.bind_session_with_materialization(
21674            &address,
21675            ProviderRuntimePolicy::raw_pty(),
21676            Some(environment_profile),
21677            Some(bundle),
21678            None,
21679            None,
21680        );
21681        selection.retain();
21682        instance_overlay.retain();
21683
21684        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21685        assert!(matches!(
21686            runtime.remove_native_launch_profile(&native_id),
21687            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21688        ));
21689        assert!(shared.remove_binding(&address).is_some());
21690        assert!(!shared
21691            .native_launch_profile_control
21692            .as_ref()
21693            .unwrap()
21694            .clear_native_instance_launch_overlay(address.session.instance_id));
21695        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21696    }
21697
21698    #[test]
21699    fn session_environment_materialization_failure_is_pre_child_and_durable() {
21700        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21701        let address = SessionAddress {
21702            workspace_id: WorkspaceId::new("primary").unwrap(),
21703            session: SessionKey {
21704                instance_id: AgentInstanceId(201),
21705                generation: SessionGeneration(1),
21706            },
21707        };
21708        let error = match shared.prepare_session_materialization(
21709            &address,
21710            &agent("claude"),
21711            SessionMode::Pty,
21712            Some(&receipt),
21713            None,
21714            None,
21715            None,
21716        ) {
21717            Ok(_) => panic!("denied local reference unexpectedly materialized"),
21718            Err(error) => error,
21719        };
21720        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21721        assert!(shared.handle.snapshot().sessions.is_empty());
21722        assert!(shared
21723            .session_bindings
21724            .lock()
21725            .unwrap_or_else(|poisoned| poisoned.into_inner())
21726            .is_empty());
21727        assert!(shared
21728            .materializations
21729            .lock()
21730            .unwrap_or_else(|poisoned| poisoned.into_inner())
21731            .is_empty());
21732        drop(shared);
21733        drop(runtime);
21734        std::fs::remove_dir_all(root).unwrap();
21735    }
21736
21737    #[test]
21738    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21739        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21740        let address = SessionAddress {
21741            workspace_id: WorkspaceId::new("primary").unwrap(),
21742            session: SessionKey {
21743                instance_id: AgentInstanceId(202),
21744                generation: SessionGeneration(1),
21745            },
21746        };
21747        let (overlay, materialization_guard) = shared
21748            .prepare_session_materialization(
21749                &address,
21750                &agent("claude"),
21751                SessionMode::Pty,
21752                Some(&receipt),
21753                None,
21754                None,
21755                None,
21756            )
21757            .unwrap()
21758            .unwrap();
21759        let materialization_id = materialization_guard.id().unwrap().clone();
21760        let materialization_root = shared
21761            .materializations
21762            .lock()
21763            .unwrap_or_else(|poisoned| poisoned.into_inner())
21764            .get(&materialization_id)
21765            .unwrap()
21766            .root()
21767            .to_path_buf();
21768        let selection = shared
21769            .select_environment_profile(
21770                address.session.instance_id,
21771                &agent("claude"),
21772                SessionMode::Pty,
21773                Some(&receipt),
21774            )
21775            .unwrap()
21776            .unwrap();
21777        assert!(shared
21778            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21779            .unwrap()
21780            .is_none());
21781        shared.bind_session_with_materialization(
21782            &address,
21783            ProviderRuntimePolicy::raw_pty(),
21784            Some(receipt),
21785            None,
21786            None,
21787            Some(materialization_id),
21788        );
21789        selection.retain();
21790        materialization_guard.retain();
21791
21792        assert!(materialization_root.is_dir());
21793        assert!(shared
21794            .session_bindings
21795            .lock()
21796            .unwrap_or_else(|poisoned| poisoned.into_inner())
21797            .contains_key(&address.session.instance_id));
21798
21799        let removed = shared.remove_binding(&address).unwrap();
21800        shared
21801            .cleanup_session_owned_materialization(&address, &removed)
21802            .unwrap();
21803        assert!(!materialization_root.exists());
21804        assert!(shared
21805            .materializations
21806            .lock()
21807            .unwrap_or_else(|poisoned| poisoned.into_inner())
21808            .is_empty());
21809        drop(shared);
21810        drop(runtime);
21811        std::fs::remove_dir_all(root).unwrap();
21812    }
21813
21814    #[tokio::test]
21815    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21816        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21817        let address = SessionAddress {
21818            workspace_id: WorkspaceId::new("primary").unwrap(),
21819            session: SessionKey {
21820                instance_id: AgentInstanceId(203),
21821                generation: SessionGeneration(1),
21822            },
21823        };
21824        let (overlay, materialization_guard) = shared
21825            .prepare_session_materialization(
21826                &address,
21827                &agent("claude"),
21828                SessionMode::Pty,
21829                Some(&receipt),
21830                None,
21831                None,
21832                None,
21833            )
21834            .unwrap()
21835            .unwrap();
21836        let materialization_id = materialization_guard.id().unwrap().clone();
21837        let materialization_root = shared
21838            .materializations
21839            .lock()
21840            .unwrap_or_else(|poisoned| poisoned.into_inner())
21841            .get(&materialization_id)
21842            .unwrap()
21843            .root()
21844            .to_path_buf();
21845        let selection = shared
21846            .select_environment_profile(
21847                address.session.instance_id,
21848                &agent("claude"),
21849                SessionMode::Pty,
21850                Some(&receipt),
21851            )
21852            .unwrap()
21853            .unwrap();
21854        assert!(shared
21855            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21856            .unwrap()
21857            .is_none());
21858        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21859        let record_id = shared
21860            .bind_spawn_session_with_materialization(
21861                &address,
21862                agent("claude"),
21863                SessionMode::Pty,
21864                policy,
21865                SpawnRecordPolicy::ProviderIdentityOnly,
21866                Some(receipt.clone()),
21867                None,
21868                None,
21869                Some(materialization_id.clone()),
21870            )
21871            .unwrap()
21872            .unwrap();
21873        selection.retain();
21874        materialization_guard.retain();
21875        assert_eq!(
21876            shared
21877                .materializations
21878                .lock()
21879                .unwrap_or_else(|poisoned| poisoned.into_inner())
21880                .get(&materialization_id)
21881                .unwrap()
21882                .owner(),
21883            &MaterializationOwner::Record {
21884                record_id: record_id.clone(),
21885            },
21886        );
21887
21888        let removed = shared.remove_binding(&address).unwrap();
21889        shared
21890            .cleanup_session_owned_materialization(&address, &removed)
21891            .unwrap();
21892        assert!(materialization_root.is_dir());
21893        assert!(shared
21894            .resolve_record_materialization(
21895                &record_id,
21896                &agent("claude"),
21897                SessionMode::Pty,
21898                Some(&receipt),
21899                None,
21900                None,
21901            )
21902            .unwrap()
21903            .is_some());
21904        {
21905            let mut records = shared
21906                .session_records
21907                .lock()
21908                .unwrap_or_else(|poisoned| poisoned.into_inner());
21909            let record = records.records.get_mut(&record_id).unwrap();
21910            record.active_session = None;
21911            record.state = ManagedSessionState::Unavailable;
21912        }
21913        shared.forget_session_record(&record_id).await.unwrap();
21914        assert!(!materialization_root.exists());
21915        assert!(shared
21916            .materializations
21917            .lock()
21918            .unwrap_or_else(|poisoned| poisoned.into_inner())
21919            .is_empty());
21920        drop(shared);
21921        drop(runtime);
21922        std::fs::remove_dir_all(root).unwrap();
21923    }
21924
21925    #[test]
21926    fn record_materialization_revalidation_failure_persists_recovery_required() {
21927        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21928        let state_path = root.join("node-state-v6.json");
21929        shared.state_path = Some(state_path.clone());
21930        let (_address, _record_id, materialization_id, materialization_root) =
21931            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21932        std::fs::write(
21933            materialization_root.join(".gate4agent-materialization-owner"),
21934            b"tampered",
21935        )
21936        .unwrap();
21937
21938        let error = match shared.resolve_record_materialization(
21939            &_record_id,
21940            &agent("claude"),
21941            SessionMode::Pty,
21942            Some(&receipt),
21943            None,
21944            None,
21945        ) {
21946            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21947            Err(error) => error,
21948        };
21949        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21950        assert_eq!(
21951            shared
21952                .materializations
21953                .lock()
21954                .unwrap_or_else(|poisoned| poisoned.into_inner())
21955                .get(&materialization_id)
21956                .unwrap()
21957                .state(),
21958            MaterializationState::RecoveryRequired,
21959        );
21960        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21961        assert_eq!(loaded.materializations.len(), 1);
21962        assert_eq!(
21963            loaded.materializations[0].state(),
21964            MaterializationState::RecoveryRequired,
21965        );
21966        drop(shared);
21967        drop(runtime);
21968        std::fs::remove_dir_all(root).unwrap();
21969    }
21970
21971    #[test]
21972    fn transient_record_materialization_resolution_failure_remains_ready() {
21973        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21974        let (_address, record_id, materialization_id, _materialization_root) =
21975            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21976        deny.store(true, Ordering::Release);
21977
21978        let error = match shared.resolve_record_materialization(
21979            &record_id,
21980            &agent("claude"),
21981            SessionMode::Pty,
21982            Some(&receipt),
21983            None,
21984            None,
21985        ) {
21986            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21987            Err(error) => error,
21988        };
21989        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21990        assert_eq!(
21991            shared
21992                .materializations
21993                .lock()
21994                .unwrap_or_else(|poisoned| poisoned.into_inner())
21995                .get(&materialization_id)
21996                .unwrap()
21997                .state(),
21998            MaterializationState::Ready,
21999        );
22000        drop(shared);
22001        drop(runtime);
22002        std::fs::remove_dir_all(root).unwrap();
22003    }
22004
22005    #[test]
22006    fn startup_missing_record_materialization_is_not_resumable() {
22007        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22008        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22009        shared
22010            .insert_record(ManagedSessionRecord {
22011                record_id: record_id.clone(),
22012                display_name: "missing materialization".to_owned(),
22013                provider: agent("claude"),
22014                mode: SessionMode::Pty,
22015                state: ManagedSessionState::Dormant,
22016                workspace_id: WorkspaceId::new("primary").unwrap(),
22017                canonical_root: opaque_windows_path(
22018                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22019                ),
22020                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22021                    key: gate4agent_types::ProviderSessionKey::SessionId,
22022                    id: "provider-session-missing-materialization".to_owned(),
22023                    transcript_path: None,
22024                }),
22025                active_session: None,
22026                environment_profile: Some(receipt),
22027                bundle: None,
22028                context_id: None,
22029                context: None,
22030                exported_context: None,
22031                task_binding: None,
22032                created_at_unix_ms: 1,
22033                updated_at_unix_ms: 1,
22034                last_error: None,
22035            })
22036            .unwrap();
22037
22038        shared.reconcile_materializations();
22039        let record = shared.record(&record_id).unwrap();
22040        assert_eq!(record.state, ManagedSessionState::Unavailable);
22041        assert!(record.provider_session.is_none());
22042        assert_eq!(
22043            record.last_error.as_deref(),
22044            Some("environment-profile-unavailable"),
22045        );
22046        drop(shared);
22047        drop(runtime);
22048        std::fs::remove_dir_all(root).unwrap();
22049    }
22050
22051    #[tokio::test]
22052    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22053        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22054        let lease = install_managed_lease(&shared);
22055        let address = SessionAddress {
22056            workspace_id: WorkspaceId::new("primary").unwrap(),
22057            session: SessionKey {
22058                instance_id: AgentInstanceId(204),
22059                generation: SessionGeneration(1),
22060            },
22061        };
22062        let (overlay, materialization_guard) = shared
22063            .prepare_session_materialization(
22064                &address,
22065                &agent("claude"),
22066                SessionMode::Pty,
22067                Some(&receipt),
22068                None,
22069                None,
22070                Some(lease.lease_id.clone()),
22071            )
22072            .unwrap()
22073            .unwrap();
22074        let materialization_id = materialization_guard.id().unwrap().clone();
22075        let materialization_root = shared
22076            .materializations
22077            .lock()
22078            .unwrap_or_else(|poisoned| poisoned.into_inner())
22079            .get(&materialization_id)
22080            .unwrap()
22081            .root()
22082            .to_path_buf();
22083        let selection = shared
22084            .select_environment_profile(
22085                address.session.instance_id,
22086                &agent("claude"),
22087                SessionMode::Pty,
22088                Some(&receipt),
22089            )
22090            .unwrap()
22091            .unwrap();
22092        assert!(shared
22093            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22094            .unwrap()
22095            .is_none());
22096        shared.bind_session_with_materialization(
22097            &address,
22098            ProviderRuntimePolicy::raw_pty(),
22099            Some(receipt),
22100            None,
22101            None,
22102            Some(materialization_id),
22103        );
22104        selection.retain();
22105        materialization_guard.retain();
22106
22107        let blocked = shared
22108            .cleanup_managed_worktree(&lease.lease_id, false)
22109            .await
22110            .unwrap_err();
22111        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22112        assert!(materialization_root.is_dir());
22113        let removed = shared.remove_binding(&address).unwrap();
22114        shared
22115            .cleanup_session_owned_materialization(&address, &removed)
22116            .unwrap();
22117        assert!(!materialization_root.exists());
22118        drop(shared);
22119        drop(runtime);
22120        std::fs::remove_dir_all(root).unwrap();
22121    }
22122
22123    #[tokio::test]
22124    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22125        let git_root = temporary_workspace_root("raw-managed-inventory");
22126        let source = git_root.join("source");
22127        let allocation = git_root.join("allocation");
22128        std::fs::create_dir_all(&source).unwrap();
22129        std::fs::create_dir_all(&allocation).unwrap();
22130        run_test_git(&source, &["init"]);
22131        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22132        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22133        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22134        run_test_git(&source, &["add", "seed.txt"]);
22135        run_test_git(&source, &["commit", "-m", "seed"]);
22136        let profile = ManagedWorktreeProfile::new(
22137            WorktreeProfileId::new("default").unwrap(),
22138            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22139            &allocation,
22140            "gate4agent",
22141            "HEAD",
22142            ManagedWorktreeRetention::RemoveWhenReleased,
22143        )
22144        .unwrap();
22145        let workspace = WorkspaceConfig::new(
22146            WorkspaceId::new("primary").unwrap(),
22147            &source,
22148        )
22149        .unwrap()
22150        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22151        .with_managed_worktree_profile(profile.clone())
22152        .unwrap();
22153        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22154            materialization_test_shared_with_workspace(false, workspace);
22155        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22156        let source_text = source.to_string_lossy().into_owned();
22157        let target_text = target.to_string_lossy().into_owned();
22158        let created = create_git_worktree(
22159            &source_text,
22160            &target_text,
22161            "gate4agent/mw-raw-inventory",
22162            Some("HEAD"),
22163        )
22164        .await
22165        .unwrap();
22166        let lease = ManagedWorktreeLeaseRecord {
22167            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22168            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22169            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22170            profile_id: profile.profile_id().clone(),
22171            profile_revision: profile.revision().clone(),
22172            target_root: created.path.clone(),
22173            branch: "gate4agent/mw-raw-inventory".to_owned(),
22174            base_commit: created.head.clone(),
22175            expected_head: Some(created.head.clone()),
22176            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22177            state: ManagedWorktreeLeaseState::Ready,
22178            session_holders: Vec::new(),
22179            record_holders: Vec::new(),
22180            cleanup_failure: None,
22181            created_at_unix_ms: 1,
22182            updated_at_unix_ms: 1,
22183        };
22184        shared
22185            .workspaces
22186            .write()
22187            .unwrap_or_else(|poisoned| poisoned.into_inner())
22188            .insert(lease.workspace_id.clone(), created.path.clone());
22189        *shared
22190            .managed_worktrees
22191            .lock()
22192            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22193            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22194        let address = SessionAddress {
22195            workspace_id: lease.workspace_id.clone(),
22196            session: SessionKey {
22197                instance_id: AgentInstanceId(205),
22198                generation: SessionGeneration(1),
22199            },
22200        };
22201        let (overlay, materialization_guard) = shared
22202            .prepare_session_materialization(
22203                &address,
22204                &agent("claude"),
22205                SessionMode::Pty,
22206                Some(&receipt),
22207                None,
22208                None,
22209                Some(lease.lease_id.clone()),
22210            )
22211            .unwrap()
22212            .unwrap();
22213        let materialization_id = materialization_guard.id().unwrap().clone();
22214        let materialization_root = shared
22215            .materializations
22216            .lock()
22217            .unwrap_or_else(|poisoned| poisoned.into_inner())
22218            .get(&materialization_id)
22219            .unwrap()
22220            .root()
22221            .to_path_buf();
22222        let selection = shared
22223            .select_environment_profile(
22224                address.session.instance_id,
22225                &agent("claude"),
22226                SessionMode::Pty,
22227                Some(&receipt),
22228            )
22229            .unwrap()
22230            .unwrap();
22231        assert!(shared
22232            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22233            .unwrap()
22234            .is_none());
22235        let record_id = shared
22236            .bind_spawn_session_with_materialization(
22237                &address,
22238                agent("claude"),
22239                SessionMode::Pty,
22240                ProviderRuntimePolicy::raw_pty(),
22241                SpawnRecordPolicy::Always,
22242                Some(receipt.clone()),
22243                None,
22244                None,
22245                Some(materialization_id.clone()),
22246            )
22247            .unwrap()
22248            .unwrap();
22249        selection.retain();
22250        materialization_guard.retain();
22251        let record = shared.record(&record_id).unwrap();
22252        assert_eq!(record.state, ManagedSessionState::Live);
22253        assert!(record.provider_session.is_none());
22254        assert!(record.environment_profile.is_none());
22255        assert!(record.bundle.is_none());
22256        assert!(record.context_id.is_none());
22257        assert!(record.context.is_none());
22258        assert_eq!(record.active_session.as_ref(), Some(&address));
22259        assert_eq!(
22260            shared
22261                .materializations
22262                .lock()
22263                .unwrap_or_else(|poisoned| poisoned.into_inner())
22264                .get(&materialization_id)
22265                .unwrap()
22266                .owner(),
22267            &MaterializationOwner::Session {
22268                incarnation_id: shared.incarnation_id,
22269                instance_id: address.session.instance_id,
22270                generation: address.session.generation,
22271            },
22272        );
22273        {
22274            let mut bindings = shared
22275                .session_bindings
22276                .lock()
22277                .unwrap_or_else(|poisoned| poisoned.into_inner());
22278            bindings
22279                .get_mut(&address.session.instance_id)
22280                .unwrap()
22281                .managed_worktree_lease_id = Some(lease.lease_id.clone());
22282        }
22283        let lease_snapshot = shared
22284            .managed_worktrees
22285            .lock()
22286            .unwrap_or_else(|poisoned| poisoned.into_inner())
22287            .bind_session(
22288                &lease.lease_id,
22289                ManagedWorktreeSessionHolder {
22290                    incarnation_id: shared.incarnation_id,
22291                    instance_id: address.session.instance_id,
22292                    generation: address.session.generation,
22293                },
22294                None,
22295                unix_time_ms(),
22296            )
22297            .unwrap();
22298        assert_eq!(lease_snapshot.active_session_count, 1);
22299        assert_eq!(lease_snapshot.managed_record_count, 0);
22300        shared.persist_state().unwrap();
22301        assert!(shared
22302            .managed_worktrees
22303            .lock()
22304            .unwrap_or_else(|poisoned| poisoned.into_inner())
22305            .get(&lease.lease_id)
22306            .unwrap()
22307            .record_holders
22308            .is_empty());
22309        shared
22310            .handle
22311            .dispatch(shared.prepare_command(ControlCommand::Register {
22312                instance_id: address.session.instance_id,
22313                agent_id: agent("claude"),
22314                transport: TransportKind::Pty,
22315            }))
22316            .unwrap();
22317        runtime.tick().await;
22318        shared.reconcile_managed_record(
22319            &record_id,
22320            &address,
22321            &ControlEvent {
22322                sequence: 1,
22323                command_id: None,
22324                instance_id: address.session.instance_id,
22325                generation: address.session.generation,
22326                event: ControlEventKind::Removed,
22327            },
22328            false,
22329            false,
22330        );
22331        let remove = shared.remove_session(&address);
22332        let drive_runtime = async {
22333            runtime.tick().await;
22334        };
22335        let (removed, ()) = tokio::join!(remove, drive_runtime);
22336        removed.unwrap();
22337
22338        assert!(!materialization_root.exists());
22339        assert!(!Path::new(&created.path).exists());
22340        assert_eq!(
22341            shared
22342                .managed_worktrees
22343                .lock()
22344                .unwrap_or_else(|poisoned| poisoned.into_inner())
22345                .get(&lease.lease_id)
22346                .unwrap()
22347                .state,
22348            ManagedWorktreeLeaseState::Removed,
22349        );
22350        let renamed = shared
22351            .rename_session_record(&record_id, "raw managed inventory".to_owned())
22352            .unwrap();
22353        assert_eq!(renamed.record_id, record_id);
22354        assert_eq!(renamed.display_name, "raw managed inventory");
22355        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22356        assert!(renamed.active_session.is_none());
22357
22358        drop(shared);
22359        drop(runtime);
22360        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22361        std::fs::remove_dir_all(git_root).unwrap();
22362    }
22363
22364    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22365        let root = std::env::current_dir().unwrap()
22366            .join("managed-test-target")
22367            .to_string_lossy()
22368            .into_owned();
22369        let lease = ManagedWorktreeLeaseRecord {
22370            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22371            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22372            workspace_id: WorkspaceId::new("managed-test").unwrap(),
22373            profile_id: WorktreeProfileId::new("default").unwrap(),
22374            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22375            target_root: root.clone(),
22376            branch: "gate4agent/mw-test".to_owned(),
22377            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22378            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22379            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22380            state: ManagedWorktreeLeaseState::Ready,
22381            session_holders: Vec::new(),
22382            record_holders: Vec::new(),
22383            cleanup_failure: None,
22384            created_at_unix_ms: 1,
22385            updated_at_unix_ms: 1,
22386        };
22387        shared.workspaces.write()
22388            .unwrap_or_else(|poisoned| poisoned.into_inner())
22389            .insert(lease.workspace_id.clone(), root);
22390        *shared.managed_worktrees.lock()
22391            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22392            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22393        lease
22394    }
22395
22396    #[cfg(feature = "fixture")]
22397    #[test]
22398    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22399        let mut shared = terminal_test_shared();
22400        shared.fixture_semantic_hook_policy = true;
22401        let lease = install_managed_lease(&shared);
22402        let address = SessionAddress {
22403            workspace_id: lease.workspace_id.clone(),
22404            session: SessionKey {
22405                instance_id: AgentInstanceId(206),
22406                generation: SessionGeneration(1),
22407            },
22408        };
22409        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22410        let effective_runtime_policy =
22411            shared.effective_spawn_runtime_policy(pre_admission_policy);
22412        assert_ne!(effective_runtime_policy, pre_admission_policy);
22413        assert!(effective_runtime_policy.provider_session_identity);
22414
22415        let record_id = shared
22416            .bind_spawn_session(
22417                &address,
22418                agent("claude"),
22419                SessionMode::Pty,
22420                effective_runtime_policy,
22421                SpawnRecordPolicy::Always,
22422                None,
22423            )
22424            .unwrap()
22425            .unwrap();
22426        assert_eq!(
22427            shared.record(&record_id).unwrap().state,
22428            ManagedSessionState::IdentityPending,
22429        );
22430        let (record_holder, raw_inventory_record_id) = {
22431            let mut bindings = shared
22432                .session_bindings
22433                .lock()
22434                .unwrap_or_else(|poisoned| poisoned.into_inner());
22435            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22436            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22437            managed_spawn_record_ownership(binding, effective_runtime_policy)
22438        };
22439        assert_eq!(record_holder.as_ref(), Some(&record_id));
22440        assert!(raw_inventory_record_id.is_none());
22441        let snapshot = shared
22442            .managed_worktrees
22443            .lock()
22444            .unwrap_or_else(|poisoned| poisoned.into_inner())
22445            .bind_session(
22446                &lease.lease_id,
22447                ManagedWorktreeSessionHolder {
22448                    incarnation_id: shared.incarnation_id,
22449                    instance_id: address.session.instance_id,
22450                    generation: address.session.generation,
22451                },
22452                record_holder,
22453                unix_time_ms(),
22454            )
22455            .unwrap();
22456        assert_eq!(snapshot.managed_record_count, 1);
22457        assert_eq!(
22458            shared
22459                .managed_worktrees
22460                .lock()
22461                .unwrap_or_else(|poisoned| poisoned.into_inner())
22462                .get(&lease.lease_id)
22463                .unwrap()
22464                .record_holders,
22465            vec![record_id.clone()],
22466        );
22467
22468        let identity = ProviderSessionIdentity {
22469            key: ProviderSessionKey::SessionId,
22470            id: "monitoring-fixture-managed-session".to_owned(),
22471            transcript_path: None,
22472        };
22473        shared.publish_control(ControlEvent {
22474            sequence: 1,
22475            command_id: None,
22476            instance_id: address.session.instance_id,
22477            generation: address.session.generation,
22478            event: ControlEventKind::ProviderEvent {
22479                sequence: 1,
22480                source: gate4agent_types::ProviderSource {
22481                    family: AdapterFamily::Hook,
22482                    binding: AdapterBinding::new(
22483                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
22484                        "fixture/v1",
22485                        gate4agent_types::AdapterVerification::SyntheticFixture,
22486                    )
22487                    .unwrap(),
22488                },
22489                source_sequence: 1,
22490                event: ProviderEvent::SessionIdentityObserved {
22491                    identity: identity.clone(),
22492                },
22493            },
22494        });
22495
22496        let live = shared.record(&record_id).unwrap();
22497        assert_eq!(live.state, ManagedSessionState::Live);
22498        assert_eq!(live.provider_session.as_ref(), Some(&identity));
22499        assert_eq!(live.active_session.as_ref(), Some(&address));
22500        assert_eq!(
22501            shared
22502                .managed_worktrees
22503                .lock()
22504                .unwrap_or_else(|poisoned| poisoned.into_inner())
22505                .get(&lease.lease_id)
22506                .unwrap()
22507                .record_holders,
22508            vec![record_id],
22509        );
22510    }
22511
22512    fn managed_v2_test_shared(
22513        allocation_root: &Path,
22514        state_path: Option<PathBuf>,
22515        profile_revision: &str,
22516    ) -> NodeShared {
22517        let profile = ManagedWorktreeProfile::new(
22518            WorktreeProfileId::new("default").unwrap(),
22519            WorktreeProfileRevision::new(profile_revision).unwrap(),
22520            allocation_root,
22521            "gate4agent",
22522            "HEAD",
22523            ManagedWorktreeRetention::RemoveWhenReleased,
22524        )
22525        .unwrap();
22526        let workspace = WorkspaceConfig::new(
22527            WorkspaceId::new("primary").unwrap(),
22528            std::env::current_dir().unwrap(),
22529        )
22530        .unwrap()
22531        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22532        .with_managed_worktree_profile(profile)
22533        .unwrap();
22534        let catalog = active_registry().unwrap();
22535        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22536        let mut shared = NodeShared::new(
22537            handle,
22538            "fixture-token".to_owned(),
22539            NodeId::new("node-terminal-test").unwrap(),
22540            vec![workspace],
22541            vec![agent("claude")],
22542        );
22543        shared.state_path = state_path;
22544        shared
22545    }
22546
22547    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22548        let mut spawn_spec = spawn_spec_fixture();
22549        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22550        ManagedWorktreeSpawnRequestV2 {
22551            spawn_spec,
22552            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22553            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22554        }
22555    }
22556
22557    #[test]
22558    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22559        let shared = terminal_test_shared();
22560        let mut lease = install_managed_lease(&shared);
22561        let session = SessionAddress {
22562            workspace_id: lease.workspace_id.clone(),
22563            session: SessionKey {
22564                instance_id: AgentInstanceId(73),
22565                generation: SessionGeneration(4),
22566            },
22567        };
22568        lease.state = ManagedWorktreeLeaseState::InUse;
22569        lease.session_holders.push(ManagedWorktreeSessionHolder {
22570            incarnation_id: shared.incarnation_id,
22571            instance_id: session.session.instance_id,
22572            generation: session.session.generation,
22573        });
22574        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22575        assert!(resolved.target.worktree_id.is_none());
22576
22577        let receipt = managed_spawn_receipt(
22578            &resolved,
22579            shared.incarnation_id,
22580            session.clone(),
22581            lease.snapshot(),
22582            None,
22583            None,
22584            None,
22585        );
22586        assert!(resolved.target.worktree_id.is_none());
22587        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22588        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22589        assert_eq!(receipt.spawn.session, session);
22590        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22591        let encoded = serde_json::to_vec(&receipt).unwrap();
22592        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22593        assert_eq!(decoded, receipt);
22594    }
22595
22596    #[tokio::test]
22597    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22598        let root = temporary_workspace_root("managed-v2-revision");
22599        let allocation = root.join("allocation");
22600        std::fs::create_dir_all(&allocation).unwrap();
22601        let shared = managed_v2_test_shared(
22602            &allocation,
22603            Some(root.join("node-state.json")),
22604            "v1",
22605        );
22606        let mut request = managed_v2_request("managed-v2-revision");
22607        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22608
22609        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22610
22611        assert_eq!(
22612            failure.code,
22613            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22614        );
22615        assert!(shared
22616            .managed_worktrees
22617            .lock()
22618            .unwrap_or_else(|poisoned| poisoned.into_inner())
22619            .records()
22620            .is_empty());
22621        assert!(shared
22622            .managed_spawn_replays
22623            .lock()
22624            .unwrap_or_else(|poisoned| poisoned.into_inner())
22625            .is_empty());
22626        std::fs::remove_dir_all(root).unwrap();
22627    }
22628
22629    #[test]
22630    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22631        let root = temporary_workspace_root("managed-v2-concurrent");
22632        let allocation = root.join("allocation");
22633        std::fs::create_dir_all(&allocation).unwrap();
22634        let shared = Arc::new(managed_v2_test_shared(
22635            &allocation,
22636            Some(root.join("node-state.json")),
22637            "v1",
22638        ));
22639        let request = managed_v2_request("managed-v2-concurrent");
22640        let barrier = Arc::new(std::sync::Barrier::new(3));
22641        let mut joins = Vec::new();
22642        for _ in 0..2 {
22643            let shared = Arc::clone(&shared);
22644            let request = request.clone();
22645            let barrier = Arc::clone(&barrier);
22646            joins.push(std::thread::spawn(move || {
22647                let digest = managed_spawn_request_digest_v2(&request).unwrap();
22648                barrier.wait();
22649                shared.acquire_durable_managed_spawn_v2(&request, digest)
22650            }));
22651        }
22652        barrier.wait();
22653        let results = joins
22654            .into_iter()
22655            .map(|join| join.join().unwrap())
22656            .collect::<Vec<_>>();
22657        assert_eq!(
22658            results
22659                .iter()
22660                .filter(|result| {
22661                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22662                })
22663                .count(),
22664            1,
22665        );
22666        assert_eq!(
22667            results
22668                .iter()
22669                .filter(|result| {
22670                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22671                })
22672                .count(),
22673            1,
22674        );
22675        assert_eq!(
22676            shared
22677                .managed_spawn_replays
22678                .lock()
22679                .unwrap_or_else(|poisoned| poisoned.into_inner())
22680                .len(),
22681            1,
22682        );
22683        assert!(shared
22684            .managed_worktrees
22685            .lock()
22686            .unwrap_or_else(|poisoned| poisoned.into_inner())
22687            .records()
22688            .is_empty());
22689        drop(shared);
22690        std::fs::remove_dir_all(root).unwrap();
22691    }
22692
22693    #[tokio::test]
22694    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22695        let root = temporary_workspace_root("managed-v2-linked-pending");
22696        let allocation = root.join("allocation");
22697        std::fs::create_dir_all(&allocation).unwrap();
22698        let state_path = root.join("node-state.json");
22699        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22700        let lease = install_managed_lease(&shared);
22701        let request = managed_v2_request("managed-v2-linked-pending");
22702        assert!(matches!(
22703            shared
22704                .acquire_durable_managed_spawn_v2(
22705                    &request,
22706                    managed_spawn_request_digest_v2(&request).unwrap(),
22707                )
22708                .unwrap(),
22709            DurableManagedSpawnReplayDecision::Reserved,
22710        ));
22711        {
22712            let _transaction = shared
22713                .state_transaction
22714                .lock()
22715                .unwrap_or_else(|poisoned| poisoned.into_inner());
22716            shared
22717                .link_durable_managed_spawn_v2_lease_locked(
22718                    &request.spawn_spec.idempotency_key,
22719                    &lease.lease_id,
22720                )
22721                .unwrap();
22722            shared.persist_state_locked().unwrap();
22723        }
22724        drop(shared);
22725
22726        let mut loaded = session_registry::load(
22727            Some(&state_path),
22728            &NodeId::new("node-terminal-test").unwrap(),
22729        )
22730        .unwrap();
22731        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22732        *reopened
22733            .managed_worktrees
22734            .lock()
22735            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22736            ManagedWorktreeRegistry::from_records(
22737                std::mem::take(&mut loaded.managed_worktrees),
22738                std::mem::take(&mut loaded.managed_worktree_tombstones),
22739            )
22740            .unwrap();
22741        *reopened
22742            .managed_spawn_replays
22743            .lock()
22744            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22745            .managed_spawn_replays
22746            .into_iter()
22747            .map(|record| (record.idempotency_key.clone(), record))
22748            .collect();
22749        let lease_count = reopened
22750            .managed_worktrees
22751            .lock()
22752            .unwrap_or_else(|poisoned| poisoned.into_inner())
22753            .records()
22754            .len();
22755
22756        let failure = reopened
22757            .spawn_managed_worktree_v2(request)
22758            .await
22759            .unwrap_err();
22760
22761        assert_eq!(
22762            failure.code,
22763            NodeFailureCode::ManagedWorktreeRecoveryRequired,
22764        );
22765        assert_eq!(
22766            reopened
22767                .managed_worktrees
22768                .lock()
22769                .unwrap_or_else(|poisoned| poisoned.into_inner())
22770                .records()
22771                .len(),
22772            lease_count,
22773        );
22774        std::fs::remove_dir_all(root).unwrap();
22775    }
22776
22777    #[tokio::test]
22778    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22779        let root = temporary_workspace_root("managed-v2-reopen");
22780        let allocation = root.join("allocation");
22781        std::fs::create_dir_all(&allocation).unwrap();
22782        let state_path = root.join("node-state.json");
22783        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22784        let lease = install_managed_lease(&shared);
22785        let request = managed_v2_request("managed-v2-reopen");
22786        let session = SessionAddress {
22787            workspace_id: lease.workspace_id.clone(),
22788            session: SessionKey {
22789                instance_id: AgentInstanceId(91),
22790                generation: SessionGeneration(1),
22791            },
22792        };
22793        let snapshot = {
22794            let mut registry = shared
22795                .managed_worktrees
22796                .lock()
22797                .unwrap_or_else(|poisoned| poisoned.into_inner());
22798            let current = registry.get_mut(&lease.lease_id).unwrap();
22799            current.state = ManagedWorktreeLeaseState::InUse;
22800            current.session_holders.push(ManagedWorktreeSessionHolder {
22801                incarnation_id: shared.incarnation_id,
22802                instance_id: session.session.instance_id,
22803                generation: session.session.generation,
22804            });
22805            current.snapshot()
22806        };
22807        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22808        let receipt = managed_spawn_receipt(
22809            &resolved,
22810            shared.incarnation_id,
22811            session,
22812            snapshot,
22813            None,
22814            None,
22815            None,
22816        );
22817        assert!(matches!(
22818            shared
22819                .acquire_durable_managed_spawn_v2(
22820                    &request,
22821                    managed_spawn_request_digest_v2(&request).unwrap(),
22822                )
22823                .unwrap(),
22824            DurableManagedSpawnReplayDecision::Reserved,
22825        ));
22826        {
22827            let _transaction = shared
22828                .state_transaction
22829                .lock()
22830                .unwrap_or_else(|poisoned| poisoned.into_inner());
22831            shared
22832                .link_durable_managed_spawn_v2_lease_locked(
22833                    &request.spawn_spec.idempotency_key,
22834                    &lease.lease_id,
22835                )
22836                .unwrap();
22837            shared.persist_state_locked().unwrap();
22838        }
22839        shared
22840            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22841            .unwrap();
22842        let legacy_same_key = ManagedWorktreeSpawnRequest {
22843            spawn_spec: request.spawn_spec.clone(),
22844            worktree_profile_id: request.worktree_profile_id.clone(),
22845        };
22846        shared.remember_managed_spawn_attempt(
22847            Some(&request.spawn_spec.idempotency_key),
22848            legacy_same_key.clone(),
22849            Ok(receipt.clone()),
22850            Instant::now(),
22851        );
22852        assert!(shared
22853            .replay_managed_spawn(&legacy_same_key, Instant::now())
22854            .unwrap()
22855            .is_none());
22856        drop(shared);
22857
22858        let mut loaded = session_registry::load(
22859            Some(&state_path),
22860            &NodeId::new("node-terminal-test").unwrap(),
22861        )
22862        .unwrap();
22863        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22864        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22865        *reopened
22866            .managed_worktrees
22867            .lock()
22868            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22869            ManagedWorktreeRegistry::from_records(
22870                std::mem::take(&mut loaded.managed_worktrees),
22871                std::mem::take(&mut loaded.managed_worktree_tombstones),
22872            )
22873            .unwrap();
22874        *reopened
22875            .managed_spawn_replays
22876            .lock()
22877            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22878            .managed_spawn_replays
22879            .into_iter()
22880            .map(|record| (record.idempotency_key.clone(), record))
22881            .collect();
22882        let lease_count_before = reopened
22883            .managed_worktrees
22884            .lock()
22885            .unwrap_or_else(|poisoned| poisoned.into_inner())
22886            .records()
22887            .len();
22888
22889        assert_eq!(
22890            reopened
22891                .spawn_managed_worktree_v2(request.clone())
22892                .await
22893                .unwrap(),
22894            receipt,
22895        );
22896        assert_eq!(
22897            reopened
22898                .managed_worktrees
22899                .lock()
22900                .unwrap_or_else(|poisoned| poisoned.into_inner())
22901                .records()
22902                .len(),
22903            lease_count_before,
22904        );
22905        let mut changed = request;
22906        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22907        let failure = reopened
22908            .spawn_managed_worktree_v2(changed)
22909            .await
22910            .unwrap_err();
22911        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22912        assert_eq!(
22913            reopened
22914                .managed_worktrees
22915                .lock()
22916                .unwrap_or_else(|poisoned| poisoned.into_inner())
22917                .records()
22918                .len(),
22919            lease_count_before,
22920        );
22921        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22922        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22923        let failure = reopened
22924            .spawn_managed_worktree_v2(new_key)
22925            .await
22926            .unwrap_err();
22927        assert_eq!(
22928            failure.code,
22929            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22930        );
22931        assert_eq!(
22932            reopened
22933                .managed_worktrees
22934                .lock()
22935                .unwrap_or_else(|poisoned| poisoned.into_inner())
22936                .records()
22937                .len(),
22938            lease_count_before,
22939        );
22940        assert_eq!(
22941            reopened
22942                .managed_spawn_replays
22943                .lock()
22944                .unwrap_or_else(|poisoned| poisoned.into_inner())
22945                .len(),
22946            1,
22947        );
22948        std::fs::remove_dir_all(root).unwrap();
22949    }
22950
22951    #[test]
22952    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22953        let shared = terminal_test_shared();
22954        let request = ManagedWorktreeSpawnRequest {
22955            spawn_spec: spawn_spec_fixture(),
22956            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22957        };
22958        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22959        shared.remember_managed_spawn(
22960            request.clone(),
22961            Err(expected.clone()),
22962            Instant::now(),
22963        );
22964
22965        assert_eq!(
22966            shared
22967                .replay_managed_spawn(&request, Instant::now())
22968                .unwrap()
22969                .unwrap(),
22970            Err(expected),
22971        );
22972        assert!(shared
22973            .managed_spawn_replays
22974            .lock()
22975            .unwrap_or_else(|poisoned| poisoned.into_inner())
22976            .is_empty());
22977    }
22978
22979    #[tokio::test]
22980    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22981        let shared = terminal_test_shared();
22982        let lease = install_managed_lease(&shared);
22983        let raw = shared.spawn_session(
22984            lease.workspace_id.clone(),
22985            agent("claude"),
22986            SessionMode::Pty,
22987            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22988            None,
22989        ).await.unwrap_err();
22990        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22991        let mut spec = spawn_spec_fixture();
22992        spec.target.workspace_id = lease.workspace_id.clone();
22993        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22994        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22995        let mut selected = spawn_spec_fixture();
22996        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22997        selected.target.worktree_id = Some(lease.workspace_id);
22998        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22999        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
23000        assert!(shared.handle.snapshot().sessions.is_empty());
23001    }
23002
23003    #[test]
23004    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
23005        let shared = terminal_test_shared();
23006        let lease = install_managed_lease(&shared);
23007        let snapshot = snapshot_for_wire(
23008            &shared, true, true, true, false, true, true, false, false, true,
23009        );
23010        assert!(snapshot.managed_worktrees.is_empty());
23011        assert!(snapshot.workspaces.iter().all(|workspace| {
23012            workspace.managed_worktree_profiles.is_none()
23013        }));
23014        let mut reply = ResponseEnvelope {
23015            request_id: 1,
23016            result: Ok(NodeResponse::Resync {
23017                event_sequence: 1,
23018                oldest_available_sequence: 1,
23019                snapshot: shared.snapshot(),
23020                events: vec![NodeEventEnvelope {
23021                    sequence: 1,
23022                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23023                }],
23024            }),
23025        };
23026        project_response_without_managed_worktrees(&mut reply);
23027        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23028            panic!("projection changed the response kind");
23029        };
23030        assert!(snapshot.managed_worktrees.is_empty());
23031        assert!(events.is_empty());
23032        let mut git = git_snapshot_for_parser();
23033        git.branch = Some(lease.branch.clone());
23034        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23035            lease_id: lease.lease_id.clone(),
23036            source_workspace_id: lease.source_workspace_id.clone(),
23037            branch: lease.branch.clone(),
23038            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23039            active_session_count: 1,
23040            managed_record_count: 0,
23041        });
23042        let mut inspection_reply = ResponseEnvelope {
23043            request_id: 2,
23044            result: Ok(NodeResponse::WorkspaceInspected {
23045                inspection: WorkspaceInspection {
23046                    workspace_id: lease.workspace_id.clone(),
23047                    entries: Vec::new(),
23048                    tree_truncated: false,
23049                    git,
23050                    truncation: None,
23051                },
23052            }),
23053        };
23054        project_response_without_managed_worktrees(&mut inspection_reply);
23055        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23056            panic!("projection changed the inspection response kind");
23057        };
23058        assert!(inspection.git.managed_worktree.is_none());
23059        let failure = managed_git_worktree_failure(GitWorktreeError {
23060            kind: GitWorktreeErrorKind::Failed,
23061            message: r"git failed at C:\private\managed-target".to_owned(),
23062        });
23063        assert!(!failure.message.contains("private"));
23064        assert!(!failure.message.contains("managed-target"));
23065    }
23066
23067    #[tokio::test]
23068    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23069        let root = temporary_workspace_root("managed-restart");
23070        let source = root.join("source");
23071        let allocation = root.join("allocation");
23072        std::fs::create_dir_all(&source).unwrap();
23073        std::fs::create_dir_all(&allocation).unwrap();
23074        run_test_git(&source, &["init"]);
23075        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23076        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23077        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23078        run_test_git(&source, &["add", "seed.txt"]);
23079        run_test_git(&source, &["commit", "-m", "seed"]);
23080        let profile = ManagedWorktreeProfile::new(
23081            WorktreeProfileId::new("default").unwrap(),
23082            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23083            &allocation,
23084            "gate4agent",
23085            "HEAD",
23086            ManagedWorktreeRetention::Retain,
23087        ).unwrap();
23088        let workspace = WorkspaceConfig::new(
23089            WorkspaceId::new("primary").unwrap(),
23090            &source,
23091        ).unwrap()
23092            .with_worktree_service_mode(WorktreeServiceMode::Managed)
23093            .with_managed_worktree_profile(profile.clone()).unwrap();
23094        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23095        let source_text = workspace.canonical_root().to_owned();
23096        let target_text = target.to_string_lossy().into_owned();
23097        let created = create_git_worktree(
23098            &source_text,
23099            &target_text,
23100            "gate4agent/mw-restart",
23101            Some("HEAD"),
23102        ).await.unwrap();
23103        let lease = ManagedWorktreeLeaseRecord {
23104            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23105            source_workspace_id: WorkspaceId::new("primary").unwrap(),
23106            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23107            profile_id: profile.profile_id().clone(),
23108            profile_revision: profile.revision().clone(),
23109            target_root: created.path.clone(),
23110            branch: "gate4agent/mw-restart".to_owned(),
23111            base_commit: created.head.clone(),
23112            expected_head: None,
23113            retention: ManagedWorktreeRetention::Retain,
23114            state: ManagedWorktreeLeaseState::Allocating,
23115            session_holders: Vec::new(),
23116            record_holders: Vec::new(),
23117            cleanup_failure: None,
23118            created_at_unix_ms: 1,
23119            updated_at_unix_ms: 1,
23120        };
23121        let catalog = active_registry().unwrap();
23122        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23123        let shared = NodeShared::new_with_incarnation(
23124            handle,
23125            "fixture-token".to_owned(),
23126            NodeId::new("node-managed-restart").unwrap(),
23127            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23128            vec![workspace],
23129            vec![agent("claude")],
23130            ProviderRuntimeStatuses::default(),
23131            None,
23132            Vec::new(),
23133            Vec::new(),
23134            SpawnProfileRegistry::default(),
23135            None,
23136            None,
23137            None,
23138            None,
23139            Vec::new(),
23140            vec![lease.clone()],
23141            Vec::new(),
23142            Vec::new(),
23143            None,
23144            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23145        );
23146        shared.reconcile_managed_worktrees().await;
23147        let retained = shared.managed_worktrees.lock()
23148            .unwrap_or_else(|poisoned| poisoned.into_inner())
23149            .get(&lease.lease_id).unwrap().clone();
23150        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23151        assert!(shared.workspaces.read()
23152            .unwrap_or_else(|poisoned| poisoned.into_inner())
23153            .contains_key(&lease.workspace_id));
23154        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23155        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23156        assert!(!Path::new(&created.path).exists());
23157        std::fs::remove_dir_all(root).unwrap();
23158    }
23159
23160    fn run_test_git(root: &Path, arguments: &[&str]) {
23161        let output = std::process::Command::new("git")
23162            .arg("-C").arg(root).args(arguments).output().unwrap();
23163        assert!(
23164            output.status.success(),
23165            "git {:?} failed: {}",
23166            arguments,
23167            String::from_utf8_lossy(&output.stderr),
23168        );
23169    }
23170
23171    #[tokio::test]
23172    async fn spawn_spec_validation_fails_before_session_mutation() {
23173        let shared = terminal_test_shared();
23174        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23175            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23176        }));
23177        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23178            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23179        }));
23180        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23181            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23182        }));
23183
23184        let mut target_mismatch = spawn_spec_fixture();
23185        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23186        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23187        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23188
23189        let mut unknown_profile = spawn_spec_fixture();
23190        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23191        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23192        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23193        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23194
23195        let mut revision_mismatch = spawn_spec_fixture();
23196        revision_mismatch.idempotency_key =
23197            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23198        revision_mismatch.expected_profile_revision =
23199            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23200        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23201        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23202
23203        let mut unsupported_materializer = spawn_spec_fixture();
23204        unsupported_materializer.idempotency_key =
23205            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23206        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23207            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23208        };
23209        let failure = shared
23210            .spawn_from_spec(unsupported_materializer)
23211            .await
23212            .unwrap_err();
23213        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23214
23215        let failure = shared
23216            .spawn_session_with_deadline(
23217                WorkspaceId::new("primary").unwrap(),
23218                agent("claude"),
23219                SessionMode::Pty,
23220                gate4agent_types::TerminalSize {
23221                    rows: 24,
23222                    columns: 80,
23223                },
23224                None,
23225                None,
23226                None,
23227                None,
23228                None,
23229                None,
23230                SpawnRecordPolicy::ProviderIdentityOnly,
23231                Some(Instant::now()),
23232                &[],
23233                None,
23234                ApprovalLevel::default(),
23235                None,
23236                None,
23237            )
23238            .await
23239            .unwrap_err();
23240        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23241        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23242        assert!(shared.handle.snapshot().sessions.is_empty());
23243    }
23244
23245    #[test]
23246    fn worktree_selection_requires_its_separate_negotiated_capability() {
23247        let mut spec = spawn_spec_fixture();
23248        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23249        let request = NodeRequest::SpawnSpec { spec };
23250        let spawn_spec_capability =
23251            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23252        assert!(request_uses_unnegotiated_capability(
23253            &request,
23254            &[spawn_spec_capability.clone()],
23255        ));
23256        let worktree_capability =
23257            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23258        assert!(request_uses_unnegotiated_capability(
23259            &request,
23260            &[spawn_spec_capability.clone(), worktree_capability.clone()],
23261        ));
23262        let profile_revision_capability =
23263            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23264        assert!(request_uses_unnegotiated_capability(
23265            &request,
23266            &[
23267                spawn_spec_capability.clone(),
23268                worktree_capability.clone(),
23269                profile_revision_capability.clone(),
23270            ],
23271        ));
23272        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
23273        // `Inherit`, not `Clear` — the profile's own defaults might still
23274        // materialize a bundle or a context pack, so both capabilities stay
23275        // conservatively required until negotiated (see
23276        // `requires_session_bundle_materialization_capability` /
23277        // `requires_history_context_pack_capability`).
23278        let bundle_materialization_capability =
23279            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23280        assert!(request_uses_unnegotiated_capability(
23281            &request,
23282            &[
23283                spawn_spec_capability.clone(),
23284                worktree_capability.clone(),
23285                profile_revision_capability.clone(),
23286                bundle_materialization_capability.clone(),
23287            ],
23288        ));
23289        let history_context_pack_capability =
23290            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23291        assert!(!request_uses_unnegotiated_capability(
23292            &request,
23293            &[
23294                spawn_spec_capability,
23295                worktree_capability,
23296                profile_revision_capability,
23297                bundle_materialization_capability,
23298                history_context_pack_capability,
23299            ],
23300        ));
23301    }
23302
23303    #[tokio::test]
23304    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23305        let mut shared = terminal_test_shared();
23306        let spec = spawn_spec_fixture();
23307        let receipt = shared
23308            .resolve_spawn_spec(&spec)
23309            .unwrap()
23310            .receipt(shared.incarnation_id, terminal_address(1));
23311        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23312
23313        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23314        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23315        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23316
23317        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23318
23319        let mut revision_conflicting = spec.clone();
23320        revision_conflicting.expected_profile_revision =
23321            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23322        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23323        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23324
23325        let mut stale_cache_miss = spec.clone();
23326        stale_cache_miss.idempotency_key =
23327            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23328        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23329        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23330
23331        let mut conflicting = spec;
23332        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23333        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23334        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23335        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23336        assert!(shared.handle.snapshot().sessions.is_empty());
23337    }
23338
23339    #[tokio::test]
23340    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23341        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23342        let mut spec = spawn_spec_fixture();
23343        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23344        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23345        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23346
23347        let failure = shared
23348            .create_worktree(
23349                WorkspaceId::new("primary").unwrap(),
23350                WorkspaceId::new("new-worktree").unwrap(),
23351                "not-an-absolute-path".to_owned(),
23352                "invalid branch".to_owned(),
23353                None,
23354            )
23355            .await
23356            .unwrap_err();
23357        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23358
23359        let failure = shared
23360            .remove_worktree(
23361                WorkspaceId::new("primary").unwrap(),
23362                "not-an-absolute-path".to_owned(),
23363            )
23364            .await
23365            .unwrap_err();
23366        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23367        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23368        assert!(shared.handle.snapshot().sessions.is_empty());
23369    }
23370
23371    #[tokio::test]
23372    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23373        let root = temporary_workspace_root("manual-worktree-compensation");
23374        let source = root.join("source");
23375        let target = root.join("target");
23376        let invalid_state_parent = root.join("state-parent-is-a-file");
23377        let invalid_state_path = invalid_state_parent.join("state.json");
23378        std::fs::create_dir_all(&source).unwrap();
23379        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23380        run_test_git(&source, &["init"]);
23381        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23382        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23383        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23384        run_test_git(&source, &["add", "seed.txt"]);
23385        run_test_git(&source, &["commit", "-m", "seed"]);
23386
23387        let catalog = active_registry().unwrap();
23388        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23389        let workspace = WorkspaceConfig::new(
23390            WorkspaceId::new("primary").unwrap(),
23391            &source,
23392        ).unwrap();
23393        let mut shared = NodeShared::new(
23394            handle,
23395            "fixture-token".to_owned(),
23396            NodeId::new("node-worktree-compensation").unwrap(),
23397            vec![workspace],
23398            vec![agent("claude")],
23399        );
23400        shared.state_path = Some(invalid_state_path);
23401
23402        let error = shared.create_worktree(
23403            WorkspaceId::new("primary").unwrap(),
23404            WorkspaceId::new("compensated").unwrap(),
23405            target.to_string_lossy().into_owned(),
23406            "gate4agent/compensated".to_owned(),
23407            Some("HEAD".to_owned()),
23408        ).await.unwrap_err();
23409
23410        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23411        assert!(!target.exists());
23412        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23413            .iter()
23414            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23415        std::fs::remove_dir_all(root).unwrap();
23416    }
23417
23418    #[test]
23419    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23420        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23421            NativeGitWorktreeSnapshot {
23422                path: path.to_owned(),
23423                head: "0123456789abcdef".to_owned(),
23424                branch: Some("refs/heads/test".to_owned()),
23425                is_bare,
23426                is_main,
23427                locked: false,
23428                lock_reason: None,
23429                prunable: false,
23430                prunable_reason: None,
23431                workspace_id: None,
23432            }
23433        }
23434
23435        let source = r"C:\repo";
23436        let selected = r"C:\repo-worktree";
23437        let listed = vec![
23438            worktree(source, true, false),
23439            worktree(selected, false, false),
23440        ];
23441        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23442
23443        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23444        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23445
23446        let bare = vec![
23447            worktree(source, true, false),
23448            worktree(selected, false, true),
23449        ];
23450        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23451        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23452
23453        let foreign_failure =
23454            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23455        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23456    }
23457
23458    fn terminal_frame(sequence: u64) -> TerminalFrame {
23459        TerminalFrame {
23460            sequence,
23461            size: gate4agent_types::TerminalSize {
23462                rows: 24,
23463                columns: 80,
23464            },
23465            cursor_row: 2,
23466            cursor_column: 3,
23467            contents: format!("frame-{sequence}"),
23468            formatted: vec![1, 2, 3],
23469            scrollback_formatted: Vec::new(),
23470            alternate_screen: false,
23471            mouse_protocol_enabled: false,
23472            mouse_protocol_encoding: Default::default(),
23473            produced_at_unix_ms: 0,
23474            screen_state: gate4agent_types::PtyScreenState::default(),
23475            bracketed_paste: None,
23476        }
23477    }
23478
23479    #[test]
23480    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23481        let shared = terminal_test_shared();
23482        let mut receiver = shared.terminal_event_tx.subscribe();
23483        let original = terminal_address(1);
23484
23485        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23486        let first = receiver.try_recv().unwrap();
23487        assert_eq!(first.len(), 1);
23488        assert!(matches!(
23489            &first[0].event,
23490            NodeEvent::TerminalFrame { address, frame }
23491                if address == &original && frame.sequence == 7
23492        ));
23493
23494        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23495        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23496        assert!(matches!(
23497            receiver.try_recv(),
23498            Err(broadcast::error::TryRecvError::Empty)
23499        ));
23500
23501        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23502        let advanced = receiver.try_recv().unwrap();
23503        assert_eq!(advanced[0].sequence, 2);
23504
23505        let resumed = terminal_address(2);
23506        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23507        let reset = receiver.try_recv().unwrap();
23508        assert_eq!(reset[0].sequence, 3);
23509        assert!(matches!(
23510            &reset[0].event,
23511            NodeEvent::TerminalFrame { address, frame }
23512                if address == &resumed && frame.sequence == 1
23513        ));
23514        shared.clear_terminal_frame_watermark(&resumed);
23515        assert!(!shared
23516            .terminal_frame_watermarks
23517            .lock()
23518            .unwrap_or_else(|poisoned| poisoned.into_inner())
23519            .contains_key(&resumed.session.instance_id));
23520    }
23521
23522    #[test]
23523    fn terminal_frame_batches_replace_without_entering_node_event_history() {
23524        let shared = terminal_test_shared();
23525        let mut receiver = shared.terminal_event_tx.subscribe();
23526        let address = terminal_address(1);
23527        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23528
23529        for sequence in 1..=count {
23530            shared.publish_terminal_frame_candidates(vec![(
23531                address.clone(),
23532                terminal_frame(sequence),
23533            )]);
23534        }
23535
23536        let history = shared
23537            .history
23538            .lock()
23539            .unwrap_or_else(|poisoned| poisoned.into_inner());
23540        assert!(history.events.is_empty());
23541        assert_eq!(history.last_sequence, count);
23542        assert_eq!(history.replay_floor_sequence, 1);
23543        drop(history);
23544        let NodeResponse::Resync {
23545            event_sequence,
23546            oldest_available_sequence,
23547            events,
23548            ..
23549        } = shared.resync(0) else {
23550            unreachable!("resync helper returned another response")
23551        };
23552        assert_eq!(event_sequence, count);
23553        assert_eq!(oldest_available_sequence, 1);
23554        assert!(events.is_empty());
23555        assert!(matches!(
23556            resync_required_event(&shared).event,
23557            NodeEvent::ResyncRequired {
23558                oldest_available_sequence: 1,
23559            }
23560        ));
23561        assert!(matches!(
23562            receiver.try_recv(),
23563            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23564        ));
23565        let latest = receiver.try_recv().unwrap();
23566        assert_eq!(latest.len(), 1);
23567        assert_eq!(latest[0].sequence, count);
23568    }
23569
23570    #[test]
23571    fn real_durable_eviction_advances_authoritative_replay_floor() {
23572        let shared = terminal_test_shared();
23573        for _ in 0..=NODE_EVENT_HISTORY_MAX {
23574            shared.publish(NodeEvent::ControllerChanged { controller: None });
23575        }
23576
23577        let NodeResponse::Resync {
23578            event_sequence,
23579            oldest_available_sequence,
23580            events,
23581            ..
23582        } = shared.resync(0) else {
23583            unreachable!("resync helper returned another response")
23584        };
23585        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23586        assert_eq!(oldest_available_sequence, 2);
23587        assert_eq!(events.first().map(|event| event.sequence), Some(2));
23588        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23589        assert!(matches!(
23590            resync_required_event(&shared).event,
23591            NodeEvent::ResyncRequired {
23592                oldest_available_sequence: 2,
23593            }
23594        ));
23595    }
23596
23597    #[test]
23598    fn legacy_terminal_frame_connection_has_no_live_subscription() {
23599        let shared = terminal_test_shared();
23600        let without_capability = terminal_event_subscription(&shared, false);
23601        let with_capability = terminal_event_subscription(&shared, true);
23602        assert!(without_capability.is_none());
23603        assert!(with_capability.is_some());
23604        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23605            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23606        }));
23607    }
23608
23609    #[test]
23610    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23611        let mut pending = Vec::new();
23612        let mut event_sequence = 0_u64;
23613        for frame_sequence in 1..=4 {
23614            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23615                event_sequence += 1;
23616                let mut address = terminal_address(1);
23617                address.session.instance_id = AgentInstanceId(instance);
23618                queue_connection_event(
23619                    &mut pending,
23620                    NodeEventEnvelope {
23621                        sequence: event_sequence,
23622                        event: NodeEvent::TerminalFrame {
23623                            address,
23624                            frame: terminal_frame(frame_sequence),
23625                        },
23626                    },
23627                    0,
23628                );
23629            }
23630        }
23631        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23632        pending.sort_unstable_by_key(|event| event.sequence);
23633
23634        let mut uninterrupted = pending.clone();
23635        let first_burst = connection_event_burst_len(uninterrupted.len());
23636        uninterrupted.drain(..first_burst);
23637        let second_burst = connection_event_burst_len(uninterrupted.len());
23638        uninterrupted.drain(..second_burst);
23639        assert!(uninterrupted.is_empty());
23640
23641        let (frame_tx, mut frame_rx) =
23642            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23643        frame_tx
23644            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23645                request_id: 77,
23646                request: NodeRequest::Snapshot,
23647            })))
23648            .unwrap();
23649        let burst_len = connection_event_burst_len(pending.len());
23650        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23651        pending.drain(..burst_len);
23652        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23653        assert!(matches!(
23654            frame_rx.try_recv(),
23655            Ok(Ok(ClientFrame::Request(RequestEnvelope {
23656                request_id: 77,
23657                request: NodeRequest::Snapshot,
23658            })))
23659        ));
23660    }
23661
23662    #[test]
23663    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23664        let mut pending = Vec::new();
23665        for sequence in [7_u64, 8] {
23666            let mut address = terminal_address(1);
23667            address.session.instance_id = AgentInstanceId(sequence);
23668            queue_connection_event(
23669                &mut pending,
23670                NodeEventEnvelope {
23671                    sequence,
23672                    event: NodeEvent::TerminalFrame {
23673                        address,
23674                        frame: terminal_frame(sequence),
23675                    },
23676                },
23677                0,
23678            );
23679        }
23680        let mut discard_through = 0;
23681        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23682        assert!(pending.is_empty());
23683
23684        let replayed = NodeEventEnvelope {
23685            sequence: 8,
23686            event: NodeEvent::TerminalFrame {
23687                address: terminal_address(1),
23688                frame: terminal_frame(8),
23689            },
23690        };
23691        let advanced = NodeEventEnvelope {
23692            sequence: 9,
23693            event: NodeEvent::TerminalFrame {
23694                address: terminal_address(2),
23695                frame: terminal_frame(1),
23696            },
23697        };
23698        queue_connection_event_batch(
23699            &mut pending,
23700            &[replayed, advanced.clone()],
23701            discard_through,
23702        );
23703        assert_eq!(pending, vec![advanced]);
23704    }
23705
23706    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23707        ManagedSessionRecord {
23708            record_id: SessionRecordId::new(record_id).unwrap(),
23709            display_name: format!("{provider} record"),
23710            provider: agent(provider),
23711            mode: SessionMode::Pty,
23712            state: ManagedSessionState::Unavailable,
23713            workspace_id: WorkspaceId::new("primary").unwrap(),
23714            canonical_root: opaque_windows_path(
23715                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23716            ),
23717            provider_session: None,
23718            active_session: None,
23719            environment_profile: None,
23720            bundle: None,
23721            context_id: None,
23722            context: None,
23723            exported_context: None,
23724            task_binding: None,
23725            created_at_unix_ms: 1,
23726            updated_at_unix_ms: 1,
23727            last_error: None,
23728        }
23729    }
23730
23731    fn retention_test_record(
23732        workspace_id: &str,
23733        record_id: &str,
23734        state: ManagedSessionState,
23735        updated_at_unix_ms: u64,
23736    ) -> ManagedSessionRecord {
23737        ManagedSessionRecord {
23738            record_id: SessionRecordId::new(record_id).unwrap(),
23739            display_name: format!("{record_id} display"),
23740            provider: agent("claude"),
23741            mode: SessionMode::Pty,
23742            state,
23743            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23744            canonical_root: opaque_windows_path(
23745                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23746            ),
23747            provider_session: None,
23748            active_session: None,
23749            environment_profile: None,
23750            bundle: None,
23751            context_id: None,
23752            context: None,
23753            exported_context: None,
23754            task_binding: None,
23755            created_at_unix_ms: updated_at_unix_ms,
23756            updated_at_unix_ms,
23757            last_error: None,
23758        }
23759    }
23760
23761    /// Two workspaces of three `Unavailable` records each, staggered by
23762    /// age, plus one never-touchable decoy per non-`Unavailable` state in
23763    /// `workspace-a` (all as old as that workspace's oldest real record --
23764    /// if the sweep's state gate ever leaked, these would be the first
23765    /// thing it wrongly retired). Ages, oldest to newest:
23766    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23767    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23768    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23769        vec![
23770            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23771            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23772            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23773            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23774            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23775            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23776            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23777            retention_test_record(
23778                "workspace-a",
23779                "decoy-pending",
23780                ManagedSessionState::IdentityPending,
23781                now - 500_000,
23782            ),
23783            retention_test_record(
23784                "workspace-a",
23785                "decoy-dormant",
23786                ManagedSessionState::Dormant,
23787                now - 500_000,
23788            ),
23789        ]
23790    }
23791
23792    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23793        let root = temporary_workspace_root("session-record-retention");
23794        std::fs::create_dir_all(&root).unwrap();
23795        let catalog = active_registry().unwrap();
23796        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23797        let workspace_a =
23798            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23799        let workspace_b =
23800            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23801        let mut shared = NodeShared::new(
23802            handle,
23803            "fixture-token".to_owned(),
23804            NodeId::new("node-retention-test").unwrap(),
23805            vec![workspace_a, workspace_b],
23806            vec![agent("claude")],
23807        );
23808        shared.state_path = Some(root.join("node-state-v10.json"));
23809        let now = unix_time_ms();
23810        for record in retention_fixture_records(now) {
23811            shared.insert_record(record).unwrap();
23812        }
23813        shared
23814            .persist_state_locked()
23815            .expect("fixture-only, empty-history persist against a fresh temp state path");
23816        (shared, runtime, root, now)
23817    }
23818
23819    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23820        shared
23821            .history
23822            .lock()
23823            .unwrap_or_else(|poisoned| poisoned.into_inner())
23824            .events
23825            .iter()
23826            .filter_map(|envelope| match &envelope.event {
23827                NodeEvent::SessionRecordRemoved { record_id } => {
23828                    Some(record_id.as_str().to_owned())
23829                }
23830                _ => None,
23831            })
23832            .collect()
23833    }
23834
23835    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23836        shared
23837            .session_records
23838            .lock()
23839            .unwrap_or_else(|poisoned| poisoned.into_inner())
23840            .records
23841            .keys()
23842            .map(|record_id| record_id.as_str().to_owned())
23843            .collect()
23844    }
23845
23846    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23847        values.iter().map(|value| (*value).to_owned()).collect()
23848    }
23849
23850    #[tokio::test]
23851    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23852        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23853        shared.session_record_retention = SessionRecordRetentionConfig {
23854            age_ms: 0,
23855            keep_per_workspace: 1,
23856        };
23857        shared.retire_unavailable_session_records().await;
23858
23859        assert_eq!(
23860            retention_remaining_record_ids(&shared),
23861            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23862        );
23863        assert_eq!(
23864            retention_removed_record_ids(&shared),
23865            owned(&["a1", "a2", "b1", "b2"]),
23866        );
23867        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23868
23869        drop(shared);
23870        drop(runtime);
23871        std::fs::remove_dir_all(root).unwrap();
23872    }
23873
23874    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23875    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23876    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23877    /// 200s old against a 250s threshold) even though it fails the keep-N
23878    /// test too, proving retirement needs BOTH, not either.
23879    #[tokio::test]
23880    async fn session_record_retention_both_set_retires_only_the_intersection() {
23881        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23882        shared.session_record_retention = SessionRecordRetentionConfig {
23883            age_ms: 250_000,
23884            keep_per_workspace: 1,
23885        };
23886        shared.retire_unavailable_session_records().await;
23887
23888        assert_eq!(
23889            retention_remaining_record_ids(&shared),
23890            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23891        );
23892        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23893        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23894
23895        drop(shared);
23896        drop(runtime);
23897        std::fs::remove_dir_all(root).unwrap();
23898    }
23899
23900    #[tokio::test]
23901    async fn session_record_retention_both_disabled_is_a_no_op() {
23902        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23903        shared.session_record_retention = SessionRecordRetentionConfig::default();
23904        shared.retire_unavailable_session_records().await;
23905
23906        assert_eq!(
23907            retention_remaining_record_ids(&shared),
23908            owned(&[
23909                "a1", "a2", "a3", "b1", "b2", "b3",
23910                "decoy-live", "decoy-pending", "decoy-dormant",
23911            ]),
23912        );
23913        assert!(retention_removed_record_ids(&shared).is_empty());
23914        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23915
23916        drop(shared);
23917        drop(runtime);
23918        std::fs::remove_dir_all(root).unwrap();
23919    }
23920
23921    #[test]
23922    fn session_record_context_export_selects_one_exact_history_identity() {
23923        let candidates = vec![
23924            HistoryCandidateSummary {
23925                id: "candidate-other".to_owned(),
23926                session_id_hint: "provider-other".to_owned(),
23927                modified_at_unix_ms: Some(1),
23928            },
23929            HistoryCandidateSummary {
23930                id: "candidate-exact".to_owned(),
23931                session_id_hint: "provider-exact".to_owned(),
23932                modified_at_unix_ms: Some(2),
23933            },
23934        ];
23935        assert_eq!(
23936            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23937            "candidate-exact",
23938        );
23939    }
23940
23941    #[test]
23942    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23943        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23944            id: id.to_owned(),
23945            session_id_hint: hint.to_owned(),
23946            modified_at_unix_ms: None,
23947        };
23948        let missing = select_history_candidate(
23949            &[candidate("candidate-other", "provider-other")],
23950            Some("provider-exact"),
23951        )
23952        .unwrap_err();
23953        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23954        let ambiguous = select_history_candidate(
23955            &[
23956                candidate("candidate-a", "provider-exact"),
23957                candidate("candidate-b", "provider-exact"),
23958            ],
23959            Some("provider-exact"),
23960        )
23961        .unwrap_err();
23962        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23963    }
23964
23965    /// The PTY-sidecar-observation shape (no fleet provider today, but
23966    /// capability-keyed rather than vendor-locked) never carries a verified
23967    /// `provider_session`; its record-bound export instead falls back to
23968    /// "exactly one discovered candidate" (see
23969    /// `export_context_pack_for_session_record_inner`). This is the same
23970    /// ambiguity refusal as the identity-filtered path, just without an
23971    /// identity to filter by.
23972    #[test]
23973    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23974        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23975            id: id.to_owned(),
23976            session_id_hint: hint.to_owned(),
23977            modified_at_unix_ms: None,
23978        };
23979        assert_eq!(
23980            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23981                .unwrap(),
23982            "candidate-sole",
23983        );
23984        let missing = select_history_candidate(&[], None).unwrap_err();
23985        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23986        let ambiguous = select_history_candidate(
23987            &[
23988                candidate("candidate-a", "sidecar-session-a"),
23989                candidate("candidate-b", "sidecar-session-b"),
23990            ],
23991            None,
23992        )
23993        .unwrap_err();
23994        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23995    }
23996
23997    #[test]
23998    fn session_record_context_export_revalidation_rejects_record_change() {
23999        let session = terminal_address(1);
24000        let identity = ProviderSessionIdentity {
24001            key: ProviderSessionKey::SessionId,
24002            id: "provider-exact".to_owned(),
24003            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
24004        };
24005        let mut expected = record("claude", "record-context-export");
24006        expected.state = ManagedSessionState::Live;
24007        expected.provider_session = Some(identity.clone());
24008        expected.active_session = Some(session.clone());
24009        let binding = SessionBinding {
24010            workspace_id: session.workspace_id.clone(),
24011            generation: session.session.generation,
24012            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24013            pending_resume: None,
24014            record_id: Some(expected.record_id.clone()),
24015            managed_worktree_lease_id: None,
24016            environment_profile: None,
24017            bundle: None,
24018            context: None,
24019            materialization_id: None,
24020        };
24021        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24022        assert!(session_record_context_export_binding_is_exact(
24023            &expected,
24024            &expected,
24025            Some(&identity),
24026            &session,
24027            &binding,
24028            &expected.provider,
24029            &current_root,
24030            false,
24031        ));
24032        let mut changed = expected.clone();
24033        changed.updated_at_unix_ms += 1;
24034        assert!(!session_record_context_export_binding_is_exact(
24035            &expected,
24036            &changed,
24037            Some(&identity),
24038            &session,
24039            &binding,
24040            &expected.provider,
24041            &current_root,
24042            false,
24043        ));
24044        // A same-instant timestamp bump alone (state and active_session
24045        // otherwise unchanged) is not the clean-detachment transition
24046        // either, even with the reactive flag on: only the exact Live ->
24047        // Dormant + active_session cleared shape qualifies.
24048        assert!(!session_record_context_export_binding_is_exact(
24049            &expected,
24050            &changed,
24051            Some(&identity),
24052            &session,
24053            &binding,
24054            &expected.provider,
24055            &current_root,
24056            true,
24057        ));
24058        let mut detached = expected.clone();
24059        detached.state = ManagedSessionState::Dormant;
24060        detached.active_session = None;
24061        detached.updated_at_unix_ms += 1;
24062        assert!(!session_record_context_export_binding_is_exact(
24063            &expected,
24064            &detached,
24065            Some(&identity),
24066            &session,
24067            &binding,
24068            &expected.provider,
24069            &current_root,
24070            false,
24071        ));
24072        assert!(session_record_context_export_binding_is_exact(
24073            &expected,
24074            &detached,
24075            Some(&identity),
24076            &session,
24077            &binding,
24078            &expected.provider,
24079            &current_root,
24080            true,
24081        ));
24082    }
24083
24084    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
24085    /// for a record whose provider never carries a verified identity (the
24086    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
24087    /// declares one today): `identity` is `None` throughout, and the
24088    /// exactness check falls back to comparing `provider_session` itself,
24089    /// which must still be `None` on both sides.
24090    #[test]
24091    fn session_record_context_export_binding_is_exact_without_identity() {
24092        let session = terminal_address(1);
24093        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24094        expected.state = ManagedSessionState::Live;
24095        expected.active_session = Some(session.clone());
24096        let binding = SessionBinding {
24097            workspace_id: session.workspace_id.clone(),
24098            generation: session.session.generation,
24099            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24100            pending_resume: None,
24101            record_id: Some(expected.record_id.clone()),
24102            managed_worktree_lease_id: None,
24103            environment_profile: None,
24104            bundle: None,
24105            context: None,
24106            materialization_id: None,
24107        };
24108        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24109        assert!(session_record_context_export_binding_is_exact(
24110            &expected,
24111            &expected,
24112            None,
24113            &session,
24114            &binding,
24115            &expected.provider,
24116            &current_root,
24117            false,
24118        ));
24119        // A record that gained an identity between the initial lookup and
24120        // this recheck no longer matches the `None` this shape expects to
24121        // keep seeing -- treated the same as any other mid-export drift.
24122        let mut drifted = expected.clone();
24123        drifted.provider_session = Some(ProviderSessionIdentity {
24124            key: ProviderSessionKey::SessionId,
24125            id: "unexpected-identity".to_owned(),
24126            transcript_path: None,
24127        });
24128        assert!(!session_record_context_export_binding_is_exact(
24129            &expected,
24130            &drifted,
24131            None,
24132            &session,
24133            &binding,
24134            &expected.provider,
24135            &current_root,
24136            false,
24137        ));
24138    }
24139
24140    #[test]
24141    fn session_record_context_export_preflight_requires_live_usable_source() {
24142        let mut source = record("claude", "record-context-export-preflight");
24143        source.state = ManagedSessionState::Live;
24144        assert!(session_record_context_export_source_is_usable(
24145            &source,
24146            &SessionStatus::Running,
24147            false,
24148        ));
24149        assert!(session_record_context_export_source_is_usable(
24150            &source,
24151            &SessionStatus::Exited { exit_code: Some(0) },
24152            false,
24153        ));
24154
24155        source.state = ManagedSessionState::Dormant;
24156        assert!(!session_record_context_export_source_is_usable(
24157            &source,
24158            &SessionStatus::Running,
24159            false,
24160        ));
24161        // The reactive auto-export-at-exit path (allow_clean_detachment) is
24162        // the only caller that may treat a Dormant, cleanly-exited record as
24163        // still usable — the on-demand path above never does.
24164        assert!(session_record_context_export_source_is_usable(
24165            &source,
24166            &SessionStatus::Exited { exit_code: Some(0) },
24167            true,
24168        ));
24169        assert!(!session_record_context_export_source_is_usable(
24170            &source,
24171            &SessionStatus::Exited { exit_code: Some(0) },
24172            false,
24173        ));
24174        source.state = ManagedSessionState::Live;
24175        assert!(!session_record_context_export_source_is_usable(
24176            &source,
24177            &SessionStatus::Starting,
24178            false,
24179        ));
24180    }
24181
24182    #[test]
24183    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24184        let catalog = active_registry().unwrap();
24185        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24186        let workspace = WorkspaceConfig::new(
24187            WorkspaceId::new("primary").unwrap(),
24188            std::env::current_dir().unwrap(),
24189        )
24190        .unwrap();
24191        let shared = NodeShared::new(
24192            handle,
24193            "fixture-token".to_owned(),
24194            NodeId::new("node-context-export-conflict").unwrap(),
24195            vec![workspace],
24196            vec![agent("claude")],
24197        );
24198        let history = HistorySessionRecord {
24199            session_id: "provider-exact".to_owned(),
24200            title: None,
24201            cwd: None,
24202            model: None,
24203            message_count: 1,
24204            completed_turn_count: None,
24205            total_tokens: 0,
24206            messages: vec![gate4agent_types::HistoryMessageRecord {
24207                role: gate4agent_types::HistoryMessageRole::User,
24208                text: "bounded context".to_owned(),
24209            }],
24210        };
24211        let pack = NodeContextPack::export(
24212            ContextPackLineageReceipt {
24213                source_node_id: shared.node_id.clone(),
24214                source_session: terminal_address(1),
24215                source_provider: agent("claude"),
24216            },
24217            &history,
24218        )
24219        .unwrap();
24220        let context_id = pack.receipt().id.clone();
24221        let session = terminal_address(1);
24222        let identity = ProviderSessionIdentity {
24223            key: ProviderSessionKey::SessionId,
24224            id: history.session_id.clone(),
24225            transcript_path: None,
24226        };
24227        let mut expected = record("claude", "record-context-export-final-conflict");
24228        expected.state = ManagedSessionState::Live;
24229        expected.provider_session = Some(identity.clone());
24230        expected.active_session = Some(session.clone());
24231        let mut changed = expected.clone();
24232        changed.updated_at_unix_ms += 1;
24233        shared.insert_record(changed).unwrap();
24234
24235        let error = shared
24236            .commit_context_pack_for_session_record(
24237                &expected,
24238                Some(&identity),
24239                &session,
24240                "candidate-exact",
24241                &history,
24242                pack,
24243                false,
24244            )
24245            .unwrap_err();
24246
24247        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24248        assert!(shared
24249            .context_catalog
24250            .read()
24251            .unwrap_or_else(|poisoned| poisoned.into_inner())
24252            .get(&context_id)
24253            .is_none());
24254    }
24255
24256    #[tokio::test]
24257    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24258        let root = temporary_workspace_root("session-task-binding");
24259        std::fs::create_dir_all(&root).unwrap();
24260        let state_path = root.join("node-state.json");
24261        let catalog = active_registry().unwrap();
24262        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24263        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24264        let node_id = NodeId::new("node-1").unwrap();
24265        let mut shared = NodeShared::new(
24266            handle,
24267            "fixture-token".to_owned(),
24268            node_id.clone(),
24269            vec![workspace],
24270            vec![agent("claude")],
24271        );
24272        shared.state_path = Some(state_path.clone());
24273        let record = record("claude", "sr-task");
24274        let record_id = record.record_id.clone();
24275        shared.insert_record(record).unwrap();
24276        shared.persist_state().unwrap();
24277        shared
24278            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24279            .unwrap();
24280
24281        let minted = process_request_inner(
24282            &shared,
24283            77,
24284            ClientRole::Operator,
24285            NodeRequest::SetSessionTask {
24286                record_id: record_id.clone(),
24287                expected_revision: 0,
24288                target: SessionTaskTargetV1::New,
24289            },
24290        )
24291        .await
24292        .unwrap();
24293        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24294            panic!("session task mutation returned another response");
24295        };
24296        let minted_binding = minted.task_binding.clone().unwrap();
24297        let minted_task_id = minted_binding.task_id.clone().unwrap();
24298        assert_eq!(minted_binding.revision, 1);
24299        assert!(minted_task_id.as_str().starts_with("task-"));
24300        assert_eq!(minted_task_id.as_str().len(), 29);
24301
24302        let event_count = shared.current_sequence();
24303        let repeated = process_request_inner(
24304            &shared,
24305            77,
24306            ClientRole::Operator,
24307            NodeRequest::SetSessionTask {
24308                record_id: record_id.clone(),
24309                expected_revision: 1,
24310                target: SessionTaskTargetV1::Existing {
24311                    task_id: minted_task_id.clone(),
24312                },
24313            },
24314        )
24315        .await
24316        .unwrap();
24317        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24318        assert_eq!(shared.current_sequence(), event_count);
24319
24320        let stale = process_request_inner(
24321            &shared,
24322            77,
24323            ClientRole::Operator,
24324            NodeRequest::SetSessionTask {
24325                record_id: record_id.clone(),
24326                expected_revision: 0,
24327                target: SessionTaskTargetV1::Existing {
24328                    task_id: minted_task_id,
24329                },
24330            },
24331        )
24332        .await
24333        .unwrap_err();
24334        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24335
24336        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24337        let assigned = process_request_inner(
24338            &shared,
24339            77,
24340            ClientRole::Operator,
24341            NodeRequest::SetSessionTask {
24342                record_id: record_id.clone(),
24343                expected_revision: 1,
24344                target: SessionTaskTargetV1::Existing {
24345                    task_id: assigned_id.clone(),
24346                },
24347            },
24348        )
24349        .await
24350        .unwrap();
24351        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24352            panic!("session task assignment returned another response");
24353        };
24354        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24355        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24356
24357        let cleared = process_request_inner(
24358            &shared,
24359            77,
24360            ClientRole::Operator,
24361            NodeRequest::SetSessionTask {
24362                record_id: record_id.clone(),
24363                expected_revision: 2,
24364                target: SessionTaskTargetV1::Clear,
24365            },
24366        )
24367        .await
24368        .unwrap();
24369        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24370            panic!("session task clear returned another response");
24371        };
24372        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24373        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24374        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24375        assert_eq!(
24376            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24377            cleared.updated_at_unix_ms,
24378        );
24379
24380        let persisted = std::fs::read(&state_path).unwrap();
24381        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24382        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24383        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24384        std::fs::remove_dir_all(root).unwrap();
24385    }
24386
24387    #[test]
24388    fn session_task_capability_strips_snapshots_events_and_replies() {
24389        let mut bound = record("claude", "sr-task-projection");
24390        bound.updated_at_unix_ms = 2;
24391        bound.task_binding = Some(SessionTaskBindingV1 {
24392            revision: 1,
24393            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24394            changed_at_unix_ms: 2,
24395        });
24396        let request = NodeRequest::SetSessionTask {
24397            record_id: bound.record_id.clone(),
24398            expected_revision: 1,
24399            target: SessionTaskTargetV1::Clear,
24400        };
24401        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24402            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24403        }));
24404        assert!(!request_uses_unnegotiated_capability(
24405            &request,
24406            &baseline_capabilities().unwrap(),
24407        ));
24408        assert!(request_uses_unnegotiated_capability(&request, &[]));
24409
24410        let mut snapshot = terminal_test_shared().snapshot();
24411        snapshot.session_records.push(bound.clone());
24412        let mut reply = ResponseEnvelope {
24413            request_id: 1,
24414            result: Ok(NodeResponse::Resync {
24415                event_sequence: 1,
24416                oldest_available_sequence: 1,
24417                snapshot,
24418                events: vec![NodeEventEnvelope {
24419                    sequence: 1,
24420                    event: NodeEvent::SessionRecordUpserted {
24421                        record: bound.clone(),
24422                    },
24423                }],
24424            }),
24425        };
24426        project_response_without_session_task_binding(&mut reply);
24427        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24428            panic!("task projection changed the response kind");
24429        };
24430        assert!(snapshot.session_records[0].task_binding.is_none());
24431        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24432            panic!("task projection changed the event kind");
24433        };
24434        assert!(record.task_binding.is_none());
24435
24436        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24437            sequence: 2,
24438            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24439        });
24440        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24441            panic!("task event projection changed the event kind");
24442        };
24443        assert!(record.task_binding.is_none());
24444
24445        let mut direct = ResponseEnvelope {
24446            request_id: 2,
24447            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24448        };
24449        project_response_without_session_task_binding(&mut direct);
24450        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24451            panic!("task reply projection changed the response kind");
24452        };
24453        assert!(record.task_binding.is_none());
24454    }
24455
24456    #[test]
24457    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24458        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24459        let workspace = WorkspaceConfig::new(
24460            WorkspaceId::new("primary").unwrap(),
24461            std::env::current_dir().unwrap(),
24462        )
24463        .unwrap();
24464        let config = NodeServerConfig::new(
24465            DEFAULT_NODE_ENDPOINT,
24466            "fixture-token",
24467            NodeId::new("node-1").unwrap(),
24468            [workspace],
24469        )
24470        .unwrap();
24471        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24472    }
24473
24474    #[cfg(all(feature = "fixture", windows))]
24475    #[test]
24476    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24477        let root = temporary_workspace_root("provider-bundle-argv-hold");
24478        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24479        std::fs::create_dir_all(&root).unwrap();
24480        std::fs::create_dir_all(&outside).unwrap();
24481        let proof_path = root.join("provider.proof");
24482        let release_signal = root.join("release.signal");
24483        let config = NodeServerConfig::new(
24484            format!(
24485                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24486                std::process::id(),
24487            ),
24488            "fixture-token",
24489            NodeId::new("provider-bundle-hold-node").unwrap(),
24490            [WorkspaceConfig::new(
24491                WorkspaceId::new("primary").unwrap(),
24492                std::env::current_dir().unwrap(),
24493            )
24494            .unwrap()],
24495        )
24496        .unwrap()
24497        .with_spawn_profiles(SpawnProfileRegistry::new([
24498            crate::protocol::SpawnProfileDefaults {
24499                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24500                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24501                    .unwrap(),
24502                provider: agent("kimi"),
24503                mode: SessionMode::Pty,
24504                terminal_size: gate4agent_types::TerminalSize {
24505                    rows: 24,
24506                    columns: 80,
24507                },
24508                prompt: None,
24509                bundle_id: None,
24510                context_id: None,
24511                environment_profile_id: None,
24512            },
24513        ])
24514        .unwrap());
24515        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24516            config,
24517            agent("kimi"),
24518            proof_path.clone(),
24519            release_signal.clone(),
24520        )
24521        .unwrap();
24522        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24523
24524        let spec = NodeServer::provider_bundle_argv_fixture_spec(
24525            agent("kimi"),
24526            proof_path.clone(),
24527            Some(release_signal.clone()),
24528        )
24529        .unwrap();
24530        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24531        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24532        let release_wait = wrapper
24533            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24534            .unwrap();
24535        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24536        assert!(proof_write < release_wait);
24537        assert!(release_wait < fixture_ready);
24538        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24539        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24540        assert_eq!(
24541            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24542            &[
24543                proof_path.to_string_lossy().into_owned(),
24544                release_signal.to_string_lossy().into_owned(),
24545            ],
24546        );
24547
24548        let error = NodeServer::provider_bundle_argv_fixture_spec(
24549            agent("kimi"),
24550            proof_path,
24551            Some(outside.join("release.signal")),
24552        )
24553        .unwrap_err();
24554        assert!(matches!(
24555            error,
24556            NodeServerError::Registry(message)
24557                if message == "provider bundle argv release path must share the proof parent"
24558        ));
24559        drop(server);
24560        std::fs::remove_dir_all(root).unwrap();
24561        std::fs::remove_dir_all(outside).unwrap();
24562    }
24563
24564    #[cfg(feature = "fixture")]
24565    #[test]
24566    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24567        let root = temporary_workspace_root("context-fixture-registry");
24568        std::fs::create_dir_all(&root).unwrap();
24569        let proof_path = root.join("context.proof");
24570        let config = NodeServerConfig::new(
24571            format!(
24572                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24573                std::process::id(),
24574            ),
24575            "fixture-token",
24576            NodeId::new("context-fixture-node").unwrap(),
24577            [WorkspaceConfig::new(
24578                WorkspaceId::new("primary").unwrap(),
24579                std::env::current_dir().unwrap(),
24580            )
24581            .unwrap()],
24582        )
24583        .unwrap();
24584        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24585        let mut enabled = server
24586            .shared
24587            .enabled_providers
24588            .iter()
24589            .map(AgentId::as_str)
24590            .collect::<Vec<_>>();
24591        enabled.sort_unstable();
24592        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24593        for provider in &server.shared.enabled_providers {
24594            assert!(server
24595                .shared
24596                .provider_adapter_contracts
24597                .iter()
24598                .any(|contract| {
24599                    &contract.provider == provider
24600                        && contract.family == AdapterFamily::History
24601                }));
24602        }
24603        drop(server);
24604        std::fs::remove_dir_all(root).unwrap();
24605    }
24606
24607    #[cfg(feature = "fixture")]
24608    #[test]
24609    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24610        let root = temporary_workspace_root("monitoring-context-fixture");
24611        std::fs::create_dir_all(&root).unwrap();
24612        let proof_path = root.join("context.proof");
24613        let standard = NodeServer::context_pack_fixture_catalog(
24614            proof_path.clone(),
24615            None,
24616            false,
24617        ).unwrap();
24618        let monitoring = NodeServer::context_pack_fixture_catalog(
24619            proof_path.clone(),
24620            None,
24621            true,
24622        ).unwrap();
24623        let claude = AgentId::new("claude").unwrap();
24624        let standard_claude = standard.get(&claude).unwrap();
24625        let monitoring_claude = monitoring.get(&claude).unwrap();
24626        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
24627        // standard nor the monitoring fixture declares a hook adapter
24628        // anymore.
24629        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24630        assert_eq!(
24631            monitoring_claude.capabilities.adapters.history,
24632            standard_claude.capabilities.adapters.history,
24633        );
24634        assert_ne!(monitoring_claude.launch, standard_claude.launch);
24635        let codex = AgentId::new("codex").unwrap();
24636        let standard_codex = standard.get(&codex).unwrap();
24637        let monitoring_codex = monitoring.get(&codex).unwrap();
24638        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24639        assert_eq!(
24640            monitoring_codex.capabilities.adapters.history,
24641            standard_codex.capabilities.adapters.history,
24642        );
24643        assert_ne!(monitoring_codex.launch, standard_codex.launch);
24644        let codex_validation = monitoring_codex.launch.fixed_args.iter()
24645            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24646            .unwrap();
24647        for required in [
24648            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24649            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24650            "@('role', 'text')",
24651            "g4a-private-provider-session-canary",
24652            "private-provider-login-identity",
24653            "$rawProof",
24654            "$env:GATE4AGENT_HOOK_URL",
24655            "g4a-private-provider-session-canary",
24656        ] {
24657            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24658        }
24659        for provider in ["grok", "kimi"] {
24660            let provider = AgentId::new(provider).unwrap();
24661            assert_eq!(monitoring.get(&provider), standard.get(&provider));
24662        }
24663
24664        let config = NodeServerConfig::new(
24665            format!(
24666                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24667                std::process::id(),
24668            ),
24669            "fixture-token",
24670            NodeId::new("monitoring-context-fixture-node").unwrap(),
24671            [WorkspaceConfig::new(
24672                WorkspaceId::new("primary").unwrap(),
24673                std::env::current_dir().unwrap(),
24674            )
24675            .unwrap()],
24676        )
24677        .unwrap();
24678        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24679        assert!(server.shared.fixture_semantic_hook_policy);
24680        drop(server);
24681        std::fs::remove_dir_all(root).unwrap();
24682    }
24683
24684    #[cfg(feature = "fixture")]
24685    #[test]
24686    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24687        let root = temporary_workspace_root("kimi-context-only-fixture");
24688        std::fs::create_dir_all(&root).unwrap();
24689        let proof_path = root.join("context.proof");
24690        let standard = NodeServer::context_pack_fixture_catalog(
24691            proof_path.clone(),
24692            None,
24693            false,
24694        ).unwrap();
24695        let kimi = AgentId::new("kimi").unwrap();
24696        let context_only = NodeServer::context_pack_fixture_catalog(
24697            proof_path,
24698            Some(&kimi),
24699            false,
24700        )
24701        .unwrap();
24702
24703        let codex = AgentId::new("codex").unwrap();
24704        assert_eq!(
24705            standard.get(&codex).unwrap().launch.fixed_args,
24706            context_only.get(&codex).unwrap().launch.fixed_args,
24707        );
24708        let script = context_only
24709            .get(&kimi)
24710            .unwrap()
24711            .launch
24712            .fixed_args
24713            .iter()
24714            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24715            .expect("Kimi context-only validation script is missing");
24716        for required in [
24717            "$bundleArgs.Count -ne 0",
24718            "GATE4AGENT_CONTEXT_ROOT",
24719            "context-pack.json",
24720            "$entries.Count -ne 1",
24721            "g4a-context-pack-v1",
24722            "$document.PSObject.Properties['cwd']",
24723            "'claude', 'codex', 'grok', 'kimi'",
24724            "$hasUser",
24725            "$hasAssistant",
24726            "SHA256",
24727            "context-only",
24728        ] {
24729            assert!(script.contains(required), "missing validation: {required}");
24730        }
24731        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24732            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24733        }
24734        assert!(!standard
24735            .get(&kimi)
24736            .unwrap()
24737            .launch
24738            .fixed_args
24739            .iter()
24740            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24741
24742        std::fs::remove_dir_all(root).unwrap();
24743    }
24744
24745    #[cfg(all(feature = "fixture", windows))]
24746    #[tokio::test]
24747    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24748        let root = temporary_workspace_root("kimi-context-only-spawn");
24749        let workspace_root = root.join("workspace");
24750        std::fs::create_dir_all(&workspace_root).unwrap();
24751        let materialization_root = root.join("private-materializations");
24752        let proof_path = root.join("context.proof");
24753        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24754        let workspace_id = WorkspaceId::new("primary").unwrap();
24755        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24756        let endpoint = format!(
24757            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24758            std::process::id(),
24759            unix_time_ms(),
24760        );
24761        let spawn_profiles = SpawnProfileRegistry::new([
24762            crate::protocol::SpawnProfileDefaults {
24763                profile_id: profile_id.clone(),
24764                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24765                provider: agent("kimi"),
24766                mode: SessionMode::Pty,
24767                terminal_size: gate4agent_types::TerminalSize {
24768                    rows: 30,
24769                    columns: 120,
24770                },
24771                prompt: None,
24772                bundle_id: None,
24773                context_id: None,
24774                environment_profile_id: None,
24775            },
24776        ])
24777        .unwrap();
24778        let mut config = NodeServerConfig::new(
24779            &endpoint,
24780            "fixture-token",
24781            node_id.clone(),
24782            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24783        )
24784        .unwrap()
24785        .with_spawn_profiles(spawn_profiles)
24786        .with_session_environment_materialization(
24787            materialization_root,
24788            Arc::new(FixtureSecretResolver {
24789                deny: Arc::new(AtomicBool::new(false)),
24790            }),
24791        )
24792        .unwrap();
24793        config.fixture_raw_pty_runtime = true;
24794        let server = NodeServer::new_context_only_proof_fixture(
24795            config,
24796            agent("kimi"),
24797            proof_path.clone(),
24798        )
24799        .unwrap();
24800        let shared = Arc::clone(&server.shared);
24801        let shutdown = server.shutdown_handle();
24802
24803        let history = HistorySessionRecord {
24804            session_id: "context-only-source-history".to_owned(),
24805            title: None,
24806            cwd: None,
24807            model: None,
24808            message_count: 2,
24809            completed_turn_count: None,
24810            total_tokens: 0,
24811            messages: vec![
24812                gate4agent_types::HistoryMessageRecord {
24813                    role: gate4agent_types::HistoryMessageRole::User,
24814                    text: "continue the bounded parent run through native C2".to_owned(),
24815                },
24816                gate4agent_types::HistoryMessageRecord {
24817                    role: gate4agent_types::HistoryMessageRole::Assistant,
24818                    text: "the exact parent context is ready for the child run".to_owned(),
24819                },
24820            ],
24821        };
24822        let pack = NodeContextPack::export(
24823            ContextPackLineageReceipt {
24824                source_node_id: node_id.clone(),
24825                source_session: SessionAddress {
24826                    workspace_id: workspace_id.clone(),
24827                    session: SessionKey {
24828                        instance_id: AgentInstanceId(71),
24829                        generation: SessionGeneration(1),
24830                    },
24831                },
24832                source_provider: agent("codex"),
24833            },
24834            &history,
24835        )
24836        .unwrap();
24837        let expected_context_hash = digest(&SHA256, pack.bytes())
24838            .as_ref()
24839            .iter()
24840            .map(|byte| format!("{byte:02x}"))
24841            .collect::<String>();
24842        let context = shared
24843            .context_catalog
24844            .write()
24845            .unwrap_or_else(|poisoned| poisoned.into_inner())
24846            .insert(pack)
24847            .unwrap();
24848        let spec = SpawnSpec {
24849            target: crate::protocol::SpawnTarget {
24850                node_id,
24851                workspace_id: workspace_id.clone(),
24852                worktree_id: None,
24853            },
24854            profile_id,
24855            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24856                "target-kimi-r1",
24857            ).unwrap(),
24858            overrides: crate::protocol::SpawnOverrides {
24859                context_id: crate::protocol::SpawnOverride::Set {
24860                    value: context.id.clone(),
24861                },
24862                ..crate::protocol::SpawnOverrides::default()
24863            },
24864            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24865            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24866            required_capabilities: SpawnRequiredCapabilities::default(),
24867        };
24868        let resolved = match shared.resolve_spawn_spec(&spec) {
24869            Ok(resolved) => resolved,
24870            Err(error) => panic!(
24871                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24872                error.code,
24873                error.message,
24874            ),
24875        };
24876        let resolved_context = match shared.resolve_context(&resolved) {
24877            Ok(Some(resolved_context)) => resolved_context,
24878            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24879            Err(error) => panic!(
24880                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24881                error.code,
24882                error.message,
24883            ),
24884        };
24885        assert_eq!(resolved_context, context);
24886        let runtime_policy = match shared
24887            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24888            .await
24889        {
24890            Ok(runtime_policy) => runtime_policy,
24891            Err(error) => panic!(
24892                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24893                error.code,
24894                error.message,
24895            ),
24896        };
24897        assert!(runtime_policy.raw_pty_lifecycle);
24898
24899        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24900        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24901            agent("kimi"),
24902            TransportKind::Pty,
24903            vec![EnvMutation {
24904                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24905                value: Some(OsString::from("must-remain-rejected")),
24906            }],
24907        )
24908        .unwrap();
24909        assert_eq!(
24910            launch_control
24911                .install_native_launch_environment_overlay(
24912                    AgentInstanceId(72),
24913                    generic_overlay,
24914                )
24915                .unwrap_err(),
24916            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24917        );
24918
24919        let diagnostic_address = SessionAddress {
24920            workspace_id: workspace_id.clone(),
24921            session: SessionKey {
24922                instance_id: AgentInstanceId(73),
24923                generation: SessionGeneration(1),
24924            },
24925        };
24926        let (diagnostic_overlay, diagnostic_materialization) = match shared
24927            .prepare_session_materialization(
24928                &diagnostic_address,
24929                &agent("kimi"),
24930                SessionMode::Pty,
24931                None,
24932                None,
24933                Some(&resolved_context),
24934                None,
24935            )
24936        {
24937            Ok(Some(prepared)) => prepared,
24938            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24939            Err(error) => panic!(
24940                "Kimi context-only materialization rejected: exact_code={:?} message={}",
24941                error.code,
24942                error.message,
24943            ),
24944        };
24945        let diagnostic_overlay = diagnostic_overlay
24946            .expect("Kimi context-only materialization omitted its environment overlay");
24947        assert!(matches!(
24948            &diagnostic_overlay,
24949            PreparedNativeLaunchOverlay::Environment(_),
24950        ));
24951        match shared.install_prepared_launch_overlay(
24952            diagnostic_address.session.instance_id,
24953            diagnostic_overlay,
24954        ) {
24955            Ok(None) => {}
24956            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24957            Err(error) => panic!(
24958                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24959                error.code,
24960                error.message,
24961            ),
24962        }
24963        assert!(launch_control.clear_native_instance_launch_overlay(
24964            diagnostic_address.session.instance_id,
24965        ));
24966        drop(diagnostic_materialization);
24967        assert!(shared
24968            .materializations
24969            .lock()
24970            .unwrap_or_else(|poisoned| poisoned.into_inner())
24971            .is_empty());
24972
24973        let node_task = tokio::spawn(server.run());
24974        let ready_client = timeout(Duration::from_secs(5), async {
24975            loop {
24976                match gate4agent_node_wire::LocalNodeClient::connect(
24977                    &endpoint,
24978                    &shared.node_id,
24979                    ClientRole::Observer,
24980                    "fixture-token",
24981                )
24982                .await
24983                {
24984                    Ok(client) => break client,
24985                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24986                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24987                }
24988            }
24989        })
24990        .await
24991        .expect("Kimi context-only Node did not become ready");
24992        assert_eq!(
24993            ready_client
24994                .hello()
24995                .snapshot
24996                .provider_runtime_statuses
24997                .iter()
24998                .find(|status| status.provider().as_str() == "kimi")
24999                .expect("Kimi runtime status is missing")
25000                .mode(),
25001            crate::protocol::ProviderRuntimeMode::RawPassthrough,
25002        );
25003        drop(ready_client);
25004
25005        let receipt = match shared.spawn_from_spec(spec).await {
25006            Ok(receipt) => receipt,
25007            Err(error) => panic!(
25008                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25009                error.code,
25010                error.message,
25011            ),
25012        };
25013        assert_eq!(receipt.provider, agent("kimi"));
25014        assert_eq!(receipt.context.as_ref(), Some(&context));
25015        assert!(receipt.context_binding_is_valid());
25016        timeout(Duration::from_secs(5), async {
25017            while !proof_path.is_file() {
25018                sleep(Duration::from_millis(10)).await;
25019            }
25020        })
25021        .await
25022        .expect("Kimi context-only fixture did not write its proof");
25023
25024        let proof = std::fs::read_to_string(&proof_path).unwrap();
25025        let lines = proof.lines().collect::<Vec<_>>();
25026        assert_eq!(lines.len(), 7);
25027        assert_eq!(lines[0], "context-only");
25028        assert!(Path::new(lines[1]).is_absolute());
25029        assert_ne!(
25030            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25031            std::fs::canonicalize(&workspace_root).unwrap(),
25032        );
25033        assert_eq!(
25034            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25035            std::fs::canonicalize(&workspace_root).unwrap(),
25036        );
25037        assert_eq!(lines[3], expected_context_hash);
25038        assert_eq!(lines[4], "g4a-context-pack-v1");
25039        assert_eq!(lines[5], "codex");
25040        assert_eq!(lines[6], "2");
25041        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25042            session.instance_id == receipt.session.session.instance_id
25043                && session.agent_id.as_str() == "kimi"
25044        }));
25045
25046        shutdown.request_shutdown().await.unwrap();
25047        timeout(Duration::from_secs(5), node_task)
25048            .await
25049            .unwrap()
25050            .unwrap()
25051            .unwrap();
25052        drop(shutdown);
25053        drop(shared);
25054        std::fs::remove_dir_all(root).unwrap();
25055    }
25056
25057    #[cfg(feature = "fixture")]
25058    #[test]
25059    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25060        let root = temporary_workspace_root("clean-exit-constructor");
25061        std::fs::create_dir_all(&root).unwrap();
25062        let config = construction_test_config("clean-exit-constructor")
25063            .with_state_path(root.join("node-state.json"))
25064            .unwrap();
25065        let server = NodeServer::new_clean_exit_fixture(
25066            config,
25067            root.clone(),
25068            root.join("started.marker"),
25069            root.join("release.signal"),
25070        )
25071        .unwrap();
25072        assert_eq!(
25073            server
25074                .shared
25075                .enabled_providers
25076                .iter()
25077                .map(AgentId::as_str)
25078                .collect::<Vec<_>>(),
25079            ["claude"],
25080        );
25081        drop(server);
25082        std::fs::remove_dir_all(root).unwrap();
25083    }
25084
25085    #[test]
25086    fn production_registry_provider_contract_manifest_is_exact() {
25087        let registry = active_registry().unwrap();
25088        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25089        assert_eq!(
25090            providers
25091                .iter()
25092                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25093                .collect::<Vec<_>>(),
25094            vec![
25095                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25096                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25097                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25098                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25099            ]
25100        );
25101        assert_eq!(
25102            adapters
25103                .iter()
25104                .map(|contract| (
25105                    contract.provider.as_str(),
25106                    contract.family,
25107                    contract.adapter_id.as_str(),
25108                    contract.revision.as_str(),
25109                ))
25110                .collect::<Vec<_>>(),
25111            vec![
25112                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25113                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25114                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25115                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25116                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25117                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25118                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25119                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25120                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25121                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25122                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25123                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25124                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25125                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25126                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25127                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25128                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25129                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25130                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25131                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25132                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25133                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25134                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25135            ]
25136        );
25137    }
25138
25139    fn construction_test_config(name: &str) -> NodeServerConfig {
25140        let workspace = WorkspaceConfig::new(
25141            WorkspaceId::new("primary").unwrap(),
25142            std::env::current_dir().unwrap(),
25143        )
25144        .unwrap();
25145        NodeServerConfig::new(
25146            format!(r"\\.\pipe\gate4agent-{name}"),
25147            "fixture-token",
25148            NodeId::new(name).unwrap(),
25149            [workspace],
25150        )
25151        .unwrap()
25152    }
25153
25154    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25155        let adapter = AdapterBinding::new(
25156            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25157            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25158            gate4agent_types::AdapterVerification::SyntheticFixture,
25159        )
25160        .unwrap();
25161        let agent_ids = ["claude", "codex", "kimi"]
25162            .into_iter()
25163            .map(|agent_id| AgentId::new(agent_id).unwrap())
25164            .collect::<Vec<_>>();
25165        let adapters = gate4agent_catalog::AdapterRegistry::new(
25166            [
25167                AdapterFamily::PtySemantic,
25168                AdapterFamily::Pipe,
25169                AdapterFamily::OneShot,
25170                AdapterFamily::Acp,
25171                AdapterFamily::Hook,
25172                AdapterFamily::ManagedHook,
25173                AdapterFamily::History,
25174                AdapterFamily::Resume,
25175                AdapterFamily::SessionOptions,
25176                AdapterFamily::CapabilityProbe,
25177            ]
25178            .into_iter()
25179            .map(|family| gate4agent_catalog::AdapterDescriptor {
25180                family,
25181                binding: adapter.clone(),
25182                agents: agent_ids.clone(),
25183            }),
25184        )
25185        .unwrap();
25186        let specs = agent_ids
25187            .iter()
25188            .map(|agent_id| {
25189                let agent_id = agent_id.as_str();
25190                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25191                spec.revision =
25192                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25193                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25194                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25195                    adapter: adapter.clone(),
25196                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25197                    launch_override: None,
25198                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25199                });
25200                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25201                    adapter: adapter.clone(),
25202                    launch_override: None,
25203                });
25204                spec.capabilities.adapters.hook = Some(adapter.clone());
25205                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25206                spec.capabilities.adapters.one_shot = Some(adapter.clone());
25207                spec.capabilities.adapters.history = Some(adapter.clone());
25208                spec.capabilities.adapters.resume = Some(adapter.clone());
25209                spec.capabilities.adapters.session_options = Some(adapter.clone());
25210                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25211                spec
25212            })
25213            .collect::<Vec<_>>();
25214        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25215    }
25216
25217    /// The real fleet's manifest is accepted by a real server construction.
25218    ///
25219    /// The count is asserted against `active_registry` itself rather than a
25220    /// literal, so this stays a test that construction succeeds for whatever
25221    /// the fleet is -- which is the property worth guarding -- instead of a
25222    /// second place the fleet size is written down and has to be kept in
25223    /// step with the first.
25224    #[test]
25225    fn node_server_construction_accepts_the_production_manifest() {
25226        let registry = active_registry().unwrap();
25227        let (providers, _) = provider_contract_manifest(&registry).unwrap();
25228        assert_eq!(providers.len(), registry.iter().count());
25229        assert!(!providers.is_empty());
25230        let server = NodeServer::new_with_registry(
25231            construction_test_config("manifest-production-bound"),
25232            registry,
25233        )
25234        .unwrap();
25235        drop(server);
25236    }
25237
25238    #[test]
25239    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25240        let registry = maximum_size_provider_manifest_registry();
25241        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25242        assert_eq!(providers.len(), 3);
25243        assert_eq!(adapters.len(), 30);
25244        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25245        let error = match NodeServer::new_with_registry(
25246            construction_test_config("manifest-handshake-overflow"),
25247            registry,
25248        ) {
25249            Ok(_) => panic!("oversized negotiated manifest was accepted"),
25250            Err(error) => error,
25251        };
25252        assert!(matches!(
25253            error,
25254            NodeServerError::ProviderContractManifest(message)
25255                if message.contains("authentication binding length")
25256                    && message.contains("8192-byte limit")
25257        ));
25258    }
25259
25260    #[test]
25261    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25262        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25263        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25264        let registry = AgentRegistry::new([spec]).unwrap();
25265        assert!(matches!(
25266            provider_contract_manifest(&registry),
25267            Err(NodeServerError::ProviderContractManifest(message))
25268                if message.contains("exceeds the 128-byte limit")
25269        ));
25270    }
25271
25272    #[test]
25273    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25274        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25275        spec.revision = "invalid revision".to_owned();
25276        let registry = AgentRegistry::new([spec]).unwrap();
25277        assert!(matches!(
25278            provider_contract_manifest(&registry),
25279            Err(NodeServerError::ProviderContractManifest(message))
25280                if message.contains("bounded lowercase ASCII")
25281        ));
25282    }
25283
25284    #[test]
25285    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25286        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25287        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25288        let conflicting_binding = builtin_registry()
25289            .get_by_id("codex")
25290            .unwrap()
25291            .capabilities
25292            .adapters
25293            .one_shot
25294            .clone()
25295            .unwrap();
25296        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25297        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25298        pipe.adapter = transport_binding;
25299        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25300        let registry = AgentRegistry::new([spec]).unwrap();
25301        assert!(matches!(
25302            provider_contract_manifest(&registry),
25303            Err(NodeServerError::ProviderContractManifest(message))
25304                if message.contains("conflicting OneShot adapter bindings")
25305        ));
25306    }
25307
25308    #[test]
25309    fn public_node_failures_expose_only_fixed_categories() {
25310        let provider = failure(
25311            NodeFailureCode::BackendOperationFailed,
25312            r"provider bearer-secret failed at C:\private\session.jsonl",
25313        );
25314        assert_eq!(provider.message, "backend-operation-failed");
25315        assert!(!provider.message.contains("bearer-secret"));
25316        assert!(!provider.message.contains(r"C:\private"));
25317        let persistence = persistence_failure(io::Error::new(
25318            io::ErrorKind::Other,
25319            r"state write failed at C:\private\state-v1.json",
25320        ));
25321        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25322        assert!(!persistence.message.contains(r"C:\private"));
25323
25324        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25325            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25326            assert_eq!(
25327                generic.to_string(),
25328                "node durable state failed: durable-state-load-failed",
25329            );
25330        }
25331    }
25332
25333    #[test]
25334    fn terminal_byte_sequences_are_bounded_before_dispatch() {
25335        let sequence = b"\x1b[1;5D".to_vec();
25336        assert_eq!(
25337            terminal_bytes_action(sequence.clone()).unwrap(),
25338            InputAction::TerminalBytes(sequence),
25339        );
25340        assert_eq!(
25341            terminal_bytes_action(Vec::new()).unwrap_err().code,
25342            NodeFailureCode::InvalidRequest,
25343        );
25344        assert_eq!(
25345            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25346                .unwrap_err()
25347                .code,
25348            NodeFailureCode::InvalidRequest,
25349        );
25350    }
25351
25352    #[test]
25353    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25354        let root = temporary_workspace_root("exclusive-state-owner");
25355        std::fs::create_dir_all(&root).unwrap();
25356        let workspace = WorkspaceConfig::new(
25357            WorkspaceId::new("primary").unwrap(),
25358            &root,
25359        )
25360        .unwrap();
25361        let state_path = root.join("state-v1.json");
25362        let endpoint = format!(
25363            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25364            std::process::id(),
25365            std::time::SystemTime::now()
25366                .duration_since(std::time::UNIX_EPOCH)
25367                .unwrap()
25368                .as_nanos(),
25369        );
25370        let config = NodeServerConfig::new(
25371            endpoint,
25372            "fixture-token",
25373            NodeId::new("node-1").unwrap(),
25374            [workspace],
25375        )
25376        .unwrap()
25377        .with_state_path(&state_path)
25378        .unwrap();
25379
25380        let first = NodeServer::new(config.clone()).unwrap();
25381        let error = match NodeServer::new(config.clone()) {
25382            Ok(_) => panic!("a second node server acquired the same durable state path"),
25383            Err(error) => error,
25384        };
25385        assert!(matches!(
25386            &error,
25387            NodeServerError::DurableState(error)
25388                if error.kind() == io::ErrorKind::WouldBlock
25389        ));
25390        assert_eq!(
25391            error.to_string(),
25392            "node durable state failed: durable-state-lock-failed",
25393        );
25394        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25395        drop(first);
25396        let second = NodeServer::new(config).unwrap();
25397        drop(second);
25398        std::fs::remove_dir_all(root).unwrap();
25399    }
25400
25401    #[test]
25402    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25403        let catalog = active_registry().unwrap();
25404        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25405        let workspace_id = WorkspaceId::new("primary").unwrap();
25406        let workspace = WorkspaceConfig::new(
25407            workspace_id.clone(),
25408            std::env::current_dir().unwrap(),
25409        )
25410        .unwrap();
25411        let shared = NodeShared::new(
25412            handle,
25413            "fixture-token".to_owned(),
25414            NodeId::new("node-1").unwrap(),
25415            vec![workspace.clone()],
25416            vec![agent("claude"), agent("codex"), agent("kimi")],
25417        );
25418        let instance_id = AgentInstanceId(17);
25419        let generation = SessionGeneration(3);
25420        assert!(shared.address_for(instance_id, generation).is_none());
25421        shared.bind_session(instance_id, workspace_id.clone(), generation);
25422        let address = SessionAddress {
25423            workspace_id,
25424            session: SessionKey { instance_id, generation },
25425        };
25426        assert_eq!(
25427            shared.workspace_root(&address.workspace_id).unwrap(),
25428            workspace.canonical_root,
25429        );
25430        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25431        shared.remove_binding(&address);
25432        assert!(shared.address_for(instance_id, generation).is_none());
25433    }
25434
25435    #[test]
25436    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25437        let address = SessionAddress {
25438            workspace_id: WorkspaceId::new("primary").unwrap(),
25439            session: SessionKey {
25440                instance_id: AgentInstanceId(7),
25441                generation: SessionGeneration(2),
25442            },
25443        };
25444        let record_id = SessionRecordId::new("record-a").unwrap();
25445        let size = gate4agent_types::TerminalSize {
25446            rows: 24,
25447            columns: 80,
25448        };
25449        let address_requests = [
25450            NodeRequest::Resume {
25451                session: address.clone(),
25452                terminal_size: size,
25453                initial_prompt: None,
25454            },
25455            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25456            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25457            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25458            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25459            NodeRequest::TerminalControl {
25460                session: address.clone(),
25461                control: TerminalControl::Enter,
25462            },
25463            NodeRequest::Resize { session: address.clone(), size },
25464            NodeRequest::Interrupt { session: address.clone() },
25465            NodeRequest::Stop { session: address.clone(), force: false },
25466            NodeRequest::Remove { session: address },
25467        ];
25468        for request in &address_requests {
25469            assert!(request_requires_open_provider_ids_with(
25470                request,
25471                |_| Some(agent("third-party-agent")),
25472                |_| Some(agent("claude")),
25473            ));
25474            assert!(!request_requires_open_provider_ids_with(
25475                request,
25476                |_| Some(agent("claude")),
25477                |_| Some(agent("claude")),
25478            ));
25479            assert!(request_requires_open_provider_ids_with(
25480                request,
25481                |_| None,
25482                |_| Some(agent("claude")),
25483            ));
25484        }
25485
25486        let record_requests = [
25487            NodeRequest::RenameSessionRecord {
25488                record_id: record_id.clone(),
25489                display_name: "renamed".to_owned(),
25490            },
25491            NodeRequest::ResumeSessionRecord {
25492                record_id: record_id.clone(),
25493                terminal_size: size,
25494                initial_prompt: None,
25495            },
25496            NodeRequest::ForgetSessionRecord { record_id },
25497        ];
25498        for request in &record_requests {
25499            assert!(request_requires_open_provider_ids_with(
25500                request,
25501                |_| Some(agent("claude")),
25502                |_| Some(agent("grok")),
25503            ));
25504            assert!(!request_requires_open_provider_ids_with(
25505                request,
25506                |_| Some(agent("claude")),
25507                |_| Some(agent("kimi")),
25508            ));
25509            assert!(request_requires_open_provider_ids_with(
25510                request,
25511                |_| Some(agent("claude")),
25512                |_| None,
25513            ));
25514        }
25515
25516        assert!(request_requires_open_provider_ids_with(
25517            &NodeRequest::Spawn {
25518                workspace_id: WorkspaceId::new("primary").unwrap(),
25519                provider: agent("third-party-agent"),
25520                mode: SessionMode::Pty,
25521                terminal_size: size,
25522                initial_prompt: None,
25523            },
25524            |_| None,
25525            |_| None,
25526        ));
25527        assert!(!request_requires_open_provider_ids_with(
25528            &NodeRequest::Snapshot,
25529            |_| None,
25530            |_| None,
25531        ));
25532        assert!(request_requires_open_provider_ids_with(
25533            &NodeRequest::ForgetContextPack {
25534                context_id: SpawnContextId::new("ctx-forget").unwrap(),
25535            },
25536            |_| None,
25537            |_| None,
25538        ));
25539    }
25540
25541    #[test]
25542    fn legacy_projection_filters_open_record_replies_and_event_history() {
25543        let catalog = active_registry().unwrap();
25544        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25545        let workspace = WorkspaceConfig::new(
25546            WorkspaceId::new("primary").unwrap(),
25547            std::env::current_dir().unwrap(),
25548        )
25549        .unwrap();
25550        let shared = NodeShared::new(
25551            handle,
25552            "fixture-token".to_owned(),
25553            NodeId::new("node-1").unwrap(),
25554            vec![workspace],
25555            vec![agent("claude"), agent("grok")],
25556        );
25557        let legacy = record("claude", "legacy-record");
25558        let open = record("grok", "open-record");
25559        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25560            record: legacy.clone(),
25561        });
25562        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25563            record: open.clone(),
25564        });
25565        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25566            record_id: legacy.record_id.clone(),
25567        });
25568        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25569            record_id: open.record_id.clone(),
25570        });
25571        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25572        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25573        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25574        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25575
25576        let mut record_reply = ResponseEnvelope {
25577            request_id: 1,
25578            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25579        };
25580        project_response_legacy_provider_ids(&shared, &mut record_reply);
25581        assert!(matches!(
25582            record_reply.result,
25583            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25584        ));
25585
25586        let mut resync = ResponseEnvelope {
25587            request_id: 2,
25588            result: Ok(NodeResponse::Resync {
25589                event_sequence: open_removed.sequence,
25590                oldest_available_sequence: 1,
25591                snapshot: shared.snapshot(),
25592                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25593            }),
25594        };
25595        project_response_legacy_provider_ids(&shared, &mut resync);
25596        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25597            panic!("legacy projection changed the resync response kind");
25598        };
25599        assert!(snapshot.session_records.iter().all(|item| {
25600            provider_id_is_legacy(&item.provider)
25601        }));
25602        assert_eq!(events.len(), 2);
25603        assert!(events.iter().all(|event| matches!(
25604            &event.event,
25605            NodeEvent::SessionRecordUpserted { record }
25606                if provider_id_is_legacy(&record.provider)
25607        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25608    }
25609
25610    #[test]
25611    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25612        let catalog = active_registry().unwrap();
25613        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25614        let workspace_id = WorkspaceId::new("primary").unwrap();
25615        let workspace = WorkspaceConfig::new(
25616            workspace_id.clone(),
25617            std::env::current_dir().unwrap(),
25618        )
25619        .unwrap();
25620        let shared = NodeShared::new(
25621            handle,
25622            "fixture-token".to_owned(),
25623            NodeId::new("node-1").unwrap(),
25624            vec![workspace],
25625            vec![agent("claude")],
25626        );
25627        for index in 0..CONTROL_SESSIONS_MAX {
25628            shared.bind_session(
25629                AgentInstanceId((index + 1) as u64),
25630                workspace_id.clone(),
25631                SessionGeneration(1),
25632            );
25633        }
25634        let error = shared.ensure_binding_capacity().unwrap_err();
25635        assert_eq!(error.code, NodeFailureCode::BackendBusy);
25636    }
25637
25638    #[tokio::test]
25639    async fn provider_spawn_admission_rejects_before_session_mutation() {
25640        let catalog = active_registry().unwrap();
25641        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25642        let workspace = WorkspaceConfig::new(
25643            WorkspaceId::new("primary").unwrap(),
25644            std::env::current_dir().unwrap(),
25645        )
25646        .unwrap();
25647        let mut shared = NodeShared::new(
25648            handle,
25649            "fixture-token".to_owned(),
25650            NodeId::new("node-provider-admission").unwrap(),
25651            vec![workspace],
25652            vec![agent("claude")],
25653        );
25654        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25655            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25656        ])
25657        .unwrap();
25658        let terminal_size = gate4agent_types::TerminalSize {
25659            rows: 24,
25660            columns: 80,
25661        };
25662
25663        let unavailable = shared
25664            .spawn_session(
25665                WorkspaceId::new("primary").unwrap(),
25666                agent("claude"),
25667                SessionMode::Pty,
25668                terminal_size,
25669                None,
25670            )
25671            .await
25672            .unwrap_err();
25673        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25674        assert_eq!(unavailable.message, "backend-operation-failed");
25675
25676        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25677            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25678                agent("claude"),
25679                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25680            ),
25681        ])
25682        .unwrap();
25683        let semantic = shared
25684            .spawn_session(
25685                WorkspaceId::new("primary").unwrap(),
25686                agent("claude"),
25687                SessionMode::Inline,
25688                terminal_size,
25689                None,
25690            )
25691            .await
25692            .unwrap_err();
25693        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25694        assert_eq!(semantic.message, "unsupported-capability");
25695
25696        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25697        assert!(shared
25698            .session_bindings
25699            .lock()
25700            .unwrap_or_else(|poisoned| poisoned.into_inner())
25701            .is_empty());
25702        assert!(shared
25703            .session_records
25704            .lock()
25705            .unwrap_or_else(|poisoned| poisoned.into_inner())
25706            .records
25707            .is_empty());
25708        assert!(shared.handle.snapshot().sessions.is_empty());
25709    }
25710
25711    #[tokio::test]
25712    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25713        let catalog = active_registry().unwrap();
25714        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25715        let workspace_id = WorkspaceId::new("primary").unwrap();
25716        let workspace = WorkspaceConfig::new(
25717            workspace_id.clone(),
25718            std::env::current_dir().unwrap(),
25719        )
25720        .unwrap();
25721        let shared = NodeShared::new(
25722            handle,
25723            "fixture-token".to_owned(),
25724            NodeId::new("node-raw-spawn-record").unwrap(),
25725            vec![workspace],
25726            vec![agent("claude")],
25727        );
25728        let address = SessionAddress {
25729            workspace_id,
25730            session: SessionKey {
25731                instance_id: AgentInstanceId(71),
25732                generation: SessionGeneration::default(),
25733            },
25734        };
25735
25736        let record_id = shared
25737            .bind_spawn_session(
25738                &address,
25739                agent("claude"),
25740                SessionMode::Pty,
25741                ProviderRuntimePolicy::raw_pty(),
25742                SpawnRecordPolicy::Always,
25743                None,
25744            )
25745            .unwrap()
25746            .unwrap();
25747        shared
25748            .handle
25749            .dispatch(shared.prepare_command(ControlCommand::Register {
25750                instance_id: address.session.instance_id,
25751                agent_id: agent("claude"),
25752                transport: TransportKind::Pty,
25753            }))
25754            .unwrap();
25755        runtime.tick().await;
25756
25757        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25758            session.instance_id == address.session.instance_id
25759                && session.generation == address.session.generation
25760        }));
25761        let record = shared.record(&record_id).unwrap();
25762        assert_eq!(record.display_name, "claude #1");
25763        assert_eq!(record.state, ManagedSessionState::Live);
25764        assert_eq!(record.active_session.as_ref(), Some(&address));
25765        assert!(record.provider_session.is_none());
25766        let binding_record_id = shared
25767            .session_bindings
25768            .lock()
25769            .unwrap_or_else(|poisoned| poisoned.into_inner())
25770            .get(&address.session.instance_id)
25771            .and_then(|binding| binding.record_id.clone());
25772        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25773
25774        let renamed = shared
25775            .rename_session_record(&record_id, "raw review".to_owned())
25776            .unwrap();
25777        assert_eq!(renamed.record_id, record_id);
25778        assert_eq!(renamed.display_name, "raw review");
25779        shared.reconcile_managed_record(
25780            &record_id,
25781            &address,
25782            &ControlEvent {
25783                sequence: 1,
25784                command_id: None,
25785                instance_id: address.session.instance_id,
25786                generation: address.session.generation,
25787                event: ControlEventKind::Running { process_id: None },
25788            },
25789            false,
25790            false,
25791        );
25792        let running = shared.record(&record_id).unwrap();
25793        assert_eq!(running.state, ManagedSessionState::Live);
25794        assert_eq!(running.active_session.as_ref(), Some(&address));
25795        assert_eq!(running.display_name, "raw review");
25796
25797        shared.reconcile_managed_record(
25798            &record_id,
25799            &address,
25800            &ControlEvent {
25801                sequence: 2,
25802                command_id: None,
25803                instance_id: address.session.instance_id,
25804                generation: address.session.generation,
25805                event: ControlEventKind::Exited {
25806                    exit_code: Some(0),
25807                    forced: false,
25808                },
25809            },
25810            false,
25811            false,
25812        );
25813        let detached = shared.record(&record_id).unwrap();
25814        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25815        assert!(detached.active_session.is_none());
25816        assert_eq!(detached.display_name, "raw review");
25817        let renamed_detached = shared
25818            .rename_session_record(&record_id, "raw review detached".to_owned())
25819            .unwrap();
25820        assert_eq!(renamed_detached.record_id, record_id);
25821        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25822        assert!(renamed_detached.active_session.is_none());
25823        assert_eq!(renamed_detached.display_name, "raw review detached");
25824        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25825    }
25826
25827    #[test]
25828    fn legacy_raw_spawn_binding_remains_untracked() {
25829        let catalog = active_registry().unwrap();
25830        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25831        let workspace_id = WorkspaceId::new("primary").unwrap();
25832        let workspace = WorkspaceConfig::new(
25833            workspace_id.clone(),
25834            std::env::current_dir().unwrap(),
25835        )
25836        .unwrap();
25837        let shared = NodeShared::new(
25838            handle,
25839            "fixture-token".to_owned(),
25840            NodeId::new("node-legacy-raw-spawn").unwrap(),
25841            vec![workspace],
25842            vec![agent("claude")],
25843        );
25844        let address = SessionAddress {
25845            workspace_id,
25846            session: SessionKey {
25847                instance_id: AgentInstanceId(72),
25848                generation: SessionGeneration::default(),
25849            },
25850        };
25851
25852        let record_id = shared
25853            .bind_spawn_session(
25854                &address,
25855                agent("claude"),
25856                SessionMode::Pty,
25857                ProviderRuntimePolicy::raw_pty(),
25858                SpawnRecordPolicy::ProviderIdentityOnly,
25859                None,
25860            )
25861            .unwrap();
25862
25863        assert!(record_id.is_none());
25864        let binding_record_id = shared
25865            .session_bindings
25866            .lock()
25867            .unwrap_or_else(|poisoned| poisoned.into_inner())
25868            .get(&address.session.instance_id)
25869            .and_then(|binding| binding.record_id.clone());
25870        assert!(binding_record_id.is_none());
25871        assert!(shared.snapshot().session_records.is_empty());
25872    }
25873
25874    #[tokio::test]
25875    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25876        let catalog = active_registry().unwrap();
25877        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25878        let workspace_id = WorkspaceId::new("primary").unwrap();
25879        let workspace = WorkspaceConfig::new(
25880            workspace_id.clone(),
25881            std::env::current_dir().unwrap(),
25882        )
25883        .unwrap();
25884        let mut shared = NodeShared::new(
25885            handle,
25886            "fixture-token".to_owned(),
25887            NodeId::new("node-raw-semantic-gate").unwrap(),
25888            vec![workspace],
25889            vec![agent("claude")],
25890        );
25891        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25892            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25893        ])
25894        .unwrap();
25895        shared
25896            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25897            .unwrap();
25898        let address = SessionAddress {
25899            workspace_id,
25900            session: SessionKey {
25901                instance_id: AgentInstanceId(72),
25902                generation: SessionGeneration::default(),
25903            },
25904        };
25905        shared.bind_session_with_policy(
25906            &address,
25907            ProviderRuntimePolicy::raw_pty(),
25908            None,
25909        );
25910        shared
25911            .handle
25912            .dispatch(shared.prepare_command(ControlCommand::Register {
25913                instance_id: address.session.instance_id,
25914                agent_id: agent("claude"),
25915                transport: TransportKind::Pty,
25916            }))
25917            .unwrap();
25918        runtime.tick().await;
25919        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25920        let revision_before = shared.handle.snapshot().revision;
25921        let history_sequence_before = shared.current_sequence();
25922        let terminal_size = gate4agent_types::TerminalSize {
25923            rows: 24,
25924            columns: 80,
25925        };
25926
25927        for request in [
25928            NodeRequest::Prompt {
25929                session: address.clone(),
25930                text: "prompt".to_owned(),
25931            },
25932            NodeRequest::Paste {
25933                session: address.clone(),
25934                text: "paste".to_owned(),
25935            },
25936            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
25937            // the same semantic capabilities as `Prompt`/`Paste`. A bare
25938            // (promptless) resume is a raw PTY relaunch and IS admitted under
25939            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
25940            // in `provider_runtime.rs`; it is deliberately not exercised here.
25941            NodeRequest::Resume {
25942                session: address.clone(),
25943                terminal_size,
25944                initial_prompt: Some("resume".to_owned()),
25945            },
25946        ] {
25947            let error = process_request_inner(
25948                &shared,
25949                77,
25950                ClientRole::Operator,
25951                request,
25952            )
25953            .await
25954            .unwrap_err();
25955            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25956        }
25957
25958        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25959        assert_eq!(shared.handle.snapshot().revision, revision_before);
25960        assert_eq!(shared.current_sequence(), history_sequence_before);
25961        let bindings = shared
25962            .session_bindings
25963            .lock()
25964            .unwrap_or_else(|poisoned| poisoned.into_inner());
25965        let binding = bindings.get(&address.session.instance_id).unwrap();
25966        assert_eq!(binding.generation, address.session.generation);
25967        assert!(binding.pending_resume.is_none());
25968        assert!(binding.record_id.is_none());
25969    }
25970
25971    #[tokio::test]
25972    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25973        let catalog = active_registry().unwrap();
25974        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25975        let primary = WorkspaceConfig::new(
25976            WorkspaceId::new("primary").unwrap(),
25977            std::env::current_dir().unwrap(),
25978        )
25979        .unwrap();
25980        let shared = NodeShared::new(
25981            handle,
25982            "fixture-token".to_owned(),
25983            NodeId::new("node-1").unwrap(),
25984            vec![primary],
25985            vec![agent("claude")],
25986        );
25987        shared
25988            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25989            .unwrap();
25990        let before = shared.snapshot();
25991        let error = process_request_inner(
25992            &shared,
25993            77,
25994            ClientRole::Operator,
25995            NodeRequest::RegisterWorkspace {
25996                workspace_id: WorkspaceId::new("foreign").unwrap(),
25997                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25998            },
25999        )
26000        .await
26001        .unwrap_err();
26002        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
26003        assert_eq!(error.message, "invalid-request");
26004        assert_eq!(shared.snapshot().workspaces, before.workspaces);
26005    }
26006
26007    #[tokio::test]
26008    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26009        let catalog = active_registry().unwrap();
26010        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26011        let primary_id = WorkspaceId::new("primary").unwrap();
26012        let primary = WorkspaceConfig::new(
26013            primary_id.clone(),
26014            std::env::current_dir().unwrap(),
26015        )
26016        .unwrap();
26017        let shared = NodeShared::new(
26018            handle,
26019            "fixture-token".to_owned(),
26020            NodeId::new("node-1").unwrap(),
26021            vec![primary.clone()],
26022            vec![agent("claude")],
26023        );
26024        let secondary_id = WorkspaceId::new("secondary").unwrap();
26025        let secondary = shared
26026            .register_workspace(
26027                secondary_id.clone(),
26028                std::env::temp_dir().to_string_lossy().into_owned(),
26029            )
26030            .await
26031            .unwrap();
26032        assert_eq!(secondary.workspace_id, secondary_id);
26033        assert!(secondary.sessions.is_empty());
26034        assert_eq!(shared.snapshot().workspaces.len(), 2);
26035
26036        let duplicate_id = shared
26037            .register_workspace(
26038                secondary_id.clone(),
26039                primary.canonical_root().to_owned(),
26040            )
26041            .await
26042            .unwrap_err();
26043        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26044        let duplicate_root = shared
26045            .register_workspace(
26046                WorkspaceId::new("duplicate-root").unwrap(),
26047                windows_path_text(&secondary.canonical_root).to_owned(),
26048            )
26049            .await
26050            .unwrap_err();
26051        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26052
26053        let address = SessionAddress {
26054            workspace_id: secondary_id.clone(),
26055            session: SessionKey {
26056                instance_id: AgentInstanceId(41),
26057                generation: SessionGeneration(1),
26058            },
26059        };
26060        shared.bind_session(
26061            address.session.instance_id,
26062            secondary_id.clone(),
26063            address.session.generation,
26064        );
26065        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26066        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26067        shared.remove_binding(&address);
26068        shared.unregister_workspace(&secondary_id).unwrap();
26069        assert_eq!(shared.snapshot().workspaces.len(), 1);
26070
26071        let last = shared.unregister_workspace(&primary_id).unwrap_err();
26072        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26073        let history = shared
26074            .history
26075            .lock()
26076            .unwrap_or_else(|poisoned| poisoned.into_inner());
26077        assert!(matches!(
26078            &history.events[0],
26079            NodeEventEnvelope {
26080                sequence: 1,
26081                event: NodeEvent::WorkspaceAdded { workspace },
26082            } if workspace.workspace_id == secondary_id
26083        ));
26084        assert!(matches!(
26085            &history.events[1],
26086            NodeEventEnvelope {
26087                sequence: 2,
26088                event: NodeEvent::WorkspaceRemoved { workspace_id },
26089            } if workspace_id == &secondary_id
26090        ));
26091    }
26092
26093    #[tokio::test]
26094    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26095        let secondary_root = temporary_workspace_root("provider-session-reference-index");
26096        std::fs::create_dir_all(&secondary_root).unwrap();
26097        let catalog = active_registry().unwrap();
26098        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26099        let primary_id = WorkspaceId::new("primary").unwrap();
26100        let secondary_id = WorkspaceId::new("secondary").unwrap();
26101        let shared = NodeShared::new(
26102            handle,
26103            "fixture-token".to_owned(),
26104            NodeId::new("node-session-reference-index").unwrap(),
26105            vec![
26106                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26107                    .unwrap(),
26108                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26109            ],
26110            vec![agent("claude"), agent("codex")],
26111        );
26112        let identity = ProviderSessionIdentity {
26113            key: gate4agent_types::ProviderSessionKey::SessionId,
26114            id: "provider-session-42".to_owned(),
26115            transcript_path: None,
26116        };
26117        let index_request = |workspace_id: WorkspaceId,
26118                             provider: AgentId,
26119                             identity: ProviderSessionIdentity,
26120                             display_name: &str| {
26121            NodeRequest::IndexProviderSession {
26122                workspace_id,
26123                provider,
26124                identity,
26125                display_name: display_name.to_owned(),
26126            }
26127        };
26128        let request = index_request(
26129            primary_id.clone(),
26130            agent("claude"),
26131            identity.clone(),
26132            "release shepherd",
26133        );
26134        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26135            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26136        }));
26137        let observer = process_request_inner(
26138            &shared,
26139            77,
26140            ClientRole::Observer,
26141            request.clone(),
26142        )
26143        .await
26144        .unwrap_err();
26145        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26146        let uncontrolled = process_request_inner(
26147            &shared,
26148            77,
26149            ClientRole::Operator,
26150            request.clone(),
26151        )
26152        .await
26153        .unwrap_err();
26154        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26155        shared
26156            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26157            .unwrap();
26158
26159        let indexed = process_request_inner(
26160            &shared,
26161            77,
26162            ClientRole::Operator,
26163            request.clone(),
26164        )
26165        .await
26166        .unwrap();
26167        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26168            panic!("provider session reference index returned another response");
26169        };
26170        assert_eq!(record.state, ManagedSessionState::Dormant);
26171        assert_eq!(record.mode, SessionMode::Pty);
26172        assert_eq!(record.workspace_id, primary_id);
26173        assert_eq!(record.provider_session.as_ref(), Some(&identity));
26174        assert!(record.active_session.is_none());
26175        assert!(record.environment_profile.is_none());
26176        assert!(record.bundle.is_none());
26177        assert!(record.context.is_none());
26178
26179        let repeated = process_request_inner(
26180            &shared,
26181            77,
26182            ClientRole::Operator,
26183            index_request(
26184                primary_id.clone(),
26185                agent("claude"),
26186                identity.clone(),
26187                "ignored replacement",
26188            ),
26189        )
26190        .await
26191        .unwrap();
26192        assert!(matches!(
26193            repeated,
26194            NodeResponse::ProviderSessionIndexed { record: ref existing }
26195                if existing.record_id == record.record_id
26196                    && existing.display_name == "release shepherd"
26197        ));
26198        let conflicting_workspace = process_request_inner(
26199            &shared,
26200            77,
26201            ClientRole::Operator,
26202            index_request(
26203                secondary_id,
26204                agent("claude"),
26205                identity.clone(),
26206                "release shepherd",
26207            ),
26208        )
26209        .await
26210        .unwrap_err();
26211        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26212        let conflicting_provider = process_request_inner(
26213            &shared,
26214            77,
26215            ClientRole::Operator,
26216            index_request(
26217                primary_id.clone(),
26218                agent("codex"),
26219                identity.clone(),
26220                "release shepherd",
26221            ),
26222        )
26223        .await
26224        .unwrap_err();
26225        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26226        let transcript_rejected = process_request_inner(
26227            &shared,
26228            77,
26229            ClientRole::Operator,
26230            index_request(
26231                primary_id,
26232                agent("claude"),
26233                ProviderSessionIdentity {
26234                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26235                    ..identity
26236                },
26237                "release shepherd",
26238            ),
26239        )
26240        .await
26241        .unwrap_err();
26242        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26243        let upserts = shared
26244            .history
26245            .lock()
26246            .unwrap_or_else(|poisoned| poisoned.into_inner())
26247            .events
26248            .iter()
26249            .filter(|event| matches!(
26250                &event.event,
26251                NodeEvent::SessionRecordUpserted { record: published }
26252                    if published.record_id == record.record_id
26253            ))
26254            .count();
26255        assert_eq!(upserts, 1);
26256        std::fs::remove_dir_all(secondary_root).unwrap();
26257    }
26258
26259    #[tokio::test]
26260    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26261        let fixture_root = temporary_workspace_root("standalone-independent");
26262        let target = fixture_root.join("independent");
26263        std::fs::create_dir_all(&fixture_root).unwrap();
26264        let catalog = active_registry().unwrap();
26265        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26266        let selected = WorkspaceConfig::new(
26267            WorkspaceId::new("primary").unwrap(),
26268            std::env::current_dir().unwrap(),
26269        )
26270        .unwrap();
26271        let selected_root = selected.canonical_root().to_owned();
26272        let shared = NodeShared::new(
26273            handle,
26274            "fixture-token".to_owned(),
26275            NodeId::new("node-standalone").unwrap(),
26276            vec![selected],
26277            vec![agent("claude")],
26278        );
26279        let workspace_id = WorkspaceId::new("independent").unwrap();
26280        let request = NodeRequest::CreateStandaloneWorkspace {
26281            workspace_id: workspace_id.clone(),
26282            root: opaque_windows_path(target.to_string_lossy().into_owned()),
26283            initial_branch: Some("main".to_owned()),
26284        };
26285        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26286            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26287        }));
26288        assert!(!request_uses_unnegotiated_capability(
26289            &request,
26290            &baseline_capabilities().unwrap(),
26291        ));
26292        assert!(request_uses_unnegotiated_capability(&request, &[]));
26293
26294        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26295            .await
26296            .unwrap_err();
26297        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26298        assert!(!target.exists());
26299        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26300            .await
26301            .unwrap_err();
26302        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26303        assert!(!target.exists());
26304        shared
26305            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26306            .unwrap();
26307
26308        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26309            .await
26310            .unwrap();
26311        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26312            panic!("standalone workspace creation returned another response");
26313        };
26314        assert_eq!(workspace.workspace_id, workspace_id);
26315        assert!(target.join(".git").is_dir());
26316        let branch = run_git_bounded(
26317            target.to_str().unwrap(),
26318            &["branch", "--show-current"],
26319            GIT_OUTPUT_MAX_BYTES,
26320        )
26321        .await
26322        .unwrap();
26323        assert!(branch.success);
26324        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26325        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26326        assert_eq!(worktrees.len(), 1);
26327        let listed_root = WorkspaceConfig::new(
26328            WorkspaceId::new("listed-independent").unwrap(),
26329            &worktrees[0].path,
26330        )
26331        .unwrap();
26332        assert!(platform::roots_equal(
26333            listed_root.canonical_root(),
26334            windows_path_text(&workspace.canonical_root),
26335        ));
26336        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26337        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26338            snapshot.workspace_id == workspace_id
26339                && snapshot.canonical_root == workspace.canonical_root
26340        }));
26341        assert!(shared
26342            .history
26343            .lock()
26344            .unwrap_or_else(|poisoned| poisoned.into_inner())
26345            .events
26346            .iter()
26347            .any(|event| matches!(
26348                &event.event,
26349                NodeEvent::WorkspaceAdded { workspace }
26350                    if workspace.workspace_id == workspace_id
26351            )));
26352        std::fs::remove_dir_all(fixture_root).unwrap();
26353    }
26354
26355    #[tokio::test]
26356    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26357        let fixture_root = temporary_workspace_root("standalone-compensation");
26358        let created_target = fixture_root.join("created-target");
26359        let existing_target = fixture_root.join("existing-target");
26360        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26361        let invalid_state_path = invalid_state_parent.join("state.json");
26362        std::fs::create_dir_all(&existing_target).unwrap();
26363        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26364        let catalog = active_registry().unwrap();
26365        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26366        let primary = WorkspaceConfig::new(
26367            WorkspaceId::new("primary").unwrap(),
26368            std::env::current_dir().unwrap(),
26369        )
26370        .unwrap();
26371        let mut shared = NodeShared::new(
26372            handle,
26373            "fixture-token".to_owned(),
26374            NodeId::new("node-standalone-compensation").unwrap(),
26375            vec![primary],
26376            vec![agent("claude")],
26377        );
26378        shared.state_path = Some(invalid_state_path);
26379
26380        let created_recovery = shared
26381            .create_standalone_workspace(
26382                WorkspaceId::new("compensated").unwrap(),
26383                created_target.to_string_lossy().into_owned(),
26384                Some("main".to_owned()),
26385            )
26386            .await
26387            .unwrap_err();
26388        assert_eq!(
26389            created_recovery.code,
26390            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26391        );
26392        assert!(created_target.join(".git").is_dir());
26393        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26394            workspace.workspace_id.as_str() != "compensated"
26395        }));
26396
26397        let existing_recovery = shared
26398            .create_standalone_workspace(
26399                WorkspaceId::new("restored").unwrap(),
26400                existing_target.to_string_lossy().into_owned(),
26401                None,
26402            )
26403            .await
26404            .unwrap_err();
26405        assert_eq!(
26406            existing_recovery.code,
26407            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26408        );
26409        assert!(existing_target.is_dir());
26410        assert!(existing_target.join(".git").is_dir());
26411        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26412            workspace.workspace_id.as_str() != "restored"
26413        }));
26414
26415        let registered_empty = fixture_root.join("registered-empty");
26416        std::fs::create_dir(&registered_empty).unwrap();
26417        let catalog = active_registry().unwrap();
26418        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26419        let registered = WorkspaceConfig::new(
26420            WorkspaceId::new("registered-empty").unwrap(),
26421            &registered_empty,
26422        )
26423        .unwrap();
26424        let duplicate_shared = NodeShared::new(
26425            handle,
26426            "fixture-token".to_owned(),
26427            NodeId::new("node-standalone-duplicate-root").unwrap(),
26428            vec![registered],
26429            vec![agent("claude")],
26430        );
26431        let duplicate = duplicate_shared
26432            .create_standalone_workspace(
26433                WorkspaceId::new("duplicate-root").unwrap(),
26434                registered_empty.to_string_lossy().into_owned(),
26435                None,
26436            )
26437            .await
26438            .unwrap_err();
26439        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26440        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
26441        std::fs::remove_dir_all(fixture_root).unwrap();
26442    }
26443
26444    #[test]
26445    fn managed_record_id_collision_preserves_the_existing_record() {
26446        let catalog = active_registry().unwrap();
26447        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26448        let workspace = WorkspaceConfig::new(
26449            WorkspaceId::new("primary").unwrap(),
26450            std::env::current_dir().unwrap(),
26451        )
26452        .unwrap();
26453        let shared = NodeShared::new(
26454            handle,
26455            "fixture-token".to_owned(),
26456            NodeId::new("node-1").unwrap(),
26457            vec![workspace.clone()],
26458            vec![agent("claude")],
26459        );
26460        let record_id = SessionRecordId::new("sr-collision").unwrap();
26461        let original = ManagedSessionRecord {
26462            record_id: record_id.clone(),
26463            display_name: "original".to_owned(),
26464            provider: agent("claude"),
26465            mode: SessionMode::Pty,
26466            state: ManagedSessionState::Dormant,
26467            workspace_id: workspace.workspace_id,
26468            canonical_root: opaque_windows_path(workspace.canonical_root),
26469            provider_session: None,
26470            active_session: None,
26471            environment_profile: None,
26472            bundle: None,
26473            context_id: None,
26474            context: None,
26475            exported_context: None,
26476            task_binding: None,
26477            created_at_unix_ms: 1,
26478            updated_at_unix_ms: 1,
26479            last_error: None,
26480        };
26481        shared.insert_record(original.clone()).unwrap();
26482        let mut collision = original.clone();
26483        collision.display_name = "replacement".to_owned();
26484        let error = shared.insert_record(collision).unwrap_err();
26485        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26486        assert_eq!(shared.record(&record_id).unwrap(), original);
26487    }
26488
26489    #[test]
26490    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26491        let catalog = active_registry().unwrap();
26492        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26493        let workspace = WorkspaceConfig::new(
26494            WorkspaceId::new("primary").unwrap(),
26495            std::env::current_dir().unwrap(),
26496        )
26497        .unwrap();
26498        let shared = NodeShared::new(
26499            handle,
26500            "fixture-token".to_owned(),
26501            NodeId::new("node-1").unwrap(),
26502            vec![workspace.clone()],
26503            vec![agent("claude")],
26504        );
26505        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26506        shared
26507            .insert_record(ManagedSessionRecord {
26508                record_id: record_id.clone(),
26509                display_name: "runtime error".to_owned(),
26510                provider: agent("claude"),
26511                mode: SessionMode::Pty,
26512                state: ManagedSessionState::IdentityPending,
26513                workspace_id: workspace.workspace_id,
26514                canonical_root: opaque_windows_path(workspace.canonical_root),
26515                provider_session: None,
26516                active_session: None,
26517                environment_profile: None,
26518                bundle: None,
26519                context_id: None,
26520                context: None,
26521                exported_context: None,
26522                task_binding: None,
26523                created_at_unix_ms: 1,
26524                updated_at_unix_ms: 1,
26525                last_error: None,
26526            })
26527            .unwrap();
26528
26529        shared
26530            .mark_record_error(
26531                &record_id,
26532                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26533            )
26534            .unwrap();
26535        let record = shared.record(&record_id).unwrap();
26536        assert_eq!(record.state, ManagedSessionState::Unavailable);
26537        assert_eq!(
26538            record.last_error.as_deref(),
26539            Some("provider-runtime-failed"),
26540        );
26541        let snapshot = shared.snapshot();
26542        assert_eq!(snapshot.session_records, vec![record]);
26543    }
26544
26545    /// Regression coverage for the "operator sees `deadline` instead of the
26546    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
26547    /// able to learn a command's asynchronous kernel rejection (here,
26548    /// specifically classified as `UnsupportedTransport`) exactly once, and
26549    /// must not still find it after that -- proving the fast path this
26550    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
26551    /// loop) drains the reason instead of leaving it to be rediscovered (or
26552    /// missed) on a later poll.
26553    #[test]
26554    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26555        let catalog = active_registry().unwrap();
26556        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26557        let workspace = WorkspaceConfig::new(
26558            WorkspaceId::new("primary").unwrap(),
26559            std::env::current_dir().unwrap(),
26560        )
26561        .unwrap();
26562        let shared = NodeShared::new(
26563            handle,
26564            "fixture-token".to_owned(),
26565            NodeId::new("node-1").unwrap(),
26566            vec![workspace],
26567            vec![agent("claude")],
26568        );
26569
26570        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26571
26572        shared.record_rejected_command(
26573            CommandId(1),
26574            "agent 'claude' does not support transport Acp".to_owned(),
26575            true,
26576        );
26577        // An unrelated command_id must not see another command's reason.
26578        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26579
26580        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26581        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26582        assert!(reason.unsupported_transport);
26583
26584        // Consumed: a second read for the same command_id finds nothing.
26585        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26586
26587        // A rejection unrelated to transport support keeps its own
26588        // classification distinct, so the reader can choose the generic
26589        // `BackendOperationFailed` code instead of misreporting
26590        // `UnsupportedTransport`.
26591        shared.record_rejected_command(
26592            CommandId(3),
26593            "backend busy".to_owned(),
26594            false,
26595        );
26596        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26597        assert!(!unclassified.unsupported_transport);
26598    }
26599
26600    #[test]
26601    fn provider_identity_update_is_ignored_without_exact_session_policy() {
26602        let catalog = active_registry().unwrap();
26603        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26604        let workspace = WorkspaceConfig::new(
26605            WorkspaceId::new("primary").unwrap(),
26606            std::env::current_dir().unwrap(),
26607        )
26608        .unwrap();
26609        let shared = NodeShared::new(
26610            handle,
26611            "fixture-token".to_owned(),
26612            NodeId::new("node-identity-policy").unwrap(),
26613            vec![workspace.clone()],
26614            vec![agent("claude")],
26615        );
26616        let address = SessionAddress {
26617            workspace_id: workspace.workspace_id.clone(),
26618            session: SessionKey {
26619                instance_id: AgentInstanceId(90),
26620                generation: SessionGeneration(1),
26621            },
26622        };
26623        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26624        shared
26625            .insert_record(ManagedSessionRecord {
26626                record_id: record_id.clone(),
26627                display_name: "policy denied".to_owned(),
26628                provider: agent("claude"),
26629                mode: SessionMode::Pty,
26630                state: ManagedSessionState::IdentityPending,
26631                workspace_id: workspace.workspace_id,
26632                canonical_root: opaque_windows_path(workspace.canonical_root),
26633                provider_session: None,
26634                active_session: Some(address.clone()),
26635                environment_profile: None,
26636                bundle: None,
26637                context_id: None,
26638                context: None,
26639                exported_context: None,
26640                task_binding: None,
26641                created_at_unix_ms: 1,
26642                updated_at_unix_ms: 1,
26643                last_error: None,
26644            })
26645            .unwrap();
26646        let event = ControlEvent {
26647            sequence: 1,
26648            command_id: None,
26649            instance_id: address.session.instance_id,
26650            generation: address.session.generation,
26651            event: ControlEventKind::ProviderEvent {
26652                sequence: 1,
26653                source: gate4agent_types::ProviderSource {
26654                    family: AdapterFamily::PtySemantic,
26655                    binding: AdapterBinding::new(
26656                        gate4agent_types::AdapterId::new("claude").unwrap(),
26657                        "fixture/v1",
26658                        gate4agent_types::AdapterVerification::SyntheticFixture,
26659                    )
26660                    .unwrap(),
26661                },
26662                source_sequence: 1,
26663                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26664                    identity: gate4agent_types::ProviderSessionIdentity {
26665                        key: gate4agent_types::ProviderSessionKey::SessionId,
26666                        id: "provider-session".to_owned(),
26667                        transcript_path: None,
26668                    },
26669                },
26670            },
26671        };
26672
26673        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26674
26675        let record = shared.record(&record_id).unwrap();
26676        assert!(record.provider_session.is_none());
26677        assert_eq!(record.state, ManagedSessionState::IdentityPending);
26678        assert_eq!(record.active_session.as_ref(), Some(&address));
26679    }
26680
26681    #[test]
26682    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26683        let secondary_root = temporary_workspace_root("identity-scope");
26684        std::fs::create_dir_all(&secondary_root).unwrap();
26685        let catalog = active_registry().unwrap();
26686        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26687        let primary = WorkspaceConfig::new(
26688            WorkspaceId::new("primary").unwrap(),
26689            std::env::current_dir().unwrap(),
26690        )
26691        .unwrap();
26692        let secondary = WorkspaceConfig::new(
26693            WorkspaceId::new("secondary").unwrap(),
26694            &secondary_root,
26695        )
26696        .unwrap();
26697        let shared = NodeShared::new(
26698            handle,
26699            "fixture-token".to_owned(),
26700            NodeId::new("node-1").unwrap(),
26701            vec![primary.clone(), secondary.clone()],
26702            vec![agent("claude")],
26703        );
26704        let identity = gate4agent_types::ProviderSessionIdentity {
26705            key: gate4agent_types::ProviderSessionKey::SessionId,
26706            id: "provider-session".to_owned(),
26707            transcript_path: None,
26708        };
26709        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26710        shared
26711            .insert_record(ManagedSessionRecord {
26712                record_id: dormant_id.clone(),
26713                display_name: "dormant".to_owned(),
26714                provider: agent("claude"),
26715                mode: SessionMode::Pty,
26716                state: ManagedSessionState::Dormant,
26717                workspace_id: primary.workspace_id,
26718                canonical_root: opaque_windows_path(primary.canonical_root),
26719                provider_session: Some(identity.clone()),
26720                active_session: None,
26721                environment_profile: None,
26722                bundle: None,
26723                context_id: None,
26724                context: None,
26725                exported_context: None,
26726                task_binding: None,
26727                created_at_unix_ms: 1,
26728                updated_at_unix_ms: 1,
26729                last_error: None,
26730            })
26731            .unwrap();
26732        let address = SessionAddress {
26733            workspace_id: secondary.workspace_id.clone(),
26734            session: SessionKey {
26735                instance_id: AgentInstanceId(91),
26736                generation: SessionGeneration(1),
26737            },
26738        };
26739        let current_id = SessionRecordId::new("sr-current").unwrap();
26740        shared
26741            .insert_record(ManagedSessionRecord {
26742                record_id: current_id.clone(),
26743                display_name: "current".to_owned(),
26744                provider: agent("claude"),
26745                mode: SessionMode::Pty,
26746                state: ManagedSessionState::IdentityPending,
26747                workspace_id: secondary.workspace_id,
26748                canonical_root: opaque_windows_path(secondary.canonical_root),
26749                provider_session: None,
26750                active_session: Some(address.clone()),
26751                environment_profile: None,
26752                bundle: None,
26753                context_id: None,
26754                context: None,
26755                exported_context: None,
26756                task_binding: None,
26757                created_at_unix_ms: 2,
26758                updated_at_unix_ms: 2,
26759                last_error: None,
26760            })
26761            .unwrap();
26762        shared.bind_managed_session(
26763            &address,
26764            current_id.clone(),
26765            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26766            None,
26767        );
26768        let event = ControlEvent {
26769            sequence: 1,
26770            command_id: None,
26771            instance_id: address.session.instance_id,
26772            generation: address.session.generation,
26773            event: ControlEventKind::ProviderEvent {
26774                sequence: 1,
26775                source: gate4agent_types::ProviderSource {
26776                    family: gate4agent_types::AdapterFamily::PtySemantic,
26777                    binding: gate4agent_types::AdapterBinding::new(
26778                        gate4agent_types::AdapterId::new("claude").unwrap(),
26779                        "fixture/v1",
26780                        gate4agent_types::AdapterVerification::SyntheticFixture,
26781                    )
26782                    .unwrap(),
26783                },
26784                source_sequence: 1,
26785                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26786                    identity,
26787                },
26788            },
26789        };
26790        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26791
26792        let binding = shared
26793            .session_bindings
26794            .lock()
26795            .unwrap_or_else(|poisoned| poisoned.into_inner())
26796            .get(&address.session.instance_id)
26797            .cloned()
26798            .unwrap();
26799        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26800        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26801        let current = shared.record(&current_id).unwrap();
26802        assert_eq!(current.state, ManagedSessionState::Unavailable);
26803        assert_eq!(current.active_session.as_ref(), Some(&address));
26804        assert_eq!(
26805            current.last_error.as_deref(),
26806            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26807        );
26808        std::fs::remove_dir_all(secondary_root).unwrap();
26809    }
26810
26811    #[tokio::test]
26812    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26813        let root = temporary_workspace_root("semantic-provider-session");
26814        std::fs::create_dir_all(&root).unwrap();
26815        let state_path = root.join("state-v1.json");
26816        let catalog = active_registry().unwrap();
26817        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26818        let workspace = WorkspaceConfig::new(
26819            WorkspaceId::new("primary").unwrap(),
26820            &root,
26821        )
26822        .unwrap();
26823        let node_id = NodeId::new("node-semantic-identity").unwrap();
26824        let shared = NodeShared::new_with_incarnation(
26825            handle,
26826            "fixture-token".to_owned(),
26827            node_id.clone(),
26828            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26829            vec![workspace.clone()],
26830            vec![agent("claude")],
26831            ProviderRuntimeStatuses::default(),
26832            None,
26833            Vec::new(),
26834            Vec::new(),
26835            SpawnProfileRegistry::default(),
26836            None,
26837            None,
26838            None,
26839            Some(state_path.clone()),
26840            Vec::new(),
26841            Vec::new(),
26842            Vec::new(),
26843            Vec::new(),
26844            None,
26845            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26846        );
26847        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26848            key: gate4agent_types::ProviderSessionKey::SessionId,
26849            id: "provider-session".to_owned(),
26850            transcript_path: None,
26851        };
26852        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26853        shared
26854            .insert_record(ManagedSessionRecord {
26855                record_id: durable_id.clone(),
26856                display_name: "durable".to_owned(),
26857                provider: agent("claude"),
26858                mode: SessionMode::Pty,
26859                state: ManagedSessionState::Dormant,
26860                workspace_id: workspace.workspace_id.clone(),
26861                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26862                provider_session: Some(persisted_identity.clone()),
26863                active_session: None,
26864                environment_profile: None,
26865                bundle: None,
26866                context_id: None,
26867                context: None,
26868                exported_context: None,
26869                task_binding: None,
26870                created_at_unix_ms: 1,
26871                updated_at_unix_ms: 1,
26872                last_error: None,
26873            })
26874            .unwrap();
26875        let address = SessionAddress {
26876            workspace_id: workspace.workspace_id.clone(),
26877            session: SessionKey {
26878                instance_id: AgentInstanceId(92),
26879                generation: SessionGeneration(2),
26880            },
26881        };
26882        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26883        shared
26884            .insert_record(ManagedSessionRecord {
26885                record_id: pending_id.clone(),
26886                display_name: "pending".to_owned(),
26887                provider: agent("claude"),
26888                mode: SessionMode::Pty,
26889                state: ManagedSessionState::IdentityPending,
26890                workspace_id: workspace.workspace_id.clone(),
26891                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26892                provider_session: None,
26893                active_session: Some(address.clone()),
26894                environment_profile: None,
26895                bundle: None,
26896                context_id: None,
26897                context: None,
26898                exported_context: None,
26899                task_binding: None,
26900                created_at_unix_ms: 2,
26901                updated_at_unix_ms: 2,
26902                last_error: None,
26903            })
26904            .unwrap();
26905        shared.bind_managed_session(
26906            &address,
26907            pending_id.clone(),
26908            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26909            None,
26910        );
26911        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26912            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26913            ..persisted_identity
26914        };
26915        let event = ControlEvent {
26916            sequence: 1,
26917            command_id: None,
26918            instance_id: address.session.instance_id,
26919            generation: address.session.generation,
26920            event: ControlEventKind::ProviderEvent {
26921                sequence: 1,
26922                source: gate4agent_types::ProviderSource {
26923                    family: gate4agent_types::AdapterFamily::PtySemantic,
26924                    binding: gate4agent_types::AdapterBinding::new(
26925                        gate4agent_types::AdapterId::new("claude").unwrap(),
26926                        "fixture/v1",
26927                        gate4agent_types::AdapterVerification::SyntheticFixture,
26928                    )
26929                    .unwrap(),
26930                },
26931                source_sequence: 1,
26932                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26933                    identity: observed_identity.clone(),
26934                },
26935            },
26936        };
26937        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26938
26939        let snapshot = shared.snapshot();
26940        assert_eq!(snapshot.session_records.len(), 1);
26941        let live = &snapshot.session_records[0];
26942        assert_eq!(live.record_id, durable_id);
26943        assert_eq!(live.state, ManagedSessionState::Live);
26944        assert_eq!(live.active_session.as_ref(), Some(&address));
26945        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26946        let binding = shared
26947            .session_bindings
26948            .lock()
26949            .unwrap_or_else(|poisoned| poisoned.into_inner())
26950            .get(&address.session.instance_id)
26951            .cloned()
26952            .unwrap();
26953        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26954
26955        let mut duplicate = live.clone();
26956        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26957        duplicate.display_name = "duplicate".to_owned();
26958        duplicate.state = ManagedSessionState::Dormant;
26959        duplicate.active_session = None;
26960        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26961        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26962        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26963        assert_eq!(shared.snapshot().session_records.len(), 1);
26964
26965        let terminal_size = gate4agent_types::TerminalSize {
26966            rows: 24,
26967            columns: 80,
26968        };
26969        let (first_busy, second_busy) = tokio::join!(
26970            shared.resume_session_record(&durable_id, terminal_size, None),
26971            shared.resume_session_record(&durable_id, terminal_size, None),
26972        );
26973        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26974            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26975            assert_eq!(busy.message, "session-record-busy");
26976        }
26977
26978        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26979        assert_eq!(loaded.records.len(), 1);
26980        assert_eq!(loaded.records[0].record_id, durable_id);
26981        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26982        assert_eq!(
26983            loaded.records[0]
26984                .provider_session
26985                .as_ref()
26986                .unwrap()
26987                .transcript_path,
26988            None,
26989        );
26990        let catalog = active_registry().unwrap();
26991        let (reloaded_handle, _reloaded_runtime) =
26992            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26993        let reloaded = NodeShared::new_with_incarnation(
26994            reloaded_handle,
26995            "fixture-token".to_owned(),
26996            node_id,
26997            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26998            vec![workspace],
26999            vec![agent("claude")],
27000            ProviderRuntimeStatuses::default(),
27001            None,
27002            Vec::new(),
27003            Vec::new(),
27004            SpawnProfileRegistry::default(),
27005            None,
27006            None,
27007            None,
27008            None,
27009            loaded.records,
27010            loaded.managed_worktrees,
27011            loaded.managed_worktree_tombstones,
27012            loaded.materializations,
27013            loaded.warning,
27014            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27015        );
27016        assert_eq!(reloaded.snapshot().session_records.len(), 1);
27017        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27018        std::fs::remove_dir_all(root).unwrap();
27019    }
27020
27021    #[tokio::test]
27022    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27023        let secondary_root = temporary_workspace_root("workspace-restore");
27024        let other_root = temporary_workspace_root("workspace-conflict");
27025        std::fs::create_dir_all(&secondary_root).unwrap();
27026        std::fs::create_dir_all(&other_root).unwrap();
27027        let catalog = active_registry().unwrap();
27028        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27029        let primary = WorkspaceConfig::new(
27030            WorkspaceId::new("primary").unwrap(),
27031            std::env::current_dir().unwrap(),
27032        )
27033        .unwrap();
27034        let secondary = WorkspaceConfig::new(
27035            WorkspaceId::new("secondary").unwrap(),
27036            &secondary_root,
27037        )
27038        .unwrap();
27039        let shared = NodeShared::new(
27040            handle,
27041            "fixture-token".to_owned(),
27042            NodeId::new("node-1").unwrap(),
27043            vec![primary, secondary.clone()],
27044            vec![agent("claude")],
27045        );
27046        let record_id = SessionRecordId::new("sr-workspace").unwrap();
27047        shared
27048            .insert_record(ManagedSessionRecord {
27049                record_id: record_id.clone(),
27050                display_name: "workspace record".to_owned(),
27051                provider: agent("claude"),
27052                mode: SessionMode::Pty,
27053                state: ManagedSessionState::Dormant,
27054                workspace_id: secondary.workspace_id.clone(),
27055                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27056                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27057                    key: gate4agent_types::ProviderSessionKey::SessionId,
27058                    id: "provider-session".to_owned(),
27059                    transcript_path: None,
27060                }),
27061                active_session: None,
27062                environment_profile: None,
27063                bundle: None,
27064                context_id: None,
27065                context: None,
27066                exported_context: None,
27067                task_binding: None,
27068                created_at_unix_ms: 1,
27069                updated_at_unix_ms: 1,
27070                last_error: None,
27071            })
27072            .unwrap();
27073        shared.unregister_workspace(&secondary.workspace_id).unwrap();
27074        let unavailable = shared.record(&record_id).unwrap();
27075        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27076        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27077
27078        let conflict = shared
27079            .register_workspace(
27080                secondary.workspace_id.clone(),
27081                other_root.to_string_lossy().into_owned(),
27082            )
27083            .await
27084            .unwrap_err();
27085        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27086        shared
27087            .register_workspace(
27088                secondary.workspace_id,
27089                secondary_root.to_string_lossy().into_owned(),
27090            )
27091            .await
27092            .unwrap();
27093        let restored = shared.record(&record_id).unwrap();
27094        assert_eq!(restored.state, ManagedSessionState::Dormant);
27095        assert!(restored.last_error.is_none());
27096        std::fs::remove_dir_all(secondary_root).unwrap();
27097        std::fs::remove_dir_all(other_root).unwrap();
27098    }
27099
27100    #[test]
27101    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27102        let error = validate_workspace_request_root(
27103            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27104        )
27105        .unwrap_err();
27106        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27107        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27108        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27109    }
27110
27111    #[test]
27112    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27113        let catalog = active_registry().unwrap();
27114        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27115        let workspace = WorkspaceConfig::new(
27116            WorkspaceId::new("primary").unwrap(),
27117            std::env::current_dir().unwrap(),
27118        )
27119        .unwrap();
27120        let shared = NodeShared::new(
27121            handle,
27122            "fixture-token".to_owned(),
27123            NodeId::new("node-1").unwrap(),
27124            vec![workspace],
27125            vec![agent("claude")],
27126        );
27127        let publishers = 4_u64;
27128        let events_per_publisher = 256_u64;
27129        let start = std::sync::Barrier::new(publishers as usize + 1);
27130        let finished = AtomicU64::new(0);
27131
27132        std::thread::scope(|scope| {
27133            for _ in 0..publishers {
27134                scope.spawn(|| {
27135                    start.wait();
27136                    for _ in 0..events_per_publisher {
27137                        shared.publish(NodeEvent::ControllerChanged { controller: None });
27138                    }
27139                    finished.fetch_add(1, Ordering::Release);
27140                });
27141            }
27142            start.wait();
27143            while finished.load(Ordering::Acquire) != publishers {
27144                assert_resync_history_is_linearized(shared.resync(0));
27145                std::thread::yield_now();
27146            }
27147        });
27148        let response = shared.resync(0);
27149        assert_resync_history_is_linearized(response.clone());
27150        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27151            unreachable!("resync helper returned another response");
27152        };
27153        assert_eq!(event_sequence, publishers * events_per_publisher);
27154        assert_eq!(events.len() as u64, event_sequence);
27155    }
27156
27157    fn assert_resync_history_is_linearized(response: NodeResponse) {
27158        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27159            unreachable!("resync helper returned another response");
27160        };
27161        assert!(events.windows(2).all(|pair| {
27162            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27163        }));
27164        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27165    }
27166
27167    #[test]
27168    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27169        assert_eq!(
27170            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27171            r"C:\repo\worktree",
27172        );
27173        assert_eq!(
27174            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27175            r"\\server\share\repo",
27176        );
27177        assert!(validate_workspace_root(
27178            &WorkspaceId::new("network").unwrap(),
27179            r"\\server\share\repo",
27180        )
27181        .is_err());
27182        let workspace = WorkspaceConfig::new(
27183            WorkspaceId::new("primary").unwrap(),
27184            std::env::current_dir().unwrap(),
27185        )
27186        .unwrap();
27187        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27188    }
27189
27190    #[test]
27191    fn resume_rebinds_only_on_the_expected_authorized_generation() {
27192        let catalog = active_registry().unwrap();
27193        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27194        let workspace_id = WorkspaceId::new("primary").unwrap();
27195        let workspace = WorkspaceConfig::new(
27196            workspace_id.clone(),
27197            std::env::current_dir().unwrap(),
27198        )
27199        .unwrap();
27200        let shared = NodeShared::new(
27201            handle,
27202            "fixture-token".to_owned(),
27203            NodeId::new("node-1").unwrap(),
27204            vec![workspace],
27205            vec![agent("claude")],
27206        );
27207        let instance_id = AgentInstanceId(17);
27208        let original = SessionAddress {
27209            workspace_id,
27210            session: SessionKey {
27211                instance_id,
27212                generation: SessionGeneration(3),
27213            },
27214        };
27215        shared.bind_session(
27216            instance_id,
27217            original.workspace_id.clone(),
27218            original.session.generation,
27219        );
27220        shared
27221            .arm_resume(
27222                &original,
27223                CommandId(41),
27224                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27225            )
27226            .unwrap();
27227
27228        shared.publish_control(ControlEvent {
27229            sequence: 1,
27230            command_id: None,
27231            instance_id,
27232            generation: SessionGeneration(4),
27233            event: ControlEventKind::Running { process_id: None },
27234        });
27235        assert_eq!(
27236            shared.address_for(instance_id, SessionGeneration(3)),
27237            Some(original.clone()),
27238        );
27239        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27240
27241        shared.publish_control(ControlEvent {
27242            sequence: 2,
27243            command_id: Some(CommandId(42)),
27244            instance_id,
27245            generation: SessionGeneration(3),
27246            event: ControlEventKind::CommandRejected {
27247                message: "unrelated command".to_owned(),
27248            },
27249        });
27250
27251        shared.publish_control(ControlEvent {
27252            sequence: 3,
27253            command_id: None,
27254            instance_id,
27255            generation: SessionGeneration(4),
27256            event: ControlEventKind::ResumeAuthorized {
27257                session: gate4agent_types::ResumeSessionSummary {
27258                    key: gate4agent_types::ProviderSessionKey::SessionId,
27259                    id: "fixture-session".to_owned(),
27260                },
27261            },
27262        });
27263        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27264        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27265        assert_eq!(rebound.workspace_id, original.workspace_id);
27266        let history = shared
27267            .history
27268            .lock()
27269            .unwrap_or_else(|poisoned| poisoned.into_inner());
27270        assert!(history.events.iter().any(|envelope| matches!(
27271            &envelope.event,
27272            NodeEvent::Control { address, event }
27273                if address == &rebound
27274                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27275        )));
27276    }
27277
27278    #[test]
27279    fn all_node_pty_prompts_use_bracketed_paste_framing() {
27280        for agent in ["claude", "codex", "kimi"] {
27281            assert_eq!(
27282                prompt_framing(&AgentId::new(agent).unwrap()),
27283                PromptFraming::BracketedPaste
27284            );
27285        }
27286    }
27287
27288    fn generous_walk_deadline() -> Instant {
27289        Instant::now() + Duration::from_secs(30)
27290    }
27291
27292    #[test]
27293    fn workspace_tree_is_relative_and_skips_heavy_directories() {
27294        let root = temporary_workspace_root("tree");
27295        std::fs::create_dir_all(root.join("src")).unwrap();
27296        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27297        std::fs::create_dir_all(root.join("target/debug")).unwrap();
27298        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27299        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27300        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27301        // A renamed/agent-scoped build directory (`target-a2`-style, per the
27302        // real repository's own build layout) that carries the Cache
27303        // Directory Tagging Specification marker cargo writes into every
27304        // `--target-dir` it creates: must be skipped even though its name
27305        // does not exactly match `target`.
27306        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27307        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27308        // A directory that merely *starts with* `target` but carries no
27309        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
27310        // on the name alone, so this stays in the response.
27311        std::fs::create_dir_all(root.join("target-notes")).unwrap();
27312        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27313
27314        let (entries, truncated, _budget) =
27315            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27316        assert!(!truncated);
27317        assert!(entries.iter().any(|entry| {
27318            entry.relative_path.as_utf8() == Some("src")
27319                && entry.kind == WorkspaceEntryKind::Directory
27320        }));
27321        assert!(entries.iter().any(|entry| {
27322            entry.relative_path.as_utf8() == Some("src/lib.rs")
27323                && entry.kind == WorkspaceEntryKind::File
27324        }));
27325        assert!(entries.iter().any(|entry| {
27326            entry.relative_path.as_utf8() == Some("target-notes")
27327                && entry.kind == WorkspaceEntryKind::Directory
27328        }));
27329        assert!(entries.iter().any(|entry| {
27330            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27331                && entry.kind == WorkspaceEntryKind::File
27332        }));
27333        assert!(entries.iter().all(|entry| {
27334            let path = entry.relative_path.as_utf8().unwrap();
27335            !path.starts_with(".git")
27336                && !(path == "target" || path.starts_with("target/"))
27337                && !(path == "target-a2" || path.starts_with("target-a2/"))
27338                && !path.starts_with("node_modules")
27339        }));
27340
27341        std::fs::remove_dir_all(&root).unwrap();
27342    }
27343
27344    #[test]
27345    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27346        let root = temporary_workspace_root("tree-entry-cap");
27347        std::fs::create_dir_all(&root).unwrap();
27348        for index in 0..20 {
27349            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27350        }
27351
27352        let (entries, truncated, budget) =
27353            collect_workspace_entries(&root, generous_walk_deadline(), 5);
27354        assert!(truncated);
27355        assert!(budget.entry_cap_exceeded);
27356        assert!(!budget.time_budget_exceeded);
27357        assert!(entries.len() <= 5);
27358
27359        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27360            .expect("an entry-cap-truncated walk must report a truncation marker");
27361        assert!(inspection_truncation.walk_entry_cap_exceeded);
27362        assert!(!inspection_truncation.walk_time_budget_exceeded);
27363        assert!(!inspection_truncation.git_time_budget_exceeded);
27364        assert!(inspection_truncation.entries_visited >= 5);
27365
27366        std::fs::remove_dir_all(&root).unwrap();
27367    }
27368
27369    #[test]
27370    fn workspace_walk_within_budget_reports_no_truncation() {
27371        let root = temporary_workspace_root("tree-no-truncation");
27372        std::fs::create_dir_all(&root).unwrap();
27373        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27374
27375        let (_entries, truncated, budget) =
27376            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27377        assert!(!truncated);
27378        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27379
27380        std::fs::remove_dir_all(&root).unwrap();
27381    }
27382
27383    #[test]
27384    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27385        let root = temporary_workspace_root("tree-prefix-siblings");
27386        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27387        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27388        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27389        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27390
27391        let (entries, truncated, _budget) =
27392            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27393        assert!(!truncated);
27394        let paths = entries
27395            .iter()
27396            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27397            .collect::<Vec<_>>();
27398        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27399        assert_eq!(
27400            paths,
27401            vec![
27402                "gate4agent-c2".to_owned(),
27403                "gate4agent-c2-client".to_owned(),
27404                "gate4agent-c2-client/lib.rs".to_owned(),
27405                "gate4agent-c2/src".to_owned(),
27406                "gate4agent-c2/src/lib.rs".to_owned(),
27407            ],
27408        );
27409
27410        std::fs::remove_dir_all(&root).unwrap();
27411    }
27412
27413    #[test]
27414    fn git_parsers_cap_status_and_commit_results() {
27415        let mut status_output = Vec::new();
27416        for index in 0..=GIT_STATUS_MAX_ENTRIES {
27417            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27418        }
27419        let mut snapshot = git_snapshot_for_parser();
27420        parse_git_status(&status_output, &mut snapshot);
27421        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27422        assert!(snapshot.truncated);
27423        assert_eq!(snapshot.status[0].index_status, " ");
27424        assert_eq!(snapshot.status[0].worktree_status, "M");
27425
27426        snapshot.truncated = false;
27427        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27428            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27429            .collect::<String>();
27430        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27431        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27432        assert!(snapshot.truncated);
27433    }
27434
27435    #[test]
27436    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27437        let mut snapshot = git_snapshot_for_parser();
27438        parse_git_status(
27439            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27440            &mut snapshot,
27441        );
27442
27443        assert!(!snapshot.truncated);
27444        assert_eq!(snapshot.status.len(), 3);
27445        assert_eq!(snapshot.status[0].index_status, "R");
27446        assert_eq!(snapshot.status[0].worktree_status, " ");
27447        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27448        assert_eq!(
27449            snapshot.status[0]
27450                .previous_path
27451                .as_ref()
27452                .and_then(RepositoryPath::as_utf8),
27453            Some("src/previous name.rs"),
27454        );
27455        assert_eq!(snapshot.status[1].index_status, " ");
27456        assert_eq!(snapshot.status[1].worktree_status, "C");
27457        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27458        assert_eq!(
27459            snapshot.status[1]
27460                .previous_path
27461                .as_ref()
27462                .and_then(RepositoryPath::as_utf8),
27463            Some("src/original.rs"),
27464        );
27465        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27466        assert!(snapshot.status[2].previous_path.is_none());
27467    }
27468
27469    #[test]
27470    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27471        let mut snapshot = git_snapshot_for_parser();
27472        parse_git_status(
27473            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
27474            &mut snapshot,
27475        );
27476
27477        assert!(snapshot.truncated);
27478        assert_eq!(snapshot.status.len(), 1);
27479        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27480        assert!(snapshot.status[0].previous_path.is_none());
27481    }
27482
27483    #[tokio::test]
27484    async fn observer_can_browse_host_directories_without_controller() {
27485        let root = temporary_workspace_root("host-directory-observer");
27486        std::fs::create_dir_all(root.join("directory-b")).unwrap();
27487        std::fs::create_dir_all(root.join("directory-a")).unwrap();
27488        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27489        let workspace_id = WorkspaceId::new("primary").unwrap();
27490        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27491        let catalog = active_registry().unwrap();
27492        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27493        let shared = NodeShared::new(
27494            handle,
27495            "fixture-token".to_owned(),
27496            NodeId::new("node-1").unwrap(),
27497            vec![workspace],
27498            vec![agent("claude")],
27499        );
27500        let request = NodeRequest::BrowseHostDirectories {
27501            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27502            after: None,
27503        };
27504        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27505            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27506        }));
27507        assert!(!request_uses_unnegotiated_capability(
27508            &request,
27509            &baseline_capabilities().unwrap(),
27510        ));
27511        assert!(request_uses_unnegotiated_capability(&request, &[]));
27512
27513        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27514            .await
27515            .unwrap();
27516        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27517            panic!("host directory browse returned another response");
27518        };
27519        assert_eq!(
27520            listing
27521                .entries
27522                .iter()
27523                .map(|entry| entry.display_name.as_str())
27524                .collect::<Vec<_>>(),
27525            vec!["directory-a", "directory-b"],
27526        );
27527        assert!(listing.entries.iter().all(|entry| !entry.is_link));
27528        assert!(!listing.incomplete);
27529        std::fs::remove_dir_all(root).unwrap();
27530    }
27531
27532    #[tokio::test]
27533    async fn observer_can_inspect_only_a_registered_workspace() {
27534        let root = temporary_workspace_root("observer");
27535        std::fs::create_dir_all(root.join("src")).unwrap();
27536        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27537        let workspace_id = WorkspaceId::new("primary").unwrap();
27538        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27539        let catalog = active_registry().unwrap();
27540        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27541        let shared = NodeShared::new(
27542            handle,
27543            "fixture-token".to_owned(),
27544            NodeId::new("node-1").unwrap(),
27545            vec![workspace],
27546            vec![agent("claude")],
27547        );
27548
27549        let response = process_request_inner(
27550            &shared,
27551            77,
27552            ClientRole::Observer,
27553            NodeRequest::InspectWorkspace {
27554                workspace_id: workspace_id.clone(),
27555            },
27556        )
27557        .await
27558        .unwrap();
27559        let NodeResponse::WorkspaceInspected { inspection } = response else {
27560            panic!("workspace inspection returned another response");
27561        };
27562        assert_eq!(inspection.workspace_id, workspace_id);
27563        assert!(inspection.entries.iter().any(|entry| {
27564            entry.relative_path.as_utf8() == Some("src/main.rs")
27565        }));
27566
27567        let unknown = process_request_inner(
27568            &shared,
27569            77,
27570            ClientRole::Observer,
27571            NodeRequest::InspectWorkspace {
27572                workspace_id: WorkspaceId::new("unknown").unwrap(),
27573            },
27574        )
27575        .await
27576        .unwrap_err();
27577        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27578        std::fs::remove_dir_all(&root).unwrap();
27579    }
27580
27581    #[test]
27582    fn context_pack_source_accepts_only_running_or_successful_exit() {
27583        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27584        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27585            exit_code: Some(0),
27586        }));
27587        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27588            exit_code: Some(1),
27589        }));
27590        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27591            exit_code: None,
27592        }));
27593        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27594            message: "provider failed".to_owned(),
27595        }));
27596        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27597        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27598        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27599    }
27600
27601    #[test]
27602    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27603        let git = git_snapshot_for_parser();
27604        let first = ContextPackRepositoryObservation {
27605            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27606            git: git.clone(),
27607            files: Vec::new(),
27608        };
27609        let second = ContextPackRepositoryObservation {
27610            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27611            git,
27612            files: Vec::new(),
27613        };
27614
27615        let error = stable_context_pack_repository(first, second).unwrap_err();
27616
27617        assert_eq!(
27618            error.code,
27619            NodeFailureCode::ContextPackMaterializationFailed,
27620        );
27621    }
27622
27623    #[test]
27624    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27625        let git = git_snapshot_for_parser();
27626        let first = ContextPackRepositoryObservation {
27627            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27628            git: git.clone(),
27629            files: vec![ContextPackRepositoryFileSource::utf8(
27630                "README.md",
27631                "first\n".to_owned(),
27632                6,
27633            )],
27634        };
27635        let second = ContextPackRepositoryObservation {
27636            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27637            git,
27638            files: vec![ContextPackRepositoryFileSource::utf8(
27639                "README.md",
27640                "other\n".to_owned(),
27641                6,
27642            )],
27643        };
27644
27645        let error = stable_context_pack_repository(first, second).unwrap_err();
27646
27647        assert_eq!(
27648            error.code,
27649            NodeFailureCode::ContextPackMaterializationFailed,
27650        );
27651    }
27652
27653    #[tokio::test]
27654    async fn native_session_catalog_zero_active_session_fixture() {
27655        let workspace_root = temporary_workspace_root("native-catalog-workspace");
27656        let history_root = temporary_workspace_root("native-catalog-history");
27657        let outside_root = temporary_workspace_root("native-catalog-outside");
27658        std::fs::create_dir_all(&workspace_root).unwrap();
27659        std::fs::create_dir_all(&history_root).unwrap();
27660        std::fs::create_dir_all(&outside_root).unwrap();
27661        let transcript = |session_id: &str, cwd: &Path, title: &str| {
27662            [
27663                serde_json::json!({
27664                    "type": "user",
27665                    "sessionId": session_id,
27666                    "cwd": cwd.to_str().unwrap(),
27667                    "message": { "content": "raw user text must not cross the catalog wire" }
27668                }),
27669                serde_json::json!({
27670                    "type": "assistant",
27671                    "sessionId": session_id,
27672                    "cwd": cwd.to_str().unwrap(),
27673                    "message": { "model": "claude-sonnet", "content": "raw answer" }
27674                }),
27675                serde_json::json!({
27676                    "type": "ai-title",
27677                    "sessionId": session_id,
27678                    "aiTitle": title
27679                }),
27680            ]
27681            .into_iter()
27682            .map(|record| record.to_string())
27683            .collect::<Vec<_>>()
27684            .join("\n")
27685        };
27686        std::fs::write(
27687            history_root.join("stable-session.jsonl"),
27688            transcript("stable-session", &workspace_root, "Workspace review"),
27689        )
27690        .unwrap();
27691        std::fs::write(
27692            history_root.join("outside-session.jsonl"),
27693            transcript("outside-session", &outside_root, "Outside review"),
27694        )
27695        .unwrap();
27696        let history = NativeHistoryConfig::new(vec![
27697            NativeHistoryRoot::new(
27698                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27699                HistorySourceLayout::SingleNdjson,
27700                history_root.clone(),
27701            )
27702            .unwrap(),
27703        ])
27704        .unwrap();
27705        let catalog = active_registry().unwrap();
27706        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27707        assert!(handle.snapshot().sessions.is_empty());
27708        let workspace_id = WorkspaceId::new("primary").unwrap();
27709        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27710        let mut shared = NodeShared::new(
27711            handle,
27712            "fixture-token".to_owned(),
27713            NodeId::new("node-native-catalog").unwrap(),
27714            vec![workspace],
27715            vec![agent("claude")],
27716        );
27717        shared.native_session_catalog = Some(Arc::new(Mutex::new(
27718            NativeSessionCatalogAuthority::new(history),
27719        )));
27720        let route = NativeSessionCatalogRoute::workspace(
27721            workspace_id.clone(),
27722            agent("claude"),
27723        );
27724        let request = NodeRequest::CatalogNativeSessions {
27725            route: route.clone(),
27726            limit: 2,
27727        };
27728        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27729            .await
27730            .unwrap_err();
27731        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27732        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27733            .await
27734            .unwrap();
27735        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27736            panic!("native catalog returned a different response");
27737        };
27738        let summary = summary.expect("node must emit native session catalog summary metadata");
27739        assert_eq!(entries.len(), 1);
27740        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27741        assert_eq!(entries[0].external_group, None);
27742        assert_eq!(entries[0].record_id, None);
27743        assert_eq!(entries[0].title, None);
27744        assert_eq!(entries[0].model, None);
27745        assert_eq!(entries[0].message_count, 0);
27746        let encoded_entries = serde_json::to_string(&entries).unwrap();
27747        assert!(!encoded_entries.contains("stable-session"));
27748        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27749        let page_request = NodeRequest::PageNativeSessions {
27750            route: route.clone(),
27751            window: crate::protocol::NativeSessionCatalogWindow::Recent,
27752            catalog_revision: summary.catalog_revision,
27753            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27754            after_selection_id: None,
27755            limit: 2,
27756        };
27757        let observer = process_request_inner(
27758            &shared,
27759            7,
27760            ClientRole::Observer,
27761            page_request.clone(),
27762        )
27763        .await
27764        .unwrap_err();
27765        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27766        let page_response = process_request_inner(
27767            &shared,
27768            7,
27769            ClientRole::Operator,
27770            page_request.clone(),
27771        )
27772        .await
27773        .unwrap();
27774        assert!(matches!(
27775            page_response,
27776            NodeResponse::NativeSessionsPaged { page, .. }
27777                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27778        ));
27779        let stale = process_request_inner(
27780            &shared,
27781            7,
27782            ClientRole::Operator,
27783            NodeRequest::PageNativeSessions {
27784                route: route.clone(),
27785                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27786                catalog_revision: summary.catalog_revision.wrapping_add(1),
27787                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27788                after_selection_id: None,
27789                limit: 2,
27790            },
27791        )
27792        .await
27793        .unwrap_err();
27794        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27795        let selection = NativeSessionSelection {
27796            route: route.clone(),
27797            catalog_revision: summary.catalog_revision,
27798            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27799            selection_id: entries[0].selection_id.clone(),
27800        };
27801        let preview_response = process_request_inner(
27802            &shared,
27803            7,
27804            ClientRole::Operator,
27805            NodeRequest::PreviewNativeSession {
27806                selection: selection.clone(),
27807                message_limit: 1,
27808            },
27809        )
27810        .await
27811        .unwrap();
27812        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27813            panic!("native preview returned a different response");
27814        };
27815        assert_eq!(preview.messages.len(), 1);
27816        assert!(preview.message_count_exact);
27817        assert_eq!(preview.messages[0].text, "raw answer");
27818        assert!(preview.truncated);
27819
27820        let index_request = NodeRequest::IndexNativeSession {
27821            selection: selection.clone(),
27822            display_name: "Saved Claude".to_owned(),
27823        };
27824        let observer = process_request_inner(
27825            &shared,
27826            7,
27827            ClientRole::Observer,
27828            index_request.clone(),
27829        )
27830        .await
27831        .unwrap_err();
27832        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27833        let without_controller = process_request_inner(
27834            &shared,
27835            7,
27836            ClientRole::Operator,
27837            index_request.clone(),
27838        )
27839        .await
27840        .unwrap_err();
27841        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27842        let acquired = process_request_inner(
27843            &shared,
27844            7,
27845            ClientRole::Operator,
27846            NodeRequest::AcquireController { lease_ms: 5_000 },
27847        )
27848        .await
27849        .unwrap();
27850        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27851        let indexed = process_request_inner(
27852            &shared,
27853            7,
27854            ClientRole::Operator,
27855            index_request,
27856        )
27857        .await
27858        .unwrap();
27859        assert!(indexed.native_session_index_contract_is_valid());
27860        let NodeResponse::NativeSessionIndexed {
27861            selection: echoed_selection,
27862            record,
27863        } = indexed else {
27864            panic!("native session index returned a different response");
27865        };
27866        assert_eq!(echoed_selection, selection);
27867        let recataloged = process_request_inner(
27868            &shared,
27869            7,
27870            ClientRole::Operator,
27871            NodeRequest::CatalogNativeSessions {
27872                route,
27873                limit: 2,
27874            },
27875        )
27876        .await
27877        .unwrap();
27878        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27879            panic!("native catalog returned a different response after indexing");
27880        };
27881        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27882
27883        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27884        let external_catalog = process_request_inner(
27885            &shared,
27886            7,
27887            ClientRole::Operator,
27888            NodeRequest::CatalogNativeSessions {
27889                route: unregistered_route.clone(),
27890                limit: 2,
27891            },
27892        )
27893        .await
27894        .unwrap();
27895        let external_json = serde_json::to_string(&external_catalog).unwrap();
27896        assert!(!external_json.contains("outside-session"));
27897        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27898        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27899        assert!(!external_json.contains("\"session_id\""));
27900        assert!(!external_json.contains("\"cwd\""));
27901        assert!(!external_json.contains("\"path\""));
27902        let NodeResponse::NativeSessionsCataloged {
27903            route: echoed_route,
27904            entries: external_entries,
27905            summary: Some(external_summary),
27906        } = external_catalog
27907        else {
27908            panic!("unregistered native catalog returned a different response");
27909        };
27910        assert_eq!(echoed_route, unregistered_route);
27911        assert_eq!(external_entries.len(), 1);
27912        assert_eq!(external_entries[0].record_id, None);
27913        let external_group = external_entries[0]
27914            .external_group
27915            .as_ref()
27916            .expect("unregistered session must have an external group");
27917        assert_eq!(
27918            external_group.kind,
27919            gate4agent_types::NativeSessionExternalGroupKind::Project,
27920        );
27921        assert!(external_group.group_id.starts_with("external-"));
27922        assert_eq!(
27923            external_group.display_name,
27924            outside_root.file_name().unwrap().to_string_lossy(),
27925        );
27926        let external_selection = NativeSessionSelection {
27927            route: unregistered_route,
27928            catalog_revision: external_summary.catalog_revision,
27929            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27930            selection_id: external_entries[0].selection_id.clone(),
27931        };
27932        let external_preview = process_request_inner(
27933            &shared,
27934            7,
27935            ClientRole::Operator,
27936            NodeRequest::PreviewNativeSession {
27937                selection: external_selection.clone(),
27938                message_limit: 1,
27939            },
27940        )
27941        .await
27942        .unwrap();
27943        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27944        assert!(!external_preview_json.contains("outside-session"));
27945        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27946        assert!(!external_preview_json.contains("\"session_id\""));
27947        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27948            panic!("unregistered native preview returned a different response");
27949        };
27950        assert_eq!(preview.messages.len(), 1);
27951        let external_index = process_request_inner(
27952            &shared,
27953            7,
27954            ClientRole::Operator,
27955            NodeRequest::IndexNativeSession {
27956                selection: external_selection,
27957                display_name: "External Claude".to_owned(),
27958            },
27959        )
27960        .await
27961        .unwrap_err();
27962        assert_eq!(
27963            external_index.code,
27964            NodeFailureCode::WorkspaceRegistrationRequired,
27965        );
27966        let record_response = process_request_inner(
27967            &shared,
27968            7,
27969            ClientRole::Operator,
27970            NodeRequest::PreviewSessionRecord {
27971                record_id: record.record_id.clone(),
27972                message_limit: 2,
27973            },
27974        )
27975        .await
27976        .unwrap();
27977        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27978            panic!("record preview returned a different response");
27979        };
27980        assert_eq!(record_id, record.record_id);
27981        assert_eq!(preview.messages.len(), 2);
27982        let encoded = serde_json::to_string(&preview).unwrap();
27983        assert!(!encoded.contains("stable-session"));
27984        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27985        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27986            panic!("record preview history summary returned a different response");
27987        };
27988        let summaries = events
27989            .iter()
27990            .filter_map(|envelope| match &envelope.event {
27991                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27992                    if record_id == &record.record_id =>
27993                {
27994                    Some(*summary)
27995                }
27996                _ => None,
27997            })
27998            .collect::<Vec<_>>();
27999        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
28000        let summary_json = serde_json::to_string(&summaries).unwrap();
28001        for private in [
28002            "stable-session",
28003            "raw user text must not cross the catalog wire",
28004            "raw answer",
28005            "Workspace review",
28006            "claude-sonnet",
28007        ] {
28008            assert!(!summary_json.contains(private), "{private}");
28009        }
28010        let identity = record.provider_session.clone().unwrap();
28011        assert_eq!(
28012            shared.revalidate_session_record_preview(&record, &identity),
28013            Ok(())
28014        );
28015        shared.remove_record_memory(&record.record_id);
28016        assert_eq!(
28017            shared
28018                .revalidate_session_record_preview(&record, &identity)
28019                .unwrap_err()
28020                .code,
28021            NodeFailureCode::UnknownSessionRecord
28022        );
28023        let released = process_request_inner(
28024            &shared,
28025            7,
28026            ClientRole::Operator,
28027            NodeRequest::ReleaseController,
28028        )
28029        .await
28030        .unwrap();
28031        assert!(matches!(released, NodeResponse::Controller { controller: None }));
28032        assert!(shared.controller_state().is_none());
28033        std::fs::remove_dir_all(workspace_root).unwrap();
28034        std::fs::remove_dir_all(history_root).unwrap();
28035        std::fs::remove_dir_all(outside_root).unwrap();
28036    }
28037
28038    #[tokio::test]
28039    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28040        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28041        std::fs::create_dir_all(&history_root).unwrap();
28042        let history = NativeHistoryConfig::new(vec![
28043            NativeHistoryRoot::new(
28044                gate4agent_types::AdapterId::new("claude-code").unwrap(),
28045                HistorySourceLayout::SingleNdjson,
28046                history_root.clone(),
28047            )
28048            .unwrap(),
28049        ])
28050        .unwrap();
28051        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28052        let held = catalog.lock().unwrap();
28053        let result = timeout(
28054            Duration::from_secs(1),
28055            run_native_session_catalog_operation(
28056                Arc::clone(&catalog),
28057                "native session catalog",
28058                |_| Ok::<(), std::convert::Infallible>(()),
28059            ),
28060        )
28061        .await
28062        .expect("concurrent native catalog request must not wait for the operation deadline")
28063        .unwrap_err();
28064        assert_eq!(result.code, NodeFailureCode::BackendBusy);
28065        drop(held);
28066        std::fs::remove_dir_all(history_root).unwrap();
28067    }
28068
28069    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28070        let unique = std::time::SystemTime::now()
28071            .duration_since(std::time::UNIX_EPOCH)
28072            .unwrap()
28073            .as_nanos();
28074        std::env::temp_dir().join(format!(
28075            "gate4agent-node-{label}-{}-{unique}",
28076            std::process::id(),
28077        ))
28078    }
28079
28080    fn git_snapshot_for_parser() -> GitSnapshot {
28081        GitSnapshot {
28082            is_repository: true,
28083            branch: Some("main".to_owned()),
28084            status: Vec::new(),
28085            recent_commits: Vec::new(),
28086            worktrees: Vec::new(),
28087            managed_worktree: None,
28088            truncated: false,
28089            diagnostic: None,
28090        }
28091    }
28092
28093    #[tokio::test]
28094    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28095        let root = temporary_workspace_root("managed-git-scope");
28096        std::fs::create_dir_all(&root).unwrap();
28097        run_test_git(&root, &["init"]);
28098        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28099        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28100        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28101        run_test_git(&root, &["add", "seed.txt"]);
28102        run_test_git(&root, &["commit", "-m", "seed"]);
28103        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28104
28105        let workspace_id = WorkspaceId::new("managed-a").unwrap();
28106        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28107        let catalog = active_registry().unwrap();
28108        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28109        let shared = NodeShared::new(
28110            handle,
28111            "fixture-token".to_owned(),
28112            NodeId::new("node-1").unwrap(),
28113            vec![workspace],
28114            vec![agent("claude")],
28115        );
28116        let mut lease = ManagedWorktreeLeaseRecord {
28117            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28118            source_workspace_id: WorkspaceId::new("primary").unwrap(),
28119            workspace_id: workspace_id.clone(),
28120            profile_id: WorktreeProfileId::new("default").unwrap(),
28121            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28122            target_root: root.to_string_lossy().into_owned(),
28123            branch: "gate4agent/a".to_owned(),
28124            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28125            expected_head: None,
28126            retention: ManagedWorktreeRetention::Retain,
28127            state: ManagedWorktreeLeaseState::InUse,
28128            session_holders: vec![ManagedWorktreeSessionHolder {
28129                incarnation_id: shared.incarnation_id,
28130                instance_id: AgentInstanceId(7),
28131                generation: SessionGeneration(1),
28132            }],
28133            record_holders: Vec::new(),
28134            cleanup_failure: None,
28135            created_at_unix_ms: 1,
28136            updated_at_unix_ms: 1,
28137        };
28138        *shared
28139            .managed_worktrees
28140            .lock()
28141            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28142            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28143
28144        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28145        let scope = inspection.git.managed_worktree.unwrap();
28146        assert_eq!(scope.lease_id, lease.lease_id);
28147        assert_eq!(scope.branch, "gate4agent/a");
28148        assert_eq!(scope.active_session_count, 1);
28149
28150        lease.branch = "gate4agent/b".to_owned();
28151        *shared
28152            .managed_worktrees
28153            .lock()
28154            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28155            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28156        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28157        assert!(mismatch.git.managed_worktree.is_none());
28158        std::fs::remove_dir_all(root).unwrap();
28159    }
28160
28161    #[tokio::test]
28162    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28163        let root = temporary_workspace_root("workspace-file-write-controller");
28164        std::fs::create_dir_all(root.join("src")).unwrap();
28165        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28166        let workspace_id = WorkspaceId::new("primary").unwrap();
28167        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28168        let catalog = active_registry().unwrap();
28169        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28170        let shared = NodeShared::new(
28171            handle,
28172            "fixture-token".to_owned(),
28173            NodeId::new("node-1").unwrap(),
28174            vec![workspace],
28175            vec![agent("claude")],
28176        );
28177        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28178        let expected_revision = workspace_file_revision(b"old\n");
28179        let request = NodeRequest::WriteWorkspaceFile {
28180            workspace_id: workspace_id.clone(),
28181            path: path.clone(),
28182            expected_revision: expected_revision.clone(),
28183            text: "new\n".to_owned(),
28184        };
28185        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28186            .await
28187            .unwrap_err();
28188        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28189        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28190            .await
28191            .unwrap_err();
28192        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28193        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28194        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28195        let NodeResponse::WorkspaceFileWritten { file } = response else {
28196            panic!("workspace file write returned another response");
28197        };
28198        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28199        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28200        let stale = process_request_inner(
28201            &shared,
28202            77,
28203            ClientRole::Operator,
28204            NodeRequest::WriteWorkspaceFile {
28205                workspace_id,
28206                path,
28207                expected_revision,
28208                text: "lost update\n".to_owned(),
28209            },
28210        )
28211        .await
28212        .unwrap_err();
28213        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28214        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28215        std::fs::remove_dir_all(root).unwrap();
28216    }
28217
28218    #[tokio::test]
28219    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28220        let root = temporary_workspace_root("workspace-entry-create-controller");
28221        std::fs::create_dir_all(root.join("src")).unwrap();
28222        let workspace_id = WorkspaceId::new("primary").unwrap();
28223        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28224        let catalog = active_registry().unwrap();
28225        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28226        let shared = NodeShared::new(
28227            handle,
28228            "fixture-token".to_owned(),
28229            NodeId::new("node-1").unwrap(),
28230            vec![workspace],
28231            vec![agent("claude")],
28232        );
28233        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28234        let request = NodeRequest::CreateWorkspaceFile {
28235            workspace_id: workspace_id.clone(),
28236            path: path.clone(),
28237        };
28238        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28239            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28240        }));
28241        assert!(!request_uses_unnegotiated_capability(
28242            &request,
28243            &baseline_capabilities().unwrap(),
28244        ));
28245        assert!(request_uses_unnegotiated_capability(&request, &[]));
28246        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28247            .await
28248            .unwrap_err();
28249        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28250        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28251            .await
28252            .unwrap_err();
28253        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28254        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28255
28256        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28257            .await
28258            .unwrap();
28259        let NodeResponse::WorkspaceFileCreated { file } = response else {
28260            panic!("workspace file create returned another response");
28261        };
28262        assert_eq!(file.workspace_id, workspace_id);
28263        assert_eq!(file.path, path);
28264        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28265        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28266
28267        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28268            .await
28269            .unwrap_err();
28270        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28271
28272        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28273        let response = process_request_inner(
28274            &shared,
28275            77,
28276            ClientRole::Operator,
28277            NodeRequest::CreateWorkspaceDirectory {
28278                workspace_id: workspace_id.clone(),
28279                path: directory_path.clone(),
28280            },
28281        )
28282        .await
28283        .unwrap();
28284        assert_eq!(
28285            response,
28286            NodeResponse::WorkspaceDirectoryCreated {
28287                workspace_id: workspace_id.clone(),
28288                entry: WorkspaceEntry {
28289                    relative_path: directory_path,
28290                    kind: WorkspaceEntryKind::Directory,
28291                },
28292            },
28293        );
28294        assert!(root.join("src/nested").is_dir());
28295
28296        let missing_parent = process_request_inner(
28297            &shared,
28298            77,
28299            ClientRole::Operator,
28300            NodeRequest::CreateWorkspaceFile {
28301                workspace_id: workspace_id.clone(),
28302                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28303            },
28304        )
28305        .await
28306        .unwrap_err();
28307        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28308        let non_directory_parent = process_request_inner(
28309            &shared,
28310            77,
28311            ClientRole::Operator,
28312            NodeRequest::CreateWorkspaceFile {
28313                workspace_id,
28314                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28315            },
28316        )
28317        .await
28318        .unwrap_err();
28319        assert_eq!(
28320            non_directory_parent.code,
28321            NodeFailureCode::RepositoryParentNotDirectory,
28322        );
28323        std::fs::remove_dir_all(root).unwrap();
28324    }
28325
28326    #[tokio::test]
28327    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28328        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28329        std::fs::create_dir_all(root.join("src")).unwrap();
28330        #[cfg(windows)]
28331        let root = std::fs::canonicalize(root).unwrap();
28332        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28333        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28334        let barrier = Arc::new(std::sync::Barrier::new(2));
28335        let done = Arc::new(AtomicBool::new(false));
28336        let task_commit_state = Arc::clone(&commit_state);
28337        let task_barrier = Arc::clone(&barrier);
28338        let task_done = Arc::clone(&done);
28339        let task_root = root.clone();
28340        let task = tokio::task::spawn_blocking(move || {
28341            task_barrier.wait();
28342            let result = create_workspace_file_on_disk(
28343                &task_root,
28344                &path,
28345                &task_commit_state,
28346            );
28347            task_done.store(true, Ordering::Release);
28348            result
28349        });
28350
28351        let error = settle_workspace_entry_create(
28352            task,
28353            Arc::clone(&commit_state),
28354            Duration::from_millis(10),
28355            "fixture deadline",
28356            "fixture task failed",
28357        )
28358        .await
28359        .unwrap_err();
28360        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28361        assert_eq!(
28362            commit_state.load(Ordering::Acquire),
28363            WORKSPACE_ENTRY_CREATE_CANCELED,
28364        );
28365
28366        barrier.wait();
28367        timeout(Duration::from_secs(1), async {
28368            while !done.load(Ordering::Acquire) {
28369                tokio::task::yield_now().await;
28370            }
28371        })
28372        .await
28373        .expect("canceled blocking create did not settle");
28374        assert!(!root.join("src/late.rs").exists());
28375        std::fs::remove_dir_all(root).unwrap();
28376    }
28377}
28378
28379#[cfg(test)]
28380mod provisional_spawn_tests {
28381    use super::*;
28382    use std::sync::Arc;
28383
28384    fn shared() -> NodeShared {
28385        let catalog = active_registry().unwrap();
28386        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28387        let workspace = WorkspaceConfig::new(
28388            WorkspaceId::new("primary").unwrap(),
28389            std::env::current_dir().unwrap(),
28390        )
28391        .unwrap()
28392        .with_worktree_service_mode(WorktreeServiceMode::Manual);
28393        NodeShared::new(
28394            handle,
28395            "fixture-token".to_owned(),
28396            NodeId::new("node-provisional-test").unwrap(),
28397            vec![workspace],
28398            vec![AgentId::new("claude").unwrap()],
28399        )
28400    }
28401
28402    /// Provisional draft map: sessionize by instance id only; 50 sequential
28403    /// mark/claim cycles never collide, and a claimed draft is never reaped.
28404    #[test]
28405    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28406        let shared = shared();
28407        let ws = WorkspaceId::new("primary").unwrap();
28408        let mut ids = Vec::with_capacity(50);
28409        for i in 1u64..=50 {
28410            let id = AgentInstanceId(i);
28411            ids.push(id);
28412            let address = SessionAddress {
28413                workspace_id: ws.clone(),
28414                session: SessionKey {
28415                    instance_id: id,
28416                    generation: SessionGeneration(1),
28417                },
28418            };
28419            shared.mark_spawn_provisional(address);
28420        }
28421        assert_eq!(
28422            shared
28423                .provisional_spawns
28424                .lock()
28425                .unwrap_or_else(|p| p.into_inner())
28426                .len(),
28427            50
28428        );
28429        for id in ids.iter().step_by(2) {
28430            shared.sessionize_spawn(*id);
28431        }
28432        assert_eq!(
28433            shared
28434                .provisional_spawns
28435                .lock()
28436                .unwrap_or_else(|p| p.into_inner())
28437                .len(),
28438            25
28439        );
28440        assert!(shared.take_due_provisional_spawns().is_empty());
28441        {
28442            let mut map = shared
28443                .provisional_spawns
28444                .lock()
28445                .unwrap_or_else(|p| p.into_inner());
28446            let past = Instant::now() - Duration::from_secs(91);
28447            for (_, (_, at)) in map.iter_mut() {
28448                *at = past;
28449            }
28450        }
28451        let due = shared.take_due_provisional_spawns();
28452        assert_eq!(due.len(), 25);
28453        let due_ids: std::collections::BTreeSet<_> =
28454            due.iter().map(|a| a.session.instance_id.0).collect();
28455        let expected: std::collections::BTreeSet<_> =
28456            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28457        assert_eq!(due_ids, expected);
28458        assert!(shared
28459            .provisional_spawns
28460            .lock()
28461            .unwrap_or_else(|p| p.into_inner())
28462            .is_empty());
28463    }
28464
28465    /// Twenty parallel provisional marks must each get their own instance key;
28466    /// killing/sessionizing one id must not touch the others.
28467    #[test]
28468    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28469        let shared = Arc::new(shared());
28470        let ws = WorkspaceId::new("primary").unwrap();
28471        let mut handles = Vec::new();
28472        for i in 1u64..=20 {
28473            let shared = Arc::clone(&shared);
28474            let ws = ws.clone();
28475            handles.push(std::thread::spawn(move || {
28476                let id = AgentInstanceId(i);
28477                let address = SessionAddress {
28478                    workspace_id: ws,
28479                    session: SessionKey {
28480                        instance_id: id,
28481                        generation: SessionGeneration(1),
28482                    },
28483                };
28484                shared.mark_spawn_provisional(address);
28485                id
28486            }));
28487        }
28488        let mut ids = Vec::new();
28489        for h in handles {
28490            ids.push(h.join().unwrap());
28491        }
28492        ids.sort_by_key(|id| id.0);
28493        assert_eq!(ids.len(), 20);
28494        ids.dedup();
28495        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28496        assert_eq!(
28497            shared
28498                .provisional_spawns
28499                .lock()
28500                .unwrap_or_else(|p| p.into_inner())
28501                .len(),
28502            20
28503        );
28504        shared.sessionize_spawn(AgentInstanceId(7));
28505        let left: Vec<_> = shared
28506            .provisional_spawns
28507            .lock()
28508            .unwrap_or_else(|p| p.into_inner())
28509            .keys()
28510            .map(|id| id.0)
28511            .collect();
28512        assert!(!left.contains(&7));
28513        assert_eq!(left.len(), 19);
28514    }
28515}
28516
28517
28518#[cfg(unix)]
28519extern "C" {
28520    #[link_name = "geteuid"]
28521    fn libc_geteuid() -> u32;
28522}
28523
28524#[cfg(test)]
28525mod call_home_link_tests {
28526    use super::*;
28527
28528    #[test]
28529    fn call_home_backoff_is_capped_and_monotonic() {
28530        let mut last = Duration::ZERO;
28531        for attempt in 1..20 {
28532            let next = call_home_backoff(attempt);
28533            assert!(next >= last);
28534            assert!(next <= Duration::from_secs(8));
28535            last = next;
28536        }
28537        assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28538    }
28539
28540    #[tokio::test]
28541    async fn call_home_socket_gets_keepalive() {
28542        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28543        let addr = listener.local_addr().unwrap();
28544        let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28545        let client = client.unwrap();
28546        enable_call_home_keepalive(&client).unwrap();
28547        let sock = socket2::SockRef::from(&client);
28548        assert!(sock.keepalive().unwrap());
28549        #[cfg(any(target_os = "linux", target_os = "macos"))]
28550        assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28551    }
28552}
28553
28554/// A provider transcript for this session id exists in a known provider home.
28555fn provider_session_exists(provider: &str, id: &str) -> bool {
28556    if provider != "claude" { return false; }
28557    let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28558    if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28559    let name = format!("{id}.jsonl");
28560    homes.iter().any(|h| std::fs::read_dir(h.join(".claude/projects")).map_or(false, |rd| rd.flatten().any(|d| d.path().join(&name).is_file())))
28561}
28562
28563/// Append launch args to a prepared overlay (or create an instance overlay for them).
28564fn overlay_with_extra_args(
28565    provider: &AgentId,
28566    mode: SessionMode,
28567    overlay: Option<PreparedNativeLaunchOverlay>,
28568    args: Vec<OsString>,
28569) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28570    let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28571    Ok(match (overlay, args) {
28572        (prepared, args) if args.is_empty() => prepared,
28573        (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28574        (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28575        (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28576            provider.clone(),
28577            match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28578            Vec::new(),
28579            args,
28580        ).map_err(|_| bad())?)),
28581    })
28582}