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                #[cfg(unix)]
10813                if let (Some(proxy), Some(pid)) = (self.signer_proxy.get(), process_id) {
10814                    proxy.attach_pid(&format!("{}-{}", self.incarnation_id, address.session.instance_id.0), *pid);
10815                }
10816                tracing::info!(
10817                    node_id = %self.node_id,
10818                    workspace_id = %address.workspace_id,
10819                    session = ?address.session,
10820                    pid = ?process_id,
10821                    "session process is running",
10822                );
10823            }
10824            ControlEventKind::Exited { exit_code, forced } => {
10825                #[cfg(unix)]
10826                if let Some(proxy) = self.signer_proxy.get().cloned() {
10827                    let key = format!("{}-{}", self.incarnation_id, address.session.instance_id.0);
10828                    if proxy.has_session(&key) {
10829                        // Revoke at core off the event path (blocking uplink call).
10830                        std::thread::spawn(move || proxy.release(&key));
10831                    }
10832                }
10833                tracing::info!(
10834                    node_id = %self.node_id,
10835                    workspace_id = %address.workspace_id,
10836                    session = ?address.session,
10837                    exit_code = ?exit_code,
10838                    forced = forced,
10839                    "session process exited",
10840                );
10841            }
10842            ControlEventKind::Failed { message } => {
10843                tracing::warn!(
10844                    node_id = %self.node_id,
10845                    workspace_id = %address.workspace_id,
10846                    session = ?address.session,
10847                    cause = %message,
10848                    "session failed",
10849                );
10850            }
10851            ControlEventKind::CommandRejected { message } => {
10852                tracing::warn!(
10853                    node_id = %self.node_id,
10854                    workspace_id = %address.workspace_id,
10855                    session = ?address.session,
10856                    cause = %message,
10857                    "session command rejected",
10858                );
10859            }
10860            ControlEventKind::Removed => {
10861                tracing::info!(
10862                    node_id = %self.node_id,
10863                    workspace_id = %address.workspace_id,
10864                    session = ?address.session,
10865                    "session removed",
10866                );
10867            }
10868            ControlEventKind::ResumeAuthorized { session: provider_session } => {
10869                tracing::info!(
10870                    node_id = %self.node_id,
10871                    workspace_id = %address.workspace_id,
10872                    session = ?address.session,
10873                    provider_session = ?provider_session,
10874                    "session resume authorized",
10875                );
10876            }
10877            ControlEventKind::Resumed { session: provider_session, process_id } => {
10878                tracing::info!(
10879                    node_id = %self.node_id,
10880                    workspace_id = %address.workspace_id,
10881                    session = ?address.session,
10882                    provider_session = ?provider_session,
10883                    pid = ?process_id,
10884                    "session resumed",
10885                );
10886            }
10887            ControlEventKind::ResumeDenied { reason } => {
10888                tracing::warn!(
10889                    node_id = %self.node_id,
10890                    workspace_id = %address.workspace_id,
10891                    session = ?address.session,
10892                    cause = %reason,
10893                    "session resume denied",
10894                );
10895            }
10896            ControlEventKind::ResumeFailed { message } => {
10897                tracing::warn!(
10898                    node_id = %self.node_id,
10899                    workspace_id = %address.workspace_id,
10900                    session = ?address.session,
10901                    cause = %message,
10902                    "session resume failed",
10903                );
10904            }
10905            _ => {}
10906        }
10907        self.apply_pending_acp_options(&address, &event);
10908        self.apply_acp_auto_permit(&address, &event);
10909        let stream_chunk = agent_stream_chunk(&event);
10910        self.publish(NodeEvent::Control {
10911            address: address.clone(),
10912            event,
10913        });
10914        if let Some(chunk) = stream_chunk {
10915            self.publish_agent_stream_chunk(address.clone(), chunk);
10916        }
10917        if revokes_harness_mcp {
10918            SessionLifecycleHook::on_session_ended(self, &address);
10919        }
10920    }
10921
10922    fn reconcile_managed_record(
10923        &self,
10924        record_id: &SessionRecordId,
10925        address: &SessionAddress,
10926        event: &ControlEvent,
10927        identity_admitted: bool,
10928        resume_command_rejected: bool,
10929    ) {
10930        let _transaction = self
10931            .state_transaction
10932            .lock()
10933            .unwrap_or_else(|poisoned| poisoned.into_inner());
10934        let observed_identity = match &event.event {
10935            ControlEventKind::ProviderEvent {
10936                event: gate4agent_types::ProviderEvent::SessionIdentityObserved { identity },
10937                ..
10938            } if identity_admitted => Some(identity.clone()),
10939            _ => None,
10940        };
10941        let identity_was_observed = observed_identity.is_some();
10942        let replacement_id = observed_identity
10943            .as_ref()
10944            .and_then(|_| self.allocate_record_id().ok());
10945        let mut upserts = Vec::new();
10946        let mut removals = Vec::new();
10947        let mut rebind = None;
10948        let original_records;
10949        let original_materializations = self
10950            .materializations
10951            .lock()
10952            .unwrap_or_else(|poisoned| poisoned.into_inner())
10953            .clone();
10954        {
10955            let mut records = self
10956                .session_records
10957                .lock()
10958                .unwrap_or_else(|poisoned| poisoned.into_inner());
10959            original_records = records.records.clone();
10960            let Some(current_snapshot) = records.records.get(record_id).cloned() else {
10961                return;
10962            };
10963            if let Some(identity) = observed_identity {
10964                let existing_id = records
10965                    .records
10966                    .values()
10967                    .find(|record| {
10968                        record.record_id != *record_id
10969                            && record.provider_session.as_ref().is_some_and(|record_identity| {
10970                                session_registry::same_provider_session(
10971                                    &record.provider,
10972                                    record_identity,
10973                                    &current_snapshot.provider,
10974                                    &identity,
10975                                )
10976                            })
10977                    })
10978                    .map(|record| record.record_id.clone());
10979                if let Some(existing_id) = existing_id {
10980                    let scope_matches = records.records.get(&existing_id).is_some_and(|record| {
10981                        record.mode == current_snapshot.mode
10982                            && record.workspace_id == current_snapshot.workspace_id
10983                            && record.environment_profile == current_snapshot.environment_profile
10984                            && record.bundle == current_snapshot.bundle
10985                            && record.context_id == current_snapshot.context_id
10986                            && record.context == current_snapshot.context
10987                            && record
10988                                .canonical_root
10989                                .as_utf8()
10990                                .zip(current_snapshot.canonical_root.as_utf8())
10991                                .is_some_and(|(left, right)| platform::roots_equal(left, right))
10992                    });
10993                    if !scope_matches {
10994                        if let Some(current) = records.records.get_mut(record_id) {
10995                            current.state = ManagedSessionState::Unavailable;
10996                            current.last_error =
10997                                Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR.to_owned());
10998                            current.updated_at_unix_ms = unix_time_ms();
10999                            upserts.push(current.clone());
11000                        }
11001                    } else {
11002                        let existing_is_active = records
11003                            .records
11004                            .get(&existing_id)
11005                            .is_some_and(|record| record.active_session.is_some());
11006                        if existing_is_active {
11007                            if let Some(current) = records.records.get_mut(record_id) {
11008                                current.state = ManagedSessionState::Unavailable;
11009                                current.last_error =
11010                                    Some(PROVIDER_SESSION_LIVE_CONFLICT_ERROR.to_owned());
11011                                current.updated_at_unix_ms = unix_time_ms();
11012                                upserts.push(current.clone());
11013                            }
11014                        } else {
11015                            if current_snapshot.provider_session.is_some() {
11016                                if let Some(current) = records.records.get_mut(record_id) {
11017                                    current.active_session = None;
11018                                    current.state = ManagedSessionState::Dormant;
11019                                    current.updated_at_unix_ms = unix_time_ms();
11020                                    upserts.push(current.clone());
11021                                }
11022                            } else {
11023                                records.records.remove(record_id);
11024                                removals.push(record_id.clone());
11025                            }
11026                            if let Some(existing) = records.records.get_mut(&existing_id) {
11027                                existing.provider_session = Some(identity.clone());
11028                                existing.active_session = Some(address.clone());
11029                                existing.state = ManagedSessionState::Live;
11030                                existing.updated_at_unix_ms = unix_time_ms();
11031                                existing.last_error = None;
11032                                upserts.push(existing.clone());
11033                            }
11034                            rebind = Some(existing_id);
11035                        }
11036                    }
11037                } else if current_snapshot
11038                    .provider_session
11039                    .as_ref()
11040                    .is_some_and(|current| {
11041                        !session_registry::same_provider_session(
11042                            &current_snapshot.provider,
11043                            current,
11044                            &current_snapshot.provider,
11045                            &identity,
11046                        )
11047                    })
11048                {
11049                    if let Some(current) = records.records.get_mut(record_id) {
11050                        current.active_session = None;
11051                        if current.bundle.is_some() || current.context.is_some() {
11052                            let had_bundle = current.bundle.is_some();
11053                            current.provider_session = None;
11054                            current.bundle = None;
11055                            current.context_id = None;
11056                            current.context = None;
11057                            current.state = ManagedSessionState::Unavailable;
11058                            current.last_error = Some(if had_bundle {
11059                                "bundle-unavailable".to_owned()
11060                            } else {
11061                                "context-unavailable".to_owned()
11062                            });
11063                        } else {
11064                            current.state = ManagedSessionState::Dormant;
11065                        }
11066                        current.updated_at_unix_ms = unix_time_ms();
11067                        upserts.push(current.clone());
11068                    }
11069                    if records.records.len() >= MAX_MANAGED_SESSION_RECORDS {
11070                        if let Some(current) = records.records.get_mut(record_id) {
11071                            current.state = ManagedSessionState::Unavailable;
11072                            current.last_error =
11073                                Some(MANAGED_SESSION_CAPACITY_ERROR.to_owned());
11074                            current.updated_at_unix_ms = unix_time_ms();
11075                            upserts.push(current.clone());
11076                        }
11077                    } else if let Some(new_record_id) = replacement_id {
11078                        let now = unix_time_ms();
11079                        let replacement = ManagedSessionRecord {
11080                            record_id: new_record_id.clone(),
11081                            display_name: format!(
11082                                "{} #{}",
11083                                current_snapshot.provider.as_str(),
11084                                records
11085                                    .records
11086                                    .values()
11087                                    .filter(|record| {
11088                                        record.provider == current_snapshot.provider
11089                                    })
11090                                    .count()
11091                                    .saturating_add(1),
11092                            ),
11093                            provider: current_snapshot.provider.clone(),
11094                            mode: current_snapshot.mode,
11095                            state: ManagedSessionState::Live,
11096                            workspace_id: current_snapshot.workspace_id,
11097                            canonical_root: current_snapshot.canonical_root,
11098                            provider_session: Some(identity),
11099                            active_session: Some(address.clone()),
11100                            environment_profile: current_snapshot.environment_profile,
11101                            bundle: current_snapshot.bundle,
11102                            context_id: current_snapshot.context_id,
11103                            context: current_snapshot.context,
11104                            // This is a fresh record_id representing the session under its
11105                            // newly-observed identity; it has not yet produced any clean
11106                            // exit of its own, so it starts with no exported pack (the old
11107                            // record, still addressable under its own record_id, keeps
11108                            // whatever exported_context it already had).
11109                            exported_context: None,
11110                            task_binding: current_snapshot.task_binding,
11111                            created_at_unix_ms: now,
11112                            updated_at_unix_ms: now,
11113                            last_error: None,
11114                        };
11115                        records
11116                            .records
11117                            .insert(new_record_id.clone(), replacement.clone());
11118                        upserts.push(replacement);
11119                        rebind = Some(new_record_id);
11120                    } else if let Some(current) = records.records.get_mut(record_id) {
11121                        current.state = ManagedSessionState::Unavailable;
11122                        current.last_error =
11123                            Some(PROVIDER_IDENTITY_ALLOCATION_ERROR.to_owned());
11124                        upserts.push(current.clone());
11125                    }
11126                } else if let Some(current) = records.records.get_mut(record_id) {
11127                    current.provider_session = Some(identity);
11128                    current.active_session = Some(address.clone());
11129                    current.state = ManagedSessionState::Live;
11130                    current.updated_at_unix_ms = unix_time_ms();
11131                    current.last_error = None;
11132                    upserts.push(current.clone());
11133                }
11134            } else if let Some(current) = records.records.get_mut(record_id) {
11135                if resume_command_rejected {
11136                    current.active_session = None;
11137                    current.state = detached_record_state(current);
11138                    current.updated_at_unix_ms = unix_time_ms();
11139                    current.last_error =
11140                        Some(PROVIDER_RESUME_REJECTED_ERROR.to_owned());
11141                    upserts.push(current.clone());
11142                } else {
11143                    match &event.event {
11144                    ControlEventKind::Running { .. }
11145                    | ControlEventKind::ResumeAuthorized { .. }
11146                    | ControlEventKind::Resumed { .. } => {
11147                        current.active_session = Some(address.clone());
11148                        current.state = active_record_state(
11149                            identity_admitted,
11150                            current.provider_session.is_some(),
11151                        );
11152                        current.updated_at_unix_ms = unix_time_ms();
11153                        current.last_error = None;
11154                        upserts.push(current.clone());
11155                    }
11156                    ControlEventKind::Exited { .. } | ControlEventKind::Removed => {
11157                        current.active_session = None;
11158                        current.state = detached_record_state(current);
11159                        current.updated_at_unix_ms = unix_time_ms();
11160                        upserts.push(current.clone());
11161                    }
11162                    ControlEventKind::Failed { message }
11163                    | ControlEventKind::ResumeFailed { message } => {
11164                        current.active_session = None;
11165                        current.state = detached_record_state(current);
11166                        current.updated_at_unix_ms = unix_time_ms();
11167                        current.last_error = Some(
11168                            session_registry::sanitized_record_error_summary(message),
11169                        );
11170                        upserts.push(current.clone());
11171                    }
11172                    ControlEventKind::ResumeDenied { reason } => {
11173                        current.active_session = None;
11174                        current.state = detached_record_state(current);
11175                        current.updated_at_unix_ms = unix_time_ms();
11176                        current.last_error = Some(
11177                            session_registry::sanitized_record_error_summary(reason),
11178                        );
11179                        upserts.push(current.clone());
11180                    }
11181                        _ => {}
11182                    }
11183                }
11184            }
11185        }
11186        let materialization_owner_record = identity_was_observed.then(|| {
11187            rebind.clone().unwrap_or_else(|| record_id.clone())
11188        }).and_then(|candidate| {
11189            self.session_records
11190                .lock()
11191                .unwrap_or_else(|poisoned| poisoned.into_inner())
11192                .records
11193                .get(&candidate)
11194                .filter(|record| {
11195                    record.provider_session.is_some()
11196                        && record.active_session.as_ref() == Some(address)
11197                })
11198                .map(|_| candidate)
11199        });
11200        if let Some(owner_record_id) = materialization_owner_record {
11201            let materialization_id = self
11202                .session_bindings
11203                .lock()
11204                .unwrap_or_else(|poisoned| poisoned.into_inner())
11205                .get(&address.session.instance_id)
11206                .and_then(|binding| binding.materialization_id.clone());
11207            if let Some(materialization_id) = materialization_id {
11208                let mut transfer_failed = false;
11209                {
11210                    let mut materializations = self
11211                        .materializations
11212                        .lock()
11213                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11214                    if let Some(ownership) = materializations.get_mut(&materialization_id) {
11215                        match ownership.owner() {
11216                            MaterializationOwner::Session {
11217                                incarnation_id,
11218                                instance_id,
11219                                generation,
11220                            } if *incarnation_id == self.incarnation_id
11221                                && *instance_id == address.session.instance_id
11222                                && *generation == address.session.generation =>
11223                            {
11224                                transfer_failed = ownership
11225                                    .transfer_to_record(owner_record_id.clone(), unix_time_ms())
11226                                    .is_err();
11227                            }
11228                            MaterializationOwner::Record { record_id }
11229                                if record_id == &owner_record_id => {}
11230                            MaterializationOwner::Record { record_id } => {
11231                                let expected = record_id.clone();
11232                                transfer_failed = ownership
11233                                    .transfer_record_owner(
11234                                        &expected,
11235                                        owner_record_id.clone(),
11236                                        unix_time_ms(),
11237                                    )
11238                                    .is_err();
11239                            }
11240                            _ => transfer_failed = true,
11241                        }
11242                        if transfer_failed {
11243                            let _ = ownership.mark_recovery_required(unix_time_ms());
11244                        }
11245                    } else {
11246                        transfer_failed = true;
11247                    }
11248                }
11249                if transfer_failed {
11250                    if let Some(record) = self
11251                        .session_records
11252                        .lock()
11253                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11254                        .records
11255                        .get_mut(&owner_record_id)
11256                    {
11257                        record.state = ManagedSessionState::Unavailable;
11258                        record.last_error = Some("environment-profile-unavailable".to_owned());
11259                        record.updated_at_unix_ms = unix_time_ms();
11260                        upserts.push(record.clone());
11261                    }
11262                }
11263            }
11264        }
11265        if let Some(new_record_id) = rebind.clone() {
11266            if let Some(binding) = self
11267                .session_bindings
11268                .lock()
11269                .unwrap_or_else(|poisoned| poisoned.into_inner())
11270                .get_mut(&address.session.instance_id)
11271            {
11272                binding.record_id = Some(new_record_id);
11273            }
11274        }
11275        if upserts.is_empty() && removals.is_empty() {
11276            return;
11277        }
11278        if self.persist_state_locked().is_err() {
11279            self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
11280            let mut records = self
11281                .session_records
11282                .lock()
11283                .unwrap_or_else(|poisoned| poisoned.into_inner());
11284            records.records = original_records;
11285            *self
11286                .materializations
11287                .lock()
11288                .unwrap_or_else(|poisoned| poisoned.into_inner()) = original_materializations;
11289            if let Some(current) = records.records.get_mut(record_id) {
11290                current.state = ManagedSessionState::Unavailable;
11291                current.last_error = Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned());
11292                current.updated_at_unix_ms = unix_time_ms();
11293                upserts = vec![current.clone()];
11294            } else {
11295                upserts.clear();
11296            }
11297            removals.clear();
11298            drop(records);
11299            if let Some(binding) = self
11300                .session_bindings
11301                .lock()
11302                .unwrap_or_else(|poisoned| poisoned.into_inner())
11303                .get_mut(&address.session.instance_id)
11304            {
11305                binding.record_id = Some(record_id.clone());
11306            }
11307            eprintln!("gate4agent-node: {DURABLE_STATE_COMMIT_FAILED_ERROR}");
11308        }
11309        for record in upserts {
11310            self.publish_record(record);
11311        }
11312        for record_id in removals {
11313            self.publish(NodeEvent::SessionRecordRemoved { record_id });
11314        }
11315    }
11316
11317    fn snapshot(&self) -> NodeSnapshot {
11318        let control = self.handle.snapshot();
11319        let bindings = self
11320            .session_bindings
11321            .lock()
11322            .unwrap_or_else(|poisoned| poisoned.into_inner())
11323            .clone();
11324        let workspace_roots = self
11325            .workspaces
11326            .read()
11327            .unwrap_or_else(|poisoned| poisoned.into_inner())
11328            .clone();
11329        let environment_profiles = self
11330            .environment_profiles
11331            .read()
11332            .unwrap_or_else(|poisoned| poisoned.into_inner());
11333        let mut workspaces = workspace_roots
11334            .iter()
11335            .map(|(workspace_id, canonical_root)| {
11336                (
11337                    workspace_id.clone(),
11338                    WorkspaceSnapshot {
11339                        workspace_id: workspace_id.clone(),
11340                        canonical_root: opaque_windows_path(canonical_root.clone()),
11341                        sessions: Vec::new(),
11342                        worktree_service_mode: Some(
11343                            self.worktree_service_modes
11344                                .get(workspace_id)
11345                                .copied()
11346                                .unwrap_or(WorktreeServiceMode::Manual),
11347                        ),
11348                        managed_worktree_profiles: Some(WorktreeProfileInventory {
11349                            profiles: self
11350                                .managed_worktree_profiles
11351                                .get(workspace_id)
11352                                .into_iter()
11353                                .flat_map(BTreeMap::values)
11354                                .map(|profile| ManagedWorktreeProfileSummary {
11355                                    id: profile.profile_id().clone(),
11356                                    revision: profile.revision().clone(),
11357                                    retention: profile.retention(),
11358                                })
11359                                .collect(),
11360                        }),
11361                    },
11362                )
11363            })
11364            .collect::<BTreeMap<_, _>>();
11365        let mut agent_progress = Vec::new();
11366        for session in &control.sessions {
11367            let Some(binding) = bindings.get(&session.instance_id) else {
11368                continue;
11369            };
11370            if binding.generation != session.generation {
11371                continue;
11372            }
11373            if let Some(workspace) = workspaces.get_mut(&binding.workspace_id) {
11374                let address = SessionAddress {
11375                    workspace_id: binding.workspace_id.clone(),
11376                    session: SessionKey {
11377                        instance_id: session.instance_id,
11378                        generation: session.generation,
11379                    },
11380                };
11381                if let Some(progress) =
11382                    agent_progress_from_provider_snapshot(address, &session.provider)
11383                {
11384                    agent_progress.push(progress);
11385                }
11386                workspace.sessions.push(session.clone());
11387            }
11388        }
11389        NodeSnapshot {
11390            node_id: self.node_id.clone(),
11391            enabled_providers: self.enabled_providers.clone(),
11392            provider_runtime_statuses: {
11393                let updates = self
11394                    .provider_runtime_status_updates
11395                    .read()
11396                    .unwrap_or_else(|poisoned| poisoned.into_inner());
11397                ProviderRuntimeStatuses::new(
11398                    self.provider_runtime_statuses
11399                        .iter()
11400                        .filter(|status| !updates.contains_key(status.provider()))
11401                        .cloned()
11402                        .chain(updates.values().cloned()),
11403                )
11404                .expect("refreshed provider runtime inventory remains bounded and unique")
11405            },
11406            workspaces: workspaces.into_values().collect(),
11407            session_records: self
11408                .session_records
11409                .lock()
11410                .unwrap_or_else(|poisoned| poisoned.into_inner())
11411                .records
11412                .values()
11413                .cloned()
11414                .collect(),
11415            managed_worktrees: self.managed_worktrees
11416                .lock()
11417                .unwrap_or_else(|poisoned| poisoned.into_inner())
11418                .snapshots(),
11419            launch_inventory: Some(LaunchInventory {
11420                spawn_profiles: Some(
11421                    self.spawn_profiles
11422                        .iter()
11423                        .filter_map(|profile| {
11424                            resolved_spawn_profile_summary(profile, &environment_profiles)
11425                        })
11426                        .collect(),
11427                ),
11428                bundles: Some(
11429                    self.bundle_catalog
11430                        .read()
11431                        .unwrap_or_else(|poisoned| poisoned.into_inner())
11432                        .iter()
11433                        .map(NodeBundle::receipt)
11434                        .collect(),
11435                ),
11436                // Opaque station allowlist ids from empty-default catalog /
11437                // optional startup loader. Never secrets. Dig2 probe stays stub.
11438                network_allowlists: {
11439                    let catalog = self
11440                        .network_allowlist_catalog
11441                        .read()
11442                        .unwrap_or_else(|poisoned| poisoned.into_inner());
11443                    if catalog.is_empty() {
11444                        None
11445                    } else {
11446                        Some(catalog.ids().cloned().collect())
11447                    }
11448                },
11449            }),
11450            agent_progress,
11451        }
11452    }
11453
11454    fn publish(&self, event: NodeEvent) -> NodeEventEnvelope {
11455        assert!(
11456            !matches!(
11457                &event,
11458                NodeEvent::TerminalFrame { .. } | NodeEvent::AgentStream { .. }
11459            ),
11460            "terminal frame and agent stream events must use their own dedicated live channel",
11461        );
11462        let mut history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11463        let sequence = history
11464            .last_sequence
11465            .checked_add(1)
11466            .expect("node event sequence exhausted");
11467        if history.events.len() == NODE_EVENT_HISTORY_MAX {
11468            if let Some(evicted) = history.events.pop_front() {
11469                history.replay_floor_sequence = evicted
11470                    .sequence
11471                    .checked_add(1)
11472                    .expect("node replay floor sequence exhausted");
11473                history.removed_record_providers.remove(&evicted.sequence);
11474            }
11475        }
11476        match &event {
11477            NodeEvent::HarnessMcpReadCall { .. } => {
11478                panic!("transient harness MCP calls must not enter durable event history")
11479            }
11480            NodeEvent::SessionRecordUpserted { record } => {
11481                history
11482                    .record_providers
11483                    .insert(record.record_id.clone(), record.provider.clone());
11484            }
11485            NodeEvent::SessionRecordRemoved { record_id } => {
11486                if let Some(provider) = history.record_providers.remove(record_id) {
11487                    history.removed_record_providers.insert(sequence, provider);
11488                }
11489            }
11490            NodeEvent::ManagedWorktreeUpserted { .. }
11491            | NodeEvent::ManagedWorktreeRemoved { .. }
11492            | NodeEvent::Control { .. }
11493            | NodeEvent::ControllerChanged { .. }
11494            | NodeEvent::SessionRecordHistorySummarized { .. }
11495            | NodeEvent::WorkspaceAdded { .. }
11496            | NodeEvent::WorkspaceRemoved { .. }
11497            | NodeEvent::ResyncRequired { .. }
11498            | NodeEvent::AgentStream { .. }
11499            | NodeEvent::TerminalFrame { .. } => {}
11500        }
11501        let envelope = NodeEventEnvelope { sequence, event };
11502        history.events.push_back(envelope.clone());
11503        history.last_sequence = sequence;
11504        let _ = self.event_tx.send(envelope.clone());
11505        drop(history);
11506        envelope
11507    }
11508
11509    /// Publishes a transient (never durable) `NodeEvent` -- today only
11510    /// `NodeEvent::HarnessMcpReadCall` -- onto the dedicated
11511    /// `harness_mcp_proxy_event_tx` broadcast (see that field's own doc for
11512    /// why this must never ride the shared `event_tx` bus). Returns whether
11513    /// the send reached at least one subscriber: `broadcast::Sender::send`
11514    /// fails in exactly one way, no active receiver at that instant, and
11515    /// the caller (`HarnessMcpProxyRegistry::begin_call`) uses a `false`
11516    /// return to fail the pending call immediately rather than leave it to
11517    /// expire silently at `LOCAL_READ_TIMEOUT`/
11518    /// `MAX_HARNESS_MCP_CALL_DEADLINE_MS`.
11519    fn publish_transient(&self, event: NodeEvent) -> bool {
11520        assert!(event.requires_harness_mcp_proxy_capability());
11521        assert!(event.harness_mcp_contract_is_valid_at(unix_time_ms()));
11522        let envelope = NodeEventEnvelope { sequence: 0, event };
11523        match self.harness_mcp_proxy_event_tx.send(Arc::new(vec![envelope])) {
11524            Ok(_) => true,
11525            Err(broadcast::error::SendError(undelivered)) => {
11526                if let Some(NodeEventEnvelope {
11527                    event: NodeEvent::HarnessMcpReadCall { call_id, request, .. },
11528                    ..
11529                }) = undelivered.first() {
11530                    // `request.content_type` only, never a per-tool label --
11531                    // this crate does not decode `request.body` and does not
11532                    // hold `hatchery-harness-api` (Nested Control Plane
11533                    // doctrine, Law 3; see this crate's dependency on
11534                    // `gate4agent-node-protocol`'s own `Forbidden:` line).
11535                    tracing::warn!(
11536                        call_id = call_id.as_str(),
11537                        content_type = ?request.content_type,
11538                        cause = "no-subscribers",
11539                        "harness MCP proxy event could not be published",
11540                    );
11541                }
11542                false
11543            }
11544        }
11545    }
11546
11547    fn publish_terminal_frames(&self) {
11548        let snapshot = self.handle.snapshot();
11549        let bindings = self
11550            .session_bindings
11551            .lock()
11552            .unwrap_or_else(|poisoned| poisoned.into_inner());
11553        let candidates = snapshot
11554            .sessions
11555            .iter()
11556            .filter_map(|session| {
11557                let binding = bindings.get(&session.instance_id)?;
11558                if binding.generation != session.generation {
11559                    return None;
11560                }
11561                let frame = session.terminal_frame.clone()?;
11562                crate::session_signals::feed(
11563                    binding.workspace_id.as_str(),
11564                    session.instance_id.0,
11565                    session.agent_id.as_str(),
11566                    &frame.contents,
11567                );
11568                Some((
11569                    SessionAddress {
11570                        workspace_id: binding.workspace_id.clone(),
11571                        session: SessionKey {
11572                            instance_id: session.instance_id,
11573                            generation: session.generation,
11574                        },
11575                    },
11576                    frame,
11577                ))
11578            })
11579            .collect();
11580        drop(bindings);
11581        self.publish_terminal_frame_candidates(candidates);
11582    }
11583
11584    fn publish_terminal_frame_candidates(
11585        &self,
11586        candidates: Vec<(SessionAddress, TerminalFrame)>,
11587    ) {
11588        let mut watermarks = self
11589            .terminal_frame_watermarks
11590            .lock()
11591            .unwrap_or_else(|poisoned| poisoned.into_inner());
11592        let mut events = Vec::with_capacity(candidates.len());
11593        for (address, frame) in candidates {
11594            let is_first_frame = !watermarks.contains_key(&address.session.instance_id);
11595            let advanced = watermarks
11596                .get(&address.session.instance_id)
11597                .map_or(true, |(current_address, sequence)| {
11598                    current_address != &address || frame.sequence > *sequence
11599                });
11600            if !advanced {
11601                continue;
11602            }
11603            if is_first_frame {
11604                // Do NOT sessionize on first PTY output: a session is a
11605                // provider conversation id, not bytes on the wire. Drafts
11606                // leave the provisional map only when HQ attaches
11607                // (Resize / TerminalBytes). The attach-never-arrived timer
11608                // (PROVISIONAL_SPAWN_GRACE_MS) covers deploy lag; HQ's
11609                // disconnect grace starts only on a real client WS close.
11610                // This is the first PTY output this node has observed for
11611                // this session -- a one-time event per spawn, not per-frame
11612                // chatter (the watermark makes sure of that). A session that
11613                // reaches this line with no controller attached anywhere on
11614                // the node is running unobserved: exactly how processes from
11615                // repeated spawn attempts went orphaned for hours without a
11616                // single line about it.
11617                if self.controller_state().is_none() {
11618                    tracing::warn!(
11619                        node_id = %self.node_id,
11620                        workspace_id = %address.workspace_id,
11621                        session = ?address.session,
11622                        terminal_size = ?frame.size,
11623                        "session producing output with no controller attached",
11624                    );
11625                } else {
11626                    tracing::info!(
11627                        node_id = %self.node_id,
11628                        workspace_id = %address.workspace_id,
11629                        session = ?address.session,
11630                        terminal_size = ?frame.size,
11631                        "session produced its first output",
11632                    );
11633                }
11634            }
11635            watermarks.insert(
11636                address.session.instance_id,
11637                (address.clone(), frame.sequence),
11638            );
11639            if let Some((at, reported)) = self
11640                .pty_input_marks
11641                .lock()
11642                .unwrap_or_else(|poisoned| poisoned.into_inner())
11643                .get_mut(&address.session.instance_id)
11644            {
11645                // First frame after an input: PTY write -> output published.
11646                if !*reported && at.elapsed() < Duration::from_secs(5) {
11647                    *reported = true;
11648                    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");
11649                }
11650            }
11651            events.push(NodeEvent::TerminalFrame { address, frame });
11652        }
11653        drop(watermarks);
11654        if events.is_empty() {
11655            return;
11656        }
11657        let mut history = self
11658            .history
11659            .lock()
11660            .unwrap_or_else(|poisoned| poisoned.into_inner());
11661        let mut envelopes = Vec::with_capacity(events.len());
11662        for event in events {
11663            let sequence = history
11664                .last_sequence
11665                .checked_add(1)
11666                .expect("node event sequence exhausted");
11667            history.last_sequence = sequence;
11668            envelopes.push(NodeEventEnvelope { sequence, event });
11669        }
11670        let _ = self.terminal_event_tx.send(Arc::new(envelopes));
11671        drop(history);
11672    }
11673
11674    /// Publishes one `AgentStreamChunkV1` onto the dedicated agent-stream
11675    /// channel, mirroring `publish_terminal_frame_candidates` above: the
11676    /// envelope's `sequence` is drawn from the same `history.last_sequence`
11677    /// counter every other event kind shares, never a counter of its own --
11678    /// `queue_connection_event`/`queue_connection_event_batch` compare
11679    /// `envelope.sequence` against one `discard_events_through` watermark
11680    /// regardless of which broadcast channel it arrived on, so a
11681    /// per-channel counter would desynchronize that comparison.
11682    fn publish_agent_stream_chunk(&self, address: SessionAddress, chunk: AgentStreamChunkV1) {
11683        let mut history = self
11684            .history
11685            .lock()
11686            .unwrap_or_else(|poisoned| poisoned.into_inner());
11687        let sequence = history
11688            .last_sequence
11689            .checked_add(1)
11690            .expect("node event sequence exhausted");
11691        history.last_sequence = sequence;
11692        drop(history);
11693        // Debug, not info/warn -- this fires on every single chunk, but it
11694        // is exactly the counter the next drop-measurement pass needs: what
11695        // the node actually emitted (`source_sequence`, `kind`) versus what
11696        // a harness subscriber's own forwarder later logs having received
11697        // (`hatchery-harness-service::runtime`'s "forwarding agent stream
11698        // event"), so a gap between the two logs names itself instead of
11699        // needing a new counter added ad hoc.
11700        tracing::debug!(
11701            source_sequence = chunk.source_sequence,
11702            kind = agent_stream_chunk_kind_label(&chunk.kind),
11703            envelope_sequence = sequence,
11704            "publishing agent stream chunk",
11705        );
11706        let envelope = NodeEventEnvelope {
11707            sequence,
11708            event: NodeEvent::AgentStream { address, chunk },
11709        };
11710        let _ = self.agent_stream_event_tx.send(Arc::new(vec![envelope]));
11711    }
11712
11713    fn current_sequence(&self) -> u64 {
11714        self.history
11715            .lock()
11716            .unwrap_or_else(|poisoned| poisoned.into_inner())
11717            .last_sequence
11718    }
11719
11720    fn controller_state(&self) -> Option<ControllerState> {
11721        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11722        let lease = controller.as_ref().copied()?;
11723        let remaining = lease.expires_at.saturating_duration_since(Instant::now());
11724        if remaining.is_zero() {
11725            *controller = None;
11726            return None;
11727        }
11728        Some(ControllerState {
11729            connection_id: lease.connection_id,
11730            lease_remaining_ms: remaining.as_millis().min(u64::MAX as u128) as u64,
11731        })
11732    }
11733
11734    fn acquire_controller(&self, connection_id: u64, role: ClientRole, lease_ms: u64) -> Result<ControllerState, NodeFailure> {
11735        if role != ClientRole::Operator {
11736            tracing::warn!(
11737                node_id = %self.node_id,
11738                connection_id,
11739                "controller lease rejected: observer connections cannot acquire control",
11740            );
11741            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections cannot acquire control"));
11742        }
11743        let lease_ms = if lease_ms == 0 {
11744            DEFAULT_CONTROLLER_LEASE_MS
11745        } else {
11746            lease_ms.clamp(MIN_CONTROLLER_LEASE_MS, MAX_CONTROLLER_LEASE_MS)
11747        };
11748        let now = Instant::now();
11749        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11750        if let Some(active) = controller.as_ref() {
11751            if active.connection_id != connection_id && active.expires_at > now {
11752                tracing::warn!(
11753                    node_id = %self.node_id,
11754                    connection_id,
11755                    holder_connection_id = active.connection_id,
11756                    "controller lease rejected: another operator holds it",
11757                );
11758                return Err(failure(NodeFailureCode::ControllerBusy, "another operator holds the controller lease"));
11759            }
11760        }
11761        // A reconnect (this connection already holds the lease, renewing
11762        // before expiry) happens on a periodic cadence; logging it at INFO
11763        // every renewal would be exactly the heartbeat chatter this crate
11764        // family's level discipline forbids. A genuine attach -- the
11765        // controller identity actually changing -- is a state change and
11766        // gets INFO; a same-connection renewal gets DEBUG.
11767        let is_new_attach = match controller.as_ref() {
11768            Some(active) => active.connection_id != connection_id,
11769            None => true,
11770        };
11771        *controller = Some(ControllerLease {
11772            connection_id,
11773            expires_at: now + Duration::from_millis(lease_ms),
11774        });
11775        drop(controller);
11776        let state = ControllerState { connection_id, lease_remaining_ms: lease_ms };
11777        if is_new_attach {
11778            tracing::info!(
11779                node_id = %self.node_id,
11780                connection_id,
11781                lease_ms,
11782                "controller lease acquired",
11783            );
11784        } else {
11785            tracing::debug!(
11786                node_id = %self.node_id,
11787                connection_id,
11788                lease_ms,
11789                "controller lease renewed",
11790            );
11791        }
11792        self.publish(NodeEvent::ControllerChanged { controller: Some(state.clone()) });
11793        Ok(state)
11794    }
11795
11796    fn release_controller(&self, connection_id: u64) -> bool {
11797        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11798        let released = controller.as_ref().is_some_and(|active| active.connection_id == connection_id);
11799        if released {
11800            *controller = None;
11801        }
11802        drop(controller);
11803        if released {
11804            tracing::info!(
11805                node_id = %self.node_id,
11806                connection_id,
11807                "controller lease released",
11808            );
11809            self.publish(NodeEvent::ControllerChanged { controller: None });
11810        }
11811        released
11812    }
11813
11814    /// Mark a fresh Spawn as provisional and arm the attach-never-arrived
11815    /// reaper (PROVISIONAL_SPAWN_GRACE_MS from spawn). Removed from this map
11816    /// only when HQ attaches (Resize / TerminalBytes) — that is *not* a
11817    /// provider session; it only cancels the orphan timer. Disconnect/reload
11818    /// draft kills are owned by HQ and arm only on a real client WS close.
11819    /// ACP path of launch options: once the agent advertises its config options, write the
11820    /// pending model / effort through session/set_config_option (session/set_model when the
11821    /// agent has no model option). Gaps are logged, never forced.
11822    fn apply_pending_acp_options(&self, address: &SessionAddress, event: &ControlEvent) {
11823        let ControlEventKind::ProviderEvent { event: ProviderEvent::ConfigOptionsUpdated { options }, .. } = &event.event else {
11824            return;
11825        };
11826        let id = address.session.instance_id;
11827        let Some((model, effort)) = self.pending_acp_options.lock().unwrap_or_else(|p| p.into_inner()).remove(&id) else {
11828            return;
11829        };
11830        let advertised: Vec<(String, Option<String>, Vec<String>)> = options
11831            .iter()
11832            .map(|o| (o.id.clone(), o.category.clone(), o.choices.iter().map(|c| c.value_json.clone()).collect()))
11833            .collect();
11834        let (writes, set_model, effort_gap) =
11835            backend_paths::acp_option_writes(&advertised, model.as_deref(), effort.as_deref());
11836        if set_model {
11837            if let Some(model_id) = model.clone() {
11838                if let Err(e) = self.dispatch(ControlCommand::SetSessionModel { instance_id: id, model_id }) {
11839                    tracing::warn!(instance_id = id.0, message = %e.message, "acp set_model failed");
11840                }
11841            }
11842        }
11843        for (option_id, value_json) in writes {
11844            if let Err(e) = self.dispatch(ControlCommand::SetSessionConfigOption { instance_id: id, option_id, value_json }) {
11845                tracing::warn!(instance_id = id.0, message = %e.message, "acp set_config_option failed");
11846            }
11847        }
11848        if effort_gap {
11849            tracing::warn!(instance_id = id.0, effort = ?effort, "acp agent advertises no matching thought_level option; effort not applied");
11850        }
11851        tracing::info!(instance_id = id.0, model = ?model, effort = ?effort, "launch options applied over acp");
11852    }
11853
11854    /// ACP auto-permit: switch to the provider's no-ask mode once modes are advertised, and
11855    /// answer every session/request_permission with approve.
11856    fn apply_acp_auto_permit(&self, address: &SessionAddress, event: &ControlEvent) {
11857        let id = address.session.instance_id;
11858        let Some(provider) = crate::auto_permit::acp_provider(id.0) else { return };
11859        if matches!(&event.event, ControlEventKind::Exited { .. } | ControlEventKind::Failed { .. } | ControlEventKind::Removed) {
11860            crate::auto_permit::forget_acp(id.0);
11861            return;
11862        }
11863        let ControlEventKind::ProviderEvent { sequence, event: provider_event, .. } = &event.event else { return };
11864        if let ProviderEvent::InteractionRequested { request_id, interaction_kind, options, .. } = provider_event {
11865            tracing::debug!(instance_id = id.0, sequence = *sequence, request_id = ?request_id, kind = ?interaction_kind, options = options.len(), "acp interaction requested");
11866        }
11867        match provider_event {
11868            ProviderEvent::ModeChanged { mode_id, available } => {
11869                if let Some(want) = crate::auto_permit::pick_acp_mode(provider, mode_id, available.iter().map(|m| m.id.as_str())) {
11870                    match self.dispatch(ControlCommand::SetSessionMode { instance_id: id, mode_id: want.clone() }) {
11871                        Ok(_) => tracing::info!(instance_id = id.0, provider, mode = %want, "acp auto-permit mode set"),
11872                        Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-permit set_mode failed"),
11873                    }
11874                }
11875            }
11876            ProviderEvent::InteractionRequested { interaction_kind: gate4agent_types::ProviderInteractionKind::Approval, tool_name, options, .. } if !options.is_empty() => {
11877                match self.dispatch(ControlCommand::ResolveInteraction {
11878                    instance_id: id,
11879                    generation: address.session.generation,
11880                    interaction_id: gate4agent_types::ProviderInteractionId(*sequence),
11881                    response: gate4agent_types::ProviderInteractionResponse::ApproveOnce,
11882                }) {
11883                    Ok(_) => tracing::info!(instance_id = id.0, provider, tool = %tool_name, "acp permission auto-approved"),
11884                    Err(e) => tracing::warn!(instance_id = id.0, provider, message = %e.message, "acp auto-approve failed"),
11885                }
11886            }
11887            // NB: ACP emits SessionEnded at the end of every *turn* (prompt stopReason), so it
11888            // must not drop the auto-permit registration; that happens on process exit below.
11889            _ => {}
11890        }
11891    }
11892
11893    fn mark_spawn_provisional(&self, address: SessionAddress) {
11894        let id = address.session.instance_id;
11895        self.provisional_spawns
11896            .lock()
11897            .unwrap_or_else(|poisoned| poisoned.into_inner())
11898            .insert(id, (address, Instant::now()));
11899        tracing::info!(
11900            node_id = %self.node_id,
11901            instance_id = id.0,
11902            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11903            "draft spawn; waiting for HQ view",
11904        );
11905    }
11906
11907    fn sessionize_spawn(&self, instance_id: AgentInstanceId) {
11908        let removed = self
11909            .provisional_spawns
11910            .lock()
11911            .unwrap_or_else(|poisoned| poisoned.into_inner())
11912            .remove(&instance_id);
11913        if removed.is_some() {
11914            tracing::info!(
11915                node_id = %self.node_id,
11916                instance_id = instance_id.0,
11917                "draft spawn claimed by HQ view",
11918            );
11919        }
11920    }
11921
11922    fn take_due_provisional_spawns(&self) -> Vec<SessionAddress> {
11923        let grace = Duration::from_millis(PROVISIONAL_SPAWN_GRACE_MS);
11924        let mut map = self
11925            .provisional_spawns
11926            .lock()
11927            .unwrap_or_else(|poisoned| poisoned.into_inner());
11928        let due: Vec<AgentInstanceId> = map
11929            .iter()
11930            .filter(|(_, (_, at))| at.elapsed() >= grace)
11931            .map(|(id, _)| *id)
11932            .collect();
11933        due.into_iter()
11934            .filter_map(|id| map.remove(&id).map(|(addr, _)| addr))
11935            .collect()
11936    }
11937
11938    async fn reap_provisional_spawn(&self, address: SessionAddress) {
11939        let instance_id = address.session.instance_id;
11940        tracing::warn!(
11941            node_id = %self.node_id,
11942            instance_id = instance_id.0,
11943            workspace = %address.workspace_id,
11944            reason = "attach_never_arrived",
11945            grace_ms = PROVISIONAL_SPAWN_GRACE_MS,
11946            "draft spawn never viewed; stopping PTY",
11947        );
11948        let _ = self.dispatch(ControlCommand::Stop {
11949            instance_id,
11950            force: true,
11951        });
11952        let _ = self.remove_session(&address).await;
11953    }
11954
11955    fn require_controller(&self, connection_id: u64, role: ClientRole) -> Result<(), NodeFailure> {
11956        if role != ClientRole::Operator {
11957            return Err(failure(NodeFailureCode::ObserverReadOnly, "observer connections are read-only"));
11958        }
11959        let mut controller = self.controller.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
11960        let now = Instant::now();
11961        match controller.as_ref().copied() {
11962            Some(active) if active.connection_id == connection_id && active.expires_at > now => Ok(()),
11963            Some(active) if active.expires_at <= now => {
11964                *controller = None;
11965                Err(failure(NodeFailureCode::ControllerRequired, "controller lease expired"))
11966            }
11967            _ => Err(failure(NodeFailureCode::ControllerRequired, "controller lease is required")),
11968        }
11969    }
11970
11971    fn validate_address(&self, address: &SessionAddress) -> Result<AgentId, NodeFailure> {
11972        if !self
11973            .workspaces
11974            .read()
11975            .unwrap_or_else(|poisoned| poisoned.into_inner())
11976            .contains_key(&address.workspace_id)
11977        {
11978            return Err(failure(NodeFailureCode::UnknownWorkspace, "workspace does not exist"));
11979        }
11980        let binding = self
11981            .session_bindings
11982            .lock()
11983            .unwrap_or_else(|poisoned| poisoned.into_inner())
11984            .get(&address.session.instance_id)
11985            .cloned()
11986            .ok_or_else(|| failure(NodeFailureCode::UnknownSession, "session instance does not exist"))?;
11987        if binding.workspace_id != address.workspace_id {
11988            return Err(failure(
11989                NodeFailureCode::SessionWorkspaceMismatch,
11990                "session belongs to another workspace",
11991            ));
11992        }
11993        if binding.generation != address.session.generation {
11994            return Err(failure(
11995                NodeFailureCode::StaleGeneration,
11996                &format!(
11997                    "stale session generation: expected {}, received {}",
11998                    binding.generation.0,
11999                    address.session.generation.0,
12000                ),
12001            ));
12002        }
12003        let snapshot = self.handle.snapshot();
12004        let Some(current) = snapshot
12005            .sessions
12006            .iter()
12007            .find(|item| item.instance_id == address.session.instance_id)
12008        else {
12009            return Err(failure(NodeFailureCode::UnknownSession, "session instance does not exist"));
12010        };
12011        if current.generation != address.session.generation {
12012            return Err(failure(
12013                NodeFailureCode::StaleGeneration,
12014                &format!(
12015                    "stale session generation: expected {}, received {}",
12016                    current.generation.0,
12017                    address.session.generation.0,
12018                ),
12019            ));
12020        }
12021        Ok(current.agent_id.clone())
12022    }
12023
12024    fn dispatch(&self, command: ControlCommand) -> Result<CommandId, NodeFailure> {
12025        self.dispatch_envelope(self.prepare_command(command))
12026    }
12027
12028    fn prepare_command(&self, command: ControlCommand) -> CommandEnvelope {
12029        let id = self.next_command_id.fetch_add(1, Ordering::AcqRel);
12030        CommandEnvelope {
12031            id: CommandId(id),
12032            command,
12033        }
12034    }
12035
12036    fn dispatch_envelope(&self, envelope: CommandEnvelope) -> Result<CommandId, NodeFailure> {
12037        let command_id = envelope.id;
12038        self.handle
12039            .dispatch(envelope)
12040            .map_err(|error| match error {
12041                PortDispatchError::Full => failure(NodeFailureCode::BackendBusy, "backend command ingress is full"),
12042                PortDispatchError::Disconnected => failure(NodeFailureCode::BackendDisconnected, "backend command ingress is disconnected"),
12043            })?;
12044        Ok(command_id)
12045    }
12046
12047    /// Records the kernel's own reason for asynchronously refusing
12048    /// `command_id` -- see `rejected_command_reasons`'s doc for why a
12049    /// spawn-dispatch waiter otherwise has no way to learn it. Bounded by
12050    /// `REJECTED_COMMAND_REASON_MAX_ENTRIES`: expired entries are swept
12051    /// first, and if the map is still at capacity after that, the single
12052    /// oldest surviving entry is evicted -- `CommandId` is monotonically
12053    /// assigned (`next_command_id.fetch_add`), so the `BTreeMap`'s first key
12054    /// is always the oldest.
12055    fn record_rejected_command(
12056        &self,
12057        command_id: CommandId,
12058        message: String,
12059        unsupported_transport: bool,
12060    ) {
12061        let now = Instant::now();
12062        let mut reasons = self.rejected_command_reasons.lock()
12063            .unwrap_or_else(|poisoned| poisoned.into_inner());
12064        reasons.retain(|_, reason| reason.expires_at > now);
12065        if reasons.len() >= REJECTED_COMMAND_REASON_MAX_ENTRIES {
12066            if let Some(&oldest) = reasons.keys().next() {
12067                reasons.remove(&oldest);
12068            }
12069        }
12070        reasons.insert(
12071            command_id,
12072            RejectedCommandReason {
12073                message,
12074                unsupported_transport,
12075                expires_at: now + Duration::from_millis(REJECTED_COMMAND_REASON_TTL_MS),
12076            },
12077        );
12078    }
12079
12080    /// Removes and returns the kernel's reason for refusing `command_id`,
12081    /// if the drive loop has recorded one and it has not expired -- see
12082    /// `rejected_command_reasons`'s doc. Consumed at most once: a spawn
12083    /// wait loop calls this instead of blindly polling out its whole
12084    /// deadline when the command it is waiting on turns out to have been
12085    /// refused rather than merely slow.
12086    fn take_rejected_command_reason(&self, command_id: CommandId) -> Option<RejectedCommandReason> {
12087        let now = Instant::now();
12088        let mut reasons = self.rejected_command_reasons.lock()
12089            .unwrap_or_else(|poisoned| poisoned.into_inner());
12090        match reasons.remove(&command_id) {
12091            Some(reason) if reason.expires_at > now => Some(reason),
12092            _ => None,
12093        }
12094    }
12095
12096    async fn dispatch_bounded(
12097        &self,
12098        command: ControlCommand,
12099        timeout_duration: Duration,
12100    ) -> Result<CommandId, NodeFailure> {
12101        let deadline = Instant::now() + timeout_duration;
12102        loop {
12103            match self.dispatch(command.clone()) {
12104                Ok(command_id) => return Ok(command_id),
12105                Err(error) if error.code == NodeFailureCode::BackendBusy && Instant::now() < deadline => {
12106                    sleep(Duration::from_millis(2)).await;
12107                }
12108                Err(error) => return Err(error),
12109            }
12110        }
12111    }
12112
12113    fn internal_session_snapshot(
12114        &self,
12115        address: &SessionAddress,
12116    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12117        self.validate_address(address)?;
12118        self.handle
12119            .snapshot()
12120            .sessions
12121            .clone()
12122            .into_iter()
12123            .find(|session| {
12124                session.instance_id == address.session.instance_id
12125                    && session.generation == address.session.generation
12126            })
12127            .ok_or_else(|| {
12128                failure(
12129                    NodeFailureCode::UnknownSession,
12130                    "session snapshot is unavailable",
12131                )
12132            })
12133    }
12134
12135    /// Confirms `address` names a live session spawned over the ACP
12136    /// transport and returns its current snapshot -- the gate every
12137    /// `acp-control-v1` verb shares. `require_session_runtime_policy`
12138    /// cannot answer this by itself: `ProviderRuntimePolicy` carries no
12139    /// transport bit for `Acp`/`Inline` (see that method's own doc), only
12140    /// the session's own recorded `TransportKind` does, and that lives on
12141    /// the live snapshot, not the binding.
12142    fn require_acp_session(
12143        &self,
12144        address: &SessionAddress,
12145    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12146        let snapshot = self.internal_session_snapshot(address)?;
12147        if snapshot.transport != TransportKind::Acp {
12148            return Err(failure(
12149                NodeFailureCode::UnsupportedTransport,
12150                "this verb requires a session spawned over the ACP transport",
12151            ));
12152        }
12153        Ok(snapshot)
12154    }
12155
12156    async fn dispatch_history_bounded(
12157        &self,
12158        address: &SessionAddress,
12159        command: ControlCommand,
12160        operation: HistoryOperation,
12161    ) -> Result<gate4agent_types::SessionSnapshot, NodeFailure> {
12162        self.validate_address(address)?;
12163        let started_after = self.current_sequence();
12164        let command_id = self
12165            .dispatch_bounded(
12166                command,
12167                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12168            )
12169            .await?;
12170        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12171        let mut scan_after = started_after;
12172        let mut accepted_after = None;
12173        loop {
12174            let events = self
12175                .history
12176                .lock()
12177                .unwrap_or_else(|poisoned| poisoned.into_inner())
12178                .events
12179                .iter()
12180                .filter(|envelope| envelope.sequence > scan_after)
12181                .cloned()
12182                .collect::<Vec<_>>();
12183            for envelope in events {
12184                scan_after = scan_after.max(envelope.sequence);
12185                let NodeEvent::Control {
12186                    address: event_address,
12187                    event,
12188                } = envelope.event
12189                else {
12190                    continue;
12191                };
12192                if event_address != *address {
12193                    continue;
12194                }
12195                if event.command_id == Some(command_id) {
12196                    match &event.event {
12197                        ControlEventKind::CommandRejected { .. } => {
12198                            return Err(failure(
12199                                NodeFailureCode::InvalidRequest,
12200                                "history request was rejected",
12201                            ));
12202                        }
12203                        ControlEventKind::HistoryRequested {
12204                            operation: requested,
12205                            ..
12206                        } if requested == &operation => {
12207                            accepted_after = Some(envelope.sequence);
12208                            continue;
12209                        }
12210                        _ => {}
12211                    }
12212                }
12213                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12214                    match (&operation, &event.event) {
12215                        (
12216                            HistoryOperation::Discover { .. },
12217                            ControlEventKind::HistoryDiscovered { .. },
12218                        )
12219                        | (
12220                            HistoryOperation::Load { .. },
12221                            ControlEventKind::HistoryLoaded { .. },
12222                        ) => return self.internal_session_snapshot(address),
12223                        (_, ControlEventKind::HistoryFailed { .. }) => {
12224                            return Err(failure(
12225                                NodeFailureCode::BackendOperationFailed,
12226                                "history operation failed",
12227                            ));
12228                        }
12229                        _ => {}
12230                    }
12231                }
12232            }
12233            if Instant::now() >= deadline {
12234                return Err(failure(
12235                    NodeFailureCode::BackendBusy,
12236                    if accepted_after.is_some() {
12237                        "history operation did not settle before the bounded deadline"
12238                    } else {
12239                        "history operation was not accepted before the bounded deadline"
12240                    },
12241                ));
12242            }
12243            sleep(Duration::from_millis(2)).await;
12244        }
12245    }
12246
12247    async fn discover_history(
12248        &self,
12249        address: &SessionAddress,
12250        limit: u16,
12251    ) -> Result<Vec<HistoryCandidateSummary>, NodeFailure> {
12252        let query = HistoryQuery {
12253            working_directory: Some(self.workspace_root(&address.workspace_id)?),
12254            limit,
12255        };
12256        query
12257            .validate()
12258            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history query"))?;
12259        let snapshot = self
12260            .dispatch_history_bounded(
12261                address,
12262                ControlCommand::DiscoverHistory {
12263                    instance_id: address.session.instance_id,
12264                    query: query.clone(),
12265                },
12266                HistoryOperation::Discover { query },
12267            )
12268            .await?;
12269        if snapshot.history.pending.is_some() || snapshot.history.loaded.is_some() {
12270            return Err(failure(
12271                NodeFailureCode::BackendOperationFailed,
12272                "history discovery settled into an invalid state",
12273            ));
12274        }
12275        Ok(snapshot.history.candidates)
12276    }
12277
12278    async fn load_history(
12279        &self,
12280        address: &SessionAddress,
12281        candidate_id: String,
12282    ) -> Result<HistorySessionRecord, NodeFailure> {
12283        validate_candidate_id(&candidate_id)
12284            .map_err(|_| failure(NodeFailureCode::InvalidRequest, "invalid history candidate"))?;
12285        let snapshot = self
12286            .dispatch_history_bounded(
12287                address,
12288                ControlCommand::LoadHistory {
12289                    instance_id: address.session.instance_id,
12290                    candidate_id: candidate_id.clone(),
12291                },
12292                HistoryOperation::Load {
12293                    candidate_id: candidate_id.clone(),
12294                },
12295            )
12296            .await?;
12297        if snapshot.history.pending.is_some()
12298            || snapshot.history.loaded_candidate_id.as_ref() != Some(&candidate_id)
12299        {
12300            return Err(failure(
12301                NodeFailureCode::BackendOperationFailed,
12302                "history load settled into an invalid state",
12303            ));
12304        }
12305        snapshot.history.loaded.ok_or_else(|| {
12306            failure(
12307                NodeFailureCode::BackendOperationFailed,
12308                "history load produced no bounded session",
12309            )
12310        })
12311    }
12312
12313    /// `allow_clean_detachment`: `false` for the on-demand
12314    /// `NodeRequest::ExportContextPackForSessionRecord` API path (the
12315    /// managed record must stay untouched, still live, for the whole call);
12316    /// `true` only for the reactive auto-export-at-exit path (`§2.1`), which
12317    /// reacts to the very clean-exit event that detaches the record out from
12318    /// under this same call — see `session_record_export_target_matches`.
12319    async fn export_context_pack_for_session_record(
12320        &self,
12321        record_id: &SessionRecordId,
12322        session: &SessionAddress,
12323        allow_clean_detachment: bool,
12324    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12325        // C2 replaces a relayed failure's prose with a canned per-code
12326        // string (`C2NodeFailure`'s own `From`), so this path's dozen
12327        // distinct refusals are indistinguishable to anyone downstream --
12328        // and a refusal here expires a continuation, which keeps no field
12329        // for why. The prose stays on the node, where it was written.
12330        let outcome = self
12331            .export_context_pack_for_session_record_inner(record_id, session, allow_clean_detachment)
12332            .await;
12333        if let Err(failure) = &outcome {
12334            tracing::warn!(
12335                record = %record_id,
12336                code = ?failure.code,
12337                reason = %failure.message,
12338                "session-record context-pack export refused",
12339            );
12340        }
12341        outcome
12342    }
12343
12344    async fn export_context_pack_for_session_record_inner(
12345        &self,
12346        record_id: &SessionRecordId,
12347        session: &SessionAddress,
12348        allow_clean_detachment: bool,
12349    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12350        // Several steps here refuse with the same BackendOperationFailed
12351        // code and no prose of their own, so which step failed is
12352        // otherwise unrecoverable -- and a refusal here expires a
12353        // continuation.
12354        let step = |name: &'static str| move |failure: NodeFailure| {
12355            tracing::warn!(step = name, code = ?failure.code, "context-pack export step refused");
12356            failure
12357        };
12358        let record = self.record(record_id).map_err(step("record-lookup"))?;
12359        // `provider_session` is `Some` only for a provider whose resolved
12360        // runtime policy admits live identity verification (raw PTY plus a
12361        // semantic PTY adapter -- `provider_runtime::policy_from_capability_flags`).
12362        // The PTY-sidecar-observation shape (no fleet provider today, but
12363        // capability-keyed rather than vendor-locked) hardcodes
12364        // `provider_session_identity: false` in `provider_runtime::evaluate`,
12365        // so its records go straight to `Live` with `provider_session: None`
12366        // and stay that way for the record's entire life -- that is this
12367        // shape's permanent resting state, not a race with observation, and
12368        // `discover_history`/`load_history` below do not need an identity to
12369        // do their job for it. `IdentityPending` is the actual race (an
12370        // identity-admitted provider whose observation has not landed yet):
12371        // it keeps refusing here rather than falling back, because that
12372        // provider's own history the fallback would otherwise trust is not
12373        // guaranteed to be exact without the identity it is still waiting on.
12374        let identity = match record.provider_session.clone() {
12375            Some(identity) => {
12376                if identity.key != ProviderSessionKey::SessionId {
12377                    return Err(step("provider-identity-key")(failure(
12378                        NodeFailureCode::BackendOperationFailed,
12379                        "managed session provider identity cannot bind history exactly",
12380                    )));
12381                }
12382                Some(identity)
12383            }
12384            None if record.state == ManagedSessionState::IdentityPending => {
12385                return Err(step("provider-session-identity")(failure(
12386                    NodeFailureCode::BackendOperationFailed,
12387                    "managed session has not yet observed its provider session identity",
12388                )));
12389            }
12390            None => None,
12391        };
12392        self.revalidate_session_record_context_export(
12393            &record,
12394            identity.as_ref(),
12395            session,
12396            allow_clean_detachment,
12397        ).map_err(step("revalidate-before-discovery"))?;
12398        let candidates = self
12399            .discover_history(session, HISTORY_DISCOVERY_LIMIT_MAX)
12400            .await
12401            .map_err(step("discover-history"))?;
12402        // With no verified identity to filter by, the discovered set must
12403        // still resolve to exactly one candidate: the same ambiguity refusal
12404        // an identity filter would otherwise produce is the only safety
12405        // property left available for this shape.
12406        let candidate_id = select_history_candidate(
12407            &candidates,
12408            identity.as_ref().map(|identity| identity.id.as_str()),
12409        ).map_err(step("unique-candidate"))?;
12410        let loaded = self.load_history(session, candidate_id.clone()).await
12411            .map_err(step("load-history"))?;
12412        if let Some(identity) = identity.as_ref() {
12413            if loaded.session_id != identity.id {
12414                return Err(failure(
12415                    NodeFailureCode::SessionRecordConflict,
12416                    "loaded history identity does not match the managed session record",
12417                ));
12418            }
12419        }
12420        self.revalidate_session_record_context_export(
12421            &record,
12422            identity.as_ref(),
12423            session,
12424            allow_clean_detachment,
12425        )?;
12426        self.revalidate_loaded_history(session, &candidate_id, &loaded)
12427            .map_err(step("revalidate-loaded-history"))?;
12428        let pack = self.materialize_context_pack(session).await
12429            .map_err(step("materialize-context-pack"))?;
12430        self.commit_context_pack_for_session_record(
12431            &record,
12432            identity.as_ref(),
12433            session,
12434            &candidate_id,
12435            &loaded,
12436            pack,
12437            allow_clean_detachment,
12438        )
12439    }
12440
12441    async fn export_context_pack(
12442        &self,
12443        address: &SessionAddress,
12444    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12445        let pack = self.materialize_context_pack(address).await?;
12446        self.commit_context_pack(pack)
12447    }
12448
12449    async fn materialize_context_pack(
12450        &self,
12451        address: &SessionAddress,
12452    ) -> Result<NodeContextPack, NodeFailure> {
12453        let snapshot = self.internal_session_snapshot(address)?;
12454        if !context_pack_source_status_is_usable(&snapshot.status) {
12455            return Err(failure(
12456                NodeFailureCode::InvalidRequest,
12457                "context export requires a running or successfully exited source session",
12458            ));
12459        }
12460        if snapshot.history.pending.is_some()
12461            || snapshot.history.loaded_candidate_id.is_none()
12462        {
12463            return Err(failure(
12464                NodeFailureCode::InvalidRequest,
12465                "context export requires settled loaded history",
12466            ));
12467        }
12468        let loaded = snapshot.history.loaded.clone().ok_or_else(|| {
12469            failure(
12470                NodeFailureCode::InvalidRequest,
12471                "context export requires settled loaded history",
12472            )
12473        })?;
12474        let source_provider = snapshot.agent_id.clone();
12475        let loaded_candidate_id = snapshot.history.loaded_candidate_id.clone();
12476        let repository = self
12477            .context_pack_repository(&address.workspace_id)
12478            .await?;
12479        let current = self.internal_session_snapshot(address)?;
12480        if !context_pack_source_status_is_usable(&current.status)
12481            || current.agent_id != source_provider
12482            || current.history.pending.is_some()
12483            || current.history.loaded_candidate_id != loaded_candidate_id
12484            || current.history.loaded.as_ref() != Some(&loaded)
12485        {
12486            return Err(failure(
12487                NodeFailureCode::ContextPackMaterializationFailed,
12488                "context source changed during bounded repository capture",
12489            ));
12490        }
12491        NodeContextPack::export_with_repository(
12492            ContextPackLineageReceipt {
12493                source_node_id: self.node_id.clone(),
12494                source_session: address.clone(),
12495                source_provider,
12496            },
12497            &loaded,
12498            Some(repository),
12499        )
12500        .map_err(|_| {
12501            failure(
12502                NodeFailureCode::ContextPackMaterializationFailed,
12503                "bounded context pack export failed",
12504            )
12505        })
12506    }
12507
12508    /// Write-through to the durable [`ContextPackStore`], if one is
12509    /// configured (`state_path` is `Some`). A node with no durable state
12510    /// path — fixtures, tests, ephemeral runs — has no `ContextPackStore`
12511    /// open at all and this is a no-op, matching `persist_state`'s own
12512    /// "durability optional, degrade gracefully when unconfigured" idiom.
12513    fn commit_context_pack_durably(&self, pack: &NodeContextPack) -> Result<(), NodeFailure> {
12514        let mut store = self
12515            .context_pack_store
12516            .lock()
12517            .unwrap_or_else(|poisoned| poisoned.into_inner());
12518        let Some(store) = store.as_mut() else {
12519            return Ok(());
12520        };
12521        store.commit(pack).map_err(|_| {
12522            failure(
12523                NodeFailureCode::ContextPackMaterializationFailed,
12524                "durable context pack commit failed",
12525            )
12526        })
12527    }
12528
12529    fn commit_context_pack(
12530        &self,
12531        pack: NodeContextPack,
12532    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12533        self.commit_context_pack_durably(&pack)?;
12534        self.context_catalog
12535            .write()
12536            .unwrap_or_else(|poisoned| poisoned.into_inner())
12537            .insert(pack)
12538            .map_err(|_| {
12539                failure(
12540                    NodeFailureCode::ContextPackMaterializationFailed,
12541                    "context pack catalog is full",
12542                )
12543            })
12544    }
12545
12546    fn commit_context_pack_for_session_record(
12547        &self,
12548        expected: &ManagedSessionRecord,
12549        identity: Option<&ProviderSessionIdentity>,
12550        session: &SessionAddress,
12551        candidate_id: &str,
12552        loaded: &HistorySessionRecord,
12553        pack: NodeContextPack,
12554        allow_clean_detachment: bool,
12555    ) -> Result<ResolvedContextPackReceipt, NodeFailure> {
12556        let current_root = self.workspace_root(&expected.workspace_id)?;
12557        let _transaction = self
12558            .state_transaction
12559            .lock()
12560            .unwrap_or_else(|poisoned| poisoned.into_inner());
12561        let records = self
12562            .session_records
12563            .lock()
12564            .unwrap_or_else(|poisoned| poisoned.into_inner());
12565        let current = records.records.get(&expected.record_id).ok_or_else(|| {
12566            failure(
12567                NodeFailureCode::SessionRecordConflict,
12568                "managed session record disappeared during context export",
12569            )
12570        })?;
12571        if !session_record_export_target_matches(expected, current, session, allow_clean_detachment) {
12572            return Err(failure(
12573                NodeFailureCode::SessionRecordConflict,
12574                "managed session record changed during context export",
12575            ));
12576        }
12577        let bindings = self
12578            .session_bindings
12579            .lock()
12580            .unwrap_or_else(|poisoned| poisoned.into_inner());
12581        let binding = bindings.get(&session.session.instance_id).ok_or_else(|| {
12582            failure(
12583                NodeFailureCode::SessionRecordConflict,
12584                "managed session runtime binding disappeared during context export",
12585            )
12586        })?;
12587        let runtime = self
12588            .handle
12589            .snapshot()
12590            .sessions
12591            .iter()
12592            .find(|snapshot| {
12593                snapshot.instance_id == session.session.instance_id
12594                    && snapshot.generation == session.session.generation
12595            })
12596            .cloned()
12597            .ok_or_else(|| {
12598                failure(
12599                    NodeFailureCode::SessionRecordConflict,
12600                    "managed session runtime disappeared during context export",
12601                )
12602            })?;
12603        let receipt = pack.receipt();
12604        if !session_record_context_export_binding_is_exact(
12605            expected,
12606            current,
12607            identity,
12608            session,
12609            binding,
12610            &runtime.agent_id,
12611            &current_root,
12612            allow_clean_detachment,
12613        ) || !session_record_context_export_source_is_usable(
12614            current,
12615            &runtime.status,
12616            allow_clean_detachment,
12617        ) || runtime.history.pending.is_some()
12618            || runtime.history.loaded_candidate_id.as_deref() != Some(candidate_id)
12619            || runtime.history.loaded.as_ref() != Some(loaded)
12620            || receipt.lineage.source_node_id != self.node_id
12621            || receipt.lineage.source_session != *session
12622            || receipt.lineage.source_provider != current.provider
12623            || receipt.source_message_count != loaded.message_count
12624        {
12625            return Err(failure(
12626                NodeFailureCode::SessionRecordConflict,
12627                "managed session source changed before context catalog commit",
12628            ));
12629        }
12630        self.commit_context_pack_durably(&pack)?;
12631        self.context_catalog
12632            .write()
12633            .unwrap_or_else(|poisoned| poisoned.into_inner())
12634            .insert(pack)
12635            .map_err(|_| {
12636                failure(
12637                    NodeFailureCode::ContextPackMaterializationFailed,
12638                    "context pack catalog is full",
12639                )
12640            })
12641    }
12642
12643    async fn context_pack_repository(
12644        &self,
12645        workspace_id: &WorkspaceId,
12646    ) -> Result<ContextPackRepository, NodeFailure> {
12647        let first = self
12648            .context_pack_repository_observation(workspace_id)
12649            .await?;
12650        let second = self
12651            .context_pack_repository_observation(workspace_id)
12652            .await?;
12653        stable_context_pack_repository(first, second)
12654    }
12655
12656    async fn context_pack_repository_observation(
12657        &self,
12658        workspace_id: &WorkspaceId,
12659    ) -> Result<ContextPackRepositoryObservation, NodeFailure> {
12660        let git = self.inspect_workspace(workspace_id.clone()).await?.git;
12661        let head = if git.is_repository {
12662            let root = self.workspace_root(workspace_id)?;
12663            match resolve_base_commit_with_timeout(
12664                &root,
12665                "HEAD",
12666                GIT_COMMAND_TIMEOUT_MS,
12667            )
12668            .await
12669            {
12670                Ok(commit) => ContextPackRepositoryHead::Commit(commit),
12671                Err(error) if error.kind == GitWorktreeErrorKind::Conflict => {
12672                    ContextPackRepositoryHead::Unborn
12673                }
12674                Err(_) => {
12675                    return Err(failure(
12676                        NodeFailureCode::ContextPackMaterializationFailed,
12677                        "context pack HEAD observation failed",
12678                    ));
12679                }
12680            }
12681        } else {
12682            ContextPackRepositoryHead::NotRepository
12683        };
12684        let mut files = Vec::with_capacity(CONTEXT_PACK_SELECTED_FILES.len());
12685        for selected_path in CONTEXT_PACK_SELECTED_FILES {
12686            let path = RepositoryPath::utf8(selected_path.to_owned()).map_err(|_| {
12687                failure(
12688                    NodeFailureCode::InvalidRepositoryPath,
12689                    "context pack selected file path is invalid",
12690                )
12691            })?;
12692            match self.read_workspace_file(workspace_id.clone(), path).await {
12693                Ok(WorkspaceFileRead {
12694                    content: WorkspaceFileContent::Utf8 { text, byte_len },
12695                    ..
12696                }) => files.push(ContextPackRepositoryFileSource::utf8(
12697                    selected_path,
12698                    text,
12699                    byte_len,
12700                )),
12701                Ok(WorkspaceFileRead {
12702                    content: WorkspaceFileContent::NonUtf8 { byte_len },
12703                    ..
12704                }) => files.push(ContextPackRepositoryFileSource::skipped(
12705                    selected_path,
12706                    ContextPackSelectedFileSkipReason::NonUtf8,
12707                    Some(byte_len),
12708                )),
12709                Ok(WorkspaceFileRead {
12710                    content: WorkspaceFileContent::TooLarge { .. },
12711                    ..
12712                }) => files.push(ContextPackRepositoryFileSource::skipped(
12713                    selected_path,
12714                    ContextPackSelectedFileSkipReason::TooLarge,
12715                    None,
12716                )),
12717                Err(error) => {
12718                    let reason = if matches!(
12719                        error.code,
12720                        NodeFailureCode::InvalidRepositoryPath
12721                            | NodeFailureCode::RepositoryFileNotRegular
12722                            | NodeFailureCode::RepositoryPathUnsafe
12723                    ) {
12724                        ContextPackSelectedFileSkipReason::Unsafe
12725                    } else {
12726                        ContextPackSelectedFileSkipReason::Unavailable
12727                    };
12728                    files.push(ContextPackRepositoryFileSource::skipped(
12729                        selected_path,
12730                        reason,
12731                        None,
12732                    ));
12733                }
12734            }
12735        }
12736        Ok(ContextPackRepositoryObservation { head, git, files })
12737    }
12738
12739    fn forget_context_pack(&self, context_id: &SpawnContextId) -> Result<(), NodeFailure> {
12740        let referenced_by_binding = self
12741            .session_bindings
12742            .lock()
12743            .unwrap_or_else(|poisoned| poisoned.into_inner())
12744            .values()
12745            .any(|binding| {
12746                binding.context.as_ref().map(|receipt| &receipt.id) == Some(context_id)
12747            });
12748        let referenced_by_record = self
12749            .session_records
12750            .lock()
12751            .unwrap_or_else(|poisoned| poisoned.into_inner())
12752            .records
12753            .values()
12754            .any(|record| record.context_id.as_ref() == Some(context_id));
12755        let referenced_by_materialization = self
12756            .materializations
12757            .lock()
12758            .unwrap_or_else(|poisoned| poisoned.into_inner())
12759            .values()
12760            .any(|ownership| {
12761                ownership.context().map(|receipt| &receipt.id) == Some(context_id)
12762            });
12763        if referenced_by_binding
12764            || referenced_by_record
12765            || referenced_by_materialization
12766        {
12767            return Err(failure(
12768                NodeFailureCode::ContextPackBusy,
12769                "context pack is still referenced",
12770            ));
12771        }
12772        let mut catalog = self.context_catalog
12773            .write()
12774            .unwrap_or_else(|poisoned| poisoned.into_inner());
12775        if catalog.get(context_id).is_none() {
12776            return Err(failure(
12777                NodeFailureCode::UnknownContextPack,
12778                "context pack is unavailable",
12779            ));
12780        }
12781        self.spawn_idempotency
12782            .lock()
12783            .unwrap_or_else(|poisoned| poisoned.into_inner())
12784            .entries
12785            .retain(|_, entry| !entry.value.references_context(context_id));
12786        let removed = catalog.remove(context_id);
12787        debug_assert!(removed.is_some(), "validated context pack remains present");
12788        Ok(())
12789    }
12790
12791    async fn dispatch_input_bounded(
12792        &self,
12793        address: &SessionAddress,
12794        action: InputAction,
12795    ) -> Result<(), NodeFailure> {
12796        let started_after = self.current_sequence();
12797        self.pty_input_marks
12798            .lock()
12799            .unwrap_or_else(|poisoned| poisoned.into_inner())
12800            .insert(address.session.instance_id, (Instant::now(), false));
12801        self.pty_input_wake.notify_one();
12802        let command_id = self
12803            .dispatch_bounded(
12804                ControlCommand::SendInput {
12805                    instance_id: address.session.instance_id,
12806                    action,
12807                },
12808                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12809            )
12810            .await?;
12811        // A semantic prompt may spend the provider's full readiness budget before
12812        // the runtime emits InputCompleted. Keep the node request deadline bounded,
12813        // but strictly beyond every readiness policy in this node's catalog.
12814        let deadline = Instant::now() + Duration::from_millis(self.input_settle_timeout_ms);
12815        let mut scan_after = started_after;
12816        let mut accepted_after = None;
12817        loop {
12818            let events = self
12819                .history
12820                .lock()
12821                .unwrap_or_else(|poisoned| poisoned.into_inner())
12822                .events
12823                .iter()
12824                .filter(|envelope| envelope.sequence > scan_after)
12825                .cloned()
12826                .collect::<Vec<_>>();
12827            for envelope in events {
12828                scan_after = scan_after.max(envelope.sequence);
12829                let NodeEvent::Control {
12830                    address: event_address,
12831                    event,
12832                } = envelope.event
12833                else {
12834                    continue;
12835                };
12836                if event_address != *address {
12837                    continue;
12838                }
12839                if event.command_id == Some(command_id) {
12840                    match &event.event {
12841                        ControlEventKind::CommandRejected { message } => {
12842                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12843                        }
12844                        ControlEventKind::InputRequested { .. } => {
12845                            accepted_after = Some(envelope.sequence);
12846                            continue;
12847                        }
12848                        _ => {}
12849                    }
12850                }
12851                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12852                    match &event.event {
12853                        ControlEventKind::InputCompleted { .. } => return Ok(()),
12854                        ControlEventKind::InputFailed { message, .. } => {
12855                            return Err(failure(
12856                                NodeFailureCode::BackendOperationFailed,
12857                                message,
12858                            ));
12859                        }
12860                        _ => {}
12861                    }
12862                }
12863            }
12864            if Instant::now() >= deadline {
12865                return Err(failure(
12866                    NodeFailureCode::BackendBusy,
12867                    if accepted_after.is_some() {
12868                        "input was accepted but did not settle before the bounded deadline"
12869                    } else {
12870                        "input was not accepted before the bounded deadline"
12871                    },
12872                ));
12873            }
12874            sleep(Duration::from_millis(2)).await;
12875        }
12876    }
12877
12878    async fn dispatch_resize_bounded(
12879        &self,
12880        address: &SessionAddress,
12881        size: gate4agent_types::TerminalSize,
12882    ) -> Result<(), NodeFailure> {
12883        let started_after = self.current_sequence();
12884        let command_id = self
12885            .dispatch_bounded(
12886                ControlCommand::Resize {
12887                    instance_id: address.session.instance_id,
12888                    size,
12889                },
12890                Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
12891            )
12892            .await?;
12893        let deadline = Instant::now() + Duration::from_millis(MUTATION_SETTLE_TIMEOUT_MS);
12894        let mut scan_after = started_after;
12895        let mut accepted_after = None;
12896        loop {
12897            let events = self
12898                .history
12899                .lock()
12900                .unwrap_or_else(|poisoned| poisoned.into_inner())
12901                .events
12902                .iter()
12903                .filter(|envelope| envelope.sequence > scan_after)
12904                .cloned()
12905                .collect::<Vec<_>>();
12906            for envelope in events {
12907                scan_after = scan_after.max(envelope.sequence);
12908                let NodeEvent::Control {
12909                    address: event_address,
12910                    event,
12911                } = envelope.event
12912                else {
12913                    continue;
12914                };
12915                if event_address != *address {
12916                    continue;
12917                }
12918                if event.command_id == Some(command_id) {
12919                    match &event.event {
12920                        ControlEventKind::CommandRejected { message } => {
12921                            return Err(failure(NodeFailureCode::InvalidRequest, message));
12922                        }
12923                        ControlEventKind::ResizeRequested {
12924                            size: requested_size,
12925                            ..
12926                        } if *requested_size == size => {
12927                            accepted_after = Some(envelope.sequence);
12928                            continue;
12929                        }
12930                        _ => {}
12931                    }
12932                }
12933                if accepted_after.is_some_and(|sequence| envelope.sequence > sequence) {
12934                    match &event.event {
12935                        ControlEventKind::Resized { size: settled_size }
12936                            if *settled_size == size =>
12937                        {
12938                            return Ok(());
12939                        }
12940                        ControlEventKind::ResizeFailed { message } => {
12941                            return Err(failure(
12942                                NodeFailureCode::BackendOperationFailed,
12943                                message,
12944                            ));
12945                        }
12946                        _ => {}
12947                    }
12948                }
12949            }
12950            if Instant::now() >= deadline {
12951                return Err(failure(
12952                    NodeFailureCode::BackendBusy,
12953                    if accepted_after.is_some() {
12954                        "resize was accepted but did not settle before the bounded deadline"
12955                    } else {
12956                        "resize was not accepted before the bounded deadline"
12957                    },
12958                ));
12959            }
12960            sleep(Duration::from_millis(2)).await;
12961        }
12962    }
12963
12964    async fn spawn_session(
12965        &self,
12966        workspace_id: WorkspaceId,
12967        provider: AgentId,
12968        mode: SessionMode,
12969        terminal_size: gate4agent_types::TerminalSize,
12970        initial_prompt: Option<String>,
12971    ) -> Result<SessionAddress, NodeFailure> {
12972        if self.managed_worktrees.lock()
12973            .unwrap_or_else(|poisoned| poisoned.into_inner())
12974            .lease_for_workspace(&workspace_id)
12975            .is_some()
12976        {
12977            return Err(failure(
12978                NodeFailureCode::ManagedWorktreeOwnershipConflict,
12979                "managed worktree targets may only be spawned through their lease request",
12980            ));
12981        }
12982        self
12983            .spawn_session_with_deadline(
12984                workspace_id,
12985                provider.clone(),
12986                mode,
12987                terminal_size,
12988                initial_prompt,
12989                None,
12990                None,
12991                None,
12992                None,
12993                None,
12994                SpawnRecordPolicy::ProviderIdentityOnly,
12995                None,
12996                &[],
12997                None,
12998                // `NodeRequest::Spawn` (this method's only caller) carries no
12999                // approval-level selection of its own -- see
13000                // `HarnessOperatorRequestV1::SpawnSession`'s own field for
13001                // the one operator-wire path that can choose something other
13002                // than the axis default. Auto-permit may lower it where the agent
13003                // cannot offer its full-auto mode (claude ACP under root).
13004                crate::auto_permit::spawn_approval_level(provider.as_str(), mode).unwrap_or_default(),
13005                None,
13006                None,
13007            )
13008            .await
13009            .map(|(session, _runtime_policy)| session)
13010    }
13011
13012    async fn spawn_session_with_deadline(
13013        &self,
13014        workspace_id: WorkspaceId,
13015        provider: AgentId,
13016        mode: SessionMode,
13017        terminal_size: gate4agent_types::TerminalSize,
13018        initial_prompt: Option<String>,
13019        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
13020        bundle: Option<ResolvedBundleReceipt>,
13021        context: Option<ResolvedContextPackReceipt>,
13022        managed_authority: Option<ManagedWorktreeLeaseId>,
13023        admitted_runtime_policy: Option<ProviderRuntimePolicy>,
13024        record_policy: SpawnRecordPolicy,
13025        deadline: Option<Instant>,
13026        required_capabilities: &[ProviderRuntimeCapability],
13027        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
13028        approval_level: ApprovalLevel,
13029        network_allowlist: Option<crate::protocol::SpawnNetworkAllowlistId>,
13030        // Opaque dig2browser station profile id (ids only). Feature
13031        // `dig2-station-probe`: after probe at resolve, spawn takes an
13032        // exclusive node-local lease; cleared on session end. Never
13033        // ImportSession / cookies on C2. Feature off: ignored (stub echo
13034        // already happened on the receipt path).
13035        browser_profile_id: Option<crate::protocol::SpawnBrowserProfileId>,
13036    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
13037        let reserved_lease = self.managed_worktrees.lock()
13038            .unwrap_or_else(|poisoned| poisoned.into_inner())
13039            .lease_for_workspace(&workspace_id);
13040        match (reserved_lease.as_ref(), managed_authority.as_ref()) {
13041            (Some(expected), Some(provided)) if expected == provided => {}
13042            (None, None) => {}
13043            _ => {
13044                return Err(failure(
13045                    NodeFailureCode::ManagedWorktreeOwnershipConflict,
13046                    "spawn lacks exact managed worktree lease authority",
13047                ));
13048            }
13049        }
13050        // Refuse concurrent distinct environment profiles (incl. ProviderHome
13051        // bindings) on the same workspace cwd. Parallel path: managed worktree
13052        // spawn so each profile gets its own workspace_id.
13053        self.ensure_workspace_environment_profile_compatible(
13054            &workspace_id,
13055            environment_profile.as_ref(),
13056        )?;
13057        let runtime_requirement = match (mode, initial_prompt.is_some()) {
13058            (SessionMode::Pty, false) => ProviderRuntimeRequirement::RawPty,
13059            (SessionMode::Pty, true) => ProviderRuntimeRequirement::SemanticPrompt,
13060            (SessionMode::Inline, _) => ProviderRuntimeRequirement::Inline,
13061            (SessionMode::Acp, _) => ProviderRuntimeRequirement::Acp,
13062        };
13063        // This session's own transport -- computed once, here, from `mode`,
13064        // and reused both to admit spawn (below) and, further down, to
13065        // compute the final `runtime_policy` this session actually operates
13066        // under (`provider_runtime::policy_for_transport`). Registration
13067        // reuses this exact value rather than recomputing it a second time.
13068        let transport = match mode {
13069            SessionMode::Pty => TransportKind::Pty,
13070            SessionMode::Inline => TransportKind::Pipe,
13071            SessionMode::Acp => TransportKind::Acp,
13072        };
13073        let pipe_transport = self.provider_supports_pipe_transport(&provider);
13074        let acp_transport = self.provider_supports_acp_transport(&provider);
13075        let runtime_policy = if let Some(runtime_policy) = admitted_runtime_policy {
13076            require_policy(runtime_policy, pipe_transport, acp_transport, runtime_requirement).map_err(|_| failure(
13077                NodeFailureCode::UnsupportedSpawnCapability,
13078                "pre-admitted provider runtime no longer satisfies spawn requirements",
13079            ))?;
13080            runtime_policy
13081        } else if let Some(deadline) = deadline {
13082            let remaining = spawn_deadline_remaining(deadline)?;
13083            timeout(
13084                remaining,
13085                self.admit_provider_runtime(&provider, runtime_requirement),
13086            )
13087            .await
13088            .map_err(|_| {
13089                failure(
13090                    NodeFailureCode::SpawnDeadlineExceeded,
13091                    "provider admission exceeded the spawn deadline",
13092                )
13093            })??
13094        } else {
13095            self.admit_provider_runtime(&provider, runtime_requirement).await?
13096        };
13097        let runtime_policy = self.effective_spawn_runtime_policy(runtime_policy);
13098        // The single point where this session's `runtime_policy` becomes
13099        // transport-correct: `admit_provider_runtime` (and the pre-admitted
13100        // branch above) only ever compute a PTY-terminal-verification-shaped
13101        // value, sound outright only for `TransportKind::Pty` (Pipe still
13102        // relies on that same value downstream -- see `policy_for_transport`'s
13103        // own doc for why). `policy_for_transport` is the explicit,
13104        // exhaustive match Item 1 asks for -- this is the ONE place its
13105        // output becomes the session's `runtime_policy`, so every later
13106        // reader (`ControlCommand::Start`, `session_bindings`, and every
13107        // engine gate that reads `state.runtime_policy`) sees the same,
13108        // correctly-computed value.
13109        let runtime_policy =
13110            crate::provider_runtime::policy_for_transport(transport, runtime_policy);
13111        if required_capabilities
13112            .iter()
13113            .any(|capability| !runtime_policy.admits(*capability))
13114        {
13115            return Err(failure(
13116                NodeFailureCode::UnsupportedSpawnCapability,
13117                "provider runtime does not admit a required spawn capability",
13118            ));
13119        }
13120        if let Some(deadline) = deadline {
13121            spawn_deadline_remaining(deadline)?;
13122        }
13123        self.ensure_binding_capacity()?;
13124        let mut working_directory = self.workspace_root(&workspace_id)?;
13125        let instance_id = AgentInstanceId(self.next_instance_id.fetch_add(1, Ordering::AcqRel));
13126        let session = SessionKey {
13127            instance_id,
13128            generation: SessionGeneration(1),
13129        };
13130        let address = SessionAddress {
13131            workspace_id: workspace_id.clone(),
13132            session,
13133        };
13134        // This is the request-accepted checkpoint: admission (managed
13135        // worktree authority, runtime capability, dispatch deadline) already
13136        // passed and a concrete session identity is minted. It is a distinct
13137        // state from the process actually running (`ControlEventKind::Running`,
13138        // logged in `publish_control`) or the terminal outcome (`Exited`/
13139        // `Failed`, logged there too) -- this line exists so that gap is no
13140        // longer silent.
13141        tracing::info!(
13142            node_id = %self.node_id,
13143            workspace_id = %workspace_id,
13144            provider = %provider,
13145            environment_profile = ?environment_profile.as_ref().map(|receipt| &receipt.profile_id),
13146            session = ?address.session,
13147            "session spawn request accepted",
13148        );
13149        let mut prepared_spawn = SpawnPreparer::prepare(
13150            self,
13151            SpawnPrepareRequest {
13152                instance_id,
13153                address: &address,
13154                provider: &provider,
13155                mode,
13156                environment_profile: environment_profile.as_ref(),
13157                bundle: bundle.as_ref(),
13158                context: context.as_ref(),
13159                managed_authority: managed_authority.as_ref(),
13160                harness_mcp,
13161                approval_level,
13162                network_allowlist: network_allowlist.as_ref(),
13163                browser_profile_id,
13164            },
13165        )?;
13166        let materialization_id = prepared_spawn.materialization_id.take();
13167        let dispatch_timeout = spawn_dispatch_timeout(deadline)?;
13168        let record_id = self.bind_spawn_session_with_materialization(
13169            &address,
13170            provider.clone(),
13171            mode,
13172            runtime_policy,
13173            record_policy,
13174            environment_profile,
13175            bundle,
13176            context,
13177            materialization_id,
13178        )?;
13179        prepared_spawn.retain_session_guards();
13180        // `transport` was already computed above, alongside this session's
13181        // `runtime_policy`; reused here for `Register` rather than derived a
13182        // second time.
13183        let session_provider = provider.clone();
13184        let agent_id = provider;
13185        let register_command_id = match self.dispatch_bounded(
13186            ControlCommand::Register {
13187                instance_id,
13188                agent_id,
13189                transport,
13190            },
13191            dispatch_timeout,
13192        )
13193        .await {
13194            Ok(command_id) => command_id,
13195            Err(error) => {
13196                if let Some(binding) = self.remove_binding(&address) {
13197                    SessionLifecycleHook::cleanup_session_materialization(self, &address, &binding)?;
13198                }
13199                if let Some(record_id) = record_id.as_ref() {
13200                    self.discard_record(record_id)?;
13201                }
13202                return Err(spawn_dispatch_error(error, deadline));
13203            }
13204        };
13205        let start_timeout = match spawn_dispatch_timeout(deadline) {
13206            Ok(timeout) => timeout,
13207            Err(error) => {
13208                let recovery = self
13209                    .rollback_spawn(
13210                        &address,
13211                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13212                    )
13213                    .await;
13214                return match recovery {
13215                    Ok(()) => {
13216                        if let Some(record_id) = record_id.as_ref() {
13217                            self.discard_record(record_id)?;
13218                        }
13219                        Err(error)
13220                    }
13221                    Err(recovery_error) => {
13222                        if let Some(record_id) = record_id.as_ref() {
13223                            self.mark_record_error(
13224                                record_id,
13225                                "spawn deadline elapsed and registration recovery failed",
13226                            )?;
13227                        }
13228                        Err(recovery_error)
13229                    }
13230                };
13231            }
13232        };
13233        if let Err(start_error) = self.verify_harness_mcp_before_start(harness_mcp) {
13234            if let Err(recovery_error) = self
13235                .rollback_spawn(&address, Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS))
13236                .await
13237            {
13238                return Err(recovery_error);
13239            }
13240            if let Some(record_id) = record_id.as_ref() {
13241                self.discard_record(record_id)?;
13242            }
13243            return Err(start_error);
13244        }
13245        // A session with its own m4a account runs in its own folder inside the workspace.
13246        if let Some(dir) = self.session_workdir(std::path::Path::new(&working_directory), instance_id, &session_provider) {
13247            working_directory = dir.to_string_lossy().into_owned();
13248        }
13249        let start_result = self
13250            .dispatch_bounded(
13251                ControlCommand::Start {
13252                    instance_id,
13253                    runtime_policy,
13254                    request: StartRequest {
13255                        working_directory,
13256                        terminal_size,
13257                        initial_prompt,
13258                        session_options: SPAWN_SESSION_OPTIONS.try_with(|o| o.clone()).ok().flatten(),
13259                        approval_level,
13260                    },
13261                },
13262                start_timeout,
13263            )
13264            .await;
13265        let start_command_id = match start_result {
13266            Ok(command_id) => command_id,
13267            Err(start_error) => {
13268                let start_error = spawn_dispatch_error(start_error, deadline);
13269                let recovery = self
13270                    .rollback_spawn(
13271                        &address,
13272                        Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13273                    )
13274                    .await;
13275                return match recovery {
13276                    Ok(()) => {
13277                        if let Some(record_id) = record_id.as_ref() {
13278                            self.discard_record(record_id)?;
13279                        }
13280                        Err(start_error)
13281                    }
13282                    Err(recovery_error) => {
13283                        let diagnostic = format!(
13284                            "start failed and registration recovery failed: {}",
13285                            recovery_error.message,
13286                        );
13287                        if let Some(record_id) = record_id.as_ref() {
13288                            self.mark_record_error(record_id, &diagnostic)?;
13289                        }
13290                        Err(failure(recovery_error.code, &diagnostic))
13291                    }
13292                };
13293            }
13294        };
13295
13296        let commit_deadline = deadline.unwrap_or_else(|| Instant::now() + dispatch_timeout);
13297        loop {
13298            // `Register`/`Start` only fail synchronously for a port-level
13299            // ingress problem (see `dispatch_bounded`); the kernel refusing
13300            // either command's content -- an unsupported transport, an
13301            // unknown agent -- lands one drive-loop tick later, and no
13302            // session ever appears in `self.handle.snapshot()` for it to
13303            // notice below. Check for that named reason before falling
13304            // through to the blind presence poll, so a rejected spawn
13305            // reports why instead of just timing out.
13306            if let Some(reason) = self.take_rejected_command_reason(register_command_id)
13307                .or_else(|| self.take_rejected_command_reason(start_command_id))
13308            {
13309                self.rollback_spawn(
13310                    &address,
13311                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13312                ).await.map_err(|error| {
13313                        failure(
13314                            error.code,
13315                            &format!(
13316                                "spawn was rejected ({}) and registration recovery failed: {}",
13317                                reason.message, error.message
13318                            ),
13319                        )
13320                    })?;
13321                if let Some(record_id) = record_id.as_ref() {
13322                    self.discard_record(record_id)?;
13323                }
13324                let code = if reason.unsupported_transport {
13325                    NodeFailureCode::UnsupportedTransport
13326                } else {
13327                    NodeFailureCode::BackendOperationFailed
13328                };
13329                return Err(failure(code, &reason.message));
13330            }
13331            if let Some(current) = self
13332                .handle
13333                .snapshot()
13334                .sessions
13335                .iter()
13336                .find(|current| current.instance_id == instance_id)
13337            {
13338                if current.generation != session.generation {
13339                    let divergence = failure(
13340                        NodeFailureCode::StaleGeneration,
13341                        "spawn generation diverged",
13342                    );
13343                    return match self
13344                        .rollback_spawn(
13345                            &address,
13346                            Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13347                        )
13348                        .await
13349                    {
13350                        Ok(()) => {
13351                            if let Some(record_id) = record_id.as_ref() {
13352                                self.discard_record(record_id)?;
13353                            }
13354                            Err(divergence)
13355                        }
13356                        Err(recovery_error) => {
13357                            if let Some(record_id) = record_id.as_ref() {
13358                                self.mark_record_error(
13359                                    record_id,
13360                                    "spawn generation diverged and recovery failed",
13361                                )?;
13362                            }
13363                            Err(recovery_error)
13364                        }
13365                    };
13366                }
13367                if !matches!(current.status, gate4agent_types::SessionStatus::Registered) {
13368                    prepared_spawn.retain_launch_guards();
13369                    return Ok((address, runtime_policy));
13370                }
13371            }
13372            if Instant::now() >= commit_deadline {
13373                self.rollback_spawn(
13374                    &address,
13375                    Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS),
13376                ).await.map_err(|error| {
13377                        failure(
13378                            error.code,
13379                            &format!(
13380                                "start did not commit and registration recovery failed: {}",
13381                                error.message
13382                            ),
13383                        )
13384                    })?;
13385                if let Some(record_id) = record_id.as_ref() {
13386                    self.discard_record(record_id)?;
13387                }
13388                return Err(failure(
13389                    if deadline.is_some() {
13390                        NodeFailureCode::SpawnDeadlineExceeded
13391                    } else {
13392                        NodeFailureCode::BackendBusy
13393                    },
13394                    "start did not commit before the bounded deadline; registration was removed",
13395                ));
13396            }
13397            sleep(Duration::from_millis(2)).await;
13398        }
13399    }
13400
13401    fn effective_spawn_runtime_policy(
13402        &self,
13403        runtime_policy: ProviderRuntimePolicy,
13404    ) -> ProviderRuntimePolicy {
13405        #[cfg(feature = "fixture")]
13406        let runtime_policy = if self.fixture_semantic_hook_policy {
13407            ProviderRuntimePolicy::new(true, true, true, true, false, true)
13408                .expect("monitoring Hook fixture policy is internally valid")
13409        } else {
13410            runtime_policy
13411        };
13412        runtime_policy
13413    }
13414
13415    fn verify_harness_mcp_before_start(
13416        &self,
13417        prepared: Option<&PreparedHarnessMcpSpawn>,
13418    ) -> Result<(), NodeFailure> {
13419        prepared
13420            .map(PreparedHarnessMcpSpawn::verify_helper_program)
13421            .transpose()
13422            .map(|_| ())
13423            .map_err(harness_mcp_failure)
13424    }
13425
13426    async fn rollback_spawn(
13427        &self,
13428        address: &SessionAddress,
13429        commit_timeout: Duration,
13430    ) -> Result<(), NodeFailure> {
13431        self.dispatch_bounded(
13432            ControlCommand::Remove {
13433                instance_id: address.session.instance_id,
13434            },
13435            commit_timeout,
13436        )
13437        .await?;
13438        self.wait_until_removed(address, commit_timeout).await?;
13439        if let Some(binding) = self.remove_binding(address) {
13440            SessionLifecycleHook::cleanup_session_materialization(self, address, &binding)?;
13441        }
13442        Ok(())
13443    }
13444
13445    async fn remove_session(&self, address: &SessionAddress) -> Result<(), NodeFailure> {
13446        let commit_timeout = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
13447        self.dispatch_bounded(
13448            ControlCommand::Remove {
13449                instance_id: address.session.instance_id,
13450            },
13451            commit_timeout,
13452        )
13453            .await?;
13454        self.wait_until_removed(address, commit_timeout).await?;
13455        let removed = self.remove_binding(address);
13456        if let Some(binding) = removed.as_ref() {
13457            SessionLifecycleHook::cleanup_session_materialization(self, address, binding)?;
13458        }
13459        if let Some(lease_id) = removed.and_then(|binding| binding.managed_worktree_lease_id) {
13460            let snapshot = self.managed_worktrees.lock()
13461                .unwrap_or_else(|poisoned| poisoned.into_inner())
13462                .release_session(
13463                    &lease_id,
13464                    self.incarnation_id,
13465                    address.session.instance_id,
13466                    address.session.generation,
13467                    unix_time_ms(),
13468                );
13469            if self.persist_state().is_err() {
13470                self.set_persistence_error(Some(DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned()));
13471            }
13472            if let Some(snapshot) = snapshot {
13473                self.publish(NodeEvent::ManagedWorktreeUpserted { lease: snapshot });
13474            }
13475            let _ = self.cleanup_managed_worktree(&lease_id, false).await;
13476        }
13477        Ok(())
13478    }
13479
13480    /// Polls until `address`'s session leaves the engine's own session map,
13481    /// or the bounded deadline expires.
13482    ///
13483    /// A `Remove` sent while the session is not yet in a terminal status
13484    /// (e.g. still `Stopping`, mid-effect right after a `Stop`) is rejected
13485    /// once by the engine (`SessionStatus::allows_remove`) and
13486    /// then discarded -- nothing re-issues it on its own, so the removal
13487    /// that would satisfy this wait never arrives and the caller was
13488    /// guaranteed to time out even though the session settles microseconds
13489    /// later. So this re-dispatches on every poll tick: a session that
13490    /// settles mid-wait -- the common case right after a `Stop` -- still
13491    /// gets removed inside the same bounded deadline.
13492    ///
13493    /// It re-dispatches only once the status will ACCEPT the command. A
13494    /// rejected re-dispatch is not the harmless no-op this loop first
13495    /// assumed: every rejection publishes a `ControlEventKind::Command
13496    /// Rejected` to every subscriber and writes a WARN line. Ungated, one
13497    /// ordinary teardown produced 241 of them in 1.6 seconds -- the whole
13498    /// `Stopping` window at one known-doomed dispatch per 2ms tick, fanned
13499    /// out to the harness and every attached client. Gated, the same
13500    /// teardown sends exactly one `Remove`, at the first tick the engine
13501    /// can act on it.
13502    ///
13503    /// The status is read from the snapshot this loop already takes, so
13504    /// the gate costs nothing extra. It races -- the status can move
13505    /// between the read and the dispatch -- and that is fine: a rare lost
13506    /// race is one rejection, and the next tick retries.
13507    ///
13508    /// Re-dispatch is separately rate-limited to
13509    /// [`REMOVE_REDISPATCH_INTERVAL_MS`], because the status gate alone
13510    /// leaves a second burst behind it. Once the session settles, every
13511    /// 2ms tick sent another `Remove` while the accepted one was still in
13512    /// flight, and each of those landed on an instance the engine had
13513    /// already dropped -- `agent instance … is not registered`, observed
13514    /// live as a tail of rejections immediately after the `session
13515    /// removed` line. The presence poll stays at 2ms so the wait still
13516    /// exits promptly; only the sending slows down.
13517    ///
13518    /// Rate-limited rather than once-only on purpose: the status gate
13519    /// covers the status precondition, but `Gate4AgentEngine::remove` has
13520    /// others (a pending operation, an in-flight capability probe or
13521    /// history request) that clear on their own. A single shot would turn
13522    /// those into a guaranteed `BackendBusy` timeout; at 10Hz they still
13523    /// get their retries, just twenty of them across the deadline instead
13524    /// of a thousand.
13525    async fn wait_until_removed(
13526        &self,
13527        address: &SessionAddress,
13528        commit_timeout: Duration,
13529    ) -> Result<(), NodeFailure> {
13530        let deadline = Instant::now() + commit_timeout;
13531        let mut next_dispatch = Instant::now();
13532        loop {
13533            let snapshot = self.handle.snapshot();
13534            let status = snapshot
13535                .sessions
13536                .iter()
13537                .find(|session| session.instance_id == address.session.instance_id)
13538                .map(|session| session.status.clone());
13539            let Some(status) = status else {
13540                return Ok(());
13541            };
13542            let now = Instant::now();
13543            if now >= deadline {
13544                return Err(failure(
13545                    NodeFailureCode::BackendBusy,
13546                    "remove did not commit before the bounded deadline; session binding was retained",
13547                ));
13548            }
13549            if status.allows_remove() && now >= next_dispatch {
13550                let _ = self.dispatch(ControlCommand::Remove {
13551                    instance_id: address.session.instance_id,
13552                });
13553                next_dispatch = now + Duration::from_millis(REMOVE_REDISPATCH_INTERVAL_MS);
13554            }
13555            sleep(Duration::from_millis(2)).await;
13556        }
13557    }
13558
13559    fn resync(&self, after_sequence: u64) -> NodeResponse {
13560        let history = self.history.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
13561        let event_sequence = history.last_sequence;
13562        let oldest_available_sequence = history.replay_floor_sequence;
13563        let events = history
13564            .events
13565            .iter()
13566            .filter(|event| event.sequence > after_sequence)
13567            .cloned()
13568            .collect();
13569        drop(history);
13570        NodeResponse::Resync {
13571            event_sequence,
13572            oldest_available_sequence,
13573            snapshot: self.snapshot(),
13574            events,
13575        }
13576    }
13577}
13578
13579/// Coarse label for one `AgentStreamChunkKindV1`, used only by
13580/// `NodeShared::publish_agent_stream_chunk`'s own debug log -- mirrors
13581/// `hatchery-harness-service`'s `agent_stream_event_kind_label` (same
13582/// "name what crossed this boundary" reasoning, one layer upstream of it).
13583fn agent_stream_chunk_kind_label(kind: &AgentStreamChunkKindV1) -> &'static str {
13584    match kind {
13585        AgentStreamChunkKindV1::Text { .. } => "text",
13586        AgentStreamChunkKindV1::Thinking { .. } => "thinking",
13587        AgentStreamChunkKindV1::InteractionPrompt { .. } => "interaction-prompt",
13588        AgentStreamChunkKindV1::ModeCatalog { .. } => "mode-catalog",
13589        AgentStreamChunkKindV1::ConfigOptions { .. } => "config-options",
13590        AgentStreamChunkKindV1::ModelCatalog { .. } => "model-catalog",
13591        AgentStreamChunkKindV1::Blocked { .. } => "blocked",
13592    }
13593}
13594
13595/// Serves this wire on the local endpoint, and -- when `call_home` names a
13596/// relay -- over an outbound connection to it as well.
13597///
13598/// The two live in one future rather than a fourth arm of `run`'s own
13599/// `select!` because they are one concern: this is where the node's wire
13600/// is served, and the direction a given connection was opened in does not
13601/// change what happens on it. It also means the existing graceful drain
13602/// below covers both without being duplicated.
13603///
13604/// Either side finishing ends the pair, which is correct: the listener
13605/// only returns on shutdown or a bind failure, and `call_home_loop` only
13606/// returns on shutdown -- a failed relay connection is retried inside it,
13607/// never propagated out.
13608async fn accept_connections(
13609    endpoint: &str,
13610    call_home: Option<std::net::SocketAddr>,
13611    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13612    shared: Arc<NodeShared>,
13613) -> Result<(), NodeServerError> {
13614    let mut connections = JoinSet::new();
13615    let result = match call_home {
13616        None => accept_connections_inner(endpoint, shared, &mut connections).await,
13617        Some(relay) => {
13618            let listener =
13619                accept_connections_inner(endpoint, Arc::clone(&shared), &mut connections);
13620            let dialler = call_home_loop(relay, identity, Arc::clone(&shared));
13621            tokio::pin!(listener);
13622            tokio::pin!(dialler);
13623            tokio::select! {
13624                result = &mut listener => result,
13625                result = &mut dialler => result,
13626            }
13627        }
13628    };
13629    let graceful_deadline = Instant::now() + Duration::from_millis(CONNECTION_SHUTDOWN_GRACE_MS);
13630    while !connections.is_empty() {
13631        let remaining = graceful_deadline.saturating_duration_since(Instant::now());
13632        if remaining.is_zero()
13633            || timeout(remaining, connections.join_next()).await.is_err()
13634        {
13635            break;
13636        }
13637    }
13638    connections.abort_all();
13639    while connections.join_next().await.is_some() {}
13640    result
13641}
13642
13643/// Backoff ladder for a relay that will not answer, capped rather than
13644/// unbounded: a node whose relay is down is not broken and should keep
13645/// trying forever, but it should not spend a core doing it.
13646const CALL_HOME_BACKOFFS_MS: [u64; 5] = [500, 1_000, 2_000, 4_000, 8_000];
13647
13648/// Dials the relay, serves this wire over the connection, and does it
13649/// again when the connection ends.
13650///
13651/// The node is the SERVER on the socket it just opened. That inversion is
13652/// the whole point: `serve_connection` is the same function the local
13653/// listener feeds, and it neither knows nor cares which end placed the
13654/// call. The one thing this direction adds is the preface -- the relay
13655/// accepted a socket and cannot know whose it is, so the node says its
13656/// name before the handshake it would otherwise begin with.
13657///
13658/// Never returns an error. A relay that is down, refusing, or speaking a
13659/// different protocol is a condition to retry, not a reason to take the
13660/// node's local endpoint down with it; the only way out is shutdown.
13661async fn call_home_loop(
13662    relay: std::net::SocketAddr,
13663    identity: Option<Arc<hatchery_enroll::NodeIdentity>>,
13664    shared: Arc<NodeShared>,
13665) -> Result<(), NodeServerError> {
13666    let mut failures = 0_usize;
13667    loop {
13668        if shared.shutdown.load(Ordering::Acquire) {
13669            return Ok(());
13670        }
13671        let permit = tokio::select! {
13672            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13673                permit.map_err(|_| NodeServerError::PreauthClosed)?
13674            }
13675            _ = shared.shutdown_notify.notified() => return Ok(()),
13676        };
13677        match dial_relay_and_serve(relay, identity.as_deref(), Arc::clone(&shared), permit).await {
13678            Ok(()) => {
13679                // A clean end is the relay hanging up, which it does on
13680                // its own restart. Reconnect immediately rather than
13681                // serving the backoff a peer that is plainly there.
13682                failures = 0;
13683                tracing::info!(node_id = %shared.node_id, %relay, "call-home connection closed; redialling");
13684            }
13685            Err(error) => {
13686                failures = failures.saturating_add(1);
13687                tracing::warn!(
13688                    node_id = %shared.node_id,
13689                    %relay,
13690                    cause = %error,
13691                    attempt = failures,
13692                    "call-home connection failed",
13693                );
13694                let backoff = call_home_backoff(failures);
13695                tokio::select! {
13696                    _ = sleep(backoff) => {}
13697                    _ = shared.shutdown_notify.notified() => return Ok(()),
13698                }
13699            }
13700        }
13701    }
13702}
13703
13704/// Idle time before the first keepalive probe on a call-home socket.
13705const CALL_HOME_KEEPALIVE_IDLE: Duration = Duration::from_secs(15);
13706/// Interval between keepalive probes once the link has gone quiet.
13707const CALL_HOME_KEEPALIVE_INTERVAL: Duration = Duration::from_secs(5);
13708
13709fn call_home_keepalive() -> socket2::TcpKeepalive {
13710    let keepalive = socket2::TcpKeepalive::new()
13711        .with_time(CALL_HOME_KEEPALIVE_IDLE)
13712        .with_interval(CALL_HOME_KEEPALIVE_INTERVAL);
13713    #[cfg(not(windows))]
13714    let keepalive = keepalive.with_retries(3);
13715    keepalive
13716}
13717
13718fn enable_call_home_keepalive(stream: &tokio::net::TcpStream) -> std::io::Result<()> {
13719    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13720}
13721
13722/// Same keepalive profile for any node->relay socket (restream uplink).
13723pub(crate) fn enable_tcp_keepalive(stream: &std::net::TcpStream) -> std::io::Result<()> {
13724    socket2::SockRef::from(stream).set_tcp_keepalive(&call_home_keepalive())
13725}
13726
13727/// Backoff for the `attempt`-th consecutive failure (1-based), capped.
13728fn call_home_backoff(attempt: usize) -> Duration {
13729    Duration::from_millis(
13730        CALL_HOME_BACKOFFS_MS[attempt.saturating_sub(1).min(CALL_HOME_BACKOFFS_MS.len() - 1)],
13731    )
13732}
13733
13734
13735/// Dial the call-home relay. Tunnel addresses go through the in-process
13736/// WireGuard dialer when the userspace session is up.
13737async fn dial_call_home(relay: std::net::SocketAddr) -> Result<tokio::net::TcpStream, NodeServerError> {
13738    #[cfg(feature = "wireguard")]
13739    {
13740        let std_stream = tokio::task::spawn_blocking(move || {
13741            gate4agent_node_core::kit_wireguard::tcp_connect(relay)
13742        })
13743        .await
13744        .map_err(|e| NodeServerError::CallHomeTransport(std::io::Error::other(e.to_string())))?
13745        .map_err(NodeServerError::CallHomeTransport)?;
13746        std_stream
13747            .set_nonblocking(true)
13748            .map_err(NodeServerError::CallHomeTransport)?;
13749        return tokio::net::TcpStream::from_std(std_stream).map_err(NodeServerError::CallHomeTransport);
13750    }
13751    #[cfg(not(feature = "wireguard"))]
13752    {
13753        tokio::net::TcpStream::connect(relay)
13754            .await
13755            .map_err(NodeServerError::CallHomeTransport)
13756    }
13757}
13758
13759/// One call-home attempt: connect, name this node, then serve.
13760async fn dial_relay_and_serve(
13761    relay: std::net::SocketAddr,
13762    identity: Option<&hatchery_enroll::NodeIdentity>,
13763    shared: Arc<NodeShared>,
13764    preauth_permit: OwnedSemaphorePermit,
13765) -> Result<(), NodeServerError> {
13766    let mut stream = dial_call_home(relay).await?;
13767    // Nagle off for the same reason the rest of this wire wants it off:
13768    // the traffic is small request/response frames, and coalescing them
13769    // adds latency to every one.
13770    let _ = stream.set_nodelay(true);
13771    // A relay that vanishes without a FIN/RST reaching us (tunnel drop,
13772    // relay-side socket kill whose RST was lost) would otherwise leave this
13773    // socket ESTABLISHED forever: the node mostly reads, so nothing ever
13774    // fails and the reconnect loop never runs. Kernel keepalive turns that
13775    // half-open link into a read error within a bounded time.
13776    if let Err(error) = enable_call_home_keepalive(&stream) {
13777        tracing::warn!(cause = %error, "could not enable TCP keepalive on the call-home socket");
13778    }
13779    gate4agent_node_wire::write_call_home_announce(&mut stream, &shared.node_id)
13780        .await
13781        .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13782    tracing::info!(node_id = %shared.node_id, %relay, "announced this node to the relay");
13783    let mut session_token = None;
13784    if let Some(identity) = identity {
13785        // An enrolled relay answers the announce with a signed-call-home challenge; a
13786        // token-mode relay sends its wire hello instead. Peek to tell them apart
13787        // without consuming either.
13788        let mut magic = [0_u8; 4];
13789        let peeked = timeout(Duration::from_secs(10), stream.peek(&mut magic))
13790            .await
13791            .map_err(|_| NodeServerError::Handshake("relay sent nothing after the announce".to_owned()))?
13792            .map_err(NodeServerError::CallHomeTransport)?;
13793        if peeked == 4 && &magic == hatchery_enroll::CHALLENGE_MAGIC {
13794            let mut frame = [0_u8; hatchery_enroll::CHALLENGE_LEN];
13795            timeout(Duration::from_secs(5), tokio::io::AsyncReadExt::read_exact(&mut stream, &mut frame))
13796                .await
13797                .map_err(|_| NodeServerError::Handshake("relay challenge timed out".to_owned()))?
13798                .map_err(NodeServerError::CallHomeTransport)?;
13799            let (answer, token) = identity
13800                .answer_challenge(shared.node_id.as_str(), &frame)
13801                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13802            tokio::io::AsyncWriteExt::write_all(&mut stream, &answer).await.map_err(NodeServerError::CallHomeTransport)?;
13803            tracing::info!(node_id = %shared.node_id, "answered the relay's signed call-home challenge");
13804            session_token = Some(token);
13805        }
13806    }
13807    serve_connection(stream, shared, session_token, preauth_permit).await
13808}
13809
13810async fn accept_connections_inner(
13811    endpoint: &str,
13812    shared: Arc<NodeShared>,
13813    connections: &mut JoinSet<Result<(), NodeServerError>>,
13814) -> Result<(), NodeServerError> {
13815    let mut listener = OwnerOnlyLocalListener::bind(endpoint).await?;
13816    loop {
13817        while connections.try_join_next().is_some() {}
13818        if shared.shutdown.load(Ordering::Acquire) {
13819            return Ok(());
13820        }
13821        let preauth_permit = tokio::select! {
13822            permit = Arc::clone(&shared.preauth_slots).acquire_owned() => {
13823                permit.map_err(|_| NodeServerError::PreauthClosed)?
13824            }
13825            _ = shared.shutdown_notify.notified() => return Ok(()),
13826        };
13827        let server = tokio::select! {
13828            result = listener.accept() => result?,
13829            _ = shared.shutdown_notify.notified() => return Ok(()),
13830        };
13831        let connection_shared = Arc::clone(&shared);
13832        connections.spawn(async move {
13833            serve_connection(server, connection_shared, None, preauth_permit).await
13834        });
13835        if shared.shutdown.load(Ordering::Acquire) {
13836            return Ok(());
13837        }
13838    }
13839}
13840
13841/// Serves one connection, whichever end opened it.
13842///
13843/// Generic over the stream rather than typed to `LocalServerStream`
13844/// because a call-home connection arrives on a `TcpStream` the node itself
13845/// dialled. Nothing in the body ever depended on the concrete type -- it
13846/// reads and writes framed JSON and then `tokio::io::split`s -- so this is
13847/// the whole of what welding "who dials" to "who serves" was costing.
13848async fn serve_connection<S>(
13849    mut pipe: S,
13850    shared: Arc<NodeShared>,
13851    session_token: Option<String>,
13852    preauth_permit: OwnedSemaphorePermit,
13853) -> Result<(), NodeServerError>
13854where
13855    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
13856{
13857    // A signed call-home connection authenticates with its own per-connection token.
13858    let auth_token: &[u8] = session_token.as_deref().map_or(shared.access_token.as_bytes(), str::as_bytes);
13859    let hello_frame = timeout(
13860        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13861        read_json_frame_limited_body_timeout(
13862            &mut pipe,
13863            MAX_NODE_HELLO_FRAME_BYTES,
13864            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13865        ),
13866    )
13867    .await
13868    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13869    let ClientFrame::Hello(hello) = hello_frame else {
13870        return Err(NodeServerError::Handshake("first frame must be hello".to_owned()));
13871    };
13872    if hello.build_stamp != BUILD_STAMP {
13873        return Err(NodeServerError::Handshake(format!(
13874            "build stamp mismatch: local={BUILD_STAMP} remote={}",
13875            hello.build_stamp,
13876        )));
13877    }
13878    let compatibility = match hello.compatibility.as_ref() {
13879        Some(offer) => {
13880            let mut selected = node_compatibility_support(&shared)?
13881                .negotiate(offer)
13882                .map_err(|error| NodeServerError::Handshake(error.to_string()))?;
13883            project_negotiated_provider_ids(&mut selected);
13884            Some(selected)
13885        }
13886        None => None,
13887    };
13888    let server_nonce = random_nonce().map_err(NodeServerError::Authentication)?;
13889    let server_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13890        (Some(offer), Some(selected)) => negotiated_auth_proof(
13891            auth_token,
13892            AuthDirection::Server,
13893            hello.role,
13894            &hello.client_nonce,
13895            &server_nonce,
13896            offer,
13897            selected,
13898        ),
13899        (None, None) => auth_proof(
13900            auth_token,
13901            AuthDirection::Server,
13902            hello.role,
13903            &hello.client_nonce,
13904            &server_nonce,
13905        ),
13906        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13907    }
13908    .map_err(NodeServerError::Authentication)?;
13909    write_json_frame_limited(
13910        &mut pipe,
13911        &ServerFrame::Challenge(ServerChallenge {
13912            build_stamp: BUILD_STAMP.to_owned(),
13913            server_nonce,
13914            server_proof,
13915            compatibility: compatibility.clone(),
13916        }),
13917        MAX_NODE_HELLO_FRAME_BYTES,
13918    )
13919    .await?;
13920    let authentication = timeout(
13921        Duration::from_millis(AUTH_FRAME_TIMEOUT_MS),
13922        read_json_frame_limited_body_timeout(
13923            &mut pipe,
13924            MAX_NODE_HELLO_FRAME_BYTES,
13925            Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
13926        ),
13927    )
13928    .await
13929    .map_err(|_| NodeServerError::AuthenticationTimedOut)??;
13930    let ClientFrame::Authenticate(authentication) = authentication else {
13931        return Err(NodeServerError::Handshake("second frame must authenticate the challenge".to_owned()));
13932    };
13933    let expected_client_proof = match (hello.compatibility.as_ref(), compatibility.as_ref()) {
13934        (Some(offer), Some(selected)) => negotiated_auth_proof(
13935            auth_token,
13936            AuthDirection::Client,
13937            hello.role,
13938            &hello.client_nonce,
13939            &server_nonce,
13940            offer,
13941            selected,
13942        ),
13943        (None, None) => auth_proof(
13944            auth_token,
13945            AuthDirection::Client,
13946            hello.role,
13947            &hello.client_nonce,
13948            &server_nonce,
13949        ),
13950        _ => Err("node compatibility authentication state is inconsistent".to_owned()),
13951    }
13952    .map_err(NodeServerError::Authentication)?;
13953    if !proofs_match(&authentication.client_proof, &expected_client_proof) {
13954        return Err(NodeServerError::Handshake("access denied".to_owned()));
13955    }
13956    let selected_capabilities = compatibility
13957        .as_ref()
13958        .map(|selected| selected.capabilities.clone())
13959        .unwrap_or_default();
13960    let include_provider_runtime_status = selected_capabilities.iter().any(|capability| {
13961        capability.as_str() == NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY
13962    });
13963    let include_open_provider_ids = selected_capabilities.iter().any(|capability| {
13964        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
13965    });
13966    let include_terminal_frame_events = selected_capabilities.iter().any(|capability| {
13967        capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
13968    });
13969    let include_agent_stream_events = selected_capabilities.iter().any(|capability| {
13970        capability.as_str() == NODE_AGENT_STREAM_EVENTS_CAPABILITY
13971    });
13972    let include_spawn_profiles = selected_capabilities.iter().any(|capability| {
13973        capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
13974    });
13975    let include_managed_worktrees = selected_capabilities.iter().any(|capability| {
13976        capability.as_str() == NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY
13977    }) && selected_capabilities.iter().any(|capability| {
13978        capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
13979    });
13980    let include_child_environment_profiles = selected_capabilities.iter().any(|capability| {
13981        capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
13982    });
13983    let include_session_bundles = selected_capabilities.iter().any(|capability| {
13984        capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
13985    });
13986    let include_history_context_packs = selected_capabilities.iter().any(|capability| {
13987        capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
13988    });
13989    let include_agent_progress = selected_capabilities.iter().any(|capability| {
13990        capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
13991    });
13992    let include_session_task_correlation = selected_capabilities.iter().any(|capability| {
13993        capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
13994    });
13995    let include_harness_mcp_proxy = selected_capabilities.iter().any(|capability| {
13996        capability.as_str() == NODE_HARNESS_MCP_READ_PROXY_CAPABILITY
13997    });
13998    let authenticated_permit = Arc::clone(&shared.authenticated_slots)
13999        .try_acquire_owned()
14000        .map_err(|_| NodeServerError::AuthenticatedConnectionLimit)?;
14001    let connection_id = shared.next_connection_id.fetch_add(1, Ordering::AcqRel);
14002    let _release_guard = ControllerReleaseGuard {
14003        shared: Arc::clone(&shared),
14004        connection_id,
14005    };
14006    drop(preauth_permit);
14007    let _authenticated_permit = authenticated_permit;
14008    let mut event_rx = shared.event_tx.subscribe();
14009    let mut terminal_event_rx = terminal_event_subscription(
14010        &shared,
14011        include_terminal_frame_events,
14012    );
14013    let mut agent_stream_event_rx = agent_stream_event_subscription(
14014        &shared,
14015        include_agent_stream_events,
14016    );
14017    let mut harness_mcp_proxy_event_rx = harness_mcp_proxy_event_subscription(
14018        &shared,
14019        include_harness_mcp_proxy,
14020    );
14021    write_json_frame(
14022        &mut pipe,
14023        &ServerFrame::Hello(NodeHello {
14024            build_stamp: BUILD_STAMP.to_owned(),
14025            incarnation_id: shared.incarnation_id,
14026            connection_id,
14027            role: hello.role,
14028            event_sequence: shared.current_sequence(),
14029            controller: shared.controller_state(),
14030            snapshot: snapshot_for_wire(
14031                &shared,
14032                include_provider_runtime_status,
14033                include_open_provider_ids,
14034                include_spawn_profiles,
14035                include_managed_worktrees,
14036                include_child_environment_profiles,
14037                include_session_bundles,
14038                include_history_context_packs,
14039                include_agent_progress,
14040                include_session_task_correlation,
14041            ),
14042            compatibility,
14043        }),
14044    )
14045    .await?;
14046    let (mut reader, mut writer) = tokio::io::split(pipe);
14047    let (frame_tx, mut frame_rx) = mpsc::channel(16);
14048    let reader_task = tokio::spawn(async move {
14049        loop {
14050            let frame = read_json_frame_limited_body_timeout::<_, ClientFrame>(
14051                &mut reader,
14052                MAX_NODE_CLIENT_FRAME_BYTES,
14053                Duration::from_millis(FRAME_BODY_TIMEOUT_MS),
14054            )
14055            .await;
14056            let terminal = frame.is_err();
14057            if frame_tx.send(frame).await.is_err() || terminal {
14058                break;
14059            }
14060        }
14061    });
14062    let _reader_abort = AbortTaskOnDrop(reader_task.abort_handle());
14063    let mut pending_events = Vec::new();
14064    let mut resync_required = false;
14065    let mut discard_events_through = 0_u64;
14066    loop {
14067        // One tick of the serve loop, whether or not it had anything to do.
14068        // Against `connection_events_sent` this separates "busy" from
14069        // "spinning": a loop turning over thousands of times a second while
14070        // sending nothing is not serving, it is burning a core.
14071        shared.connection_loop_iterations.fetch_add(1, Ordering::Relaxed);
14072        let durable_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14073            .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14074        for _ in 0..durable_drain_budget {
14075            match event_rx.try_recv() {
14076                Ok(event) => queue_connection_event(
14077                    &mut pending_events,
14078                    event,
14079                    discard_events_through,
14080                ),
14081                Err(broadcast::error::TryRecvError::Empty) => break,
14082                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14083                    resync_required = true;
14084                    break;
14085                }
14086                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14087            }
14088        }
14089        if let Some(receiver) = terminal_event_rx.as_mut() {
14090            match receiver.try_recv() {
14091                Ok(events) => queue_connection_event_batch(
14092                    &mut pending_events,
14093                    &events,
14094                    discard_events_through,
14095                ),
14096                Err(broadcast::error::TryRecvError::Empty) => {}
14097                Err(broadcast::error::TryRecvError::Lagged(_)) => {
14098                    resync_required = true;
14099                }
14100                Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14101            }
14102        }
14103        if let Some(receiver) = agent_stream_event_rx.as_mut() {
14104            // Loops up to the same remaining-room budget the durable
14105            // `event_rx` drain above uses, rather than one `try_recv` per
14106            // tick: each successful receive here is itself only ONE
14107            // `NodeEventEnvelope` batch (`publish_agent_stream_chunk` always
14108            // sends `vec![envelope]`, never more), so a single-shot drain
14109            // caps this connection's agent-stream throughput at one chunk
14110            // per outer-loop tick regardless of how many are already queued
14111            // -- exactly the kind of self-inflicted stall this crate's own
14112            // durable-event budget exists to avoid.
14113            let agent_stream_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14114                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14115            for _ in 0..agent_stream_drain_budget {
14116                match receiver.try_recv() {
14117                    Ok(events) => queue_connection_event_batch(
14118                        &mut pending_events,
14119                        &events,
14120                        discard_events_through,
14121                    ),
14122                    Err(broadcast::error::TryRecvError::Empty) => break,
14123                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14124                        tracing::warn!(
14125                            connection_id,
14126                            dropped,
14127                            "agent stream event subscription lagged; forcing a resync",
14128                        );
14129                        resync_required = true;
14130                        break;
14131                    }
14132                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14133                }
14134            }
14135        }
14136        if let Some(receiver) = harness_mcp_proxy_event_rx.as_mut() {
14137            // Mirrors the agent-stream drain immediately above: each
14138            // successful receive is one single-envelope batch
14139            // (`publish_transient` always sends `vec![envelope]`), so this
14140            // loops up to the same remaining-room budget rather than
14141            // stopping after one `try_recv`.
14142            let harness_mcp_proxy_drain_budget = NODE_CONNECTION_EVENT_BURST_MAX
14143                .saturating_sub(pending_events.len().min(NODE_CONNECTION_EVENT_BURST_MAX));
14144            for _ in 0..harness_mcp_proxy_drain_budget {
14145                match receiver.try_recv() {
14146                    Ok(events) => queue_connection_event_batch(
14147                        &mut pending_events,
14148                        &events,
14149                        discard_events_through,
14150                    ),
14151                    Err(broadcast::error::TryRecvError::Empty) => break,
14152                    Err(broadcast::error::TryRecvError::Lagged(dropped)) => {
14153                        tracing::warn!(
14154                            connection_id,
14155                            dropped,
14156                            "harness MCP proxy event subscription lagged; forcing a resync",
14157                        );
14158                        resync_required = true;
14159                        break;
14160                    }
14161                    Err(broadcast::error::TryRecvError::Closed) => return Ok(()),
14162                }
14163            }
14164        }
14165        if resync_required {
14166            let event = resync_required_event(&shared);
14167            apply_connection_resync_watermark(
14168                &mut pending_events,
14169                &mut discard_events_through,
14170                event.sequence,
14171            );
14172            write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14173            resync_required = false;
14174            continue;
14175        }
14176        if !pending_events.is_empty() {
14177            pending_events.sort_unstable_by_key(|event| event.sequence);
14178            let burst_len = connection_event_burst_len(pending_events.len());
14179            shared
14180                .connection_events_sent
14181                .fetch_add(burst_len as u64, Ordering::Relaxed);
14182            for event in pending_events.drain(..burst_len) {
14183                let event = if include_managed_worktrees {
14184                    Some(event)
14185                } else {
14186                    project_event_without_managed_worktrees(event)
14187                };
14188                let event = event.and_then(|event| if include_open_provider_ids {
14189                    Some(event)
14190                } else {
14191                    project_event_legacy_provider_ids(&shared, event)
14192                });
14193                let event = event.map(|event| if include_child_environment_profiles {
14194                    event
14195                } else {
14196                    project_event_without_child_environment_profile(event)
14197                });
14198                let event = event.map(|event| if include_session_bundles {
14199                    event
14200                } else {
14201                    project_event_without_session_bundle(event)
14202                });
14203                let event = event.map(|event| {
14204                    project_event_history_for_wire(event, include_history_context_packs)
14205                });
14206                let event = event.map(|event| if include_history_context_packs {
14207                    event
14208                } else {
14209                    project_event_without_context_pack(event)
14210                });
14211                let event = event.map(|event| if include_session_task_correlation {
14212                    event
14213                } else {
14214                    project_event_without_session_task_binding(event)
14215                });
14216                let exact_harness_controller = hello.role == ClientRole::Operator
14217                    && shared.controller_state().is_some_and(|controller| {
14218                        controller.connection_id == connection_id
14219                    });
14220                let event = event.and_then(|event| {
14221                    if include_harness_mcp_proxy && exact_harness_controller {
14222                        Some(event)
14223                    } else {
14224                        project_event_without_harness_mcp_proxy(event)
14225                    }
14226                });
14227                if let Some(event) = event {
14228                    write_json_frame(&mut writer, &ServerFrame::Event(event)).await?;
14229                }
14230            }
14231        }
14232        tokio::select! {
14233            biased;
14234            frame = frame_rx.recv() => {
14235                let frame = match frame {
14236                    Some(frame) => frame,
14237                    None => break,
14238                };
14239                let frame = match frame {
14240                    Ok(frame) => frame,
14241                    Err(FrameError::Io(error)) if matches!(error.kind(), io::ErrorKind::BrokenPipe | io::ErrorKind::UnexpectedEof) => break,
14242                    Err(error) => return Err(error.into()),
14243                };
14244                let ClientFrame::Request(request) = frame else {
14245                    return Err(NodeServerError::Handshake("hello may only be sent once".to_owned()));
14246                };
14247                #[cfg(feature = "fixture")]
14248                let probe_spawn_managed_worktree_v2 = matches!(
14249                    &request.request,
14250                    NodeRequest::SpawnManagedWorktreeV2 { .. }
14251                );
14252                let requires_open_provider_ids =
14253                    request_requires_open_provider_ids(&shared, &request.request);
14254                let requires_child_environment_profile =
14255                    request_requires_child_environment_profile(&shared, &request.request);
14256                let requires_session_bundle =
14257                    request_requires_session_bundle(&shared, &request.request);
14258                let requires_history_context_pack =
14259                    request_requires_history_context_pack(&shared, &request.request);
14260                let mut reply = if requires_open_provider_ids && !include_open_provider_ids {
14261                    ResponseEnvelope {
14262                        request_id: request.request_id,
14263                        result: Err(failure(
14264                            NodeFailureCode::UnsupportedCapability,
14265                            "open provider IDs were not negotiated",
14266                        )),
14267                    }
14268                } else if requires_child_environment_profile
14269                    && !include_child_environment_profiles
14270                {
14271                    ResponseEnvelope {
14272                        request_id: request.request_id,
14273                        result: Err(failure(
14274                            NodeFailureCode::UnsupportedCapability,
14275                            "child environment profiles were not negotiated",
14276                        )),
14277                    }
14278                } else if requires_session_bundle && !include_session_bundles {
14279                    ResponseEnvelope {
14280                        request_id: request.request_id,
14281                        result: Err(failure(
14282                            NodeFailureCode::UnsupportedCapability,
14283                            "session bundles were not negotiated",
14284                        )),
14285                    }
14286                } else if requires_history_context_pack && !include_history_context_packs {
14287                    ResponseEnvelope {
14288                        request_id: request.request_id,
14289                        result: Err(failure(
14290                            NodeFailureCode::UnsupportedCapability,
14291                            "history context packs were not negotiated",
14292                        )),
14293                    }
14294                } else if request_uses_unnegotiated_capability(
14295                    &request.request,
14296                    &selected_capabilities,
14297                )
14298                {
14299                    ResponseEnvelope {
14300                        request_id: request.request_id,
14301                        result: Err(failure(
14302                            NodeFailureCode::UnsupportedCapability,
14303                            "request capability was not negotiated",
14304                        )),
14305                    }
14306                } else {
14307                    process_request(&shared, connection_id, hello.role, request).await
14308                };
14309                #[cfg(feature = "fixture")]
14310                if probe_spawn_managed_worktree_v2 {
14311                    shared
14312                        .fixture_spawn_managed_worktree_v2_failure_probe
14313                        .record(reply.result.as_ref().err().map(|failure| failure.code));
14314                }
14315                if !include_provider_runtime_status {
14316                    clear_response_provider_runtime_status(&mut reply);
14317                }
14318                if !include_managed_worktrees {
14319                    project_response_without_managed_worktrees(&mut reply);
14320                }
14321                if !include_open_provider_ids {
14322                    project_response_legacy_provider_ids(&shared, &mut reply);
14323                }
14324                if !include_child_environment_profiles {
14325                    project_response_without_child_environment_profile(&mut reply);
14326                }
14327                if !include_session_bundles {
14328                    project_response_without_session_bundle(&mut reply);
14329                }
14330                project_response_history_for_wire(
14331                    &mut reply,
14332                    include_history_context_packs,
14333                );
14334                if !include_history_context_packs {
14335                    project_response_without_context_pack(&mut reply);
14336                }
14337                if !include_agent_progress {
14338                    project_response_without_agent_progress(&mut reply);
14339                }
14340                if !include_session_task_correlation {
14341                    project_response_without_session_task_binding(&mut reply);
14342                }
14343                write_json_frame(&mut writer, &ServerFrame::Reply(reply)).await?;
14344            }
14345            _ = tokio::task::yield_now(), if !pending_events.is_empty() => {}
14346            event = event_rx.recv() => {
14347                match event {
14348                    Ok(event) => queue_connection_event(
14349                        &mut pending_events,
14350                        event,
14351                        discard_events_through,
14352                    ),
14353                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14354                    Err(broadcast::error::RecvError::Closed) => break,
14355                }
14356            }
14357            events = receive_terminal_event_batch(&mut terminal_event_rx) => {
14358                match events {
14359                    Ok(events) => queue_connection_event_batch(
14360                        &mut pending_events,
14361                        &events,
14362                        discard_events_through,
14363                    ),
14364                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14365                    Err(broadcast::error::RecvError::Closed) => break,
14366                }
14367            }
14368            events = receive_terminal_event_batch(&mut agent_stream_event_rx) => {
14369                match events {
14370                    Ok(events) => queue_connection_event_batch(
14371                        &mut pending_events,
14372                        &events,
14373                        discard_events_through,
14374                    ),
14375                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14376                    Err(broadcast::error::RecvError::Closed) => break,
14377                }
14378            }
14379            events = receive_terminal_event_batch(&mut harness_mcp_proxy_event_rx) => {
14380                match events {
14381                    Ok(events) => queue_connection_event_batch(
14382                        &mut pending_events,
14383                        &events,
14384                        discard_events_through,
14385                    ),
14386                    Err(broadcast::error::RecvError::Lagged(_)) => resync_required = true,
14387                    Err(broadcast::error::RecvError::Closed) => break,
14388                }
14389            }
14390            _ = shared.shutdown_notify.notified() => break,
14391        }
14392    }
14393    Ok(())
14394}
14395
14396async fn receive_terminal_event_batch(
14397    receiver: &mut Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>>,
14398) -> Result<Arc<Vec<NodeEventEnvelope>>, broadcast::error::RecvError> {
14399    match receiver {
14400        Some(receiver) => receiver.recv().await,
14401        None => std::future::pending().await,
14402    }
14403}
14404
14405fn terminal_event_subscription(
14406    shared: &NodeShared,
14407    enabled: bool,
14408) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14409    enabled.then(|| shared.terminal_event_tx.subscribe())
14410}
14411
14412/// Mirrors `terminal_event_subscription` exactly, over the agent stream's
14413/// own channel -- see `NodeShared::agent_stream_event_tx`'s own doc for why
14414/// this needs a dedicated channel rather than a flag on the terminal one.
14415fn agent_stream_event_subscription(
14416    shared: &NodeShared,
14417    enabled: bool,
14418) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14419    enabled.then(|| shared.agent_stream_event_tx.subscribe())
14420}
14421
14422/// Mirrors `agent_stream_event_subscription` exactly, over the harness MCP
14423/// proxy's own channel -- see `NodeShared::harness_mcp_proxy_event_tx`'s
14424/// own doc for why this needs a dedicated channel rather than a ride on
14425/// the durable `event_tx`.
14426fn harness_mcp_proxy_event_subscription(
14427    shared: &NodeShared,
14428    enabled: bool,
14429) -> Option<broadcast::Receiver<Arc<Vec<NodeEventEnvelope>>>> {
14430    enabled.then(|| shared.harness_mcp_proxy_event_tx.subscribe())
14431}
14432
14433fn queue_connection_event(
14434    pending: &mut Vec<NodeEventEnvelope>,
14435    event: NodeEventEnvelope,
14436    discard_through: u64,
14437) {
14438    if event.sequence <= discard_through
14439        && !event.event.requires_harness_mcp_proxy_capability()
14440    {
14441        return;
14442    }
14443    if let NodeEvent::TerminalFrame { address, .. } = &event.event {
14444        if let Some(index) = pending.iter().position(|current| {
14445            matches!(
14446                &current.event,
14447                NodeEvent::TerminalFrame {
14448                    address: current_address,
14449                    ..
14450                } if current_address == address
14451            )
14452        }) {
14453            pending.remove(index);
14454        }
14455    }
14456    pending.push(event);
14457}
14458
14459fn project_event_without_harness_mcp_proxy(
14460    envelope: NodeEventEnvelope,
14461) -> Option<NodeEventEnvelope> {
14462    (!envelope.event.requires_harness_mcp_proxy_capability()).then_some(envelope)
14463}
14464
14465fn queue_connection_event_batch(
14466    pending: &mut Vec<NodeEventEnvelope>,
14467    events: &[NodeEventEnvelope],
14468    discard_through: u64,
14469) {
14470    for event in events {
14471        queue_connection_event(pending, event.clone(), discard_through);
14472    }
14473}
14474
14475fn apply_connection_resync_watermark(
14476    pending: &mut Vec<NodeEventEnvelope>,
14477    discard_through: &mut u64,
14478    marker_sequence: u64,
14479) {
14480    *discard_through = (*discard_through).max(marker_sequence);
14481    pending.retain(|event| event.sequence > *discard_through);
14482}
14483
14484fn connection_event_burst_len(pending_len: usize) -> usize {
14485    pending_len.min(NODE_CONNECTION_EVENT_BURST_MAX)
14486}
14487
14488fn resync_required_event(shared: &NodeShared) -> NodeEventEnvelope {
14489    let history = shared
14490        .history
14491        .lock()
14492        .unwrap_or_else(|poisoned| poisoned.into_inner());
14493    let sequence = history.last_sequence;
14494    let oldest_available_sequence = history.replay_floor_sequence;
14495    NodeEventEnvelope {
14496        sequence,
14497        event: NodeEvent::ResyncRequired {
14498            oldest_available_sequence,
14499        },
14500    }
14501}
14502
14503fn node_compatibility_support(
14504    shared: &NodeShared,
14505) -> Result<NodeCompatibilitySupport, NodeServerError> {
14506    let mut support = node_compatibility_support_for_manifest(
14507        &shared.provider_contracts,
14508        &shared.provider_adapter_contracts,
14509    )?;
14510    if shared
14511        .delivery_store
14512        .lock()
14513        .unwrap_or_else(|poisoned| poisoned.into_inner())
14514        .is_some()
14515    {
14516        support.capabilities.push(
14517            CapabilityId::new(NODE_DELIVERY_BUNDLE_V2_STAGE_COMMIT_CAPABILITY)
14518                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14519        );
14520    }
14521    if shared.harness_mcp_registry.is_some() {
14522        support.capabilities.push(
14523            CapabilityId::new(NODE_HARNESS_MCP_READ_PROXY_CAPABILITY)
14524                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14525        );
14526    }
14527    Ok(support)
14528}
14529
14530fn node_compatibility_support_for_manifest(
14531    provider_contracts: &[ProviderContractSupport],
14532    provider_adapter_contracts: &[ProviderAdapterContractSupport],
14533) -> Result<NodeCompatibilitySupport, NodeServerError> {
14534    Ok(NodeCompatibilitySupport {
14535        build_stamp: BUILD_STAMP.to_owned(),
14536        capabilities: baseline_capabilities()?,
14537        host: platform::host_descriptor().map_err(NodeServerError::Handshake)?,
14538        path_semantics: platform::path_semantics(),
14539        local_transport: platform::local_transport(),
14540        state_schema: StateSchemaSupport {
14541            versions: ProtocolRange::new(NODE_STATE_SCHEMA_V1, NODE_STATE_SCHEMA_V10)
14542                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14543        },
14544        provider_contracts: provider_contracts.to_vec(),
14545        provider_adapter_contracts: provider_adapter_contracts.to_vec(),
14546    })
14547}
14548
14549fn baseline_capabilities() -> Result<Vec<CapabilityId>, NodeServerError> {
14550    let capabilities = [
14551        NODE_COMPATIBILITY_METADATA_CAPABILITY,
14552        CAPABILITY_HOST_DIRECTORY_BROWSE_V1,
14553        NODE_REPOSITORY_PATH_CAPABILITY,
14554        NODE_WORKSPACE_FILE_READ_CAPABILITY,
14555        NODE_WORKSPACE_FILE_WRITE_CAPABILITY,
14556        NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY,
14557        NODE_GIT_READ_CAPABILITY,
14558        NODE_PROVIDER_CONTRACT_MANIFEST_CAPABILITY,
14559        NODE_PROVIDER_ID_OPEN_CAPABILITY,
14560        NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY,
14561        NODE_PROVIDER_RUNTIME_STATUS_CAPABILITY,
14562        NODE_TERMINAL_FRAME_EVENTS_CAPABILITY,
14563        NODE_AGENT_STREAM_EVENTS_CAPABILITY,
14564        NODE_ACP_CONTROL_CAPABILITY,
14565        NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY,
14566        NODE_SPAWN_PROFILE_REVISION_CAPABILITY,
14567        NODE_WORKTREE_SELECTION_CAPABILITY,
14568        NODE_MANAGED_WORKTREE_LIFECYCLE_CAPABILITY,
14569        NODE_MANAGED_WORKTREE_SPAWN_V2_CAPABILITY,
14570        NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY,
14571        NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY,
14572        NODE_HISTORY_CONTEXT_PACK_CAPABILITY,
14573        NODE_SESSION_RECORD_CONTEXT_EXPORT_CAPABILITY,
14574        NODE_NATIVE_SESSION_CATALOG_CAPABILITY,
14575        NODE_NATIVE_SESSION_CATALOG_PAGING_CAPABILITY,
14576        NODE_NATIVE_SESSION_INDEX_CAPABILITY,
14577        NODE_NATIVE_SESSION_PREVIEW_CAPABILITY,
14578        NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY,
14579        NODE_SESSION_TASK_CORRELATION_CAPABILITY,
14580    ]
14581        .into_iter()
14582        .map(|capability| {
14583            CapabilityId::new(capability)
14584                .map_err(|error| NodeServerError::Handshake(error.to_string()))
14585        })
14586        .collect::<Result<Vec<_>, _>>()?;
14587    #[cfg(windows)]
14588    let capabilities = {
14589        let mut capabilities = capabilities;
14590        capabilities.push(
14591            CapabilityId::new(NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY)
14592                .map_err(|error| NodeServerError::Handshake(error.to_string()))?,
14593        );
14594        capabilities
14595    };
14596    Ok(capabilities)
14597}
14598
14599fn request_uses_unnegotiated_capability(
14600    request: &NodeRequest,
14601    selected_capabilities: &[CapabilityId],
14602) -> bool {
14603    let selected = |required: &str| {
14604        selected_capabilities
14605            .iter()
14606            .any(|capability| capability.as_str() == required)
14607    };
14608    request
14609        .required_capability()
14610        .is_some_and(|required| !selected(required))
14611        || (request.requires_worktree_selection_capability()
14612            && !selected(NODE_WORKTREE_SELECTION_CAPABILITY))
14613        || (request.requires_spawn_spec_defaults_overrides_capability()
14614            && !selected(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY))
14615        || (request.requires_spawn_profile_revision_capability()
14616            && !selected(NODE_SPAWN_PROFILE_REVISION_CAPABILITY))
14617        || (request.requires_child_environment_profile_capability()
14618            && !selected(NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY))
14619        || (request.requires_session_bundle_materialization_capability()
14620            && !selected(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY))
14621        || (request.requires_history_context_pack_capability()
14622            && !selected(NODE_HISTORY_CONTEXT_PACK_CAPABILITY))
14623}
14624
14625fn snapshot_for_wire(
14626    shared: &NodeShared,
14627    include_provider_runtime_status: bool,
14628    include_open_provider_ids: bool,
14629    include_spawn_profiles: bool,
14630    include_managed_worktrees: bool,
14631    include_child_environment_profiles: bool,
14632    include_session_bundles: bool,
14633    include_history_context_packs: bool,
14634    include_agent_progress: bool,
14635    include_session_task_correlation: bool,
14636) -> NodeSnapshot {
14637    let mut snapshot = shared.snapshot();
14638    if !include_provider_runtime_status {
14639        snapshot.provider_runtime_statuses.clear();
14640    }
14641    if !include_open_provider_ids {
14642        project_snapshot_legacy_provider_ids(&mut snapshot);
14643    }
14644    if !include_managed_worktrees {
14645        snapshot.managed_worktrees.clear();
14646        for workspace in &mut snapshot.workspaces {
14647            workspace.managed_worktree_profiles = None;
14648        }
14649    }
14650    if let Some(inventory) = snapshot.launch_inventory.as_mut() {
14651        if !include_spawn_profiles {
14652            inventory.spawn_profiles = None;
14653        }
14654        if !include_session_bundles {
14655            inventory.bundles = None;
14656        }
14657        if inventory.spawn_profiles.is_none()
14658            && inventory.bundles.is_none()
14659            && inventory.network_allowlists.is_none()
14660        {
14661            snapshot.launch_inventory = None;
14662        }
14663    }
14664    if !include_child_environment_profiles {
14665        clear_snapshot_child_environment_profiles(&mut snapshot);
14666    }
14667    if !include_session_bundles {
14668        clear_snapshot_session_bundles(&mut snapshot);
14669    }
14670    project_snapshot_history_for_wire(&mut snapshot, include_history_context_packs);
14671    if !include_history_context_packs {
14672        clear_snapshot_context_packs(&mut snapshot);
14673    }
14674    if !include_agent_progress {
14675        snapshot.agent_progress.clear();
14676    }
14677    if !include_session_task_correlation {
14678        clear_snapshot_session_task_bindings(&mut snapshot);
14679    }
14680    snapshot
14681}
14682
14683fn clear_snapshot_session_task_bindings(snapshot: &mut NodeSnapshot) {
14684    for record in &mut snapshot.session_records {
14685        record.task_binding = None;
14686    }
14687}
14688
14689fn project_event_without_session_task_binding(
14690    mut envelope: NodeEventEnvelope,
14691) -> NodeEventEnvelope {
14692    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
14693        record.task_binding = None;
14694    }
14695    envelope
14696}
14697
14698fn project_response_without_session_task_binding(reply: &mut ResponseEnvelope) {
14699    let Ok(response) = reply.result.as_mut() else { return };
14700    match response {
14701        NodeResponse::Snapshot { snapshot, .. } => {
14702            clear_snapshot_session_task_bindings(snapshot);
14703        }
14704        NodeResponse::Resync { snapshot, events, .. } => {
14705            clear_snapshot_session_task_bindings(snapshot);
14706            for event in events {
14707                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
14708                    record.task_binding = None;
14709                }
14710            }
14711        }
14712        NodeResponse::SessionRecordUpdated { record }
14713        | NodeResponse::ProviderSessionIndexed { record }
14714        | NodeResponse::NativeSessionIndexed { record, .. }
14715        | NodeResponse::SessionRecordResumed { record, .. } => {
14716            record.task_binding = None;
14717        }
14718        _ => {}
14719    }
14720}
14721
14722fn project_response_without_agent_progress(reply: &mut ResponseEnvelope) {
14723    let Ok(response) = reply.result.as_mut() else { return };
14724    match response {
14725        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
14726            snapshot.agent_progress.clear();
14727        }
14728        _ => {}
14729    }
14730}
14731
14732fn project_event_without_managed_worktrees(
14733    mut envelope: NodeEventEnvelope,
14734) -> Option<NodeEventEnvelope> {
14735    if matches!(
14736        envelope.event,
14737        NodeEvent::ManagedWorktreeUpserted { .. }
14738            | NodeEvent::ManagedWorktreeRemoved { .. }
14739    ) {
14740        return None;
14741    }
14742    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
14743        workspace.managed_worktree_profiles = None;
14744    }
14745    Some(envelope)
14746}
14747
14748fn project_response_without_managed_worktrees(reply: &mut ResponseEnvelope) {
14749    if matches!(
14750        reply.result,
14751        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { .. })
14752            | Ok(NodeResponse::ManagedWorktreeCleanup { .. })
14753    ) {
14754        reply.result = Err(failure(
14755            NodeFailureCode::UnsupportedCapability,
14756            "managed worktree capability was not negotiated",
14757        ));
14758        return;
14759    }
14760    let Ok(response) = reply.result.as_mut() else { return };
14761    match response {
14762        NodeResponse::Snapshot { snapshot, .. } => {
14763            snapshot.managed_worktrees.clear();
14764            for workspace in &mut snapshot.workspaces {
14765                workspace.managed_worktree_profiles = None;
14766            }
14767        }
14768        NodeResponse::Resync { snapshot, events, .. } => {
14769            snapshot.managed_worktrees.clear();
14770            for workspace in &mut snapshot.workspaces {
14771                workspace.managed_worktree_profiles = None;
14772            }
14773            *events = std::mem::take(events)
14774                .into_iter()
14775                .filter_map(project_event_without_managed_worktrees)
14776                .collect();
14777        }
14778        NodeResponse::WorkspaceRegistered { workspace }
14779        | NodeResponse::StandaloneWorkspaceCreated { workspace }
14780        | NodeResponse::WorktreeCreated { workspace, .. } => {
14781            workspace.managed_worktree_profiles = None;
14782        }
14783        NodeResponse::WorkspaceInspected { inspection } => {
14784            inspection.git.managed_worktree = None;
14785        }
14786        _ => {}
14787    }
14788}
14789
14790fn project_negotiated_provider_ids(compatibility: &mut NegotiatedNodeCompatibility) {
14791    let includes_open_provider_ids = compatibility.capabilities.iter().any(|capability| {
14792        capability.as_str() == NODE_PROVIDER_ID_OPEN_CAPABILITY
14793    });
14794    if includes_open_provider_ids {
14795        return;
14796    }
14797    compatibility
14798        .provider_contracts
14799        .retain(|contract| provider_id_is_legacy(&contract.provider));
14800    compatibility
14801        .provider_adapter_contracts
14802        .retain(|contract| provider_id_is_legacy(&contract.provider));
14803}
14804
14805fn request_requires_open_provider_ids(shared: &NodeShared, request: &NodeRequest) -> bool {
14806    if let Some(spec) = match request {
14807        NodeRequest::SpawnSpec { spec }
14808        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14809        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14810        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14811        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14812        _ => None,
14813    } {
14814        return shared
14815            .resolve_spawn_spec(spec)
14816            .is_ok_and(|resolved| !provider_id_is_legacy(&resolved.provider));
14817    }
14818    request_requires_open_provider_ids_with(
14819        request,
14820        |session| shared.validate_address(session).ok(),
14821        |record_id| shared.record(record_id).ok().map(|record| record.provider),
14822    )
14823}
14824
14825fn request_requires_child_environment_profile(
14826    shared: &NodeShared,
14827    request: &NodeRequest,
14828) -> bool {
14829    if let Some(spec) = match request {
14830        NodeRequest::SpawnSpec { spec }
14831        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14832        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14833        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14834        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14835        _ => None,
14836    } {
14837        return shared
14838            .resolve_spawn_spec(spec)
14839            .is_ok_and(|resolved| resolved.environment_profile_id.is_some());
14840    }
14841    match request {
14842        NodeRequest::Resume { session, .. }
14843        | NodeRequest::Prompt { session, .. }
14844        | NodeRequest::Paste { session, .. }
14845        | NodeRequest::Input { session, .. }
14846        | NodeRequest::TerminalBytes { session, .. }
14847        | NodeRequest::TerminalControl { session, .. }
14848        | NodeRequest::Resize { session, .. }
14849        | NodeRequest::Interrupt { session }
14850        | NodeRequest::Stop { session, .. }
14851        | NodeRequest::Remove { session }
14852        | NodeRequest::ResolveInteraction { session, .. }
14853        | NodeRequest::SetSessionMode { session, .. }
14854        | NodeRequest::SetSessionConfigOption { session, .. }
14855        | NodeRequest::SetSessionModel { session, .. }
14856        | NodeRequest::DiscoverHistory { session, .. }
14857        | NodeRequest::LoadHistory { session, .. }
14858        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14859        | NodeRequest::ExportContextPack { session } => shared
14860            .session_bindings
14861            .lock()
14862            .unwrap_or_else(|poisoned| poisoned.into_inner())
14863            .get(&session.session.instance_id)
14864            .filter(|binding| {
14865                binding.workspace_id == session.workspace_id
14866                    && binding.generation == session.session.generation
14867            })
14868            .map_or(true, |binding| binding.environment_profile.is_some()),
14869        NodeRequest::RenameSessionRecord { record_id, .. }
14870        | NodeRequest::SetSessionTask { record_id, .. }
14871        | NodeRequest::ResumeSessionRecord { record_id, .. }
14872        | NodeRequest::ForgetSessionRecord { record_id } => shared
14873            .record(record_id)
14874            .map_or(true, |record| record.environment_profile.is_some()),
14875        NodeRequest::ArmHarnessMcpReservation { .. }
14876        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14877        | NodeRequest::ActivateHarnessMcpReservation { .. }
14878        | NodeRequest::AbortHarnessMcpReservation { .. }
14879        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14880        | NodeRequest::RejectHarnessMcpCall { .. }
14881        | NodeRequest::Snapshot
14882        | NodeRequest::Resync { .. }
14883        | NodeRequest::BeginDeliveryStage { .. }
14884        | NodeRequest::PutDeliveryBlobChunk { .. }
14885        | NodeRequest::CommitDeliveryStage { .. }
14886        | NodeRequest::AbortDeliveryStage { .. }
14887        | NodeRequest::BrowseHostDirectories { .. }
14888        | NodeRequest::InspectWorkspace { .. }
14889        | NodeRequest::ReadWorkspaceFile { .. }
14890        | NodeRequest::WriteWorkspaceFile { .. }
14891        | NodeRequest::CreateWorkspaceFile { .. }
14892        | NodeRequest::CreateWorkspaceDirectory { .. }
14893        | NodeRequest::ReadGitHistory { .. }
14894        | NodeRequest::ReadGitDiff { .. }
14895        | NodeRequest::AcquireController { .. }
14896        | NodeRequest::ReleaseController
14897        | NodeRequest::Spawn { .. }
14898        | NodeRequest::SpawnWithOptions { .. }
14899        | NodeRequest::SpawnSpec { .. }
14900        | NodeRequest::RegisterWorkspace { .. }
14901        | NodeRequest::CreateStandaloneWorkspace { .. }
14902        | NodeRequest::UnregisterWorkspace { .. }
14903        | NodeRequest::CreateWorktree { .. }
14904        | NodeRequest::RemoveWorktree { .. }
14905        | NodeRequest::SpawnManagedWorktree { .. }
14906        | NodeRequest::SpawnManagedWorktreeV2 { .. }
14907        | NodeRequest::CleanupManagedWorktree { .. }
14908        | NodeRequest::IndexProviderSession { .. }
14909        | NodeRequest::IndexNativeSession { .. }
14910        | NodeRequest::CatalogNativeSessions { .. }
14911        | NodeRequest::PageNativeSessions { .. }
14912        | NodeRequest::PreviewNativeSession { .. }
14913        | NodeRequest::PreviewSessionRecord { .. }
14914        | NodeRequest::ForgetContextPack { .. }
14915        | NodeRequest::ResolveDurableContextPack { .. }
14916        | NodeRequest::ReadContextPack { .. }
14917        | NodeRequest::Shutdown => false,
14918    }
14919}
14920
14921fn request_requires_session_bundle(shared: &NodeShared, request: &NodeRequest) -> bool {
14922    if let Some(spec) = match request {
14923        NodeRequest::SpawnSpec { spec }
14924        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
14925        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
14926        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
14927        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
14928        _ => None,
14929    } {
14930        return shared
14931            .resolve_spawn_spec(spec)
14932            .is_ok_and(|resolved| resolved.bundle_id.is_some());
14933    }
14934    match request {
14935        NodeRequest::Resume { session, .. }
14936        | NodeRequest::Prompt { session, .. }
14937        | NodeRequest::Paste { session, .. }
14938        | NodeRequest::Input { session, .. }
14939        | NodeRequest::TerminalBytes { session, .. }
14940        | NodeRequest::TerminalControl { session, .. }
14941        | NodeRequest::Resize { session, .. }
14942        | NodeRequest::Interrupt { session }
14943        | NodeRequest::Stop { session, .. }
14944        | NodeRequest::Remove { session }
14945        | NodeRequest::ResolveInteraction { session, .. }
14946        | NodeRequest::SetSessionMode { session, .. }
14947        | NodeRequest::SetSessionConfigOption { session, .. }
14948        | NodeRequest::SetSessionModel { session, .. }
14949        | NodeRequest::DiscoverHistory { session, .. }
14950        | NodeRequest::LoadHistory { session, .. }
14951        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
14952        | NodeRequest::ExportContextPack { session } => shared
14953            .session_bindings
14954            .lock()
14955            .unwrap_or_else(|poisoned| poisoned.into_inner())
14956            .get(&session.session.instance_id)
14957            .filter(|binding| {
14958                binding.workspace_id == session.workspace_id
14959                    && binding.generation == session.session.generation
14960            })
14961            .map_or(true, |binding| binding.bundle.is_some()),
14962        NodeRequest::RenameSessionRecord { record_id, .. }
14963        | NodeRequest::SetSessionTask { record_id, .. }
14964        | NodeRequest::ResumeSessionRecord { record_id, .. }
14965        | NodeRequest::ForgetSessionRecord { record_id } => shared
14966            .record(record_id)
14967            .map_or(true, |record| record.bundle.is_some()),
14968        NodeRequest::ArmHarnessMcpReservation { .. }
14969        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
14970        | NodeRequest::ActivateHarnessMcpReservation { .. }
14971        | NodeRequest::AbortHarnessMcpReservation { .. }
14972        | NodeRequest::PutHarnessMcpReplyChunk { .. }
14973        | NodeRequest::RejectHarnessMcpCall { .. }
14974        | NodeRequest::Snapshot
14975        | NodeRequest::Resync { .. }
14976        | NodeRequest::BeginDeliveryStage { .. }
14977        | NodeRequest::PutDeliveryBlobChunk { .. }
14978        | NodeRequest::CommitDeliveryStage { .. }
14979        | NodeRequest::AbortDeliveryStage { .. }
14980        | NodeRequest::BrowseHostDirectories { .. }
14981        | NodeRequest::InspectWorkspace { .. }
14982        | NodeRequest::ReadWorkspaceFile { .. }
14983        | NodeRequest::WriteWorkspaceFile { .. }
14984        | NodeRequest::CreateWorkspaceFile { .. }
14985        | NodeRequest::CreateWorkspaceDirectory { .. }
14986        | NodeRequest::ReadGitHistory { .. }
14987        | NodeRequest::ReadGitDiff { .. }
14988        | NodeRequest::AcquireController { .. }
14989        | NodeRequest::ReleaseController
14990        | NodeRequest::Spawn { .. }
14991        | NodeRequest::SpawnWithOptions { .. }
14992        | NodeRequest::SpawnSpec { .. }
14993        | NodeRequest::RegisterWorkspace { .. }
14994        | NodeRequest::CreateStandaloneWorkspace { .. }
14995        | NodeRequest::UnregisterWorkspace { .. }
14996        | NodeRequest::CreateWorktree { .. }
14997        | NodeRequest::RemoveWorktree { .. }
14998        | NodeRequest::SpawnManagedWorktree { .. }
14999        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15000        | NodeRequest::CleanupManagedWorktree { .. }
15001        | NodeRequest::IndexProviderSession { .. }
15002        | NodeRequest::IndexNativeSession { .. }
15003        | NodeRequest::CatalogNativeSessions { .. }
15004        | NodeRequest::PageNativeSessions { .. }
15005        | NodeRequest::PreviewNativeSession { .. }
15006        | NodeRequest::PreviewSessionRecord { .. }
15007        | NodeRequest::ForgetContextPack { .. }
15008        | NodeRequest::ResolveDurableContextPack { .. }
15009        | NodeRequest::ReadContextPack { .. }
15010        | NodeRequest::Shutdown => false,
15011    }
15012}
15013
15014fn request_requires_history_context_pack(shared: &NodeShared, request: &NodeRequest) -> bool {
15015    if request.requires_history_context_pack_capability() {
15016        return true;
15017    }
15018    if let Some(spec) = match request {
15019        NodeRequest::SpawnSpec { spec }
15020        | NodeRequest::SpawnSpecWithHarnessMcp { spec, .. }
15021        | NodeRequest::ArmHarnessMcpReservation { spawn_spec: spec, .. } => Some(spec),
15022        NodeRequest::SpawnManagedWorktree { request } => Some(&request.spawn_spec),
15023        NodeRequest::SpawnManagedWorktreeV2 { request } => Some(&request.spawn_spec),
15024        _ => None,
15025    } {
15026        return shared
15027            .resolve_spawn_spec(spec)
15028            .map_or(true, |resolved| resolved.context_id.is_some());
15029    }
15030    match request {
15031        NodeRequest::Resume { session, .. }
15032        | NodeRequest::Prompt { session, .. }
15033        | NodeRequest::Paste { session, .. }
15034        | NodeRequest::Input { session, .. }
15035        | NodeRequest::TerminalBytes { session, .. }
15036        | NodeRequest::TerminalControl { session, .. }
15037        | NodeRequest::Resize { session, .. }
15038        | NodeRequest::Interrupt { session }
15039        | NodeRequest::Stop { session, .. }
15040        | NodeRequest::Remove { session }
15041        | NodeRequest::ResolveInteraction { session, .. }
15042        | NodeRequest::SetSessionMode { session, .. }
15043        | NodeRequest::SetSessionConfigOption { session, .. }
15044        | NodeRequest::SetSessionModel { session, .. } => shared
15045            .session_bindings
15046            .lock()
15047            .unwrap_or_else(|poisoned| poisoned.into_inner())
15048            .get(&session.session.instance_id)
15049            .filter(|binding| {
15050                binding.workspace_id == session.workspace_id
15051                    && binding.generation == session.session.generation
15052            })
15053            .map_or(true, |binding| binding.context.is_some()),
15054        NodeRequest::RenameSessionRecord { record_id, .. }
15055        | NodeRequest::SetSessionTask { record_id, .. }
15056        | NodeRequest::ResumeSessionRecord { record_id, .. }
15057        | NodeRequest::ForgetSessionRecord { record_id } => shared
15058            .record(record_id)
15059            .map_or(true, |record| record.context.is_some()),
15060        NodeRequest::ArmHarnessMcpReservation { .. }
15061        | NodeRequest::SpawnSpecWithHarnessMcp { .. }
15062        | NodeRequest::ActivateHarnessMcpReservation { .. }
15063        | NodeRequest::AbortHarnessMcpReservation { .. }
15064        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15065        | NodeRequest::RejectHarnessMcpCall { .. }
15066        | NodeRequest::Snapshot
15067        | NodeRequest::Resync { .. }
15068        | NodeRequest::BeginDeliveryStage { .. }
15069        | NodeRequest::PutDeliveryBlobChunk { .. }
15070        | NodeRequest::CommitDeliveryStage { .. }
15071        | NodeRequest::AbortDeliveryStage { .. }
15072        | NodeRequest::BrowseHostDirectories { .. }
15073        | NodeRequest::InspectWorkspace { .. }
15074        | NodeRequest::ReadWorkspaceFile { .. }
15075        | NodeRequest::WriteWorkspaceFile { .. }
15076        | NodeRequest::CreateWorkspaceFile { .. }
15077        | NodeRequest::CreateWorkspaceDirectory { .. }
15078        | NodeRequest::ReadGitHistory { .. }
15079        | NodeRequest::ReadGitDiff { .. }
15080        | NodeRequest::AcquireController { .. }
15081        | NodeRequest::ReleaseController
15082        | NodeRequest::Spawn { .. }
15083        | NodeRequest::SpawnWithOptions { .. }
15084        | NodeRequest::SpawnSpec { .. }
15085        | NodeRequest::RegisterWorkspace { .. }
15086        | NodeRequest::CreateStandaloneWorkspace { .. }
15087        | NodeRequest::UnregisterWorkspace { .. }
15088        | NodeRequest::CreateWorktree { .. }
15089        | NodeRequest::RemoveWorktree { .. }
15090        | NodeRequest::SpawnManagedWorktree { .. }
15091        | NodeRequest::SpawnManagedWorktreeV2 { .. }
15092        | NodeRequest::CleanupManagedWorktree { .. }
15093        | NodeRequest::IndexProviderSession { .. }
15094        | NodeRequest::IndexNativeSession { .. }
15095        | NodeRequest::CatalogNativeSessions { .. }
15096        | NodeRequest::PageNativeSessions { .. }
15097        | NodeRequest::PreviewNativeSession { .. }
15098        | NodeRequest::PreviewSessionRecord { .. }
15099        | NodeRequest::DiscoverHistory { .. }
15100        | NodeRequest::LoadHistory { .. }
15101        | NodeRequest::ExportContextPackForSessionRecord { .. }
15102        | NodeRequest::ExportContextPack { .. }
15103        | NodeRequest::ForgetContextPack { .. }
15104        | NodeRequest::ResolveDurableContextPack { .. }
15105        | NodeRequest::ReadContextPack { .. }
15106        | NodeRequest::Shutdown => false,
15107    }
15108}
15109
15110fn request_requires_open_provider_ids_with(
15111    request: &NodeRequest,
15112    provider_for_session: impl Fn(&SessionAddress) -> Option<AgentId>,
15113    provider_for_record: impl Fn(&SessionRecordId) -> Option<AgentId>,
15114) -> bool {
15115    match request {
15116        NodeRequest::Spawn { provider, .. } | NodeRequest::SpawnWithOptions { provider, .. } => !provider_id_is_legacy(provider),
15117        NodeRequest::IndexProviderSession { provider, .. } => !provider_id_is_legacy(provider),
15118        NodeRequest::CatalogNativeSessions { route, .. }
15119        | NodeRequest::PageNativeSessions { route, .. } => {
15120            !provider_id_is_legacy(&route.provider)
15121        }
15122        NodeRequest::PreviewNativeSession { selection, .. }
15123        | NodeRequest::IndexNativeSession { selection, .. } => {
15124            !provider_id_is_legacy(&selection.route.provider)
15125        }
15126        NodeRequest::SpawnSpec { .. }
15127        | NodeRequest::SpawnSpecWithHarnessMcp { .. } => false,
15128        NodeRequest::SpawnManagedWorktree { .. }
15129        | NodeRequest::SpawnManagedWorktreeV2 { .. } => false,
15130        NodeRequest::Resume { session, .. }
15131        | NodeRequest::Prompt { session, .. }
15132        | NodeRequest::Paste { session, .. }
15133        | NodeRequest::Input { session, .. }
15134        | NodeRequest::TerminalBytes { session, .. }
15135        | NodeRequest::TerminalControl { session, .. }
15136        | NodeRequest::Resize { session, .. }
15137        | NodeRequest::Interrupt { session }
15138        | NodeRequest::Stop { session, .. }
15139        | NodeRequest::Remove { session }
15140        | NodeRequest::ResolveInteraction { session, .. }
15141        | NodeRequest::SetSessionMode { session, .. }
15142        | NodeRequest::SetSessionConfigOption { session, .. }
15143        | NodeRequest::SetSessionModel { session, .. }
15144        | NodeRequest::DiscoverHistory { session, .. }
15145        | NodeRequest::LoadHistory { session, .. }
15146        | NodeRequest::ExportContextPackForSessionRecord { session, .. }
15147        | NodeRequest::ExportContextPack { session } => provider_for_session(session)
15148            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15149        NodeRequest::RenameSessionRecord { record_id, .. }
15150        | NodeRequest::SetSessionTask { record_id, .. }
15151        | NodeRequest::ResumeSessionRecord { record_id, .. }
15152        | NodeRequest::ForgetSessionRecord { record_id } => provider_for_record(record_id)
15153            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15154        NodeRequest::PreviewSessionRecord { record_id, .. } => provider_for_record(record_id)
15155            .map_or(true, |provider| !provider_id_is_legacy(&provider)),
15156        NodeRequest::ArmHarnessMcpReservation { .. }
15157        | NodeRequest::ActivateHarnessMcpReservation { .. }
15158        | NodeRequest::AbortHarnessMcpReservation { .. }
15159        | NodeRequest::PutHarnessMcpReplyChunk { .. }
15160        | NodeRequest::RejectHarnessMcpCall { .. }
15161        | NodeRequest::Snapshot
15162        | NodeRequest::Resync { .. }
15163        | NodeRequest::BeginDeliveryStage { .. }
15164        | NodeRequest::PutDeliveryBlobChunk { .. }
15165        | NodeRequest::CommitDeliveryStage { .. }
15166        | NodeRequest::AbortDeliveryStage { .. }
15167        | NodeRequest::BrowseHostDirectories { .. }
15168        | NodeRequest::InspectWorkspace { .. }
15169        | NodeRequest::ReadWorkspaceFile { .. }
15170        | NodeRequest::WriteWorkspaceFile { .. }
15171        | NodeRequest::CreateWorkspaceFile { .. }
15172        | NodeRequest::CreateWorkspaceDirectory { .. }
15173        | NodeRequest::ReadGitHistory { .. }
15174        | NodeRequest::ReadGitDiff { .. }
15175        | NodeRequest::AcquireController { .. }
15176        | NodeRequest::ReleaseController
15177        | NodeRequest::RegisterWorkspace { .. }
15178        | NodeRequest::CreateStandaloneWorkspace { .. }
15179        | NodeRequest::UnregisterWorkspace { .. }
15180        | NodeRequest::CreateWorktree { .. }
15181        | NodeRequest::RemoveWorktree { .. }
15182        | NodeRequest::CleanupManagedWorktree { .. }
15183        | NodeRequest::Shutdown => false,
15184        NodeRequest::ForgetContextPack { .. } => true,
15185        // No session/record to cheaply resolve a provider from here either;
15186        // the resolved receipt can carry an open-id provider, so require the
15187        // same capability as ForgetContextPack unconditionally. `ReadContextPack`
15188        // is the same digest-keyed lookup one step further into the same
15189        // catalog, so it inherits the same conservative answer.
15190        NodeRequest::ResolveDurableContextPack { .. }
15191        | NodeRequest::ReadContextPack { .. } => true,
15192    }
15193}
15194
15195fn project_snapshot_legacy_provider_ids(snapshot: &mut NodeSnapshot) {
15196    snapshot.enabled_providers.retain(provider_id_is_legacy);
15197    snapshot.provider_runtime_statuses = ProviderRuntimeStatuses::new(
15198        snapshot
15199            .provider_runtime_statuses
15200            .as_slice()
15201            .iter()
15202            .filter(|status| provider_id_is_legacy(status.provider()))
15203            .cloned(),
15204    )
15205    .expect("a filtered provider runtime projection remains valid");
15206    snapshot
15207        .session_records
15208        .retain(|record| provider_id_is_legacy(&record.provider));
15209    for workspace in &mut snapshot.workspaces {
15210        workspace
15211            .sessions
15212            .retain(|session| provider_id_is_legacy(&session.agent_id));
15213    }
15214}
15215
15216fn clear_snapshot_child_environment_profiles(snapshot: &mut NodeSnapshot) {
15217    for record in &mut snapshot.session_records {
15218        record.environment_profile = None;
15219    }
15220    if let Some(profiles) = snapshot
15221        .launch_inventory
15222        .as_mut()
15223        .and_then(|inventory| inventory.spawn_profiles.as_mut())
15224    {
15225        for profile in profiles {
15226            profile.environment_profile = None;
15227        }
15228    }
15229}
15230
15231fn project_event_without_child_environment_profile(
15232    mut envelope: NodeEventEnvelope,
15233) -> NodeEventEnvelope {
15234    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15235        record.environment_profile = None;
15236    }
15237    envelope
15238}
15239
15240fn project_response_without_child_environment_profile(reply: &mut ResponseEnvelope) {
15241    let contains_environment_profile = match reply.result.as_ref() {
15242        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15243            receipt.environment_profile.is_some()
15244        }
15245        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15246            receipt.spawn.environment_profile.is_some()
15247        }
15248        _ => false,
15249    };
15250    if contains_environment_profile {
15251        reply.result = Err(failure(
15252            NodeFailureCode::UnsupportedCapability,
15253            "child environment profiles were not negotiated",
15254        ));
15255        return;
15256    }
15257    let Ok(response) = reply.result.as_mut() else {
15258        return;
15259    };
15260    match response {
15261        NodeResponse::Snapshot { snapshot, .. } => {
15262            clear_snapshot_child_environment_profiles(snapshot);
15263        }
15264        NodeResponse::Resync { snapshot, events, .. } => {
15265            clear_snapshot_child_environment_profiles(snapshot);
15266            for event in events {
15267                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15268                    record.environment_profile = None;
15269                }
15270            }
15271        }
15272        NodeResponse::SessionRecordUpdated { record }
15273        | NodeResponse::ProviderSessionIndexed { record }
15274        | NodeResponse::NativeSessionIndexed { record, .. }
15275        | NodeResponse::SessionRecordResumed { record, .. } => {
15276            record.environment_profile = None;
15277        }
15278        NodeResponse::Armed { .. }
15279        | NodeResponse::Spawned { .. }
15280        | NodeResponse::Activated { .. }
15281        | NodeResponse::Aborted { .. }
15282        | NodeResponse::ReplyChunkAccepted { .. }
15283        | NodeResponse::CallRejected { .. }
15284        | NodeResponse::DeliveryStageBegun { .. }
15285        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15286        | NodeResponse::DeliveryCommitted { .. }
15287        | NodeResponse::DeliveryStageAborted { .. }
15288        | NodeResponse::WorkspaceInspected { .. }
15289        | NodeResponse::HostDirectoriesBrowsed { .. }
15290        | NodeResponse::WorkspaceFileRead { .. }
15291        | NodeResponse::WorkspaceFileWritten { .. }
15292        | NodeResponse::WorkspaceFileCreated { .. }
15293        | NodeResponse::WorkspaceDirectoryCreated { .. }
15294        | NodeResponse::GitHistoryRead { .. }
15295        | NodeResponse::GitDiffRead { .. }
15296        | NodeResponse::Controller { .. }
15297        | NodeResponse::SpawnAccepted { .. }
15298        | NodeResponse::SpawnSpecAccepted { .. }
15299        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15300        | NodeResponse::ManagedWorktreeCleanup { .. }
15301        | NodeResponse::NativeSessionsCataloged { .. }
15302        | NodeResponse::NativeSessionsPaged { .. }
15303        | NodeResponse::NativeSessionPreviewed { .. }
15304        | NodeResponse::SessionRecordPreviewed { .. }
15305        | NodeResponse::HistoryDiscovered { .. }
15306        | NodeResponse::HistoryLoaded { .. }
15307        | NodeResponse::ContextPackForSessionRecordExported { .. }
15308        | NodeResponse::ContextPackExported { .. }
15309        | NodeResponse::ContextPackForgotten { .. }
15310        | NodeResponse::DurableContextPackResolved { .. }
15311        | NodeResponse::ContextPackBytesRead { .. }
15312        | NodeResponse::SessionRecordForgotten { .. }
15313        | NodeResponse::WorkspaceRegistered { .. }
15314        | NodeResponse::StandaloneWorkspaceCreated { .. }
15315        | NodeResponse::WorkspaceUnregistered { .. }
15316        | NodeResponse::WorktreeCreated { .. }
15317        | NodeResponse::WorktreeRemoved { .. }
15318        | NodeResponse::Accepted
15319        | NodeResponse::ShuttingDown => {}
15320    }
15321}
15322
15323fn clear_snapshot_session_bundles(snapshot: &mut NodeSnapshot) {
15324    for record in &mut snapshot.session_records {
15325        record.bundle = None;
15326    }
15327}
15328
15329fn project_event_without_session_bundle(
15330    mut envelope: NodeEventEnvelope,
15331) -> NodeEventEnvelope {
15332    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15333        record.bundle = None;
15334    }
15335    envelope
15336}
15337
15338fn project_response_without_session_bundle(reply: &mut ResponseEnvelope) {
15339    let contains_bundle = match reply.result.as_ref() {
15340        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.bundle.is_some(),
15341        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15342            receipt.spawn.bundle.is_some()
15343        }
15344        _ => false,
15345    };
15346    if contains_bundle {
15347        reply.result = Err(failure(
15348            NodeFailureCode::UnsupportedCapability,
15349            "session bundles were not negotiated",
15350        ));
15351        return;
15352    }
15353    let Ok(response) = reply.result.as_mut() else {
15354        return;
15355    };
15356    match response {
15357        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_session_bundles(snapshot),
15358        NodeResponse::Resync { snapshot, events, .. } => {
15359            clear_snapshot_session_bundles(snapshot);
15360            for event in events {
15361                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15362                    record.bundle = None;
15363                }
15364            }
15365        }
15366        NodeResponse::SessionRecordUpdated { record }
15367        | NodeResponse::ProviderSessionIndexed { record }
15368        | NodeResponse::NativeSessionIndexed { record, .. }
15369        | NodeResponse::SessionRecordResumed { record, .. } => record.bundle = None,
15370        NodeResponse::Armed { .. }
15371        | NodeResponse::Spawned { .. }
15372        | NodeResponse::Activated { .. }
15373        | NodeResponse::Aborted { .. }
15374        | NodeResponse::ReplyChunkAccepted { .. }
15375        | NodeResponse::CallRejected { .. }
15376        | NodeResponse::DeliveryStageBegun { .. }
15377        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15378        | NodeResponse::DeliveryCommitted { .. }
15379        | NodeResponse::DeliveryStageAborted { .. }
15380        | NodeResponse::WorkspaceInspected { .. }
15381        | NodeResponse::HostDirectoriesBrowsed { .. }
15382        | NodeResponse::WorkspaceFileRead { .. }
15383        | NodeResponse::WorkspaceFileWritten { .. }
15384        | NodeResponse::WorkspaceFileCreated { .. }
15385        | NodeResponse::WorkspaceDirectoryCreated { .. }
15386        | NodeResponse::GitHistoryRead { .. }
15387        | NodeResponse::GitDiffRead { .. }
15388        | NodeResponse::Controller { .. }
15389        | NodeResponse::SpawnAccepted { .. }
15390        | NodeResponse::SpawnSpecAccepted { .. }
15391        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15392        | NodeResponse::ManagedWorktreeCleanup { .. }
15393        | NodeResponse::NativeSessionsCataloged { .. }
15394        | NodeResponse::NativeSessionsPaged { .. }
15395        | NodeResponse::NativeSessionPreviewed { .. }
15396        | NodeResponse::SessionRecordPreviewed { .. }
15397        | NodeResponse::HistoryDiscovered { .. }
15398        | NodeResponse::HistoryLoaded { .. }
15399        | NodeResponse::ContextPackForSessionRecordExported { .. }
15400        | NodeResponse::ContextPackExported { .. }
15401        | NodeResponse::ContextPackForgotten { .. }
15402        | NodeResponse::DurableContextPackResolved { .. }
15403        | NodeResponse::ContextPackBytesRead { .. }
15404        | NodeResponse::SessionRecordForgotten { .. }
15405        | NodeResponse::WorkspaceRegistered { .. }
15406        | NodeResponse::StandaloneWorkspaceCreated { .. }
15407        | NodeResponse::WorkspaceUnregistered { .. }
15408        | NodeResponse::WorktreeCreated { .. }
15409        | NodeResponse::WorktreeRemoved { .. }
15410        | NodeResponse::Accepted
15411        | NodeResponse::ShuttingDown => {}
15412    }
15413}
15414
15415fn project_snapshot_history_for_wire(
15416    snapshot: &mut NodeSnapshot,
15417    include_history_metadata: bool,
15418) {
15419    for workspace in &mut snapshot.workspaces {
15420        for session in &mut workspace.sessions {
15421            if include_history_metadata {
15422                session.history.loaded = None;
15423            } else {
15424                session.history = Default::default();
15425            }
15426        }
15427    }
15428}
15429
15430fn project_event_history_for_wire(
15431    mut envelope: NodeEventEnvelope,
15432    include_history_metadata: bool,
15433) -> NodeEventEnvelope {
15434    if let NodeEvent::WorkspaceAdded { workspace } = &mut envelope.event {
15435        for session in &mut workspace.sessions {
15436            if include_history_metadata {
15437                session.history.loaded = None;
15438            } else {
15439                session.history = Default::default();
15440            }
15441        }
15442    }
15443    envelope
15444}
15445
15446fn project_response_history_for_wire(
15447    reply: &mut ResponseEnvelope,
15448    include_history_metadata: bool,
15449) {
15450    let Ok(response) = reply.result.as_mut() else {
15451        return;
15452    };
15453    match response {
15454        NodeResponse::Snapshot { snapshot, .. } => {
15455            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15456        }
15457        NodeResponse::Resync {
15458            snapshot, events, ..
15459        } => {
15460            project_snapshot_history_for_wire(snapshot, include_history_metadata);
15461            for event in events {
15462                *event = project_event_history_for_wire(
15463                    event.clone(),
15464                    include_history_metadata,
15465                );
15466            }
15467        }
15468        NodeResponse::WorkspaceRegistered { workspace }
15469        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15470        | NodeResponse::WorktreeCreated { workspace, .. } => {
15471            for session in &mut workspace.sessions {
15472                if include_history_metadata {
15473                    session.history.loaded = None;
15474                } else {
15475                    session.history = Default::default();
15476                }
15477            }
15478        }
15479        _ => {}
15480    }
15481}
15482
15483fn clear_snapshot_context_packs(snapshot: &mut NodeSnapshot) {
15484    for record in &mut snapshot.session_records {
15485        record.context_id = None;
15486        record.context = None;
15487        record.exported_context = None;
15488    }
15489}
15490
15491fn project_event_without_context_pack(
15492    mut envelope: NodeEventEnvelope,
15493) -> NodeEventEnvelope {
15494    if let NodeEvent::SessionRecordUpserted { record } = &mut envelope.event {
15495        record.context_id = None;
15496        record.context = None;
15497        record.exported_context = None;
15498    }
15499    envelope
15500}
15501
15502fn project_response_without_context_pack(reply: &mut ResponseEnvelope) {
15503    let contains_context = match reply.result.as_ref() {
15504        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => receipt.context.is_some(),
15505        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15506            receipt.spawn.context.is_some()
15507        }
15508        Ok(NodeResponse::HistoryDiscovered { .. })
15509        | Ok(NodeResponse::HistoryLoaded { .. })
15510        | Ok(NodeResponse::ContextPackForSessionRecordExported { .. })
15511        | Ok(NodeResponse::ContextPackExported { .. })
15512        | Ok(NodeResponse::ContextPackForgotten { .. })
15513        | Ok(NodeResponse::DurableContextPackResolved { .. }) => true,
15514        _ => false,
15515    };
15516    if contains_context {
15517        reply.result = Err(failure(
15518            NodeFailureCode::UnsupportedCapability,
15519            "history context packs were not negotiated",
15520        ));
15521        return;
15522    }
15523    let Ok(response) = reply.result.as_mut() else {
15524        return;
15525    };
15526    match response {
15527        NodeResponse::Snapshot { snapshot, .. } => clear_snapshot_context_packs(snapshot),
15528        NodeResponse::Resync {
15529            snapshot, events, ..
15530        } => {
15531            clear_snapshot_context_packs(snapshot);
15532            for event in events {
15533                if let NodeEvent::SessionRecordUpserted { record } = &mut event.event {
15534                    record.context_id = None;
15535                    record.context = None;
15536                    record.exported_context = None;
15537                }
15538            }
15539        }
15540        NodeResponse::SessionRecordUpdated { record }
15541        | NodeResponse::ProviderSessionIndexed { record }
15542        | NodeResponse::NativeSessionIndexed { record, .. }
15543        | NodeResponse::SessionRecordResumed { record, .. } => {
15544            record.context_id = None;
15545            record.context = None;
15546            record.exported_context = None;
15547        }
15548        _ => {}
15549    }
15550}
15551
15552fn project_response_legacy_provider_ids(shared: &NodeShared, reply: &mut ResponseEnvelope) {
15553    let contains_open_record = match reply.result.as_ref() {
15554        Ok(NodeResponse::SessionRecordUpdated { record })
15555        | Ok(NodeResponse::ProviderSessionIndexed { record })
15556        | Ok(NodeResponse::SessionRecordResumed { record, .. }) => {
15557            !provider_id_is_legacy(&record.provider)
15558        }
15559        Ok(NodeResponse::NativeSessionIndexed { selection, record }) => {
15560            !provider_id_is_legacy(&selection.route.provider)
15561                || !provider_id_is_legacy(&record.provider)
15562        }
15563        Ok(NodeResponse::SpawnSpecAccepted { receipt }) => {
15564            !provider_id_is_legacy(&receipt.provider)
15565        }
15566        Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt }) => {
15567            !provider_id_is_legacy(&receipt.spawn.provider)
15568        }
15569        Ok(NodeResponse::NativeSessionsCataloged { route, .. })
15570        | Ok(NodeResponse::NativeSessionsPaged { route, .. }) => {
15571            !provider_id_is_legacy(&route.provider)
15572        }
15573        Ok(NodeResponse::NativeSessionPreviewed { selection, .. }) => {
15574            !provider_id_is_legacy(&selection.route.provider)
15575        }
15576        _ => false,
15577    };
15578    if contains_open_record {
15579        reply.result = Err(failure(
15580            NodeFailureCode::UnsupportedCapability,
15581            "open provider IDs were not negotiated",
15582        ));
15583        return;
15584    }
15585    let Ok(response) = reply.result.as_mut() else {
15586        return;
15587    };
15588    match response {
15589        NodeResponse::Snapshot { snapshot, .. } => {
15590            project_snapshot_legacy_provider_ids(snapshot);
15591        }
15592        NodeResponse::Resync { snapshot, events, .. } => {
15593            project_snapshot_legacy_provider_ids(snapshot);
15594            *events = std::mem::take(events)
15595                .into_iter()
15596                .filter_map(|event| project_event_legacy_provider_ids(shared, event))
15597                .collect();
15598        }
15599        NodeResponse::WorkspaceRegistered { workspace }
15600        | NodeResponse::StandaloneWorkspaceCreated { workspace }
15601        | NodeResponse::WorktreeCreated { workspace, .. } => {
15602            workspace
15603                .sessions
15604                .retain(|session| provider_id_is_legacy(&session.agent_id));
15605        }
15606        NodeResponse::Armed { .. }
15607        | NodeResponse::Spawned { .. }
15608        | NodeResponse::Activated { .. }
15609        | NodeResponse::Aborted { .. }
15610        | NodeResponse::ReplyChunkAccepted { .. }
15611        | NodeResponse::CallRejected { .. }
15612        | NodeResponse::DeliveryStageBegun { .. }
15613        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15614        | NodeResponse::DeliveryCommitted { .. }
15615        | NodeResponse::DeliveryStageAborted { .. }
15616        | NodeResponse::WorkspaceInspected { .. }
15617        | NodeResponse::HostDirectoriesBrowsed { .. }
15618        | NodeResponse::WorkspaceFileRead { .. }
15619        | NodeResponse::WorkspaceFileWritten { .. }
15620        | NodeResponse::WorkspaceFileCreated { .. }
15621        | NodeResponse::WorkspaceDirectoryCreated { .. }
15622        | NodeResponse::GitHistoryRead { .. }
15623        | NodeResponse::GitDiffRead { .. }
15624        | NodeResponse::Controller { .. }
15625        | NodeResponse::SpawnAccepted { .. }
15626        | NodeResponse::SpawnSpecAccepted { .. }
15627        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15628        | NodeResponse::ManagedWorktreeCleanup { .. }
15629        | NodeResponse::NativeSessionsCataloged { .. }
15630        | NodeResponse::NativeSessionsPaged { .. }
15631        | NodeResponse::NativeSessionPreviewed { .. }
15632        | NodeResponse::SessionRecordPreviewed { .. }
15633        | NodeResponse::HistoryDiscovered { .. }
15634        | NodeResponse::HistoryLoaded { .. }
15635        | NodeResponse::ContextPackForSessionRecordExported { .. }
15636        | NodeResponse::ContextPackExported { .. }
15637        | NodeResponse::ContextPackForgotten { .. }
15638        | NodeResponse::DurableContextPackResolved { .. }
15639        | NodeResponse::ContextPackBytesRead { .. }
15640        | NodeResponse::SessionRecordUpdated { .. }
15641        | NodeResponse::ProviderSessionIndexed { .. }
15642        | NodeResponse::NativeSessionIndexed { .. }
15643        | NodeResponse::SessionRecordResumed { .. }
15644        | NodeResponse::SessionRecordForgotten { .. }
15645        | NodeResponse::WorkspaceUnregistered { .. }
15646        | NodeResponse::WorktreeRemoved { .. }
15647        | NodeResponse::Accepted
15648        | NodeResponse::ShuttingDown => {}
15649    }
15650}
15651
15652fn project_event_legacy_provider_ids(
15653    shared: &NodeShared,
15654    mut envelope: NodeEventEnvelope,
15655) -> Option<NodeEventEnvelope> {
15656    let include = match &mut envelope.event {
15657        NodeEvent::HarnessMcpReadCall { .. } => true,
15658        NodeEvent::Control { address, .. }
15659        | NodeEvent::AgentStream { address, .. }
15660        | NodeEvent::TerminalFrame { address, .. } => shared
15661            .validate_address(address)
15662            .is_ok_and(|provider| provider_id_is_legacy(&provider)),
15663        NodeEvent::WorkspaceAdded { workspace } => {
15664            workspace
15665                .sessions
15666                .retain(|session| provider_id_is_legacy(&session.agent_id));
15667            true
15668        }
15669        NodeEvent::SessionRecordUpserted { record } => {
15670            provider_id_is_legacy(&record.provider)
15671        }
15672        NodeEvent::SessionRecordHistorySummarized { .. } => false,
15673        NodeEvent::SessionRecordRemoved { .. } => shared
15674            .history
15675            .lock()
15676            .unwrap_or_else(|poisoned| poisoned.into_inner())
15677            .removed_record_providers
15678            .get(&envelope.sequence)
15679            .is_some_and(provider_id_is_legacy),
15680        NodeEvent::ControllerChanged { .. }
15681        | NodeEvent::ManagedWorktreeUpserted { .. }
15682        | NodeEvent::ManagedWorktreeRemoved { .. }
15683        | NodeEvent::WorkspaceRemoved { .. }
15684        | NodeEvent::ResyncRequired { .. } => true,
15685    };
15686    include.then_some(envelope)
15687}
15688
15689fn clear_response_provider_runtime_status(reply: &mut ResponseEnvelope) {
15690    let Ok(response) = reply.result.as_mut() else {
15691        return;
15692    };
15693    match response {
15694        NodeResponse::Snapshot { snapshot, .. } | NodeResponse::Resync { snapshot, .. } => {
15695            snapshot.provider_runtime_statuses.clear();
15696        }
15697        NodeResponse::Armed { .. }
15698        | NodeResponse::Spawned { .. }
15699        | NodeResponse::Activated { .. }
15700        | NodeResponse::Aborted { .. }
15701        | NodeResponse::ReplyChunkAccepted { .. }
15702        | NodeResponse::CallRejected { .. }
15703        | NodeResponse::DeliveryStageBegun { .. }
15704        | NodeResponse::DeliveryBlobChunkAccepted { .. }
15705        | NodeResponse::DeliveryCommitted { .. }
15706        | NodeResponse::DeliveryStageAborted { .. }
15707        | NodeResponse::WorkspaceInspected { .. }
15708        | NodeResponse::HostDirectoriesBrowsed { .. }
15709        | NodeResponse::WorkspaceFileRead { .. }
15710        | NodeResponse::WorkspaceFileWritten { .. }
15711        | NodeResponse::WorkspaceFileCreated { .. }
15712        | NodeResponse::WorkspaceDirectoryCreated { .. }
15713        | NodeResponse::GitHistoryRead { .. }
15714        | NodeResponse::GitDiffRead { .. }
15715        | NodeResponse::Controller { .. }
15716        | NodeResponse::SpawnAccepted { .. }
15717        | NodeResponse::SpawnSpecAccepted { .. }
15718        | NodeResponse::ManagedWorktreeSpawnAccepted { .. }
15719        | NodeResponse::ManagedWorktreeCleanup { .. }
15720        | NodeResponse::NativeSessionsCataloged { .. }
15721        | NodeResponse::NativeSessionsPaged { .. }
15722        | NodeResponse::NativeSessionPreviewed { .. }
15723        | NodeResponse::SessionRecordPreviewed { .. }
15724        | NodeResponse::HistoryDiscovered { .. }
15725        | NodeResponse::HistoryLoaded { .. }
15726        | NodeResponse::ContextPackForSessionRecordExported { .. }
15727        | NodeResponse::ContextPackExported { .. }
15728        | NodeResponse::ContextPackForgotten { .. }
15729        | NodeResponse::DurableContextPackResolved { .. }
15730        | NodeResponse::ContextPackBytesRead { .. }
15731        | NodeResponse::SessionRecordUpdated { .. }
15732        | NodeResponse::ProviderSessionIndexed { .. }
15733        | NodeResponse::NativeSessionIndexed { .. }
15734        | NodeResponse::SessionRecordResumed { .. }
15735        | NodeResponse::SessionRecordForgotten { .. }
15736        | NodeResponse::WorkspaceRegistered { .. }
15737        | NodeResponse::StandaloneWorkspaceCreated { .. }
15738        | NodeResponse::WorkspaceUnregistered { .. }
15739        | NodeResponse::WorktreeCreated { .. }
15740        | NodeResponse::WorktreeRemoved { .. }
15741        | NodeResponse::Accepted
15742        | NodeResponse::ShuttingDown => {}
15743    }
15744}
15745
15746struct ControllerReleaseGuard {
15747    shared: Arc<NodeShared>,
15748    connection_id: u64,
15749}
15750
15751impl Drop for ControllerReleaseGuard {
15752    fn drop(&mut self) {
15753        self.shared.release_controller(self.connection_id);
15754    }
15755}
15756
15757struct AbortTaskOnDrop(AbortHandle);
15758
15759impl Drop for AbortTaskOnDrop {
15760    fn drop(&mut self) {
15761        self.0.abort();
15762    }
15763}
15764
15765/// Reads an unsigned env-config override, following the pattern of every
15766/// other bounded knob in this module: missing/unparsable/zero falls back to
15767/// `default`, and the resolved value is always clamped to `max` so a bad
15768/// override cannot reopen a bound this budget exists to enforce.
15769fn env_config_bounded(name: &str, default: u64, max: u64) -> u64 {
15770    std::env::var(name)
15771        .ok()
15772        .and_then(|value| value.parse::<u64>().ok())
15773        .filter(|value| *value > 0)
15774        .unwrap_or(default)
15775        .min(max)
15776}
15777
15778fn workspace_inspection_time_budget_ms() -> u64 {
15779    env_config_bounded(
15780        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_BUDGET_MS",
15781        WORKSPACE_INSPECTION_TIME_BUDGET_MS_DEFAULT,
15782        WORKSPACE_INSPECTION_TIME_BUDGET_MS_MAX,
15783    )
15784}
15785
15786fn workspace_inspection_entry_cap() -> usize {
15787    env_config_bounded(
15788        "GATE4AGENT_NODE_WORKSPACE_INSPECTION_ENTRY_CAP",
15789        WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT as u64,
15790        WORKSPACE_INSPECTION_ENTRY_CAP_MAX as u64,
15791    ) as usize
15792}
15793
15794/// Tracks the shared walk-side budget across the whole recursive walk: a
15795/// wall-clock deadline (shared with the git phase that runs after the walk)
15796/// and a cap on directory entries visited, whether or not they were pushed
15797/// to the response. Cheap to check per entry — one `Instant::now()` and one
15798/// integer compare.
15799struct WorkspaceWalkBudget {
15800    deadline: Instant,
15801    entry_cap: usize,
15802    entries_visited: u64,
15803    time_budget_exceeded: bool,
15804    entry_cap_exceeded: bool,
15805}
15806
15807impl WorkspaceWalkBudget {
15808    fn new(deadline: Instant, entry_cap: usize) -> Self {
15809        Self {
15810            deadline,
15811            entry_cap,
15812            entries_visited: 0,
15813            time_budget_exceeded: false,
15814            entry_cap_exceeded: false,
15815        }
15816    }
15817
15818    fn is_exhausted(&self) -> bool {
15819        self.time_budget_exceeded || self.entry_cap_exceeded
15820    }
15821
15822    /// Records one visited directory entry (pushed to the response or not)
15823    /// and reports whether the walk must stop now.
15824    fn record_visit(&mut self) -> bool {
15825        self.entries_visited += 1;
15826        if self.entries_visited as usize >= self.entry_cap {
15827            self.entry_cap_exceeded = true;
15828        }
15829        if Instant::now() >= self.deadline {
15830            self.time_budget_exceeded = true;
15831        }
15832        self.is_exhausted()
15833    }
15834}
15835
15836fn collect_workspace_entries(
15837    root: &Path,
15838    deadline: Instant,
15839    entry_cap: usize,
15840) -> (Vec<WorkspaceEntry>, bool, WorkspaceWalkBudget) {
15841    let mut entries = Vec::new();
15842    let mut truncated = false;
15843    let mut budget = WorkspaceWalkBudget::new(deadline, entry_cap);
15844    walk_workspace_directory(root, None, 0, &mut entries, &mut truncated, &mut budget);
15845    // DFS pre-order emits `x/child` between prefix-named siblings `x` and
15846    // `x-y` ('-' sorts below '/'), while the bounded operator projections
15847    // require strict global byte order over relative paths.
15848    entries.sort_by(|left, right| left.relative_path.as_utf8().cmp(&right.relative_path.as_utf8()));
15849    (entries, truncated, budget)
15850}
15851
15852/// Builds the additive `WorkspaceInspectionTruncationV1` for an
15853/// `inspect_workspace` response — `None` once the walk and git phase both
15854/// completed inside their shared budget.
15855fn workspace_inspection_truncation(
15856    walk_budget: &WorkspaceWalkBudget,
15857    git_time_budget_exceeded: bool,
15858    elapsed: Duration,
15859) -> Option<WorkspaceInspectionTruncationV1> {
15860    if !walk_budget.time_budget_exceeded
15861        && !walk_budget.entry_cap_exceeded
15862        && !git_time_budget_exceeded
15863    {
15864        return None;
15865    }
15866    Some(WorkspaceInspectionTruncationV1 {
15867        walk_time_budget_exceeded: walk_budget.time_budget_exceeded,
15868        walk_entry_cap_exceeded: walk_budget.entry_cap_exceeded,
15869        git_time_budget_exceeded,
15870        entries_visited: walk_budget.entries_visited,
15871        elapsed_ms: u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX),
15872    })
15873}
15874
15875fn walk_workspace_directory(
15876    directory: &Path,
15877    relative_directory: Option<&RepositoryPath>,
15878    depth: usize,
15879    entries: &mut Vec<WorkspaceEntry>,
15880    truncated: &mut bool,
15881    budget: &mut WorkspaceWalkBudget,
15882) {
15883    if budget.is_exhausted() {
15884        *truncated = true;
15885        return;
15886    }
15887    let read_dir = match std::fs::read_dir(directory) {
15888        Ok(read_dir) => read_dir,
15889        Err(_) => {
15890            *truncated = true;
15891            return;
15892        }
15893    };
15894    let mut children = Vec::new();
15895    for child in read_dir {
15896        match child {
15897            Ok(child) => children.push(child),
15898            Err(_) => *truncated = true,
15899        }
15900    }
15901    children.sort_by_key(|child| child.file_name());
15902    for child in children {
15903        if budget.record_visit() {
15904            *truncated = true;
15905            return;
15906        }
15907        if entries.len() >= WORKSPACE_TREE_MAX_ENTRIES {
15908            *truncated = true;
15909            return;
15910        }
15911        let file_type = match child.file_type() {
15912            Ok(file_type) => file_type,
15913            Err(_) => {
15914                *truncated = true;
15915                continue;
15916            }
15917        };
15918        if file_type.is_symlink() {
15919            continue;
15920        }
15921        let name = match child.file_name().into_string() {
15922            Ok(name) => name,
15923            Err(_) => {
15924                *truncated = true;
15925                continue;
15926            }
15927        };
15928        if file_type.is_dir() && is_skipped_workspace_directory(&name, &child.path()) {
15929            continue;
15930        }
15931        let relative_path = match relative_directory {
15932            None => windows_repository_path(name),
15933            Some(parent) => parent
15934                .as_utf8()
15935                .and_then(|parent| windows_repository_path(format!("{parent}/{name}"))),
15936        };
15937        let Some(relative_path) = relative_path else {
15938            *truncated = true;
15939            continue;
15940        };
15941        let kind = if file_type.is_dir() {
15942            WorkspaceEntryKind::Directory
15943        } else if file_type.is_file() {
15944            WorkspaceEntryKind::File
15945        } else {
15946            continue;
15947        };
15948        entries.push(WorkspaceEntry {
15949            relative_path: relative_path.clone(),
15950            kind,
15951        });
15952        if file_type.is_dir() {
15953            if depth + 1 < WORKSPACE_TREE_MAX_DEPTH {
15954                walk_workspace_directory(
15955                    &child.path(),
15956                    Some(&relative_path),
15957                    depth + 1,
15958                    entries,
15959                    truncated,
15960                    budget,
15961                );
15962            } else {
15963                *truncated = true;
15964            }
15965        }
15966    }
15967}
15968
15969fn windows_repository_path(value: String) -> Option<RepositoryPath> {
15970    if value.contains('\\') {
15971        return None;
15972    }
15973    RepositoryPath::utf8(value).ok()
15974}
15975
15976fn windows_repository_path_from_bytes(value: &[u8]) -> Option<RepositoryPath> {
15977    windows_repository_path(std::str::from_utf8(value).ok()?.to_owned())
15978}
15979
15980/// Excludes a directory from the workspace tree walk. The three literal
15981/// names are the common convention-based cases; the `CACHEDIR.TAG` check
15982/// beneath them catches every build-output directory regardless of name
15983/// (cargo writes `CACHEDIR.TAG` into every `--target-dir` it creates, so
15984/// this covers `target-a2`, `target-a2-tui`, and any future renamed build
15985/// directory without maintaining a name heuristic list). The name check
15986/// runs first and returns early so a directory rejected by name never pays
15987/// for the extra `CACHEDIR.TAG` stat.
15988fn is_skipped_workspace_directory(name: &str, path: &Path) -> bool {
15989    if name.eq_ignore_ascii_case(".git")
15990        || name.eq_ignore_ascii_case("target")
15991        || name.eq_ignore_ascii_case("node_modules")
15992    {
15993        return true;
15994    }
15995    path.join("CACHEDIR.TAG").is_file()
15996}
15997
15998fn context_pack_source_status_is_usable(status: &SessionStatus) -> bool {
15999    matches!(
16000        status,
16001        SessionStatus::Running | SessionStatus::Exited { exit_code: Some(0) }
16002    )
16003}
16004
16005#[derive(Clone, Debug, Eq, PartialEq)]
16006enum ContextPackRepositoryHead {
16007    NotRepository,
16008    Unborn,
16009    Commit(String),
16010}
16011
16012#[derive(Clone, Debug, Eq, PartialEq)]
16013struct ContextPackRepositoryObservation {
16014    head: ContextPackRepositoryHead,
16015    git: GitSnapshot,
16016    files: Vec<ContextPackRepositoryFileSource>,
16017}
16018
16019fn stable_context_pack_repository(
16020    first: ContextPackRepositoryObservation,
16021    second: ContextPackRepositoryObservation,
16022) -> Result<ContextPackRepository, NodeFailure> {
16023    if first != second {
16024        return Err(failure(
16025            NodeFailureCode::ContextPackMaterializationFailed,
16026            "context repository changed during bounded capture",
16027        ));
16028    }
16029    Ok(ContextPackRepository::from_sources(
16030        &second.git,
16031        second.files,
16032    ))
16033}
16034
16035/// Appends a "skipped: budget exhausted" diagnostic and returns `true` once
16036/// (and only once) the shared walk+git `deadline` has already passed. Every
16037/// remaining phase in `inspect_git_workspace` checks this immediately
16038/// before issuing its own git call, so a budget spent by the walk (or by an
16039/// earlier git call) skips the rest of the chain instead of paying for it
16040/// anyway.
16041fn git_budget_exhausted(snapshot: &mut GitSnapshot, deadline: Instant, stage: &str) -> bool {
16042    if Instant::now() < deadline {
16043        return false;
16044    }
16045    append_git_diagnostic(
16046        snapshot,
16047        &format!("{stage} skipped: workspace inspection time budget exhausted"),
16048    );
16049    true
16050}
16051
16052/// Deadline-aware sibling of `run_git_bounded`, used only by
16053/// `inspect_git_workspace`'s six-call chain: caps the per-call timeout to
16054/// whatever remains of the shared `deadline` (never more than
16055/// `GIT_COMMAND_TIMEOUT_MS`), so a slow walk phase that already spent most
16056/// of the budget cannot still hand a full per-call timeout to every git
16057/// probe behind it. `inspect_git_workspace` already guards each call site
16058/// with `git_budget_exhausted`, so `deadline` is guaranteed not to have
16059/// passed yet here.
16060async fn run_git_bounded_with_deadline(
16061    root: &str,
16062    arguments: &[&str],
16063    output_limit: usize,
16064    deadline: Instant,
16065) -> io::Result<GitCommandOutput> {
16066    let remaining = deadline.checked_duration_since(Instant::now());
16067    let timeout_ms = remaining
16068        .and_then(|remaining| u64::try_from(remaining.as_millis()).ok())
16069        .unwrap_or(0)
16070        .clamp(1, GIT_COMMAND_TIMEOUT_MS);
16071    run_git_read_bounded(root, arguments, output_limit, timeout_ms).await
16072}
16073
16074/// Runs the bounded six-call git probe chain used by `inspect_workspace`.
16075/// `deadline` is the same absolute instant the preceding directory walk was
16076/// bounded by, so time already spent walking counts against what remains
16077/// for git enrichment. Returns the resulting `GitSnapshot` plus whether the
16078/// shared budget (not an individual per-call timeout) is what cut the git
16079/// phase short.
16080async fn inspect_git_workspace(root: &str, deadline: Instant) -> (GitSnapshot, bool) {
16081    let mut snapshot = GitSnapshot {
16082        is_repository: false,
16083        branch: None,
16084        status: Vec::new(),
16085        recent_commits: Vec::new(),
16086        worktrees: Vec::new(),
16087        managed_worktree: None,
16088        truncated: false,
16089        diagnostic: None,
16090    };
16091    if git_budget_exhausted(&mut snapshot, deadline, "git repository probe") {
16092        return (snapshot, true);
16093    }
16094    let repository = match run_git_bounded_with_deadline(
16095        root,
16096        &["rev-parse", "--is-inside-work-tree"],
16097        4 * 1_024,
16098        deadline,
16099    )
16100    .await
16101    {
16102        Ok(output) => output,
16103        Err(error) => {
16104            append_git_diagnostic(
16105                &mut snapshot,
16106                &format!("git inspection unavailable: {error}"),
16107            );
16108            return (snapshot, false);
16109        }
16110    };
16111    snapshot.truncated |= repository.truncated;
16112    if repository.timed_out {
16113        append_git_diagnostic(&mut snapshot, "git repository probe timed out");
16114        return (snapshot, false);
16115    }
16116    if !repository.success
16117        || String::from_utf8_lossy(&repository.stdout).trim() != "true"
16118    {
16119        let stderr = String::from_utf8_lossy(&repository.stderr);
16120        if !stderr.to_ascii_lowercase().contains("not a git repository") {
16121            append_git_diagnostic(&mut snapshot, stderr.trim());
16122        }
16123        return (snapshot, false);
16124    }
16125    snapshot.is_repository = true;
16126
16127    if git_budget_exhausted(&mut snapshot, deadline, "git branch query") {
16128        return (snapshot, true);
16129    }
16130    match run_git_bounded_with_deadline(root, &["branch", "--show-current"], 4 * 1_024, deadline).await {
16131        Ok(output) => {
16132            snapshot.truncated |= output.truncated;
16133            if output.timed_out {
16134                append_git_diagnostic(&mut snapshot, "git branch query timed out");
16135            } else if output.success {
16136                let branch = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16137                if !branch.is_empty() {
16138                    snapshot.branch = Some(truncate_git_field(
16139                        &branch,
16140                        MAX_REPOSITORY_PATH_BYTES,
16141                        &mut snapshot.truncated,
16142                    ));
16143                }
16144            }
16145        }
16146        Err(error) => append_git_diagnostic(
16147            &mut snapshot,
16148            &format!("git branch query failed: {error}"),
16149        ),
16150    }
16151    if snapshot.branch.is_none() && !git_budget_exhausted(&mut snapshot, deadline, "git detached-HEAD probe") {
16152        if let Ok(output) = run_git_bounded_with_deadline(root, &["rev-parse", "--short", "HEAD"], 4 * 1_024, deadline).await {
16153            snapshot.truncated |= output.truncated;
16154            if output.success && !output.timed_out {
16155                let head = String::from_utf8_lossy(&output.stdout).trim().to_owned();
16156                if !head.is_empty() {
16157                    snapshot.branch = Some(format!("detached:{head}"));
16158                }
16159            }
16160        }
16161    }
16162
16163    if git_budget_exhausted(&mut snapshot, deadline, "git status") {
16164        return (snapshot, true);
16165    }
16166    match run_git_bounded_with_deadline(
16167        root,
16168        &[
16169            "status",
16170            "--porcelain=v1",
16171            "-z",
16172            "--renames",
16173            "--untracked-files=normal",
16174            "--",
16175            ".",
16176        ],
16177        GIT_OUTPUT_MAX_BYTES,
16178        deadline,
16179    )
16180    .await
16181    {
16182        Ok(output) => {
16183            snapshot.truncated |= output.truncated;
16184            if output.timed_out {
16185                append_git_diagnostic(&mut snapshot, "git status timed out");
16186            } else if output.success {
16187                parse_git_status(&output.stdout, &mut snapshot);
16188            } else {
16189                append_git_diagnostic(
16190                    &mut snapshot,
16191                    String::from_utf8_lossy(&output.stderr).trim(),
16192                );
16193            }
16194        }
16195        Err(error) => append_git_diagnostic(
16196            &mut snapshot,
16197            &format!("git status failed: {error}"),
16198        ),
16199    }
16200
16201    if git_budget_exhausted(&mut snapshot, deadline, "git log") {
16202        return (snapshot, true);
16203    }
16204    let log_limit = (GIT_COMMIT_MAX_ENTRIES + 1).to_string();
16205    match run_git_bounded_with_deadline(
16206        root,
16207        &["log", "-n", &log_limit, "--pretty=format:%H%x1f%s", "--", "."],
16208        16 * 1_024,
16209        deadline,
16210    )
16211    .await
16212    {
16213        Ok(output) => {
16214            snapshot.truncated |= output.truncated;
16215            if output.timed_out {
16216                append_git_diagnostic(&mut snapshot, "git log timed out");
16217            } else if output.success {
16218                parse_git_commits(&output.stdout, &mut snapshot);
16219            } else {
16220                let stderr = String::from_utf8_lossy(&output.stderr);
16221                if !stderr.to_ascii_lowercase().contains("does not have any commits") {
16222                    append_git_diagnostic(&mut snapshot, stderr.trim());
16223                }
16224            }
16225        }
16226        Err(error) => append_git_diagnostic(
16227            &mut snapshot,
16228            &format!("git log failed: {error}"),
16229        ),
16230    }
16231
16232    if git_budget_exhausted(&mut snapshot, deadline, "git worktree list") {
16233        return (snapshot, true);
16234    }
16235    match list_git_worktrees_with_deadline(root, deadline).await {
16236        Ok(worktrees) => {
16237            snapshot.worktrees = worktrees.into_iter().map(protocol_worktree).collect()
16238        }
16239        Err(error) => append_git_diagnostic(
16240            &mut snapshot,
16241            &format!("git worktree list failed: {}", error.message),
16242        ),
16243    }
16244    (snapshot, false)
16245}
16246
16247fn parse_git_status(output: &[u8], snapshot: &mut GitSnapshot) {
16248    let mut remaining = output;
16249    while !remaining.is_empty() {
16250        if snapshot.status.len() >= GIT_STATUS_MAX_ENTRIES {
16251            snapshot.truncated = true;
16252            break;
16253        }
16254        let Some(record_end) = remaining.iter().position(|byte| *byte == 0) else {
16255            snapshot.truncated = true;
16256            break;
16257        };
16258        let record = &remaining[..record_end];
16259        remaining = &remaining[record_end + 1..];
16260        let has_previous_path = record
16261            .get(..2)
16262            .is_some_and(|status| status.iter().any(|byte| matches!(*byte, b'R' | b'C')));
16263        let previous_path_bytes = if has_previous_path {
16264            let Some(previous_end) = remaining.iter().position(|byte| *byte == 0) else {
16265                snapshot.truncated = true;
16266                break;
16267            };
16268            let previous = &remaining[..previous_end];
16269            remaining = &remaining[previous_end + 1..];
16270            Some(previous)
16271        } else {
16272            None
16273        };
16274        if record.len() < 4
16275            || record[2] != b' '
16276            || !is_git_status_code(record[0])
16277            || !is_git_status_code(record[1])
16278        {
16279            snapshot.truncated = true;
16280            continue;
16281        }
16282        let Some(path) = windows_repository_path_from_bytes(&record[3..]) else {
16283            snapshot.truncated = true;
16284            continue;
16285        };
16286        let previous_path = match previous_path_bytes {
16287            Some(previous) => {
16288                let Some(previous) = windows_repository_path_from_bytes(previous) else {
16289                    snapshot.truncated = true;
16290                    continue;
16291                };
16292                Some(previous)
16293            }
16294            None => None,
16295        };
16296        snapshot.status.push(GitStatusEntry {
16297            index_status: (record[0] as char).to_string(),
16298            worktree_status: (record[1] as char).to_string(),
16299            path,
16300            previous_path,
16301        });
16302    }
16303}
16304
16305fn is_git_status_code(byte: u8) -> bool {
16306    matches!(byte, b' ' | b'M' | b'T' | b'A' | b'D' | b'R' | b'C' | b'U' | b'?' | b'!')
16307}
16308
16309fn parse_git_commits(output: &[u8], snapshot: &mut GitSnapshot) {
16310    for line in String::from_utf8_lossy(output).lines() {
16311        let Some((id, summary)) = line.split_once('\u{1f}') else {
16312            continue;
16313        };
16314        if snapshot.recent_commits.len() >= GIT_COMMIT_MAX_ENTRIES {
16315            snapshot.truncated = true;
16316            break;
16317        }
16318        let id = truncate_git_field(id, 64, &mut snapshot.truncated);
16319        let summary = truncate_git_field(summary, 512, &mut snapshot.truncated);
16320        snapshot.recent_commits.push(GitCommitSummary { id, summary });
16321    }
16322}
16323
16324fn truncate_git_field(value: &str, max_bytes: usize, truncated: &mut bool) -> String {
16325    if value.len() <= max_bytes {
16326        return value.to_owned();
16327    }
16328    *truncated = true;
16329    let mut boundary = max_bytes;
16330    while boundary > 0 && !value.is_char_boundary(boundary) {
16331        boundary -= 1;
16332    }
16333    value[..boundary].to_owned()
16334}
16335
16336fn append_git_diagnostic(snapshot: &mut GitSnapshot, message: &str) {
16337    if message.is_empty() {
16338        return;
16339    }
16340    let normalized = message
16341        .chars()
16342        .map(|character| if character.is_control() { ' ' } else { character })
16343        .collect::<String>();
16344    let mut diagnostic = snapshot.diagnostic.take().unwrap_or_default();
16345    if !diagnostic.is_empty() {
16346        diagnostic.push_str("; ");
16347    }
16348    diagnostic.push_str(normalized.trim());
16349    snapshot.diagnostic = Some(truncate_git_field(
16350        &diagnostic,
16351        GIT_DIAGNOSTIC_MAX_BYTES,
16352        &mut snapshot.truncated,
16353    ));
16354}
16355
16356async fn run_git_bounded(
16357    root: &str,
16358    arguments: &[&str],
16359    output_limit: usize,
16360) -> io::Result<GitCommandOutput> {
16361    run_git_read_bounded(root, arguments, output_limit, GIT_COMMAND_TIMEOUT_MS).await
16362}
16363
16364async fn read_git_history_bounded(
16365    root: &str,
16366    path: Option<&RepositoryPath>,
16367    before: Option<&GitObjectId>,
16368    limit: u16,
16369) -> Result<GitHistoryPage, NodeFailure> {
16370    let path = path
16371        .map(|path| {
16372            path.as_utf8().ok_or_else(|| {
16373                failure(
16374                    NodeFailureCode::InvalidRepositoryPath,
16375                    "git history path must use UTF-8",
16376                )
16377            })
16378        })
16379        .transpose()?
16380        .unwrap_or(".");
16381    let count = (usize::from(limit) + 1).to_string();
16382    let start = before
16383        .map(|revision| format!("{}^", revision.as_str()))
16384        .unwrap_or_else(|| "HEAD".to_owned());
16385    let arguments = [
16386        "--no-pager",
16387        "log",
16388        "--no-decorate",
16389        "--date=iso-strict",
16390        "--pretty=format:%H%x1f%P%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1e",
16391        "-n",
16392        count.as_str(),
16393        start.as_str(),
16394        "--",
16395        path,
16396    ];
16397    let output = run_git_bounded(root, &arguments, 256 * 1_024)
16398        .await
16399        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git history command failed"))?;
16400    if output.timed_out {
16401        return Err(failure(NodeFailureCode::GitReadTimedOut, "git history exceeded its bounded deadline"));
16402    }
16403    if !output.success {
16404        return Err(failure(NodeFailureCode::GitReadFailed, "git history could not be read"));
16405    }
16406    let mut commits = parse_git_history(&output.stdout);
16407    let has_more = commits.len() > usize::from(limit) || output.truncated;
16408    commits.truncate(usize::from(limit));
16409    let next_before = has_more.then(|| commits.last().map(|commit| commit.id.clone())).flatten();
16410    Ok(GitHistoryPage { commits, next_before, truncated: output.truncated })
16411}
16412
16413fn parse_git_history(output: &[u8]) -> Vec<GitCommitDetails> {
16414    String::from_utf8_lossy(output)
16415        .split('\u{1e}')
16416        .filter_map(|record| {
16417            let fields = record.trim_matches(['\r', '\n']).split('\u{1f}').collect::<Vec<_>>();
16418            if fields.len() != 9 {
16419                return None;
16420            }
16421            let id = GitObjectId::new(fields[0].to_owned()).ok()?;
16422            let parents = if fields[1].is_empty() {
16423                Vec::new()
16424            } else {
16425                fields[1]
16426                    .split(' ')
16427                    .map(|value| GitObjectId::new(value.to_owned()))
16428                    .collect::<Result<Vec<_>, _>>()
16429                    .ok()?
16430            };
16431            Some(GitCommitDetails {
16432                id,
16433                parents,
16434                subject: bounded_git_text(fields[2], 512),
16435                author_name: bounded_git_text(fields[3], 256),
16436                author_email: bounded_git_text(fields[4], 320),
16437                authored_at: bounded_git_text(fields[5], 64),
16438                committer_name: bounded_git_text(fields[6], 256),
16439                committer_email: bounded_git_text(fields[7], 320),
16440                committed_at: bounded_git_text(fields[8], 64),
16441                signature_status: GitSignatureStatus::NoSignature,
16442                signer: None,
16443            })
16444        })
16445        .take(usize::from(MAX_GIT_HISTORY_COMMITS) + 1)
16446        .collect()
16447}
16448
16449fn bounded_git_text(value: &str, max_bytes: usize) -> String {
16450    let normalized = value
16451        .chars()
16452        .map(|character| if character.is_control() { ' ' } else { character })
16453        .collect::<String>();
16454    if normalized.len() <= max_bytes {
16455        return normalized;
16456    }
16457    let mut boundary = max_bytes;
16458    while boundary > 0 && !normalized.is_char_boundary(boundary) {
16459        boundary -= 1;
16460    }
16461    normalized[..boundary].to_owned()
16462}
16463
16464async fn read_git_diff_bounded(root: &str, request: GitDiffRequest) -> Result<GitDiff, NodeFailure> {
16465    let path = request.path.as_ref().map(|path| {
16466        path.as_utf8()
16467            .ok_or_else(|| failure(NodeFailureCode::InvalidRepositoryPath, "git diff path must use UTF-8"))
16468    }).transpose()?;
16469    let mut arguments = match &request.mode {
16470        GitDiffMode::Working => vec!["--no-pager", "-c", "diff.external=", "diff", "--no-ext-diff", "--no-textconv"],
16471        GitDiffMode::Staged => vec!["--no-pager", "-c", "diff.external=", "diff", "--cached", "--no-ext-diff", "--no-textconv"],
16472        GitDiffMode::Commit { .. } => vec!["--no-pager", "-c", "diff.external=", "show", "--format=fuller", "--no-ext-diff", "--no-textconv"],
16473    };
16474    if let GitDiffMode::Commit { revision } = &request.mode {
16475        arguments.push(revision.as_str());
16476    }
16477    arguments.push("--");
16478    if let Some(path) = path {
16479        arguments.push(path);
16480    }
16481    let output = run_git_bounded(root, &arguments, MAX_GIT_DIFF_BYTES)
16482        .await
16483        .map_err(|_| failure(NodeFailureCode::GitReadFailed, "git diff command failed"))?;
16484    if output.timed_out {
16485        return Err(failure(NodeFailureCode::GitReadTimedOut, "git diff exceeded its bounded deadline"));
16486    }
16487    if !output.success {
16488        return Err(failure(NodeFailureCode::GitReadFailed, "git diff could not be read"));
16489    }
16490    Ok(GitDiff {
16491        mode: request.mode,
16492        path: request.path,
16493        text: String::from_utf8_lossy(&output.stdout).into_owned(),
16494        truncated: output.truncated,
16495    })
16496}
16497
16498async fn process_request(shared: &NodeShared, connection_id: u64, role: ClientRole, envelope: RequestEnvelope) -> ResponseEnvelope {
16499    let result = process_request_inner(shared, connection_id, role, envelope.request).await;
16500    ResponseEnvelope { request_id: envelope.request_id, result }
16501}
16502
16503async fn process_request_inner(shared: &NodeShared, connection_id: u64, role: ClientRole, request: NodeRequest) -> Result<NodeResponse, NodeFailure> {
16504    if !request.harness_mcp_contract_is_valid_at(unix_time_ms()) {
16505        return Err(failure(NodeFailureCode::InvalidRequest, "harness MCP request is invalid or stale"));
16506    }
16507    let read_only = matches!(
16508        &request,
16509        NodeRequest::Snapshot
16510            | NodeRequest::Resync { .. }
16511            | NodeRequest::BrowseHostDirectories { .. }
16512            | NodeRequest::InspectWorkspace { .. }
16513            | NodeRequest::ReadWorkspaceFile { .. }
16514            | NodeRequest::ReadGitHistory { .. }
16515            | NodeRequest::ReadGitDiff { .. }
16516            | NodeRequest::CatalogNativeSessions { .. }
16517            | NodeRequest::PageNativeSessions { .. }
16518            | NodeRequest::PreviewNativeSession { .. }
16519            | NodeRequest::PreviewSessionRecord { .. }
16520    );
16521    let _mutation_guard = if read_only {
16522        None
16523    } else {
16524        let guard = shared.mutation_gate.lock().await;
16525        if shared.shutdown.load(Ordering::Acquire) {
16526            return Err(failure(NodeFailureCode::ShuttingDown, "node shutdown has begun"));
16527        }
16528        Some(guard)
16529    };
16530    match request {
16531        NodeRequest::Snapshot => Ok(NodeResponse::Snapshot {
16532            event_sequence: shared.current_sequence(),
16533            controller: shared.controller_state(),
16534            snapshot: shared.snapshot(),
16535        }),
16536        NodeRequest::Resync { after_sequence } => Ok(shared.resync(after_sequence)),
16537        NodeRequest::ArmHarnessMcpReservation {
16538            reservation_id,
16539            activation_digest,
16540            spawn_spec,
16541            launch,
16542            expires_at_unix_ms,
16543        } => {
16544            shared.require_controller(connection_id, role)?;
16545            let registry = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16546                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16547            })?;
16548            let expires_at_unix_ms = registry
16549                .arm(reservation_id.clone(), activation_digest.clone(), spawn_spec, launch, expires_at_unix_ms)
16550                .await
16551                .map_err(harness_mcp_failure)?;
16552            Ok(NodeResponse::Armed { reservation_id, activation_digest, expires_at_unix_ms })
16553        }
16554        NodeRequest::SpawnSpecWithHarnessMcp {
16555            reservation_id,
16556            activation_digest,
16557            spec,
16558            deadline_unix_ms,
16559        } => {
16560            shared.require_controller(connection_id, role)?;
16561            let receipt = shared
16562                .spawn_from_spec_with_harness_mcp(
16563                    reservation_id.clone(), activation_digest.clone(), spec, deadline_unix_ms,
16564                )
16565                .await?;
16566            Ok(NodeResponse::Spawned { reservation_id, activation_digest, receipt })
16567        }
16568        NodeRequest::ActivateHarnessMcpReservation {
16569            reservation_id,
16570            activation_digest,
16571            record_id,
16572            session,
16573        } => {
16574            shared.require_controller(connection_id, role)?;
16575            let provider_root_pid = shared.handle.snapshot().sessions.iter().find(|current| {
16576                current.instance_id == session.session.instance_id
16577                    && current.generation == session.session.generation
16578            }).and_then(|current| current.process_id).ok_or_else(|| {
16579                failure(NodeFailureCode::BindingMismatch, "provider root process is unavailable")
16580            })?;
16581            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16582                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16583            })?.activate(
16584                &reservation_id, &activation_digest, &record_id, &session, provider_root_pid,
16585            ).map_err(harness_mcp_failure)?;
16586            Ok(NodeResponse::Activated { reservation_id, activation_digest, record_id, session })
16587        }
16588        NodeRequest::AbortHarnessMcpReservation { reservation_id, activation_digest } => {
16589            shared.require_controller(connection_id, role)?;
16590            let instance_id = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16591                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16592            })?.abort(&reservation_id, &activation_digest).map_err(harness_mcp_failure)?;
16593            if let Some(instance_id) = instance_id {
16594                SessionLifecycleHook::on_harness_reservation_aborted(&*shared, instance_id);
16595            }
16596            Ok(NodeResponse::Aborted { reservation_id, activation_digest })
16597        }
16598        NodeRequest::PutHarnessMcpReplyChunk {
16599            reservation_id, activation_digest, record_id, session, call_id,
16600            offset, final_chunk, chunk_hex,
16601        } => {
16602            shared.require_controller(connection_id, role)?;
16603            let (next_offset, completed) = shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16604                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16605            })?.put_reply_chunk(
16606                &reservation_id, &activation_digest, &record_id, &session, &call_id,
16607                offset, final_chunk, &chunk_hex,
16608            ).map_err(harness_mcp_failure)?;
16609            Ok(NodeResponse::ReplyChunkAccepted {
16610                reservation_id, activation_digest, record_id, session, call_id,
16611                next_offset, completed,
16612            })
16613        }
16614        NodeRequest::RejectHarnessMcpCall {
16615            reservation_id, activation_digest, record_id, session, call_id, reason,
16616        } => {
16617            shared.require_controller(connection_id, role)?;
16618            shared.harness_mcp_registry.as_ref().ok_or_else(|| {
16619                failure(NodeFailureCode::HarnessMcpUnavailable, "harness MCP proxy is not configured")
16620            })?.reject_call(
16621                &reservation_id, &activation_digest, &record_id, &session, &call_id, reason,
16622            ).map_err(harness_mcp_failure)?;
16623            Ok(NodeResponse::CallRejected {
16624                reservation_id, activation_digest, record_id, session, call_id,
16625            })
16626        }
16627        NodeRequest::BeginDeliveryStage { manifest } => {
16628            shared.require_controller(connection_id, role)?;
16629            shared.begin_delivery_stage(manifest)
16630        }
16631        NodeRequest::PutDeliveryBlobChunk {
16632            stage_id,
16633            blob_digest,
16634            offset,
16635            chunk_hex,
16636        } => {
16637            shared.require_controller(connection_id, role)?;
16638            shared.put_delivery_blob_chunk(stage_id, blob_digest, offset, chunk_hex)
16639        }
16640        NodeRequest::CommitDeliveryStage { stage_id } => {
16641            shared.require_controller(connection_id, role)?;
16642            let receipt = shared.commit_delivery_stage(stage_id)?;
16643            Ok(NodeResponse::DeliveryCommitted { receipt })
16644        }
16645        NodeRequest::AbortDeliveryStage { stage_id } => {
16646            shared.require_controller(connection_id, role)?;
16647            shared.abort_delivery_stage(&stage_id)?;
16648            Ok(NodeResponse::DeliveryStageAborted { stage_id })
16649        }
16650        NodeRequest::BrowseHostDirectories { directory, after } => {
16651            let listing = shared.browse_host_directories(directory, after).await?;
16652            Ok(NodeResponse::HostDirectoriesBrowsed { listing })
16653        }
16654        NodeRequest::InspectWorkspace { workspace_id } => {
16655            let inspection = shared.inspect_workspace(workspace_id).await?;
16656            Ok(NodeResponse::WorkspaceInspected { inspection })
16657        }
16658        NodeRequest::ReadWorkspaceFile { workspace_id, path } => {
16659            let file = shared.read_workspace_file(workspace_id, path).await?;
16660            Ok(NodeResponse::WorkspaceFileRead { file })
16661        }
16662        NodeRequest::WriteWorkspaceFile { workspace_id, path, expected_revision, text } => {
16663            shared.require_controller(connection_id, role)?;
16664            let file = shared
16665                .write_workspace_file(workspace_id, path, expected_revision, text)
16666                .await?;
16667            Ok(NodeResponse::WorkspaceFileWritten { file })
16668        }
16669        NodeRequest::CreateWorkspaceFile { workspace_id, path } => {
16670            shared.require_controller(connection_id, role)?;
16671            let file = shared.create_workspace_file(workspace_id, path).await?;
16672            Ok(NodeResponse::WorkspaceFileCreated { file })
16673        }
16674        NodeRequest::CreateWorkspaceDirectory { workspace_id, path } => {
16675            shared.require_controller(connection_id, role)?;
16676            let entry = shared
16677                .create_workspace_directory(workspace_id.clone(), path)
16678                .await?;
16679            Ok(NodeResponse::WorkspaceDirectoryCreated { workspace_id, entry })
16680        }
16681        NodeRequest::ReadGitHistory { workspace_id, path, before, limit } => {
16682            if role != ClientRole::Operator {
16683                return Err(failure(NodeFailureCode::ObserverReadOnly, "git history requires an operator connection"));
16684            }
16685            if !(1..=MAX_GIT_HISTORY_COMMITS).contains(&limit) {
16686                return Err(failure(NodeFailureCode::InvalidRequest, "git history limit is invalid"));
16687            }
16688            let page = shared
16689                .read_git_history(workspace_id.clone(), path, before, limit)
16690                .await?;
16691            Ok(NodeResponse::GitHistoryRead { workspace_id, page })
16692        }
16693        NodeRequest::ReadGitDiff { workspace_id, request } => {
16694            if role != ClientRole::Operator {
16695                return Err(failure(NodeFailureCode::ObserverReadOnly, "git diff requires an operator connection"));
16696            }
16697            let diff = shared.read_git_diff(workspace_id.clone(), request).await?;
16698            Ok(NodeResponse::GitDiffRead { workspace_id, diff })
16699        }
16700        NodeRequest::CatalogNativeSessions { route, limit } => {
16701            if role != ClientRole::Operator {
16702                return Err(failure(
16703                    NodeFailureCode::ObserverReadOnly,
16704                    "native session catalog requires an operator connection",
16705                ));
16706            }
16707            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit) {
16708                return Err(failure(
16709                    NodeFailureCode::InvalidRequest,
16710                    "native session catalog limit is invalid",
16711                ));
16712            }
16713            let (entries, summary) = shared
16714                .catalog_native_sessions(route.clone(), limit)
16715                .await?;
16716            Ok(NodeResponse::NativeSessionsCataloged {
16717                route,
16718                entries,
16719                summary: Some(summary),
16720            })
16721        }
16722        NodeRequest::PageNativeSessions {
16723            route,
16724            window,
16725            catalog_revision,
16726            recent_cutoff_unix_ms,
16727            after_selection_id,
16728            limit,
16729        } => {
16730            if role != ClientRole::Operator {
16731                return Err(failure(
16732                    NodeFailureCode::ObserverReadOnly,
16733                    "native session catalog paging requires an operator connection",
16734                ));
16735            }
16736            if !(1..=gate4agent_types::NATIVE_SESSION_CATALOG_LIMIT_MAX).contains(&limit)
16737                || after_selection_id
16738                    .as_deref()
16739                    .is_some_and(|cursor| validate_candidate_id(cursor).is_err())
16740            {
16741                return Err(failure(
16742                    NodeFailureCode::InvalidRequest,
16743                    "native session catalog page request is invalid",
16744                ));
16745            }
16746            let page = shared
16747                .page_native_sessions(
16748                    route.clone(),
16749                    window,
16750                    catalog_revision,
16751                    recent_cutoff_unix_ms,
16752                    after_selection_id,
16753                    limit,
16754                )
16755                .await?;
16756            Ok(NodeResponse::NativeSessionsPaged {
16757                route,
16758                page,
16759            })
16760        }
16761        NodeRequest::PreviewNativeSession {
16762            selection,
16763            message_limit,
16764        } => {
16765            if role != ClientRole::Operator {
16766                return Err(failure(
16767                    NodeFailureCode::ObserverReadOnly,
16768                    "native session preview requires an operator connection",
16769                ));
16770            }
16771            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16772                .contains(&message_limit)
16773                || selection.validate().is_err()
16774            {
16775                return Err(failure(
16776                    NodeFailureCode::InvalidRequest,
16777                    "native session preview request is invalid",
16778                ));
16779            }
16780            let preview = shared
16781                .preview_native_session(selection.clone(), message_limit)
16782                .await?;
16783            Ok(NodeResponse::NativeSessionPreviewed {
16784                selection,
16785                preview,
16786            })
16787        }
16788        NodeRequest::IndexNativeSession {
16789            selection,
16790            display_name,
16791        } => {
16792            shared.require_controller(connection_id, role)?;
16793            let record = shared
16794                .index_native_session(selection.clone(), display_name)
16795                .await?;
16796            Ok(NodeResponse::NativeSessionIndexed { selection, record })
16797        }
16798        NodeRequest::PreviewSessionRecord {
16799            record_id,
16800            message_limit,
16801        } => {
16802            if role != ClientRole::Operator {
16803                return Err(failure(
16804                    NodeFailureCode::ObserverReadOnly,
16805                    "native session preview requires an operator connection",
16806                ));
16807            }
16808            if !(1..=gate4agent_types::NATIVE_SESSION_PREVIEW_MESSAGE_LIMIT_MAX)
16809                .contains(&message_limit)
16810            {
16811                return Err(failure(
16812                    NodeFailureCode::InvalidRequest,
16813                    "native session preview request is invalid",
16814                ));
16815            }
16816            let record = shared.record(&record_id)?;
16817            let working_directory = shared.workspace_root(&record.workspace_id)?;
16818            if !platform::roots_equal(
16819                &working_directory,
16820                windows_path_text(&record.canonical_root),
16821            ) {
16822                return Err(failure(
16823                    NodeFailureCode::SessionRecordConflict,
16824                    "managed session workspace root changed; refusing to preview another directory",
16825                ));
16826            }
16827            let identity = record.provider_session.clone().ok_or_else(|| {
16828                failure(
16829                    NodeFailureCode::BackendOperationFailed,
16830                    "native session preview is unavailable for this session record",
16831                )
16832            })?;
16833            if identity.key != ProviderSessionKey::SessionId {
16834                return Err(failure(
16835                    NodeFailureCode::BackendOperationFailed,
16836                    "native session preview is unavailable for this session identity",
16837                ));
16838            }
16839            let preview = shared
16840                .preview_native_session_by_session_id(
16841                    record.workspace_id.clone(), record.provider.clone(), identity.id.clone(), message_limit,
16842                )
16843                .await?;
16844            if preview.session_id != identity.id {
16845                return Err(failure(
16846                    NodeFailureCode::SessionRecordConflict,
16847                    "native session preview identity changed while loading",
16848                ));
16849            }
16850            shared.revalidate_session_record_preview(&record, &identity)?;
16851            let preview: gate4agent_types::SessionRecordPreview = preview.into();
16852            shared.publish(NodeEvent::SessionRecordHistorySummarized {
16853                record_id: record_id.clone(),
16854                summary: SessionHistorySummaryV1::from(&preview),
16855            });
16856            Ok(NodeResponse::SessionRecordPreviewed {
16857                record_id,
16858                preview,
16859            })
16860        }
16861        NodeRequest::AcquireController { lease_ms } => {
16862            let controller = shared.acquire_controller(connection_id, role, lease_ms)?;
16863            Ok(NodeResponse::Controller { controller: Some(controller) })
16864        }
16865        NodeRequest::ReleaseController => {
16866            shared.release_controller(connection_id);
16867            Ok(NodeResponse::Controller { controller: shared.controller_state() })
16868        }
16869        NodeRequest::RegisterWorkspace { workspace_id, root } => {
16870            shared.require_controller(connection_id, role)?;
16871            let root = require_windows_path(root)?;
16872            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16873            let workspace = shared.register_workspace(workspace_id, root).await?;
16874            Ok(NodeResponse::WorkspaceRegistered { workspace })
16875        }
16876        NodeRequest::CreateStandaloneWorkspace {
16877            workspace_id,
16878            root,
16879            initial_branch,
16880        } => {
16881            shared.require_controller(connection_id, role)?;
16882            let root = require_windows_path(root)?;
16883            shared.reject_managed_reservation(Some(&workspace_id), Some(&root))?;
16884            let workspace = shared
16885                .create_standalone_workspace(workspace_id, root, initial_branch)
16886                .await?;
16887            Ok(NodeResponse::StandaloneWorkspaceCreated { workspace })
16888        }
16889        NodeRequest::UnregisterWorkspace { workspace_id } => {
16890            shared.require_controller(connection_id, role)?;
16891            shared.reject_managed_reservation(Some(&workspace_id), None)?;
16892            shared.unregister_workspace(&workspace_id)?;
16893            Ok(NodeResponse::WorkspaceUnregistered { workspace_id })
16894        }
16895        NodeRequest::CreateWorktree {
16896            source_workspace_id,
16897            workspace_id,
16898            target_root,
16899            branch,
16900            base,
16901        } => {
16902            shared.require_controller(connection_id, role)?;
16903            let target_root = require_windows_path(target_root)?;
16904            shared.reject_managed_reservation(Some(&workspace_id), Some(&target_root))?;
16905            let (worktree, workspace) = shared
16906                .create_worktree(
16907                    source_workspace_id,
16908                    workspace_id,
16909                    target_root,
16910                    branch,
16911                    base,
16912                )
16913                .await?;
16914            Ok(NodeResponse::WorktreeCreated { worktree, workspace })
16915        }
16916        NodeRequest::RemoveWorktree {
16917            source_workspace_id,
16918            target_root,
16919        } => {
16920            shared.require_controller(connection_id, role)?;
16921            let native_target_root = require_windows_path(target_root.clone())?;
16922            shared.reject_managed_reservation(None, Some(&native_target_root))?;
16923            let workspace_id = shared
16924                .remove_worktree(source_workspace_id, native_target_root)
16925                .await?;
16926            Ok(NodeResponse::WorktreeRemoved {
16927                target_root,
16928                workspace_id,
16929            })
16930        }
16931        NodeRequest::Spawn { workspace_id, provider, mode, terminal_size, initial_prompt } => {
16932            shared.require_controller(connection_id, role)?;
16933            if !terminal_size.is_valid() {
16934                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16935            }
16936            if let Some(prompt) = initial_prompt.as_deref() {
16937                validate_node_text("spawn initial prompt", prompt)?;
16938            }
16939            match shared
16940                .spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt)
16941                .await
16942            {
16943                Ok(session) => {
16944                    shared.mark_spawn_provisional(session.clone());
16945                    Ok(NodeResponse::SpawnAccepted { session })
16946                }
16947                Err(err) => {
16948                    tracing::warn!(
16949                        %provider,
16950                        %workspace_id,
16951                        ?mode,
16952                        code = ?err.code,
16953                        message = %err.message,
16954                        "session spawn refused"
16955                    );
16956                    Err(err)
16957                }
16958            }
16959        }
16960        NodeRequest::SpawnWithOptions { workspace_id, provider, mode, terminal_size, initial_prompt, options } => {
16961            shared.require_controller(connection_id, role)?;
16962            if !terminal_size.is_valid() {
16963                return Err(failure(NodeFailureCode::InvalidRequest, "spawn requires a valid terminal size"));
16964            }
16965            if let Some(prompt) = initial_prompt.as_deref() {
16966                validate_node_text("spawn initial prompt", prompt)?;
16967            }
16968            let plan = backend_paths::plan_launch_options(
16969                provider.as_str(),
16970                mode,
16971                options.model.is_some(),
16972                options.effort.is_some(),
16973            )
16974            .map_err(|r| {
16975                tracing::warn!(%provider, reason = %r.message, "launch option path refused");
16976                failure(NodeFailureCode::InvalidRequest, "launch option path refused")
16977            })?;
16978            let selection = spawn_selection(&options)?;
16979            let pty_selection = match plan {
16980                backend_paths::LaunchOptionsPlan::PtyFlags => selection,
16981                _ => None,
16982            };
16983            let spawned = SPAWN_ORIGIN.scope(options.origin.clone(), async {
16984                SPAWN_PARENT.scope(options.parent_session_id.clone(), async {
16985                    SPAWN_SESSION_OPTIONS
16986                        .scope(pty_selection, shared.spawn_session(workspace_id.clone(), provider.clone(), mode, terminal_size, initial_prompt))
16987                        .await
16988                }).await
16989            }).await;
16990            match spawned {
16991                Ok(session) => {
16992                    if plan == backend_paths::LaunchOptionsPlan::AcpAfterSpawn {
16993                        shared
16994                            .pending_acp_options
16995                            .lock()
16996                            .unwrap_or_else(|p| p.into_inner())
16997                            .insert(session.session.instance_id, (options.model.clone(), options.effort.clone()));
16998                    }
16999                    shared.mark_spawn_provisional(session.clone());
17000                    tracing::info!(%provider, model = ?options.model, effort = ?options.effort, "session spawned with launch options");
17001                    Ok(NodeResponse::SpawnAccepted { session })
17002                }
17003                Err(err) => {
17004                    tracing::warn!(%provider, %workspace_id, code = ?err.code, message = %err.message, "session spawn with options refused");
17005                    Err(err)
17006                }
17007            }
17008        }
17009        NodeRequest::SpawnSpec { spec } => {
17010            shared.require_controller(connection_id, role)?;
17011            let receipt = shared.spawn_from_spec(spec).await?;
17012            Ok(NodeResponse::SpawnSpecAccepted { receipt })
17013        }
17014        NodeRequest::SpawnManagedWorktree { request } => {
17015            shared.require_controller(connection_id, role)?;
17016            let receipt = shared.spawn_managed_worktree(request).await?;
17017            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17018        }
17019        NodeRequest::SpawnManagedWorktreeV2 { request } => {
17020            shared.require_controller(connection_id, role)?;
17021            let receipt = shared.spawn_managed_worktree_v2(request).await?;
17022            Ok(NodeResponse::ManagedWorktreeSpawnAccepted { receipt })
17023        }
17024        NodeRequest::CleanupManagedWorktree { lease_id } => {
17025            shared.require_controller(connection_id, role)?;
17026            let lease = shared.cleanup_managed_worktree(&lease_id, true).await?;
17027            Ok(NodeResponse::ManagedWorktreeCleanup { lease })
17028        }
17029        NodeRequest::DiscoverHistory { session, limit } => {
17030            controlled_session(shared, connection_id, role, &session)?;
17031            let candidates = shared.discover_history(&session, limit).await?;
17032            Ok(NodeResponse::HistoryDiscovered {
17033                session,
17034                candidates,
17035            })
17036        }
17037        NodeRequest::LoadHistory {
17038            session,
17039            candidate_id,
17040        } => {
17041            controlled_session(shared, connection_id, role, &session)?;
17042            let loaded = shared
17043                .load_history(&session, candidate_id)
17044                .await?;
17045            Ok(NodeResponse::HistoryLoaded {
17046                session,
17047                session_id: loaded.session_id,
17048                message_count: loaded.message_count,
17049                completed_turn_count: loaded.completed_turn_count,
17050            })
17051        }
17052        NodeRequest::ExportContextPackForSessionRecord { record_id, session } => {
17053            shared.require_controller(connection_id, role)?;
17054            let context = shared
17055                .export_context_pack_for_session_record(&record_id, &session, false)
17056                .await?;
17057            Ok(NodeResponse::ContextPackForSessionRecordExported {
17058                record_id,
17059                session,
17060                context,
17061            })
17062        }
17063        NodeRequest::ExportContextPack { session } => {
17064            controlled_session(shared, connection_id, role, &session)?;
17065            let context = shared.export_context_pack(&session).await?;
17066            Ok(NodeResponse::ContextPackExported { context })
17067        }
17068        NodeRequest::ForgetContextPack { context_id } => {
17069            shared.require_controller(connection_id, role)?;
17070            shared.forget_context_pack(&context_id)?;
17071            Ok(NodeResponse::ContextPackForgotten { context_id })
17072        }
17073        NodeRequest::ResolveDurableContextPack { context_id } => {
17074            // Read-only, idempotent, no controller lease required: this lets
17075            // a caller preflight "is this durably exported pack still here"
17076            // without holding the Node's mutation controller.
17077            let context = shared
17078                .context_catalog
17079                .read()
17080                .unwrap_or_else(|poisoned| poisoned.into_inner())
17081                .get(&context_id)
17082                .map(|pack| pack.receipt().clone())
17083                .ok_or_else(|| {
17084                    failure(
17085                        NodeFailureCode::UnknownContextPack,
17086                        "context pack is not present",
17087                    )
17088                })?;
17089            Ok(NodeResponse::DurableContextPackResolved { context })
17090        }
17091        NodeRequest::ReadContextPack { digest } => {
17092            // Read-only fetch of the pack's own bytes, keyed by content digest
17093            // rather than the caller's `SpawnContextId`: same no-controller-lease
17094            // rationale as `ResolveDurableContextPack` above, one step further
17095            // down the same catalog.
17096            let (id, byte_len, bytes) = shared
17097                .context_catalog
17098                .read()
17099                .unwrap_or_else(|poisoned| poisoned.into_inner())
17100                .get_by_digest(&digest)
17101                .map(|pack| {
17102                    (pack.receipt().id.clone(), pack.receipt().byte_len, pack.bytes().to_vec())
17103                })
17104                .ok_or_else(|| {
17105                    failure(
17106                        NodeFailureCode::UnknownContextPack,
17107                        &format!("context pack {digest} not on this node"),
17108                    )
17109                })?;
17110            tracing::debug!(
17111                digest = digest.as_str(),
17112                byte_len,
17113                "read context pack bytes",
17114            );
17115            Ok(NodeResponse::ContextPackBytesRead {
17116                pack: ContextPackBytesRead {
17117                    digest,
17118                    id,
17119                    byte_len,
17120                    bytes,
17121                },
17122            })
17123        }
17124        NodeRequest::Resume { session, terminal_size, initial_prompt } => {
17125            let provider = controlled_session(shared, connection_id, role, &session)?;
17126            if let Some(prompt) = initial_prompt.as_deref() {
17127                validate_node_text("resume initial prompt", prompt)?;
17128            }
17129            let runtime_requirement = if initial_prompt.is_some() {
17130                ProviderRuntimeRequirement::ResumeWithPrompt
17131            } else {
17132                ProviderRuntimeRequirement::Resume
17133            };
17134            shared.require_session_runtime_policy(&session, runtime_requirement)?;
17135            let runtime_policy = shared
17136                .admit_provider_runtime(&provider, runtime_requirement)
17137                .await?;
17138            let working_directory = shared.bound_workspace_root(&session)?;
17139            let request = ResumeLaunchRequest {
17140                working_directory,
17141                terminal_size,
17142                initial_prompt,
17143            };
17144            request
17145                .validate()
17146                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17147            let command = shared.prepare_command(ControlCommand::Resume {
17148                instance_id: session.session.instance_id,
17149                target: ResumeTarget::CurrentProvider,
17150                runtime_policy,
17151                request,
17152            });
17153            shared.arm_resume(&session, command.id, runtime_policy)?;
17154            let dispatch = shared.dispatch_envelope(command);
17155            if let Err(error) = dispatch {
17156                shared.clear_armed_resume(&session);
17157                return Err(error);
17158            }
17159            Ok(NodeResponse::Accepted)
17160        }
17161        NodeRequest::RenameSessionRecord {
17162            record_id,
17163            display_name,
17164        } => {
17165            shared.require_controller(connection_id, role)?;
17166            let record = shared.rename_session_record(&record_id, display_name)?;
17167            Ok(NodeResponse::SessionRecordUpdated { record })
17168        }
17169        NodeRequest::SetSessionTask {
17170            record_id,
17171            expected_revision,
17172            target,
17173        } => {
17174            shared.require_controller(connection_id, role)?;
17175            let record = shared.set_session_task(&record_id, expected_revision, target)?;
17176            Ok(NodeResponse::SessionRecordUpdated { record })
17177        }
17178        NodeRequest::IndexProviderSession {
17179            workspace_id,
17180            provider,
17181            identity,
17182            display_name,
17183        } => {
17184            shared.require_controller(connection_id, role)?;
17185            let record = shared.index_provider_session(
17186                workspace_id,
17187                provider,
17188                identity,
17189                display_name,
17190            )?;
17191            Ok(NodeResponse::ProviderSessionIndexed { record })
17192        }
17193        NodeRequest::ResumeSessionRecord {
17194            record_id,
17195            terminal_size,
17196            initial_prompt,
17197        } => {
17198            shared.require_controller(connection_id, role)?;
17199            if let Some(prompt) = initial_prompt.as_deref() {
17200                validate_node_text("managed resume initial prompt", prompt)?;
17201            }
17202            let (record, session) = shared
17203                .resume_session_record(
17204                    &record_id,
17205                    terminal_size,
17206                    initial_prompt,
17207                )
17208                .await?;
17209            Ok(NodeResponse::SessionRecordResumed { record, session })
17210        }
17211        NodeRequest::ForgetSessionRecord { record_id } => {
17212            shared.require_controller(connection_id, role)?;
17213            shared.forget_session_record(&record_id).await?;
17214            Ok(NodeResponse::SessionRecordForgotten { record_id })
17215        }
17216        NodeRequest::Prompt { session, text } => {
17217            let agent_id = controlled_session(shared, connection_id, role, &session)?;
17218            validate_node_text("prompt", &text)?;
17219            shared.require_session_runtime_policy(
17220                &session,
17221                ProviderRuntimeRequirement::SemanticPrompt,
17222            )?;
17223            let framing = prompt_framing(&agent_id);
17224            shared
17225                .dispatch_input_bounded(
17226                    &session,
17227                    InputAction::SubmitPrompt(PromptPayload { text, framing }),
17228                )
17229                .await?;
17230            Ok(NodeResponse::Accepted)
17231        }
17232        NodeRequest::Paste { session, text } => {
17233            controlled_session(shared, connection_id, role, &session)?;
17234            validate_node_text("paste", &text)?;
17235            shared.require_session_runtime_policy(
17236                &session,
17237                ProviderRuntimeRequirement::SemanticPrompt,
17238            )?;
17239            shared
17240                .dispatch_input_bounded(
17241                    &session,
17242                    InputAction::InsertDraft(PromptPayload {
17243                        text,
17244                        framing: PromptFraming::BracketedPaste,
17245                    }),
17246                )
17247                .await?;
17248            Ok(NodeResponse::Accepted)
17249        }
17250        NodeRequest::Input { session, text } => {
17251            controlled_session(shared, connection_id, role, &session)?;
17252            validate_node_text("terminal input", &text)?;
17253            shared
17254                .dispatch_input_bounded(
17255                    &session,
17256                    InputAction::TerminalText(TerminalText { text }),
17257                )
17258                .await?;
17259            Ok(NodeResponse::Accepted)
17260        }
17261        NodeRequest::TerminalBytes { session, bytes } => {
17262            controlled_session(shared, connection_id, role, &session)?;
17263            shared.sessionize_spawn(session.session.instance_id);
17264            let action = terminal_bytes_action(bytes)?;
17265            shared
17266                .dispatch_input_bounded(&session, action)
17267                .await?;
17268            Ok(NodeResponse::Accepted)
17269        }
17270        NodeRequest::TerminalControl { session, control } => {
17271            controlled_session(shared, connection_id, role, &session)?;
17272            shared
17273                .dispatch_input_bounded(&session, InputAction::TerminalControl(control))
17274                .await?;
17275            Ok(NodeResponse::Accepted)
17276        }
17277        NodeRequest::Resize { session, size } => {
17278            controlled_session(shared, connection_id, role, &session)?;
17279            shared.sessionize_spawn(session.session.instance_id);
17280            if !size.is_valid() {
17281                return Err(failure(NodeFailureCode::InvalidRequest, "terminal size is invalid"));
17282            }
17283            shared.dispatch_resize_bounded(&session, size).await?;
17284            Ok(NodeResponse::Accepted)
17285        }
17286        NodeRequest::Interrupt { session } => {
17287            controlled_session(shared, connection_id, role, &session)?;
17288            shared
17289                .dispatch_input_bounded(
17290                    &session,
17291                    InputAction::TerminalControl(TerminalControl::Interrupt),
17292                )
17293                .await?;
17294            Ok(NodeResponse::Accepted)
17295        }
17296        NodeRequest::Stop { session, force } => {
17297            controlled_session(shared, connection_id, role, &session)?;
17298            shared.sessionize_spawn(session.session.instance_id);
17299            shared.dispatch(ControlCommand::Stop { instance_id: session.session.instance_id, force })?;
17300            shared.retire_session_grok_leader(session.session.instance_id);
17301            Ok(NodeResponse::Accepted)
17302        }
17303        NodeRequest::Remove { session } => {
17304            controlled_session(shared, connection_id, role, &session)?;
17305            shared.remove_session(&session).await?;
17306            Ok(NodeResponse::Accepted)
17307        }
17308        NodeRequest::ResolveInteraction { session, correlation_id, response } => {
17309            shared.require_controller(connection_id, role)?;
17310            let snapshot = shared.require_acp_session(&session)?;
17311            let interaction = snapshot
17312                .provider
17313                .interactions
17314                .iter()
17315                .find(|interaction| {
17316                    interaction_correlation(
17317                        session.session.instance_id,
17318                        session.session.generation,
17319                        interaction.id.0,
17320                    ) == correlation_id
17321                })
17322                .ok_or_else(|| failure(
17323                    NodeFailureCode::InvalidRequest,
17324                    "unknown interaction correlation",
17325                ))?;
17326            if interaction.status != ProviderInteractionStatus::Pending {
17327                return Err(failure(
17328                    NodeFailureCode::BackendOperationFailed,
17329                    "interaction has already been resolved",
17330                ));
17331            }
17332            response
17333                .validate_for(interaction.interaction_kind)
17334                .map_err(|error| failure(NodeFailureCode::InvalidRequest, &error.to_string()))?;
17335            let interaction_id = interaction.id;
17336            let response_kind = response.kind();
17337            let response_outcome = response.outcome();
17338            shared.dispatch(ControlCommand::ResolveInteraction {
17339                instance_id: session.session.instance_id,
17340                generation: session.session.generation,
17341                interaction_id,
17342                response,
17343            })?;
17344            // The one line that lets an operator compare what THIS node
17345            // recorded against what the provider itself reports doing --
17346            // measured live on a kimi run where our side resolved `true`
17347            // while the agent reported "rejected" and neither layer had a
17348            // line to line up against the other.
17349            tracing::info!(
17350                node_id = %shared.node_id,
17351                session = ?session.session,
17352                correlation_id = %correlation_id,
17353                interaction_id = interaction_id.0,
17354                response_kind = ?response_kind,
17355                outcome = ?response_outcome,
17356                "interaction resolved",
17357            );
17358            Ok(NodeResponse::Accepted)
17359        }
17360        NodeRequest::SetSessionMode { session, mode_id } => {
17361            shared.require_controller(connection_id, role)?;
17362            shared.require_acp_session(&session)?;
17363            shared.dispatch(ControlCommand::SetSessionMode {
17364                instance_id: session.session.instance_id,
17365                mode_id,
17366            })?;
17367            Ok(NodeResponse::Accepted)
17368        }
17369        NodeRequest::SetSessionConfigOption {
17370            session,
17371            option_id,
17372            value_json,
17373        } => {
17374            shared.require_controller(connection_id, role)?;
17375            shared.require_acp_session(&session)?;
17376            shared.dispatch(ControlCommand::SetSessionConfigOption {
17377                instance_id: session.session.instance_id,
17378                option_id,
17379                value_json,
17380            })?;
17381            Ok(NodeResponse::Accepted)
17382        }
17383        NodeRequest::SetSessionModel { session, model_id } => {
17384            shared.require_controller(connection_id, role)?;
17385            shared.require_acp_session(&session)?;
17386            shared.dispatch(ControlCommand::SetSessionModel {
17387                instance_id: session.session.instance_id,
17388                model_id,
17389            })?;
17390            Ok(NodeResponse::Accepted)
17391        }
17392        NodeRequest::Shutdown => {
17393            shared.require_controller(connection_id, role)?;
17394            shared.begin_shutdown_locked().await?;
17395            Ok(NodeResponse::ShuttingDown)
17396        }
17397    }
17398}
17399
17400fn controlled_session(shared: &NodeShared, connection_id: u64, role: ClientRole, session: &SessionAddress) -> Result<AgentId, NodeFailure> {
17401    shared.require_controller(connection_id, role)?;
17402    shared.validate_address(session)
17403}
17404
17405/// Selects the sole history candidate matching `provider_session_id` when
17406/// the managed record carries a verified identity, or the sole candidate in
17407/// the discovered set at all when it does not (see
17408/// `export_context_pack_for_session_record_inner`'s identity match). Either
17409/// way, more than one match refuses instead of guessing which one is exact.
17410fn select_history_candidate(
17411    candidates: &[HistoryCandidateSummary],
17412    provider_session_id: Option<&str>,
17413) -> Result<String, NodeFailure> {
17414    let mut matches = candidates.iter().filter(|candidate| match provider_session_id {
17415        Some(id) => candidate.session_id_hint == id,
17416        None => true,
17417    });
17418    let candidate = matches.next().ok_or_else(|| {
17419        failure(
17420            NodeFailureCode::SessionRecordConflict,
17421            if provider_session_id.is_some() {
17422                "no history candidate matches the managed provider session"
17423            } else {
17424                "no history candidate exists for the managed session"
17425            },
17426        )
17427    })?;
17428    if matches.next().is_some() {
17429        return Err(failure(
17430            NodeFailureCode::SessionRecordConflict,
17431            if provider_session_id.is_some() {
17432                "history identity is ambiguous for the managed provider session"
17433            } else {
17434                "history is ambiguous without a verified provider session identity"
17435            },
17436        ));
17437    }
17438    Ok(candidate.id.clone())
17439}
17440
17441fn resolved_spawn_profile_summary(
17442    profile: &SpawnProfileDefaults,
17443    environment_profiles: &BTreeMap<SpawnEnvironmentProfileId, EnvironmentProfileBinding>,
17444) -> Option<SpawnProfileSummary> {
17445    let environment_profile = match profile.environment_profile_id.as_ref() {
17446        None => None,
17447        Some(profile_id) => {
17448            let binding = environment_profiles.get(profile_id)?;
17449            if binding.provider != profile.provider
17450                || binding.native_profile_id(profile.mode).is_none()
17451            {
17452                return None;
17453            }
17454            Some(ResolvedEnvironmentProfileReceipt {
17455                profile_id: binding.id.clone(),
17456                profile_revision: binding.revision.clone(),
17457                network_allowlist: None,
17458                browser_profile_id: None,
17459            })
17460        }
17461    };
17462    Some(SpawnProfileSummary {
17463        id: profile.profile_id.clone(),
17464        revision: profile.revision.clone(),
17465        environment_profile,
17466    })
17467}
17468
17469/// True if `current` is an acceptable version of `expected` at export
17470/// revalidation/commit time.
17471///
17472/// When `allow_clean_detachment` is `false` (the on-demand,
17473/// `NodeRequest::ExportContextPackForSessionRecord` API path), `current` must
17474/// be byte-identical to `expected` and still actively bound to `session` —
17475/// the original, unchanged invariant.
17476///
17477/// When `true` (the reactive auto-export-at-exit path, `§2.1`), `current` may
17478/// ALSO be the exact Live -> Dormant transition that the same clean exit this
17479/// export reacts to produces on its own: `state` Live -> Dormant,
17480/// `active_session` `Some(session)` -> `None`, `updated_at_unix_ms`
17481/// advancing. Every other field — including `provider_session`, `context`,
17482/// `bundle`, `task_binding` — must still match exactly. A record that changed
17483/// in any other way (rebind, a different active session, an identity
17484/// conflict, capacity eviction) is rejected either way; this widens nothing
17485/// about which sessions are eligible, only how the record's own expected
17486/// detachment is tolerated mid-export.
17487fn session_record_export_target_matches(
17488    expected: &ManagedSessionRecord,
17489    current: &ManagedSessionRecord,
17490    session: &SessionAddress,
17491    allow_clean_detachment: bool,
17492) -> bool {
17493    if current == expected && current.active_session.as_ref() == Some(session) {
17494        return current.state == ManagedSessionState::Live;
17495    }
17496    allow_clean_detachment
17497        && current.state == ManagedSessionState::Dormant
17498        && current.active_session.is_none()
17499        && current.record_id == expected.record_id
17500        && current.display_name == expected.display_name
17501        && current.provider == expected.provider
17502        && current.mode == expected.mode
17503        && current.workspace_id == expected.workspace_id
17504        && current.canonical_root == expected.canonical_root
17505        && current.provider_session == expected.provider_session
17506        && current.environment_profile == expected.environment_profile
17507        && current.bundle == expected.bundle
17508        && current.context_id == expected.context_id
17509        && current.context == expected.context
17510        && current.exported_context == expected.exported_context
17511        && current.task_binding == expected.task_binding
17512        && current.created_at_unix_ms == expected.created_at_unix_ms
17513        && current.last_error == expected.last_error
17514}
17515
17516fn session_record_context_export_binding_is_exact(
17517    expected: &ManagedSessionRecord,
17518    current: &ManagedSessionRecord,
17519    identity: Option<&ProviderSessionIdentity>,
17520    session: &SessionAddress,
17521    binding: &SessionBinding,
17522    provider: &AgentId,
17523    current_root: &str,
17524    allow_clean_detachment: bool,
17525) -> bool {
17526    session_record_export_target_matches(expected, current, session, allow_clean_detachment)
17527        && current.provider_session.as_ref() == identity
17528        && current.workspace_id == session.workspace_id
17529        && provider == &current.provider
17530        && binding.workspace_id == session.workspace_id
17531        && binding.generation == session.session.generation
17532        && binding.record_id.as_ref() == Some(&current.record_id)
17533        && platform::roots_equal(
17534            current_root,
17535            windows_path_text(&current.canonical_root),
17536        )
17537}
17538
17539fn session_record_context_export_source_is_usable(
17540    record: &ManagedSessionRecord,
17541    status: &SessionStatus,
17542    allow_clean_detachment: bool,
17543) -> bool {
17544    (record.state == ManagedSessionState::Live
17545        || (allow_clean_detachment && record.state == ManagedSessionState::Dormant))
17546        && context_pack_source_status_is_usable(status)
17547}
17548
17549fn prompt_framing(_agent_id: &AgentId) -> PromptFraming {
17550    PromptFraming::BracketedPaste
17551}
17552
17553fn validate_node_text(field: &str, text: &str) -> Result<(), NodeFailure> {
17554    if text.len() > MAX_NODE_TEXT_BYTES {
17555        return Err(failure(
17556            NodeFailureCode::InvalidRequest,
17557            &format!(
17558                "{field} length {} exceeds the {MAX_NODE_TEXT_BYTES}-byte node limit",
17559                text.len(),
17560            ),
17561        ));
17562    }
17563    Ok(())
17564}
17565
17566fn validate_terminal_bytes(bytes: &[u8]) -> Result<(), NodeFailure> {
17567    if bytes.is_empty() || bytes.len() > MAX_NODE_TERMINAL_BYTES {
17568        return Err(failure(
17569            NodeFailureCode::InvalidRequest,
17570            &format!(
17571                "terminal byte sequence must contain 1..={MAX_NODE_TERMINAL_BYTES} bytes; received {}",
17572                bytes.len(),
17573            ),
17574        ));
17575    }
17576    Ok(())
17577}
17578
17579fn terminal_bytes_action(bytes: Vec<u8>) -> Result<InputAction, NodeFailure> {
17580    validate_terminal_bytes(&bytes)?;
17581    Ok(InputAction::TerminalBytes(bytes))
17582}
17583
17584fn validate_workspace_request_root(root: &str) -> Result<(), NodeFailure> {
17585    if root.is_empty()
17586        || root.len() > MAX_WORKSPACE_ROOT_BYTES
17587        || root.chars().any(char::is_control)
17588    {
17589        return Err(failure(
17590            NodeFailureCode::InvalidWorkspaceRoot,
17591            &format!(
17592                "workspace root must contain 1..={MAX_WORKSPACE_ROOT_BYTES} bytes and no control characters",
17593            ),
17594        ));
17595    }
17596    Ok(())
17597}
17598
17599fn canonical_standalone_workspace_candidate(
17600    workspace_id: &WorkspaceId,
17601    root: &str,
17602) -> Result<String, NodeServerError> {
17603    let path = Path::new(root);
17604    match std::fs::symlink_metadata(path) {
17605        Ok(_) => WorkspaceConfig::new(workspace_id.clone(), path)
17606            .map(|workspace| workspace.canonical_root),
17607        Err(error) if error.kind() == io::ErrorKind::NotFound => {
17608            if !path.is_absolute() {
17609                return Err(NodeServerError::InvalidWorkspaceRoot {
17610                    workspace_id: workspace_id.clone(),
17611                    path: root.to_owned(),
17612                    message: "path is not absolute".to_owned(),
17613                });
17614            }
17615            let name = path.file_name().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17616                workspace_id: workspace_id.clone(),
17617                path: root.to_owned(),
17618                message: "path has no standalone directory name".to_owned(),
17619            })?;
17620            let parent = path.parent().ok_or_else(|| NodeServerError::InvalidWorkspaceRoot {
17621                workspace_id: workspace_id.clone(),
17622                path: root.to_owned(),
17623                message: "path has no parent directory".to_owned(),
17624            })?;
17625            let canonical = std::fs::canonicalize(parent)
17626                .map(|parent| parent.join(name))
17627                .map_err(|error| NodeServerError::InvalidWorkspaceRoot {
17628                    workspace_id: workspace_id.clone(),
17629                    path: root.to_owned(),
17630                    message: error.to_string(),
17631                })?;
17632            let canonical_root = canonical.into_os_string().into_string().map_err(|path| {
17633                NodeServerError::InvalidWorkspaceRoot {
17634                    workspace_id: workspace_id.clone(),
17635                    path: path.to_string_lossy().into_owned(),
17636                    message: "canonical path is not valid Unicode".to_owned(),
17637                }
17638            })?;
17639            let canonical_root = platform::normalize_canonical_root(canonical_root);
17640            validate_workspace_root(workspace_id, &canonical_root)?;
17641            Ok(canonical_root)
17642        }
17643        Err(error) => Err(NodeServerError::InvalidWorkspaceRoot {
17644            workspace_id: workspace_id.clone(),
17645            path: root.to_owned(),
17646            message: error.to_string(),
17647        }),
17648    }
17649}
17650
17651fn validate_git_revision(field: &str, revision: Option<&str>) -> Result<(), NodeFailure> {
17652    let Some(revision) = revision else {
17653        return Ok(());
17654    };
17655    if revision.is_empty()
17656        || revision.len() > MAX_REPOSITORY_PATH_BYTES
17657        || revision.chars().any(char::is_control)
17658    {
17659        return Err(failure(
17660            NodeFailureCode::InvalidRequest,
17661            &format!(
17662                "{field} must contain 1..={MAX_REPOSITORY_PATH_BYTES} bytes and no control characters",
17663            ),
17664        ));
17665    }
17666    Ok(())
17667}
17668
17669fn git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17670    let code = match error.kind {
17671        GitWorktreeErrorKind::Invalid => NodeFailureCode::InvalidRequest,
17672        GitWorktreeErrorKind::NotRepository => NodeFailureCode::NotGitRepository,
17673        GitWorktreeErrorKind::Conflict => NodeFailureCode::WorktreeConflict,
17674        GitWorktreeErrorKind::Protected => NodeFailureCode::WorktreeProtected,
17675        GitWorktreeErrorKind::Dirty => NodeFailureCode::WorktreeDirty,
17676        GitWorktreeErrorKind::Locked => NodeFailureCode::WorktreeLocked,
17677        GitWorktreeErrorKind::Failed => NodeFailureCode::BackendOperationFailed,
17678    };
17679    failure(code, &error.message)
17680}
17681
17682fn standalone_workspace_failure(error: StandaloneWorkspaceError) -> NodeFailure {
17683    let code = match error.kind {
17684        StandaloneWorkspaceErrorKind::InvalidRoot => NodeFailureCode::InvalidWorkspaceRoot,
17685        StandaloneWorkspaceErrorKind::InvalidInitialBranch => NodeFailureCode::InvalidRequest,
17686        StandaloneWorkspaceErrorKind::GitInitializationFailed => {
17687            NodeFailureCode::BackendOperationFailed
17688        }
17689        StandaloneWorkspaceErrorKind::RecoveryRequired => {
17690            NodeFailureCode::StandaloneWorkspaceRecoveryRequired
17691        }
17692    };
17693    failure(code, error.message)
17694}
17695
17696fn managed_git_worktree_failure(error: GitWorktreeError) -> NodeFailure {
17697    let (code, message) = match error.kind {
17698        GitWorktreeErrorKind::Invalid => (
17699            NodeFailureCode::InvalidRequest,
17700            "managed Git input is invalid",
17701        ),
17702        GitWorktreeErrorKind::NotRepository => (
17703            NodeFailureCode::NotGitRepository,
17704            "managed source is not a Git repository",
17705        ),
17706        GitWorktreeErrorKind::Conflict => (
17707            NodeFailureCode::WorktreeConflict,
17708            "managed Git identity conflicts with existing repository state",
17709        ),
17710        GitWorktreeErrorKind::Protected => (
17711            NodeFailureCode::ManagedWorktreeOwnershipConflict,
17712            "managed worktree ownership validation failed",
17713        ),
17714        GitWorktreeErrorKind::Dirty => (
17715            NodeFailureCode::WorktreeDirty,
17716            "managed worktree contains uncommitted changes",
17717        ),
17718        GitWorktreeErrorKind::Locked => (
17719            NodeFailureCode::WorktreeLocked,
17720            "managed worktree is locked",
17721        ),
17722        GitWorktreeErrorKind::Failed => (
17723            NodeFailureCode::BackendOperationFailed,
17724            "managed Git operation failed",
17725        ),
17726    };
17727    NodeFailure { code, message: message.to_owned() }
17728}
17729
17730fn managed_spawn_receipt(
17731    resolved: &ResolvedSpawnSpec,
17732    incarnation_id: NodeIncarnationId,
17733    session: SessionAddress,
17734    lease: ManagedWorktreeLeaseSnapshot,
17735    environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
17736    bundle: Option<ResolvedBundleReceipt>,
17737    context: Option<ResolvedContextPackReceipt>,
17738) -> ManagedWorktreeSpawnReceipt {
17739    let mut managed = resolved.clone();
17740    managed.target.worktree_id = Some(lease.workspace_id.clone());
17741    ManagedWorktreeSpawnReceipt {
17742        spawn: managed.receipt_with_materialization(
17743            incarnation_id,
17744            session,
17745            environment_profile,
17746            bundle,
17747            context,
17748        ),
17749        lease,
17750    }
17751}
17752
17753fn validate_selected_worktree(
17754    source_root: &str,
17755    selected_root: &str,
17756    worktrees: &[NativeGitWorktreeSnapshot],
17757) -> Result<(), NodeFailure> {
17758    if !worktrees
17759        .iter()
17760        .any(|worktree| worktree_paths_equal(&worktree.path, source_root) && !worktree.is_bare)
17761    {
17762        return Err(failure(
17763            NodeFailureCode::WorktreeProtected,
17764            "source workspace is not an authoritative non-bare Git worktree root",
17765        ));
17766    }
17767    if !worktrees.iter().any(|worktree| {
17768        worktree_paths_equal(&worktree.path, selected_root)
17769            && !worktree.is_main
17770            && !worktree.is_bare
17771    }) {
17772        return Err(failure(
17773            NodeFailureCode::WorktreeProtected,
17774            "selected workspace is not an eligible Git worktree in the source repository",
17775        ));
17776    }
17777    Ok(())
17778}
17779
17780fn workspace_file_failure(error: WorkspaceFileReadError) -> NodeFailure {
17781    let code = match error.kind() {
17782        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17783        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17784            NodeFailureCode::RepositoryPathUnsafe
17785        }
17786        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17787        WorkspaceFileReadErrorKind::NotRegularFile => {
17788            NodeFailureCode::RepositoryFileNotRegular
17789        }
17790        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17791            NodeFailureCode::RepositoryFileReadFailed
17792        }
17793        WorkspaceFileReadErrorKind::RevisionConflict => {
17794            NodeFailureCode::RepositoryFileRevisionConflict
17795        }
17796        WorkspaceFileReadErrorKind::AlreadyExists
17797        | WorkspaceFileReadErrorKind::ParentNotFound
17798        | WorkspaceFileReadErrorKind::ParentNotDirectory
17799        | WorkspaceFileReadErrorKind::Canceled => {
17800            NodeFailureCode::RepositoryFileReadFailed
17801        }
17802    };
17803    failure(code, "repository file read failed")
17804}
17805
17806fn workspace_file_write_failure(error: WorkspaceFileReadError) -> NodeFailure {
17807    let code = match error.kind() {
17808        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17809        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17810            NodeFailureCode::RepositoryPathUnsafe
17811        }
17812        WorkspaceFileReadErrorKind::NotFound => NodeFailureCode::RepositoryFileNotFound,
17813        WorkspaceFileReadErrorKind::NotRegularFile => {
17814            NodeFailureCode::RepositoryFileNotRegular
17815        }
17816        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17817            NodeFailureCode::RepositoryFileWriteFailed
17818        }
17819        WorkspaceFileReadErrorKind::RevisionConflict => {
17820            NodeFailureCode::RepositoryFileRevisionConflict
17821        }
17822        WorkspaceFileReadErrorKind::AlreadyExists
17823        | WorkspaceFileReadErrorKind::ParentNotFound
17824        | WorkspaceFileReadErrorKind::ParentNotDirectory
17825        | WorkspaceFileReadErrorKind::Canceled => {
17826            NodeFailureCode::RepositoryFileWriteFailed
17827        }
17828    };
17829    failure(code, "repository file write failed")
17830}
17831
17832async fn settle_workspace_entry_create<T>(
17833    mut task: tokio::task::JoinHandle<Result<T, WorkspaceFileReadError>>,
17834    commit_state: Arc<AtomicU8>,
17835    deadline: Duration,
17836    timeout_message: &'static str,
17837    task_failure_message: &'static str,
17838) -> Result<T, NodeFailure> {
17839    let joined = match timeout(deadline, &mut task).await {
17840        Ok(joined) => joined,
17841        Err(_) => {
17842            if commit_state
17843                .compare_exchange(
17844                    WORKSPACE_ENTRY_CREATE_PENDING,
17845                    WORKSPACE_ENTRY_CREATE_CANCELED,
17846                    Ordering::AcqRel,
17847                    Ordering::Acquire,
17848                )
17849                .is_ok()
17850            {
17851                return Err(failure(
17852                    NodeFailureCode::RepositoryEntryCreateTimedOut,
17853                    timeout_message,
17854                ));
17855            }
17856            debug_assert_eq!(
17857                commit_state.load(Ordering::Acquire),
17858                WORKSPACE_ENTRY_CREATE_COMMITTING,
17859            );
17860            task.await
17861        }
17862    };
17863    joined
17864        .map_err(|_| {
17865            failure(
17866                NodeFailureCode::RepositoryEntryCreateFailed,
17867                task_failure_message,
17868            )
17869        })?
17870        .map_err(workspace_entry_create_failure)
17871}
17872
17873fn workspace_entry_create_failure(error: WorkspaceFileReadError) -> NodeFailure {
17874    let code = match error.kind() {
17875        WorkspaceFileReadErrorKind::InvalidPath => NodeFailureCode::InvalidRepositoryPath,
17876        WorkspaceFileReadErrorKind::UnsafePath | WorkspaceFileReadErrorKind::ReparsePoint => {
17877            NodeFailureCode::RepositoryPathUnsafe
17878        }
17879        WorkspaceFileReadErrorKind::AlreadyExists => {
17880            NodeFailureCode::RepositoryEntryAlreadyExists
17881        }
17882        WorkspaceFileReadErrorKind::ParentNotFound | WorkspaceFileReadErrorKind::NotFound => {
17883            NodeFailureCode::RepositoryParentNotFound
17884        }
17885        WorkspaceFileReadErrorKind::ParentNotDirectory
17886        | WorkspaceFileReadErrorKind::NotRegularFile => {
17887            NodeFailureCode::RepositoryParentNotDirectory
17888        }
17889        WorkspaceFileReadErrorKind::AccessDenied | WorkspaceFileReadErrorKind::Io => {
17890            NodeFailureCode::RepositoryEntryCreateFailed
17891        }
17892        WorkspaceFileReadErrorKind::RevisionConflict => {
17893            NodeFailureCode::RepositoryEntryCreateFailed
17894        }
17895        WorkspaceFileReadErrorKind::Canceled => {
17896            NodeFailureCode::RepositoryEntryCreateTimedOut
17897        }
17898    };
17899    failure(code, "repository entry create failed")
17900}
17901
17902fn workspace_file_revision(bytes: &[u8]) -> WorkspaceFileRevision {
17903    let value = digest(&SHA256, bytes)
17904        .as_ref()
17905        .iter()
17906        .map(|byte| format!("{byte:02x}"))
17907        .collect();
17908    WorkspaceFileRevision::new(value).expect("SHA-256 produces a valid workspace revision")
17909}
17910
17911fn managed_spawn_request_digest_v2(
17912    request: &ManagedWorktreeSpawnRequestV2,
17913) -> Result<String, NodeFailure> {
17914    let encoded = serde_json::to_vec(request).map_err(|_| {
17915        failure(
17916            NodeFailureCode::InvalidRequest,
17917            "managed worktree V2 request could not be canonicalized",
17918        )
17919    })?;
17920    let mut value = String::with_capacity(71);
17921    value.push_str("sha256:");
17922    for byte in digest(&SHA256, &encoded).as_ref() {
17923        use std::fmt::Write as _;
17924        write!(&mut value, "{byte:02x}").expect("writing to a String cannot fail");
17925    }
17926    Ok(value)
17927}
17928
17929fn native_session_preview_failure(error: NativeSessionPreviewError) -> NodeFailure {
17930    let code = match error {
17931        NativeSessionPreviewError::InvalidLimit => NodeFailureCode::InvalidRequest,
17932        NativeSessionPreviewError::WorkspaceUnavailable => NodeFailureCode::UnknownWorkspace,
17933        NativeSessionPreviewError::StaleCatalog => NodeFailureCode::StaleNativeSessionCatalog,
17934        NativeSessionPreviewError::UnsupportedProvider
17935        | NativeSessionPreviewError::PreviewUnavailable
17936        | NativeSessionPreviewError::SessionNotFound
17937        | NativeSessionPreviewError::AmbiguousSession => {
17938            NodeFailureCode::BackendOperationFailed
17939        }
17940    };
17941    failure(code, "native session preview failed")
17942}
17943
17944fn host_directory_failure(error: HostDirectoryBrowseError) -> NodeFailure {
17945    let code = match error.kind() {
17946        HostDirectoryBrowseErrorKind::Invalid => NodeFailureCode::HostDirectoryInvalid,
17947        HostDirectoryBrowseErrorKind::ReadFailed => NodeFailureCode::HostDirectoryReadFailed,
17948    };
17949    failure(code, "host directory browse failed")
17950}
17951
17952fn delivery_failure(error: DeliveryStoreError) -> NodeFailure {
17953    let code = match error {
17954        DeliveryStoreError::InvalidManifest => NodeFailureCode::DeliveryManifestInvalid,
17955        DeliveryStoreError::UnknownStage => NodeFailureCode::UnknownDeliveryStage,
17956        DeliveryStoreError::Capacity | DeliveryStoreError::StageConflict => {
17957            NodeFailureCode::DeliveryStageConflict
17958        }
17959        DeliveryStoreError::UnexpectedBlob => NodeFailureCode::DeliveryBlobUnexpected,
17960        DeliveryStoreError::ChunkOutOfOrder | DeliveryStoreError::ChunkOverflow => {
17961            NodeFailureCode::DeliveryChunkOutOfOrder
17962        }
17963        DeliveryStoreError::BlobDigestMismatch => {
17964            NodeFailureCode::DeliveryBlobDigestMismatch
17965        }
17966        DeliveryStoreError::BundleDigestMismatch => {
17967            NodeFailureCode::DeliveryBundleDigestMismatch
17968        }
17969        DeliveryStoreError::StageIncomplete => NodeFailureCode::DeliveryStageIncomplete,
17970        DeliveryStoreError::Unavailable
17971        | DeliveryStoreError::Corrupt
17972        | DeliveryStoreError::Storage(_) => NodeFailureCode::DeliveryStageStorageFailed,
17973    };
17974    failure(code, "delivery operation failed")
17975}
17976
17977fn validate_workspace_root(
17978    workspace_id: &WorkspaceId,
17979    canonical_root: &str,
17980) -> Result<(), NodeServerError> {
17981    #[cfg(windows)]
17982    if canonical_root.starts_with(r"\\") {
17983        return Err(NodeServerError::InvalidWorkspaceRoot {
17984            workspace_id: workspace_id.clone(),
17985            path: canonical_root.to_owned(),
17986            message: "UNC workspace roots are unsupported by Windows PTY providers".to_owned(),
17987        });
17988    }
17989    if !platform::workspace_root_supported(canonical_root)
17990        || canonical_root.len() > WORKING_DIRECTORY_MAX_BYTES
17991    {
17992        return Err(NodeServerError::InvalidWorkspaceRoot {
17993            workspace_id: workspace_id.clone(),
17994            path: canonical_root.to_owned(),
17995            message: "canonical path must be absolute, bounded, and free of control characters"
17996                .to_owned(),
17997        });
17998    }
17999    Ok(())
18000}
18001
18002fn failure(code: NodeFailureCode, _message: &str) -> NodeFailure {
18003    NodeFailure {
18004        code,
18005        message: node_failure_category(code).to_owned(),
18006    }
18007}
18008
18009fn harness_mcp_failure(error: HarnessMcpProxyError) -> NodeFailure {
18010    let code = match error {
18011        HarnessMcpProxyError::Unavailable => NodeFailureCode::HarnessMcpUnavailable,
18012        HarnessMcpProxyError::NotFound => NodeFailureCode::ReservationNotFound,
18013        HarnessMcpProxyError::Conflict => NodeFailureCode::ReservationConflict,
18014        HarnessMcpProxyError::Expired => NodeFailureCode::ReservationExpired,
18015        HarnessMcpProxyError::BindingMismatch => NodeFailureCode::BindingMismatch,
18016        HarnessMcpProxyError::NotActivated => NodeFailureCode::NotActivated,
18017        HarnessMcpProxyError::CallNotFound => NodeFailureCode::CallNotFound,
18018        HarnessMcpProxyError::ChunkOutOfOrder => NodeFailureCode::ChunkOutOfOrder,
18019        HarnessMcpProxyError::ResponseTooLarge => NodeFailureCode::ResponseTooLarge,
18020    };
18021    failure(code, "harness MCP proxy operation failed")
18022}
18023
18024/// Numbers each harness-MCP overlay this process constructs, in
18025/// construction order, so two overlays built back to back in one node process
18026/// still get distinct trace files.
18027static NEXT_HARNESS_MCP_TRACE_SEQUENCE: AtomicU64 = AtomicU64::new(1);
18028
18029/// Builds the optional trace environment entry of one harness-MCP overlay.
18030/// The caller's launch description names both the entry and the node-process
18031/// variable that opts in: when that variable holds a non-empty directory, the
18032/// entry names a trace file under it. Returns `None` when the launch has no
18033/// trace, or the directory is unset, empty, or not valid Unicode. Never creates
18034/// the directory; that is the operator's job.
18035fn harness_mcp_trace_env_entry(launch: &HarnessMcpLaunchV1) -> Option<(OsString, OsString)> {
18036    let trace = launch.trace.as_ref()?;
18037    let trace_dir = std::env::var(&trace.dir_env).ok()?;
18038    if trace_dir.is_empty() {
18039        return None;
18040    }
18041    let sequence = NEXT_HARNESS_MCP_TRACE_SEQUENCE.fetch_add(1, Ordering::Relaxed);
18042    let path = std::path::Path::new(&trace_dir).join(format!("harness-mcp-{sequence}.trace"));
18043    Some((OsString::from(trace.env.as_str()), path.into_os_string()))
18044}
18045
18046fn spawn_deadline_remaining(deadline: Instant) -> Result<Duration, NodeFailure> {
18047    let remaining = deadline.saturating_duration_since(Instant::now());
18048    if remaining.is_zero() {
18049        Err(failure(
18050            NodeFailureCode::SpawnDeadlineExceeded,
18051            "spawn deadline elapsed",
18052        ))
18053    } else {
18054        Ok(remaining)
18055    }
18056}
18057
18058fn spawn_dispatch_timeout(deadline: Option<Instant>) -> Result<Duration, NodeFailure> {
18059    let bounded = Duration::from_millis(SPAWN_DISPATCH_TIMEOUT_MS);
18060    match deadline {
18061        Some(deadline) => Ok(spawn_deadline_remaining(deadline)?.min(bounded)),
18062        None => Ok(bounded),
18063    }
18064}
18065
18066fn spawn_dispatch_error(error: NodeFailure, deadline: Option<Instant>) -> NodeFailure {
18067    if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
18068        failure(
18069            NodeFailureCode::SpawnDeadlineExceeded,
18070            "spawn dispatch exceeded the spawn deadline",
18071        )
18072    } else {
18073        error
18074    }
18075}
18076
18077fn spawn_runtime_capabilities(
18078    capabilities: &SpawnRequiredCapabilities,
18079) -> Result<Vec<ProviderRuntimeCapability>, NodeFailure> {
18080    capabilities
18081        .iter()
18082        .map(|capability| match capability.as_str() {
18083            SPAWN_RUNTIME_RAW_PTY_LIFECYCLE => Ok(ProviderRuntimeCapability::RawPtyLifecycle),
18084            SPAWN_RUNTIME_SEMANTIC_READINESS => Ok(ProviderRuntimeCapability::SemanticReadiness),
18085            SPAWN_RUNTIME_STRUCTURED_PROMPT => Ok(ProviderRuntimeCapability::StructuredPrompt),
18086            SPAWN_RUNTIME_PROVIDER_SESSION_IDENTITY => {
18087                Ok(ProviderRuntimeCapability::ProviderSessionIdentity)
18088            }
18089            SPAWN_RUNTIME_SEMANTIC_RESUME => Ok(ProviderRuntimeCapability::SemanticResume),
18090            _ => Err(failure(
18091                NodeFailureCode::UnsupportedSpawnCapability,
18092                "spawn required capability is unknown",
18093            )),
18094        })
18095        .collect()
18096}
18097
18098fn persistence_failure(_error: io::Error) -> NodeFailure {
18099    NodeFailure {
18100        code: NodeFailureCode::BackendOperationFailed,
18101        message: DURABLE_STATE_COMMIT_FAILED_ERROR.to_owned(),
18102    }
18103}
18104
18105fn node_failure_category(code: NodeFailureCode) -> &'static str {
18106    match code {
18107        NodeFailureCode::InvalidRequest => "invalid-request",
18108        NodeFailureCode::UnsupportedCapability => "unsupported-capability",
18109        NodeFailureCode::HarnessMcpUnavailable => "harness-mcp-unavailable",
18110        NodeFailureCode::ReservationNotFound => "reservation-not-found",
18111        NodeFailureCode::ReservationConflict => "reservation-conflict",
18112        NodeFailureCode::ReservationExpired => "reservation-expired",
18113        NodeFailureCode::BindingMismatch => "binding-mismatch",
18114        NodeFailureCode::NotActivated => "not-activated",
18115        NodeFailureCode::CallNotFound => "call-not-found",
18116        NodeFailureCode::ChunkOutOfOrder => "chunk-out-of-order",
18117        NodeFailureCode::ResponseTooLarge => "response-too-large",
18118        NodeFailureCode::DeliveryManifestInvalid => "delivery-manifest-invalid",
18119        NodeFailureCode::UnknownDeliveryStage => "unknown-delivery-stage",
18120        NodeFailureCode::DeliveryStageConflict => "delivery-stage-conflict",
18121        NodeFailureCode::DeliveryBlobUnexpected => "delivery-blob-unexpected",
18122        NodeFailureCode::DeliveryChunkOutOfOrder => "delivery-chunk-out-of-order",
18123        NodeFailureCode::DeliveryBlobDigestMismatch => "delivery-blob-digest-mismatch",
18124        NodeFailureCode::DeliveryBundleDigestMismatch => "delivery-bundle-digest-mismatch",
18125        NodeFailureCode::DeliveryStageIncomplete => "delivery-stage-incomplete",
18126        NodeFailureCode::DeliveryStageStorageFailed => "delivery-stage-storage-failed",
18127        NodeFailureCode::Unauthorized => "unauthorized",
18128        NodeFailureCode::ObserverReadOnly => "observer-read-only",
18129        NodeFailureCode::ControllerBusy => "controller-busy",
18130        NodeFailureCode::ControllerRequired => "controller-required",
18131        NodeFailureCode::UnknownWorkspace => "unknown-workspace",
18132        NodeFailureCode::HostDirectoryInvalid => "host-directory-invalid",
18133        NodeFailureCode::HostDirectoryReadFailed => "host-directory-read-failed",
18134        NodeFailureCode::HostDirectoryReadTimedOut => "host-directory-read-timed-out",
18135        NodeFailureCode::InvalidRepositoryPath => "invalid-repository-path",
18136        NodeFailureCode::RepositoryFileNotFound => "repository-file-not-found",
18137        NodeFailureCode::RepositoryFileNotRegular => "repository-file-not-regular",
18138        NodeFailureCode::RepositoryPathUnsafe => "repository-path-unsafe",
18139        NodeFailureCode::RepositoryFileReadTimedOut => "repository-file-read-timed-out",
18140        NodeFailureCode::RepositoryFileReadFailed => "repository-file-read-failed",
18141        NodeFailureCode::RepositoryFileWriteTimedOut => "repository-file-write-timed-out",
18142        NodeFailureCode::RepositoryFileWriteFailed => "repository-file-write-failed",
18143        NodeFailureCode::RepositoryFileRevisionConflict => "repository-file-revision-conflict",
18144        NodeFailureCode::RepositoryEntryAlreadyExists => "repository-entry-already-exists",
18145        NodeFailureCode::RepositoryParentNotFound => "repository-parent-not-found",
18146        NodeFailureCode::RepositoryParentNotDirectory => "repository-parent-not-directory",
18147        NodeFailureCode::RepositoryEntryCreateTimedOut => "repository-entry-create-timed-out",
18148        NodeFailureCode::RepositoryEntryCreateFailed => "repository-entry-create-failed",
18149        NodeFailureCode::GitReadTimedOut => "git-read-timed-out",
18150        NodeFailureCode::GitReadFailed => "git-read-failed",
18151        NodeFailureCode::UnknownSpawnProfile => "unknown-spawn-profile",
18152        NodeFailureCode::SpawnProfileRevisionMismatch => "spawn-profile-revision-mismatch",
18153        NodeFailureCode::SpawnTargetMismatch => "spawn-target-mismatch",
18154        NodeFailureCode::SpawnIdempotencyConflict => "spawn-idempotency-conflict",
18155        NodeFailureCode::SpawnIdempotencyCapacity => "spawn-idempotency-capacity",
18156        NodeFailureCode::SpawnDeadlineExceeded => "spawn-deadline-exceeded",
18157        NodeFailureCode::UnsupportedSpawnCapability => "unsupported-spawn-capability",
18158        NodeFailureCode::UnsupportedTransport => "unsupported-transport",
18159        NodeFailureCode::TurnInFlight => "turn-in-flight",
18160        NodeFailureCode::UnknownEnvironmentProfile => "unknown-environment-profile",
18161        NodeFailureCode::UnknownNetworkAllowlist => "unknown-network-allowlist",
18162        NodeFailureCode::UnsupportedNetworkAllowlistMapping => {
18163            "unsupported-network-allowlist-mapping"
18164        }
18165        NodeFailureCode::BrowserStationProbeUnavailable => {
18166            "browser-station-probe-unavailable"
18167        }
18168        NodeFailureCode::BrowserStationUnreachable => "browser-station-unreachable",
18169        NodeFailureCode::BrowserStationProfileBusy => "browser-station-profile-busy",
18170        NodeFailureCode::EnvironmentProfileBindingMismatch => {
18171            "environment-profile-binding-mismatch"
18172        }
18173        NodeFailureCode::UnknownBundle => "unknown-bundle",
18174        NodeFailureCode::BundleBindingMismatch => "bundle-binding-mismatch",
18175        NodeFailureCode::BundleMaterializationFailed => "bundle-materialization-failed",
18176        NodeFailureCode::UnknownContextPack => "unknown-context-pack",
18177        NodeFailureCode::ContextPackBusy => "context-pack-busy",
18178        NodeFailureCode::ContextPackMaterializationFailed => {
18179            "context-pack-materialization-failed"
18180        }
18181        NodeFailureCode::InvalidWorkspaceRoot => "invalid-workspace-root",
18182        NodeFailureCode::DuplicateWorkspaceId => "duplicate-workspace-id",
18183        NodeFailureCode::DuplicateWorkspaceRoot => "duplicate-workspace-root",
18184        NodeFailureCode::WorkspaceBusy => "workspace-busy",
18185        NodeFailureCode::LastWorkspace => "last-workspace",
18186        NodeFailureCode::NotGitRepository => "not-git-repository",
18187        NodeFailureCode::WorktreeConflict => "worktree-conflict",
18188        NodeFailureCode::WorktreeProtected => "worktree-protected",
18189        NodeFailureCode::WorktreeDirty => "worktree-dirty",
18190        NodeFailureCode::WorktreeLocked => "worktree-locked",
18191        NodeFailureCode::UnknownManagedWorktreeLease => "unknown-managed-worktree-lease",
18192        NodeFailureCode::ManagedWorktreeBusy => "managed-worktree-busy",
18193        NodeFailureCode::ManagedWorktreeOwnershipConflict => "managed-worktree-ownership-conflict",
18194        NodeFailureCode::ManagedWorktreeProfileRevisionMismatch => {
18195            "managed-worktree-profile-revision-mismatch"
18196        }
18197        NodeFailureCode::ManagedWorktreeRecoveryRequired => "managed-worktree-recovery-required",
18198        NodeFailureCode::StandaloneWorkspaceRecoveryRequired => {
18199            "standalone-workspace-recovery-required"
18200        }
18201        NodeFailureCode::UnknownSession => "unknown-session",
18202        NodeFailureCode::UnknownSessionRecord => "unknown-session-record",
18203        NodeFailureCode::SessionRecordNotResumable => "session-record-not-resumable",
18204        NodeFailureCode::SessionRecordBusy => "session-record-busy",
18205        NodeFailureCode::SessionRecordConflict => "session-record-conflict",
18206        NodeFailureCode::SessionWorkspaceMismatch => "session-workspace-mismatch",
18207        NodeFailureCode::WorkspaceRegistrationRequired => {
18208            "workspace-registration-required"
18209        }
18210        NodeFailureCode::StaleNativeSessionCatalog => "stale-native-session-catalog",
18211        NodeFailureCode::StaleGeneration => "stale-generation",
18212        NodeFailureCode::BackendBusy => "backend-busy",
18213        NodeFailureCode::BackendDisconnected => "backend-disconnected",
18214        NodeFailureCode::BackendOperationFailed => "backend-operation-failed",
18215        NodeFailureCode::ShuttingDown => "shutting-down",
18216    }
18217}
18218
18219fn durable_state_server_error(error: io::Error, category: &'static str) -> NodeServerError {
18220    NodeServerError::DurableState(io::Error::new(error.kind(), category))
18221}
18222
18223fn durable_state_load_error(error: io::Error) -> NodeServerError {
18224    let category = match session_registry::state_load_refusal(&error) {
18225        Some(session_registry::StateLoadRefusal::UnsupportedSchema) => {
18226            DURABLE_STATE_SCHEMA_UNSUPPORTED_ERROR
18227        }
18228        Some(session_registry::StateLoadRefusal::PathSemanticsUnsupported) => {
18229            DURABLE_STATE_PATH_SEMANTICS_UNSUPPORTED_ERROR
18230        }
18231        Some(session_registry::StateLoadRefusal::NodeIdentityMismatch) => {
18232            DURABLE_STATE_CONFLICT_ERROR
18233        }
18234        _ => DURABLE_STATE_LOAD_FAILED_ERROR,
18235    };
18236    durable_state_server_error(error, category)
18237}
18238
18239fn unix_time_ms() -> u64 {
18240    std::time::SystemTime::now()
18241        .duration_since(std::time::UNIX_EPOCH)
18242        .unwrap_or_default()
18243        .as_millis()
18244        .min(u64::MAX as u128) as u64
18245}
18246
18247fn detached_record_state(record: &ManagedSessionRecord) -> ManagedSessionState {
18248    if record.provider_session.is_some() {
18249        ManagedSessionState::Dormant
18250    } else {
18251        ManagedSessionState::Unavailable
18252    }
18253}
18254
18255fn active_record_state(
18256    identity_admitted: bool,
18257    provider_session_is_known: bool,
18258) -> ManagedSessionState {
18259    if identity_admitted && !provider_session_is_known {
18260        ManagedSessionState::IdentityPending
18261    } else {
18262        ManagedSessionState::Live
18263    }
18264}
18265
18266fn managed_worktree_record_holders(
18267    records: &[ManagedSessionRecord],
18268) -> Vec<(SessionRecordId, WorkspaceId)> {
18269    records
18270        .iter()
18271        .filter(|record| record.provider_session.is_some())
18272        .map(|record| (record.record_id.clone(), record.workspace_id.clone()))
18273        .collect()
18274}
18275
18276fn managed_spawn_record_ownership(
18277    binding: &SessionBinding,
18278    effective_runtime_policy: ProviderRuntimePolicy,
18279) -> (Option<SessionRecordId>, Option<SessionRecordId>) {
18280    debug_assert_eq!(binding.runtime_policy, effective_runtime_policy);
18281    if effective_runtime_policy.provider_session_identity {
18282        (binding.record_id.clone(), None)
18283    } else {
18284        (None, binding.record_id.clone())
18285    }
18286}
18287
18288#[derive(Debug, Error)]
18289pub enum NodeServerError {
18290    #[error("node endpoint must be a bounded absolute local transport path")]
18291    InvalidEndpoint,
18292    #[error("node access token must contain 1..=4096 bytes")]
18293    InvalidAccessToken,
18294    #[error("node HTTP observer must listen on a loopback address: {0}")]
18295    InvalidApiListen(std::net::SocketAddr),
18296    #[error("node browser bridge must listen on a loopback address: {0}")]
18297    InvalidBridgeListen(std::net::SocketAddr),
18298    #[error("node browser bridge token must contain 1..=4096 bytes")]
18299    InvalidBridgeToken,
18300    #[error("bridge underlay requires --bridge-listen (local HTTP+WS door)")]
18301    BridgeUnderlayRequiresBridgeListen,
18302    #[error("bridge underlay requires GATE4AGENT_BRIDGE_TOKEN (application barrier)")]
18303    BridgeUnderlayRequiresBridgeToken,
18304    #[error("mesh underlay auth token must contain 1..=4096 bytes")]
18305    InvalidBridgeUnderlayToken,
18306    #[error("mesh underlay transport key must be 32 bytes (64 hex digits)")]
18307    InvalidBridgeUnderlayKey,
18308    #[error("node call-home relay must be loopback or a concrete C2 tunnel address with a nonzero port: {0}")]
18309    InvalidCallHome(std::net::SocketAddr),
18310    #[error("node could not reach its call-home relay: {0}")]
18311    CallHomeTransport(#[source] std::io::Error),
18312    #[error("node durable state path must be an absolute file path: {0}")]
18313    InvalidStatePath(String),
18314    #[error("harness MCP helper must be an exact reviewed absolute regular file")]
18315    InvalidHarnessMcpHelper,
18316    #[error("network allowlist catalog path must be an absolute regular file: {0}")]
18317    InvalidNetworkAllowlistCatalogPath(String),
18318    #[error("network allowlist catalog could not be read: {0}")]
18319    NetworkAllowlistCatalogIo(#[source] std::io::Error),
18320    #[error("network allowlist catalog line {line}: {message}")]
18321    InvalidNetworkAllowlistCatalogEntry { line: usize, message: String },
18322    #[error("network allowlist catalog: {0}")]
18323    InvalidNetworkAllowlistCatalog(String),
18324    #[error("network allowlist catalog exceeds the {max}-entry limit")]
18325    NetworkAllowlistCatalogCapacity { max: usize },
18326    #[error("the local state directory is unavailable; supply an explicit state path")]
18327    LocalStateDirectoryUnavailable,
18328    #[error("the local runtime directory is unavailable; supply an explicit endpoint")]
18329    LocalRuntimeDirectoryUnavailable,
18330    #[error("node durable state failed: {0}")]
18331    DurableState(io::Error),
18332    #[error("node requires at least one configured workspace")]
18333    NoWorkspaces,
18334    #[error("workspace '{workspace_id}' root '{path}' is invalid: {message}")]
18335    InvalidWorkspaceRoot {
18336        workspace_id: WorkspaceId,
18337        path: String,
18338        message: String,
18339    },
18340    #[error("duplicate workspace ID: {0}")]
18341    DuplicateWorkspaceId(WorkspaceId),
18342    #[error("workspaces '{first}' and '{second}' resolve to the same root '{root}'")]
18343    DuplicateWorkspaceRoot {
18344        first: WorkspaceId,
18345        second: WorkspaceId,
18346        root: String,
18347    },
18348    #[error("workspace '{0}' uses managed worktrees but has no local profile")]
18349    ManagedWorktreeProfileRequired(WorkspaceId),
18350    #[error("workspace '{0}' has managed worktree profiles but is not in managed mode")]
18351    ManagedWorktreeProfileModeMismatch(WorkspaceId),
18352    #[error("workspace '{workspace_id}' has duplicate managed worktree profile '{profile_id}'")]
18353    DuplicateManagedWorktreeProfile {
18354        workspace_id: WorkspaceId,
18355        profile_id: WorktreeProfileId,
18356    },
18357    #[error("workspace '{workspace_id}' exceeds the {max}-profile managed worktree limit")]
18358    ManagedWorktreeProfileCapacity {
18359        workspace_id: WorkspaceId,
18360        max: usize,
18361    },
18362    #[error("workspace '{workspace_id}' managed worktree profile '{profile_id}' is invalid: {message}")]
18363    InvalidManagedWorktreeProfile {
18364        workspace_id: WorkspaceId,
18365        profile_id: WorktreeProfileId,
18366        message: String,
18367    },
18368    #[error("duplicate node environment profile: {0}")]
18369    DuplicateEnvironmentProfile(SpawnEnvironmentProfileId),
18370    #[error("node environment profile provider is unavailable: {0}")]
18371    EnvironmentProfileProviderUnavailable(AgentId),
18372    #[error("node environment profile capacity is {max}")]
18373    EnvironmentProfileCapacity { max: usize },
18374    #[error("node environment profiles reuse native profile ID '{0}'")]
18375    DuplicateNativeEnvironmentProfile(String),
18376    #[error("node native environment profile is invalid: {0}")]
18377    NativeEnvironmentProfile(String),
18378    #[error("node session-environment materialization root must be absolute")]
18379    InvalidSessionEnvironmentRoot,
18380    #[error("node session-environment materializer is unavailable")]
18381    SessionEnvironmentMaterializerRequired,
18382    #[error("node session-environment materializer failed to initialize")]
18383    SessionEnvironmentMaterializer,
18384    #[error("node bundle catalog is invalid: {0}")]
18385    BundleCatalog(String),
18386    #[error("node delivery store failed to initialize: {0}")]
18387    DeliveryStore(String),
18388    #[error("node context pack store failed to initialize")]
18389    ContextPackStore,
18390    #[error("active agent registry failed: {0}")]
18391    Registry(String),
18392    #[error("node provider contract manifest is invalid: {0}")]
18393    ProviderContractManifest(String),
18394    #[error("named pipe I/O failed: {0}")]
18395    Io(#[from] io::Error),
18396    #[error("node HTTP observer failed: {0}")]
18397    HttpApi(io::Error),
18398    #[error("node browser bridge failed: {0}")]
18399    BridgeApi(io::Error),
18400    #[error(transparent)]
18401    Frame(#[from] FrameError),
18402    #[error("node handshake failed: {0}")]
18403    Handshake(String),
18404    #[error("node authentication frame was not received before the bounded deadline")]
18405    AuthenticationTimedOut,
18406    #[error("node authentication primitive failed: {0}")]
18407    Authentication(String),
18408    #[error("node incarnation identity generation failed: {0}")]
18409    IncarnationIdentity(String),
18410    #[error("node pre-authentication limiter is closed")]
18411    PreauthClosed,
18412    #[error("node authenticated connection limit was reached")]
18413    AuthenticatedConnectionLimit,
18414    #[error("node Ctrl+C/CtrlBreak signal stream closed")]
18415    SignalStreamClosed,
18416    #[error("node stop-all dispatch failed: {0:?}")]
18417    ShutdownDispatch(NodeFailure),
18418    #[error("node shutdown timed out with {active_native_sessions} physical sessions retained")]
18419    ShutdownTimedOut { active_native_sessions: usize },
18420}
18421
18422impl From<crate::network_allowlist_catalog::NetworkAllowlistCatalogError> for NodeServerError {
18423    fn from(error: crate::network_allowlist_catalog::NetworkAllowlistCatalogError) -> Self {
18424        use crate::network_allowlist_catalog::NetworkAllowlistCatalogError as E;
18425        match error {
18426            E::InvalidPath(path) => Self::InvalidNetworkAllowlistCatalogPath(path),
18427            E::Io(error) => Self::NetworkAllowlistCatalogIo(error),
18428            E::InvalidEntry { line, message } => {
18429                Self::InvalidNetworkAllowlistCatalogEntry { line, message }
18430            }
18431            E::InvalidCatalog(message) => Self::InvalidNetworkAllowlistCatalog(message),
18432            E::Capacity { max } => Self::NetworkAllowlistCatalogCapacity { max },
18433        }
18434    }
18435}
18436
18437#[cfg(test)]
18438mod agent_stream_blocked_tests {
18439    use super::*;
18440    use gate4agent_types::{
18441        AdapterId, AdapterVerification, HostRequestOutcome as ProviderHostRequestOutcome,
18442        ProviderSource,
18443    };
18444
18445    fn provider_source(family: AdapterFamily, adapter: &str) -> ProviderSource {
18446        ProviderSource {
18447            family,
18448            binding: AdapterBinding::new(
18449                AdapterId::new(adapter).unwrap(),
18450                "fixture/v1",
18451                AdapterVerification::SyntheticFixture,
18452            )
18453            .unwrap(),
18454        }
18455    }
18456
18457    fn provider_control_event_at(
18458        family: AdapterFamily,
18459        adapter: &str,
18460        source_sequence: u64,
18461        provider_event: ProviderEvent,
18462    ) -> ControlEvent {
18463        ControlEvent {
18464            sequence: 41,
18465            command_id: None,
18466            instance_id: AgentInstanceId(7),
18467            generation: SessionGeneration(3),
18468            event: ControlEventKind::ProviderEvent {
18469                sequence: 8,
18470                source: provider_source(family, adapter),
18471                source_sequence,
18472                event: provider_event,
18473            },
18474        }
18475    }
18476
18477    fn provider_control_event(family: AdapterFamily, provider_event: ProviderEvent) -> ControlEvent {
18478        provider_control_event_at(family, "fixture", 9, provider_event)
18479    }
18480
18481    fn denied_host_request(
18482        by: ProviderHostDecisionAuthority,
18483        reason: Option<String>,
18484    ) -> ControlEvent {
18485        provider_control_event_at(
18486            AdapterFamily::Acp,
18487            "claude-code",
18488            2,
18489            ProviderEvent::HostRequestObserved {
18490                method: "terminal/create".to_owned(),
18491                params_json: String::new(),
18492                decision: ProviderHostRequestDecision::Denied { by },
18493                outcome: ProviderHostRequestOutcome::Executed,
18494                reason,
18495            },
18496        )
18497    }
18498
18499    fn blocked(
18500        event: &ControlEvent,
18501    ) -> (
18502        Option<String>,
18503        String,
18504        BlockAuthorityV1,
18505        Option<String>,
18506        String,
18507        Option<String>,
18508    ) {
18509        let chunk = agent_stream_chunk(event).expect("expected a Blocked chunk");
18510        chunk.validate().expect("minted chunk must validate");
18511        let AgentStreamChunkKindV1::Blocked {
18512            correlation_id,
18513            tool_class,
18514            authority,
18515            reason_kind,
18516            reason,
18517            help,
18518        } = chunk.kind
18519        else {
18520            panic!("expected AgentStreamChunkKindV1::Blocked");
18521        };
18522        (correlation_id, tool_class, authority, reason_kind, reason, help)
18523    }
18524
18525    /// Pins the fleet and every adapter each member declares: the fleet is
18526    /// exactly what `active_registry` says, and no member declares a hook
18527    /// adapter (lifecycle hooks are retired).
18528    #[test]
18529    fn the_enabled_provider_matrix_is_exact() {
18530        let registry = active_registry().unwrap();
18531        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
18532        assert_eq!(
18533            providers
18534                .iter()
18535                .map(|contract| contract.provider.as_str())
18536                .collect::<Vec<_>>(),
18537            // `shell` is the plain PTY provider (operator terminals and smoke tests).
18538            vec!["shell", "claude", "codex", "grok", "kimi"],
18539        );
18540        assert!(adapters.iter().all(|contract| {
18541            !matches!(contract.family, AdapterFamily::Hook | AdapterFamily::ManagedHook)
18542        }));
18543    }
18544
18545    /// A gate-denied host request, WITH its reason text carried all the way
18546    /// from the ACP host, mints a `Blocked` chunk naming the gate.
18547    #[test]
18548    fn gate_denied_host_request_mints_a_blocked_chunk() {
18549        let gate_text =
18550            "blocked by dangerous-command gate: rule=filesystem-wipe, argument=rm -rf /";
18551        let event = denied_host_request(
18552            ProviderHostDecisionAuthority::Gate,
18553            Some(gate_text.to_owned()),
18554        );
18555        let chunk = agent_stream_chunk(&event).expect("expected a Blocked chunk");
18556        assert_eq!(chunk.source_sequence, 2);
18557        let (correlation_id, tool_class, authority, reason_kind, reason, help) = blocked(&event);
18558        assert_eq!(authority, BlockAuthorityV1::HostGate);
18559        assert_eq!(reason, gate_text);
18560        assert_eq!(correlation_id, None);
18561        assert_eq!(reason_kind, None);
18562        assert_eq!(help, None);
18563        assert_eq!(tool_class, "Write");
18564    }
18565
18566    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.";
18567
18568    /// The owner's classifier sample, verbatim, arriving as a
18569    /// `tool_call_update`-derived `ProviderEvent::ToolCompleted` with
18570    /// `non_execution_kind: "permission-rule"` -- mints a `Blocked` chunk with
18571    /// `authority: ProviderPermissionRule`, the `Reason:` sentence as `reason`
18572    /// and the guidance tail as `help`.
18573    #[test]
18574    fn classifier_sample_permission_rule_mints_a_blocked_chunk() {
18575        let event = provider_control_event(
18576            AdapterFamily::Acp,
18577            ProviderEvent::ToolCompleted {
18578                id: "toolu_9".to_owned(),
18579                output: CLASSIFIER_SAMPLE.to_owned(),
18580                is_error: true,
18581                duration_ms: None,
18582                agent_id: None,
18583                non_execution_kind: Some("permission-rule".to_owned()),
18584            },
18585        );
18586        let (correlation_id, _, authority, reason_kind, reason, help) = blocked(&event);
18587        assert_eq!(authority, BlockAuthorityV1::ProviderPermissionRule);
18588        assert_eq!(reason, "Blocked by classifier");
18589        assert_eq!(reason_kind, Some("permission-rule".to_owned()));
18590        assert!(help.as_deref().is_some_and(|help| help.starts_with("If you have other tasks")));
18591        assert!(correlation_id.is_some(), "a tool-correlated block must carry a correlation id");
18592    }
18593
18594    /// A `session/prompt` RPC error with `data.codexErrorInfo:
18595    /// "usageLimitExceeded"` (measured live 2026-09-05) mints a `Blocked` chunk
18596    /// with `authority: ProviderQuota`, the message verbatim as `reason` and the
18597    /// vendor code as `reason_kind`.
18598    #[test]
18599    fn codex_quota_rpc_error_mints_a_blocked_chunk() {
18600        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.";
18601        let event = provider_control_event(
18602            AdapterFamily::Acp,
18603            ProviderEvent::SessionEnded {
18604                result: "JSON-RPC error -32603: Internal error".to_owned(),
18605                cost_usd: None,
18606                is_error: true,
18607                stop_reason: Some(ProviderStopReason::ProviderError {
18608                    code: -32603,
18609                    message: message.to_owned(),
18610                    vendor_code: Some("usageLimitExceeded".to_owned()),
18611                }),
18612            },
18613        );
18614        let (_, tool_class, authority, reason_kind, reason, _) = blocked(&event);
18615        assert_eq!(authority, BlockAuthorityV1::ProviderQuota);
18616        assert_eq!(reason, message);
18617        assert_eq!(reason_kind, Some("usageLimitExceeded".to_owned()));
18618        assert_eq!(tool_class, "Session");
18619    }
18620
18621    /// ACP's `stopReason: "refusal"` mints a `Blocked` chunk with
18622    /// `authority: ProviderRefusal`.
18623    #[test]
18624    fn session_end_stop_reason_refusal_mints_a_blocked_chunk() {
18625        let event = provider_control_event(
18626            AdapterFamily::Acp,
18627            ProviderEvent::SessionEnded {
18628                result: "refusal".to_owned(),
18629                cost_usd: None,
18630                is_error: true,
18631                stop_reason: Some(ProviderStopReason::Refusal),
18632            },
18633        );
18634        let (_, _, authority, _, _, _) = blocked(&event);
18635        assert_eq!(authority, BlockAuthorityV1::ProviderRefusal);
18636    }
18637
18638    /// A failed tool completion with no typed `non_execution_kind` -- or one
18639    /// that only says the turn stopped -- is a plain execution failure, never a
18640    /// block; nor is an agent's own narrative claiming one.
18641    #[test]
18642    fn failures_and_narrative_without_a_typed_block_mint_no_blocked_chunk() {
18643        for non_execution_kind in [None, Some("interrupted".to_owned()), Some("cancelled".to_owned())] {
18644            let event = provider_control_event(
18645                AdapterFamily::Acp,
18646                ProviderEvent::ToolCompleted {
18647                    id: "toolu_1".to_owned(),
18648                    output: "command exited with status 1".to_owned(),
18649                    is_error: true,
18650                    duration_ms: Some(12),
18651                    agent_id: None,
18652                    non_execution_kind,
18653                },
18654            );
18655            assert!(agent_stream_chunk(&event).is_none());
18656        }
18657        let narrative = provider_control_event(
18658            AdapterFamily::Acp,
18659            ProviderEvent::ToolCompleted {
18660                id: "toolu_2".to_owned(),
18661                output: "the harness blocked the write, approval needed".to_owned(),
18662                is_error: false,
18663                duration_ms: Some(3),
18664                agent_id: None,
18665                non_execution_kind: None,
18666            },
18667        );
18668        assert!(agent_stream_chunk(&narrative).is_none());
18669    }
18670
18671    /// `Granted`/`Deferred` decisions never mint a `Blocked` chunk, whatever the
18672    /// execution outcome: a granted request that failed while running was
18673    /// still authorized.
18674    #[test]
18675    fn granted_and_deferred_host_requests_never_mint_a_blocked_chunk() {
18676        for (decision, outcome) in [
18677            (
18678                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18679                ProviderHostRequestOutcome::Executed,
18680            ),
18681            (ProviderHostRequestDecision::Deferred, ProviderHostRequestOutcome::Executed),
18682            (
18683                ProviderHostRequestDecision::Granted { by: ProviderHostDecisionAuthority::Policy },
18684                ProviderHostRequestOutcome::Failed {
18685                    error: "terminal/create spawn failed: os error 3".to_owned(),
18686                },
18687            ),
18688        ] {
18689            let event = provider_control_event_at(
18690                AdapterFamily::Acp,
18691                "claude-code",
18692                3,
18693                ProviderEvent::HostRequestObserved {
18694                    method: "fs/read_text_file".to_owned(),
18695                    params_json: String::new(),
18696                    decision: decision.clone(),
18697                    outcome,
18698                    reason: None,
18699                },
18700            );
18701            assert!(agent_stream_chunk(&event).is_none(), "{decision:?}");
18702        }
18703    }
18704
18705    /// A deferred `session/request_permission` resolved by an operator or an
18706    /// expired deadline carries no reason text -- the chunk falls back to a
18707    /// categorical sentence naming the authority rather than inventing detail.
18708    #[test]
18709    fn denied_host_request_with_no_reason_falls_back_to_a_categorical_sentence() {
18710        for (by, expected_authority, expected_reason) in [
18711            (
18712                ProviderHostDecisionAuthority::Operator,
18713                BlockAuthorityV1::Operator,
18714                "declined by the operator",
18715            ),
18716            (
18717                ProviderHostDecisionAuthority::DeadlinePolicy,
18718                BlockAuthorityV1::HostDeadline,
18719                "denied by host policy after the operator deadline expired",
18720            ),
18721        ] {
18722            let (_, _, authority, _, reason, _) = blocked(&denied_host_request(by, None));
18723            assert_eq!(authority, expected_authority);
18724            assert_eq!(reason, expected_reason);
18725        }
18726    }
18727
18728    /// An over-long reason is cut to the wire bound BEFORE the chunk is built,
18729    /// at a safe UTF-8 boundary -- `validate` rejects an over-long value
18730    /// outright rather than truncating.
18731    #[test]
18732    fn overlong_blocked_reason_is_truncated_to_the_wire_bound() {
18733        let oversized = "x".repeat(MAX_ACP_BLOCKED_REASON_BYTES + 500);
18734        let (_, _, _, _, reason, _) = blocked(&denied_host_request(
18735            ProviderHostDecisionAuthority::Policy,
18736            Some(oversized),
18737        ));
18738        assert!(reason.len() <= MAX_ACP_BLOCKED_REASON_BYTES);
18739    }
18740}
18741
18742#[cfg(test)]
18743mod standalone_capability_platform_tests {
18744    use super::*;
18745
18746    #[test]
18747    fn standalone_workspace_capability_requires_windows_identity_guards() {
18748        let capabilities = baseline_capabilities().unwrap();
18749        let advertised = capabilities.iter().any(|capability| {
18750            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
18751        });
18752        assert_eq!(advertised, cfg!(windows));
18753
18754        let request = NodeRequest::CreateStandaloneWorkspace {
18755            workspace_id: WorkspaceId::new("standalone-platform").unwrap(),
18756            root: OpaqueHostPath::utf8("standalone-platform-root".to_owned()).unwrap(),
18757            initial_branch: None,
18758        };
18759        assert_eq!(
18760            request_uses_unnegotiated_capability(&request, &capabilities),
18761            !cfg!(windows),
18762        );
18763    }
18764}
18765
18766
18767#[cfg(test)]
18768mod station_knobs_resolve_tests {
18769    use super::*;
18770    use gate4agent_catalog::EnvMutation;
18771    use gate4agent_runtime_native::{
18772        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
18773        NativeLaunchProfileId,
18774    };
18775    use std::ffi::OsString;
18776    use std::sync::Arc;
18777
18778    struct EmptyEnvironmentResolver;
18779
18780    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
18781        fn resolve_child_environment(
18782            &self,
18783        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
18784            Ok(vec![EnvMutation {
18785                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
18786                value: None,
18787            }])
18788        }
18789    }
18790
18791    fn agent(value: &str) -> AgentId {
18792        AgentId::new(value).unwrap()
18793    }
18794
18795    fn station_knobs_fixture() -> (NodeShared, crate::protocol::SpawnProfileDefaults) {
18796        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
18797        let profile_revision =
18798            crate::protocol::SpawnEnvironmentProfileRevision::new("local-claude-r1").unwrap();
18799        let spawn_defaults = crate::protocol::SpawnProfileDefaults {
18800            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
18801            revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
18802            provider: agent("claude"),
18803            mode: SessionMode::Pty,
18804            terminal_size: gate4agent_types::TerminalSize {
18805                rows: 24,
18806                columns: 80,
18807            },
18808            prompt: None,
18809            bundle_id: None,
18810            context_id: None,
18811            environment_profile_id: Some(profile_id.clone()),
18812        };
18813        let spawn_profiles = SpawnProfileRegistry::new([spawn_defaults.clone()]).unwrap();
18814        let catalog = active_registry().unwrap();
18815        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
18816        let control = runtime.native_launch_profile_control();
18817        let node_profile = NodeEnvironmentProfile::new(
18818            profile_id.clone(),
18819            profile_revision,
18820            agent("claude"),
18821            [NativeLaunchProfile::new(
18822                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
18823                agent("claude"),
18824                TransportKind::Pty,
18825                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
18826                Arc::new(EmptyEnvironmentResolver),
18827            )
18828            .unwrap()],
18829        )
18830        .unwrap();
18831        let (binding, native_profiles, _materialization) = node_profile.into_parts();
18832        for native_profile in native_profiles {
18833            runtime.upsert_native_launch_profile(native_profile).unwrap();
18834        }
18835        let workspace = WorkspaceConfig::new(
18836            WorkspaceId::new("primary").unwrap(),
18837            std::env::current_dir().unwrap(),
18838        )
18839        .unwrap();
18840        let shared = NodeShared::new_with_incarnation(
18841            handle,
18842            "fixture-token".to_owned(),
18843            NodeId::new("node-station-knobs-test").unwrap(),
18844            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
18845            vec![workspace],
18846            vec![agent("claude")],
18847            ProviderRuntimeStatuses::default(),
18848            None,
18849            Vec::new(),
18850            Vec::new(),
18851            spawn_profiles,
18852            Some(control),
18853            None,
18854            None,
18855            None,
18856            Vec::new(),
18857            Vec::new(),
18858            Vec::new(),
18859            Vec::new(),
18860            None,
18861            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
18862        );
18863        shared
18864            .environment_profiles
18865            .write()
18866            .unwrap_or_else(|poisoned| poisoned.into_inner())
18867            .insert(binding.id.clone(), binding);
18868        (shared, spawn_defaults)
18869    }
18870
18871    /// Empty-default catalog refuses unknown network_allowlist ids.
18872    /// Feature-off dig2-station-probe keeps browser_profile stub echo.
18873    #[test]
18874    fn resolve_environment_profile_refuses_unknown_network_allowlist() {
18875        let (shared, defaults) = station_knobs_fixture();
18876        let unknown = crate::protocol::SpawnNetworkAllowlistId::new("not-registered").unwrap();
18877        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18878        let spec = crate::protocol::SpawnSpec {
18879            target: crate::protocol::SpawnTarget {
18880                node_id: shared.node_id.clone(),
18881                workspace_id: WorkspaceId::new("primary").unwrap(),
18882                worktree_id: None,
18883            },
18884            profile_id: defaults.profile_id.clone(),
18885            expected_profile_revision: defaults.revision.clone(),
18886            overrides: crate::protocol::SpawnOverrides {
18887                network_allowlist: Some(unknown),
18888                browser_profile_id: Some(browser),
18889                ..crate::protocol::SpawnOverrides::default()
18890            },
18891            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18892            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18893                "station-knobs-unknown-allowlist",
18894            )
18895            .unwrap(),
18896            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18897        };
18898        let resolved = spec.resolve(&defaults).unwrap();
18899        let failure = shared
18900            .resolve_environment_profile(&resolved)
18901            .expect_err("unknown allowlist must refuse");
18902        assert_eq!(failure.code, NodeFailureCode::UnknownNetworkAllowlist);
18903    }
18904
18905    /// Registered network_allowlist (+ browser_profile_id when feature off)
18906    /// echo onto the env-profile receipt. Empty catalog still allows omit
18907    /// (None). Empty/whitespace refuse is type-level. Feature-off
18908    /// dig2-station-probe keeps stub browser echo; feature-on refuse is
18909    /// covered by `…_refuses_browser_profile_when_station_probe_fails`.
18910    #[test]
18911    fn resolve_environment_profile_echoes_registered_station_network_and_browser_knobs() {
18912        let (shared, defaults) = station_knobs_fixture();
18913        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
18914        #[cfg(not(feature = "dig2-station-probe"))]
18915        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18916        shared
18917            .network_allowlist_catalog
18918            .write()
18919            .unwrap_or_else(|poisoned| poisoned.into_inner())
18920            .insert_id(allowlist.clone())
18921            .unwrap();
18922        let spec = crate::protocol::SpawnSpec {
18923            target: crate::protocol::SpawnTarget {
18924                node_id: shared.node_id.clone(),
18925                workspace_id: WorkspaceId::new("primary").unwrap(),
18926                worktree_id: None,
18927            },
18928            profile_id: defaults.profile_id.clone(),
18929            expected_profile_revision: defaults.revision.clone(),
18930            overrides: crate::protocol::SpawnOverrides {
18931                network_allowlist: Some(allowlist.clone()),
18932                #[cfg(not(feature = "dig2-station-probe"))]
18933                browser_profile_id: Some(browser.clone()),
18934                ..crate::protocol::SpawnOverrides::default()
18935            },
18936            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18937            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-echo")
18938                .unwrap(),
18939            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18940        };
18941        let resolved = spec.resolve(&defaults).unwrap();
18942        assert_eq!(resolved.network_allowlist.as_ref(), Some(&allowlist));
18943        #[cfg(not(feature = "dig2-station-probe"))]
18944        assert_eq!(resolved.browser_profile_id.as_ref(), Some(&browser));
18945        let receipt = shared
18946            .resolve_environment_profile(&resolved)
18947            .expect("resolve ok")
18948            .expect("env profile present");
18949        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
18950        #[cfg(not(feature = "dig2-station-probe"))]
18951        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
18952        #[cfg(feature = "dig2-station-probe")]
18953        assert_eq!(receipt.browser_profile_id, None);
18954        assert_eq!(receipt.profile_id.as_str(), "local-claude");
18955
18956        let omitted = crate::protocol::SpawnSpec {
18957            overrides: crate::protocol::SpawnOverrides::default(),
18958            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("station-knobs-omit")
18959                .unwrap(),
18960            ..spec
18961        };
18962        let resolved_omit = omitted.resolve(&defaults).unwrap();
18963        let bare = shared
18964            .resolve_environment_profile(&resolved_omit)
18965            .expect("resolve ok")
18966            .expect("env profile present");
18967        assert_eq!(bare.network_allowlist, None);
18968        assert_eq!(bare.browser_profile_id, None);
18969    }
18970
18971    /// Feature `dig2-station-probe`: browser_profile_id set refuses when the
18972    /// cheap station probe cannot succeed. On non-Windows (this CI host) that
18973    /// is `BrowserStationProbeUnavailable`. Never cookies on C2. Lease map is
18974    /// unit-tested separately (probe refuses before lease on Linux).
18975    #[cfg(feature = "dig2-station-probe")]
18976    #[test]
18977    fn resolve_environment_profile_refuses_browser_profile_when_station_probe_fails() {
18978        let (shared, defaults) = station_knobs_fixture();
18979        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
18980        let spec = crate::protocol::SpawnSpec {
18981            target: crate::protocol::SpawnTarget {
18982                node_id: shared.node_id.clone(),
18983                workspace_id: WorkspaceId::new("primary").unwrap(),
18984                worktree_id: None,
18985            },
18986            profile_id: defaults.profile_id.clone(),
18987            expected_profile_revision: defaults.revision.clone(),
18988            overrides: crate::protocol::SpawnOverrides {
18989                browser_profile_id: Some(browser),
18990                ..crate::protocol::SpawnOverrides::default()
18991            },
18992            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
18993            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
18994                "station-knobs-probe-refuse",
18995            )
18996            .unwrap(),
18997            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
18998        };
18999        let resolved = spec.resolve(&defaults).unwrap();
19000        let failure = shared
19001            .resolve_environment_profile(&resolved)
19002            .expect_err("browser_profile with failing probe must refuse");
19003        #[cfg(windows)]
19004        {
19005            assert!(
19006                matches!(
19007                    failure.code,
19008                    NodeFailureCode::BrowserStationUnreachable
19009                        | NodeFailureCode::BrowserStationProbeUnavailable
19010                ),
19011                "unexpected code {:?}",
19012                failure.code
19013            );
19014        }
19015        #[cfg(not(windows))]
19016        {
19017            assert_eq!(
19018                failure.code,
19019                NodeFailureCode::BrowserStationProbeUnavailable
19020            );
19021        }
19022    }
19023
19024    /// Feature on: omitting browser_profile_id still resolves (no probe).
19025    #[cfg(feature = "dig2-station-probe")]
19026    #[test]
19027    fn resolve_environment_profile_skips_station_probe_when_browser_profile_omitted() {
19028        let (shared, defaults) = station_knobs_fixture();
19029        let spec = crate::protocol::SpawnSpec {
19030            target: crate::protocol::SpawnTarget {
19031                node_id: shared.node_id.clone(),
19032                workspace_id: WorkspaceId::new("primary").unwrap(),
19033                worktree_id: None,
19034            },
19035            profile_id: defaults.profile_id.clone(),
19036            expected_profile_revision: defaults.revision.clone(),
19037            overrides: crate::protocol::SpawnOverrides::default(),
19038            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19039            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19040                "station-knobs-probe-omit",
19041            )
19042            .unwrap(),
19043            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19044        };
19045        let resolved = spec.resolve(&defaults).unwrap();
19046        let receipt = shared
19047            .resolve_environment_profile(&resolved)
19048            .expect("omit browser_profile must not probe")
19049            .expect("env profile present");
19050        assert_eq!(receipt.browser_profile_id, None);
19051    }
19052
19053    /// Feature dig2-station-probe: exclusive BrowserStationLease on NodeShared
19054    /// refuses a second holder for the same opaque profile id; release on
19055    /// remove_binding allows reacquire. Never ImportSession/cookies on C2.
19056    #[cfg(feature = "dig2-station-probe")]
19057    #[test]
19058    fn browser_station_lease_exclusive_and_clears_on_remove_binding() {
19059        let (shared, _defaults) = station_knobs_fixture();
19060        let profile = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19061        let workspace_id = WorkspaceId::new("primary").unwrap();
19062        let address_a = SessionAddress {
19063            workspace_id: workspace_id.clone(),
19064            session: SessionKey {
19065                instance_id: AgentInstanceId(101),
19066                generation: SessionGeneration(1),
19067            },
19068        };
19069        let address_b = SessionAddress {
19070            workspace_id: workspace_id.clone(),
19071            session: SessionKey {
19072                instance_id: AgentInstanceId(102),
19073                generation: SessionGeneration(1),
19074            },
19075        };
19076        let guard_a = crate::dig2_station_lease::acquire_lease(
19077            &shared.browser_station_leases,
19078            profile.clone(),
19079            address_a.session.instance_id,
19080            1,
19081        )
19082        .expect("first lease");
19083        let busy = crate::dig2_station_lease::acquire_lease(
19084            &shared.browser_station_leases,
19085            profile.clone(),
19086            address_b.session.instance_id,
19087            2,
19088        )
19089        .expect_err("second holder must refuse");
19090        assert!(matches!(
19091            busy,
19092            crate::dig2_station_lease::BrowserStationLeaseError::ProfileBusy { .. }
19093        ));
19094        // Simulate a bound session so remove_binding clears the lease.
19095        shared.bind_session_with_policy(
19096            &address_a,
19097            ProviderRuntimePolicy::raw_pty(),
19098            None,
19099        );
19100        guard_a.retain();
19101        assert!(shared.remove_binding(&address_a).is_some());
19102        crate::dig2_station_lease::acquire_lease(
19103            &shared.browser_station_leases,
19104            profile,
19105            address_b.session.instance_id,
19106            3,
19107        )
19108        .expect("reacquire after clear")
19109        .retain();
19110        assert!(
19111            crate::dig2_station_lease::release_lease_for_holder(
19112                &shared.browser_station_leases,
19113                address_b.session.instance_id,
19114            )
19115        );
19116    }
19117
19118    /// Feature-off: no browser_station_leases field; stub echo documented in
19119    /// resolve_environment_profile_echoes_registered_station_network_and_browser_knobs.
19120    #[cfg(not(feature = "dig2-station-probe"))]
19121    #[test]
19122    fn browser_station_lease_feature_off_keeps_stub_without_lease_table() {
19123        let (shared, defaults) = station_knobs_fixture();
19124        let browser = crate::protocol::SpawnBrowserProfileId::new("station-profile-a").unwrap();
19125        let spec = crate::protocol::SpawnSpec {
19126            target: crate::protocol::SpawnTarget {
19127                node_id: shared.node_id.clone(),
19128                workspace_id: WorkspaceId::new("primary").unwrap(),
19129                worktree_id: None,
19130            },
19131            profile_id: defaults.profile_id.clone(),
19132            expected_profile_revision: defaults.revision.clone(),
19133            overrides: crate::protocol::SpawnOverrides {
19134                browser_profile_id: Some(browser.clone()),
19135                ..crate::protocol::SpawnOverrides::default()
19136            },
19137            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19138            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("lease-stub-off")
19139                .unwrap(),
19140            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19141        };
19142        let resolved = spec.resolve(&defaults).unwrap();
19143        let receipt = shared
19144            .resolve_environment_profile(&resolved)
19145            .expect("stub resolve")
19146            .expect("env profile");
19147        assert_eq!(receipt.browser_profile_id.as_ref(), Some(&browser));
19148    }
19149
19150
19151    /// Startup file loader registers ids into the empty-default catalog.
19152    /// Feature-off dig2 probe stays stub. Never secrets on C2.
19153    #[test]
19154    fn network_allowlist_catalog_loads_from_temp_file() {
19155        let unique = std::time::SystemTime::now()
19156            .duration_since(std::time::UNIX_EPOCH)
19157            .unwrap()
19158            .as_nanos();
19159        let path = std::env::temp_dir().join(format!(
19160            "gate4agent-node-allowlist-config-{}-{unique}.txt",
19161            std::process::id(),
19162        ));
19163        std::fs::write(
19164            &path,
19165            "# opaque station allowlist ids\negress-default\nlab-egress\n",
19166        )
19167        .unwrap();
19168        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19169            .expect("temp catalog file must load");
19170        let _ = std::fs::remove_file(&path);
19171        assert_eq!(loaded.len(), 2);
19172        let (shared, defaults) = station_knobs_fixture();
19173        {
19174            let mut catalog = shared
19175                .network_allowlist_catalog
19176                .write()
19177                .unwrap_or_else(|poisoned| poisoned.into_inner());
19178            *catalog = loaded;
19179        }
19180        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19181        let spec = crate::protocol::SpawnSpec {
19182            target: crate::protocol::SpawnTarget {
19183                node_id: shared.node_id.clone(),
19184                workspace_id: WorkspaceId::new("primary").unwrap(),
19185                worktree_id: None,
19186            },
19187            profile_id: defaults.profile_id.clone(),
19188            expected_profile_revision: defaults.revision.clone(),
19189            overrides: crate::protocol::SpawnOverrides {
19190                network_allowlist: Some(allowlist.clone()),
19191                ..crate::protocol::SpawnOverrides::default()
19192            },
19193            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19194            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19195                "station-knobs-catalog-file",
19196            )
19197            .unwrap(),
19198            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19199        };
19200        let resolved = spec.resolve(&defaults).unwrap();
19201        let receipt = shared
19202            .resolve_environment_profile(&resolved)
19203            .expect("registered via file load")
19204            .expect("env profile present");
19205        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19206    }
19207
19208    /// Snapshot launch inventory echoes registered catalog ids (opaque).
19209    /// Dig2 station probe is orthogonal (feature `dig2-station-probe`). Never secrets on C2.
19210    #[test]
19211    fn snapshot_launch_inventory_lists_network_allowlist_catalog_ids() {
19212        let (shared, _defaults) = station_knobs_fixture();
19213        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19214        shared
19215            .network_allowlist_catalog
19216            .write()
19217            .unwrap_or_else(|poisoned| poisoned.into_inner())
19218            .insert_id(allowlist.clone())
19219            .unwrap();
19220        let snapshot = shared.snapshot();
19221        let inventory = snapshot
19222            .launch_inventory
19223            .expect("launch inventory present");
19224        let ids = inventory
19225            .network_allowlists
19226            .expect("non-empty catalog surfaces ids");
19227        assert_eq!(ids, vec![allowlist]);
19228    }
19229
19230    /// Track B: registered id with provider_native codex_network_access refuses
19231    /// clearly for Claude (fixture provider). Unknown id still UnknownNetworkAllowlist.
19232    /// Never secrets on C2. Codex Moderate maps to `-c` overlay; RO/FullAuto refuse.
19233    #[test]
19234    fn resolve_environment_profile_refuses_unsupported_provider_native_mapping() {
19235        let (shared, defaults) = station_knobs_fixture();
19236        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-only").unwrap();
19237        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19238            id: allowlist.clone(),
19239            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19240                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19241                peer: "127.0.0.1:443".to_owned(),
19242            }],
19243            provider_native: Some(
19244                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19245                    codex_network_access: Some(true),
19246                },
19247            ),
19248        };
19249        shared
19250            .network_allowlist_catalog
19251            .write()
19252            .unwrap_or_else(|poisoned| poisoned.into_inner())
19253            .insert(entry)
19254            .unwrap();
19255        let spec = crate::protocol::SpawnSpec {
19256            target: crate::protocol::SpawnTarget {
19257                node_id: shared.node_id.clone(),
19258                workspace_id: WorkspaceId::new("primary").unwrap(),
19259                worktree_id: None,
19260            },
19261            profile_id: defaults.profile_id.clone(),
19262            expected_profile_revision: defaults.revision.clone(),
19263            overrides: crate::protocol::SpawnOverrides {
19264                network_allowlist: Some(allowlist),
19265                ..crate::protocol::SpawnOverrides::default()
19266            },
19267            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19268            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19269                "station-knobs-unsupported-mapping",
19270            )
19271            .unwrap(),
19272            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19273        };
19274        let resolved = spec.resolve(&defaults).unwrap();
19275        assert_eq!(resolved.provider.as_str(), "claude");
19276        let failure = shared
19277            .resolve_environment_profile(&resolved)
19278            .expect_err("claude + codex_network_access must refuse");
19279        assert_eq!(
19280            failure.code,
19281            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19282        );
19283    }
19284
19285    /// Codex Moderate + codex_network_access yields `-c` overlay argv.
19286    /// ReadOnly / FullAuto refuse UnsupportedNetworkAllowlistMapping.
19287    /// Never secrets on C2.
19288    #[test]
19289    fn resolve_codex_network_access_overlay_moderate_only() {
19290        let (shared, defaults) = station_knobs_fixture();
19291        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-codex-net").unwrap();
19292        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19293            id: allowlist.clone(),
19294            permits: Vec::new(),
19295            provider_native: Some(
19296                crate::network_allowlist_catalog::ProviderNativeNetworkSketch {
19297                    codex_network_access: Some(true),
19298                },
19299            ),
19300        };
19301        shared
19302            .network_allowlist_catalog
19303            .write()
19304            .unwrap_or_else(|poisoned| poisoned.into_inner())
19305            .insert(entry)
19306            .unwrap();
19307
19308        let overlay = shared
19309            .resolve_codex_network_access_overlay_args(
19310                &agent("codex"),
19311                SessionMode::Pty,
19312                ApprovalLevel::Moderate,
19313                Some(&allowlist),
19314            )
19315            .expect("Moderate Codex maps network_access");
19316        assert_eq!(
19317            overlay,
19318            vec![
19319                std::ffi::OsString::from("-c"),
19320                std::ffi::OsString::from("sandbox_workspace_write.network_access=true"),
19321            ]
19322        );
19323
19324        let acp = shared
19325            .resolve_codex_network_access_overlay_args(
19326                &agent("codex"),
19327                SessionMode::Acp,
19328                ApprovalLevel::Moderate,
19329                Some(&allowlist),
19330            )
19331            .unwrap();
19332        assert_eq!(acp, overlay);
19333
19334        let inline = shared
19335            .resolve_codex_network_access_overlay_args(
19336                &agent("codex"),
19337                SessionMode::Inline,
19338                ApprovalLevel::Moderate,
19339                Some(&allowlist),
19340            )
19341            .unwrap_err();
19342        assert_eq!(
19343            inline.code,
19344            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19345        );
19346
19347        for level in [ApprovalLevel::ReadOnly, ApprovalLevel::FullAuto] {
19348            let spec = crate::protocol::SpawnSpec {
19349                target: crate::protocol::SpawnTarget {
19350                    node_id: shared.node_id.clone(),
19351                    workspace_id: WorkspaceId::new("primary").unwrap(),
19352                    worktree_id: None,
19353                },
19354                profile_id: defaults.profile_id.clone(),
19355                expected_profile_revision: defaults.revision.clone(),
19356                overrides: crate::protocol::SpawnOverrides {
19357                    provider: crate::protocol::SpawnOverride::Set {
19358                        value: agent("codex"),
19359                    },
19360                    approval_level: Some(level),
19361                    network_allowlist: Some(allowlist.clone()),
19362                    ..crate::protocol::SpawnOverrides::default()
19363                },
19364                deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19365                idempotency_key: crate::protocol::SpawnIdempotencyKey::new(format!(
19366                    "codex-net-refuse-{}",
19367                    match level {
19368                        ApprovalLevel::ReadOnly => "ro",
19369                        ApprovalLevel::FullAuto => "fa",
19370                        _ => "other",
19371                    }
19372                ))
19373                .unwrap(),
19374                required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19375            };
19376            let resolved = spec.resolve(&defaults).unwrap();
19377            let failure = shared
19378                .resolve_environment_profile(&resolved)
19379                .expect_err("RO/FullAuto must refuse");
19380            assert_eq!(
19381                failure.code,
19382                NodeFailureCode::UnsupportedNetworkAllowlistMapping
19383            );
19384        }
19385    }
19386
19387    /// Claude PTY + catalog permits → ProviderHome `--settings` sidecar with
19388    /// sandbox.network.allowedDomains. ACP / missing ProviderHome refuse.
19389    /// Never secrets on C2; does not invent provider_native Claude keys.
19390    #[test]
19391    fn resolve_claude_settings_network_overlay_pty_writes_sidecar() {
19392        let (shared, _defaults) = station_knobs_fixture();
19393        let allowlist =
19394            crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-domains").unwrap();
19395        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19396            id: allowlist.clone(),
19397            permits: vec![
19398                crate::network_allowlist_catalog::NetworkPermitSketch {
19399                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19400                    peer: "github.com:443".to_owned(),
19401                },
19402                crate::network_allowlist_catalog::NetworkPermitSketch {
19403                    protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19404                    peer: "*.npmjs.org".to_owned(),
19405                },
19406            ],
19407            provider_native: None,
19408        };
19409        shared
19410            .network_allowlist_catalog
19411            .write()
19412            .unwrap_or_else(|poisoned| poisoned.into_inner())
19413            .insert(entry)
19414            .unwrap();
19415
19416        let unique = std::time::SystemTime::now()
19417            .duration_since(std::time::UNIX_EPOCH)
19418            .unwrap()
19419            .as_nanos();
19420        let provider_home = std::env::temp_dir().join(format!(
19421            "gate4agent-claude-provider-home-{}-{unique}",
19422            std::process::id(),
19423        ));
19424        std::fs::create_dir_all(&provider_home).unwrap();
19425
19426        let overlay = shared
19427            .resolve_network_allowlist_overlay_args(
19428                &agent("claude"),
19429                SessionMode::Pty,
19430                ApprovalLevel::Moderate,
19431                Some(&allowlist),
19432                Some(provider_home.as_path()),
19433            )
19434            .expect("Claude PTY settings overlay on unix");
19435        let settings_path = provider_home.join(
19436            crate::network_allowlist_catalog::CLAUDE_STATION_NETWORK_SETTINGS_FILE,
19437        );
19438        assert_eq!(
19439            overlay,
19440            vec![
19441                std::ffi::OsString::from("--settings"),
19442                std::ffi::OsString::from(settings_path.as_os_str()),
19443            ]
19444        );
19445        let body = std::fs::read_to_string(&settings_path).unwrap();
19446        let value: serde_json::Value = serde_json::from_str(&body).unwrap();
19447        assert_eq!(value["sandbox"]["enabled"], true);
19448        assert_eq!(
19449            value["sandbox"]["network"]["allowedDomains"],
19450            serde_json::json!(["github.com", "*.npmjs.org"])
19451        );
19452        assert!(settings_path.starts_with(&provider_home));
19453
19454        let acp = shared
19455            .resolve_network_allowlist_overlay_args(
19456                &agent("claude"),
19457                SessionMode::Acp,
19458                ApprovalLevel::Moderate,
19459                Some(&allowlist),
19460                Some(provider_home.as_path()),
19461            )
19462            .expect_err("Claude ACP must refuse settings overlay");
19463        assert_eq!(
19464            acp.code,
19465            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19466        );
19467
19468        let no_home = shared
19469            .resolve_network_allowlist_overlay_args(
19470                &agent("claude"),
19471                SessionMode::Pty,
19472                ApprovalLevel::Moderate,
19473                Some(&allowlist),
19474                None,
19475            )
19476            .expect_err("Claude overlay requires ProviderHome");
19477        assert_eq!(
19478            no_home.code,
19479            NodeFailureCode::UnsupportedNetworkAllowlistMapping
19480        );
19481
19482        let bare = crate::protocol::SpawnNetworkAllowlistId::new("egress-claude-bare").unwrap();
19483        shared
19484            .network_allowlist_catalog
19485            .write()
19486            .unwrap_or_else(|poisoned| poisoned.into_inner())
19487            .insert_id(bare.clone())
19488            .unwrap();
19489        let empty = shared
19490            .resolve_network_allowlist_overlay_args(
19491                &agent("claude"),
19492                SessionMode::Pty,
19493                ApprovalLevel::Moderate,
19494                Some(&bare),
19495                Some(provider_home.as_path()),
19496            )
19497            .unwrap();
19498        assert!(empty.is_empty());
19499
19500        let _ = std::fs::remove_dir_all(&provider_home);
19501    }
19502
19503    /// Kimi / Grok with permits stay without invented argv overlays.
19504    #[test]
19505    fn resolve_kimi_grok_network_overlay_stays_empty_without_native() {
19506        let (shared, _defaults) = station_knobs_fixture();
19507        let allowlist =
19508            crate::protocol::SpawnNetworkAllowlistId::new("egress-kimi-permits").unwrap();
19509        let entry = crate::network_allowlist_catalog::NetworkAllowlistEntry {
19510            id: allowlist.clone(),
19511            permits: vec![crate::network_allowlist_catalog::NetworkPermitSketch {
19512                protocol: crate::network_allowlist_catalog::NetworkPermitProtocol::Tcp,
19513                peer: "example.com:443".to_owned(),
19514            }],
19515            provider_native: None,
19516        };
19517        shared
19518            .network_allowlist_catalog
19519            .write()
19520            .unwrap_or_else(|poisoned| poisoned.into_inner())
19521            .insert(entry)
19522            .unwrap();
19523        for provider in ["kimi", "grok"] {
19524            let overlay = shared
19525                .resolve_network_allowlist_overlay_args(
19526                    &agent(provider),
19527                    SessionMode::Pty,
19528                    ApprovalLevel::Moderate,
19529                    Some(&allowlist),
19530                    None,
19531                )
19532                .unwrap();
19533            assert!(
19534                overlay.is_empty(),
19535                "{provider} must not invent network argv from permits"
19536            );
19537        }
19538    }
19539
19540    /// Track B: JSON v2 catalog file loads permits; inventory still ids only.
19541    #[test]
19542    fn network_allowlist_catalog_loads_json_v2_and_lists_ids_only() {
19543        let unique = std::time::SystemTime::now()
19544            .duration_since(std::time::UNIX_EPOCH)
19545            .unwrap()
19546            .as_nanos();
19547        let path = std::env::temp_dir().join(format!(
19548            "gate4agent-node-allowlist-json-{}-{unique}.json",
19549            std::process::id(),
19550        ));
19551        std::fs::write(
19552            &path,
19553            r#"{
19554              "schema_version": 2,
19555              "entries": [
19556                {
19557                  "id": "egress-default",
19558                  "permits": [{ "protocol": "tcp", "peer": "127.0.0.1:443" }]
19559                },
19560                { "id": "lab-egress" }
19561              ]
19562            }"#,
19563        )
19564        .unwrap();
19565        let loaded = crate::network_allowlist_catalog::load_network_allowlist_catalog_file(&path)
19566            .expect("json v2 catalog must load");
19567        let _ = std::fs::remove_file(&path);
19568        assert_eq!(loaded.len(), 2);
19569        let entry = loaded
19570            .get(&crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap())
19571            .unwrap();
19572        assert_eq!(entry.permits.len(), 1);
19573        let (shared, defaults) = station_knobs_fixture();
19574        {
19575            let mut catalog = shared
19576                .network_allowlist_catalog
19577                .write()
19578                .unwrap_or_else(|poisoned| poisoned.into_inner());
19579            *catalog = loaded;
19580        }
19581        let allowlist = crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap();
19582        let spec = crate::protocol::SpawnSpec {
19583            target: crate::protocol::SpawnTarget {
19584                node_id: shared.node_id.clone(),
19585                workspace_id: WorkspaceId::new("primary").unwrap(),
19586                worktree_id: None,
19587            },
19588            profile_id: defaults.profile_id.clone(),
19589            expected_profile_revision: defaults.revision.clone(),
19590            overrides: crate::protocol::SpawnOverrides {
19591                network_allowlist: Some(allowlist.clone()),
19592                ..crate::protocol::SpawnOverrides::default()
19593            },
19594            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
19595            idempotency_key: crate::protocol::SpawnIdempotencyKey::new(
19596                "station-knobs-catalog-json",
19597            )
19598            .unwrap(),
19599            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
19600        };
19601        let resolved = spec.resolve(&defaults).unwrap();
19602        let receipt = shared
19603            .resolve_environment_profile(&resolved)
19604            .expect("permits-only entry ok for claude")
19605            .expect("env profile present");
19606        assert_eq!(receipt.network_allowlist.as_ref(), Some(&allowlist));
19607        let inventory = shared
19608            .snapshot()
19609            .launch_inventory
19610            .expect("launch inventory present");
19611        let ids = inventory.network_allowlists.expect("ids surface");
19612        assert_eq!(
19613            ids,
19614            vec![
19615                crate::protocol::SpawnNetworkAllowlistId::new("egress-default").unwrap(),
19616                crate::protocol::SpawnNetworkAllowlistId::new("lab-egress").unwrap(),
19617            ]
19618        );
19619    }
19620
19621
19622}
19623
19624
19625#[cfg(test)]
19626mod workspace_environment_collision_tests {
19627    use super::*;
19628    use crate::session_environment::{
19629        NodeSessionMaterializationProfile, NodeSessionPathBinding, NodeSessionPathClass,
19630    };
19631    use gate4agent_catalog::EnvMutation;
19632    use gate4agent_runtime_native::{
19633        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver, NativeLaunchProfile,
19634        NativeLaunchProfileId,
19635    };
19636    use std::ffi::OsString;
19637    use std::sync::Arc;
19638
19639    struct EmptyEnvironmentResolver;
19640
19641    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19642        fn resolve_child_environment(
19643            &self,
19644        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19645            Ok(vec![EnvMutation {
19646                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19647                value: None,
19648            }])
19649        }
19650    }
19651
19652    fn agent(value: &str) -> AgentId {
19653        AgentId::new(value).unwrap()
19654    }
19655
19656    /// Two Claude ProviderHome profiles (`CLAUDE_CONFIG_DIR` A vs B) on one node.
19657    fn claude_config_dir_collision_fixture() -> (
19658        NodeShared,
19659        ResolvedEnvironmentProfileReceipt,
19660        ResolvedEnvironmentProfileReceipt,
19661    ) {
19662        let catalog = active_registry().unwrap();
19663        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19664        let control = runtime.native_launch_profile_control();
19665        let workspace = WorkspaceConfig::new(
19666            WorkspaceId::new("primary").unwrap(),
19667            std::env::current_dir().unwrap(),
19668        )
19669        .unwrap();
19670        let shared = NodeShared::new_with_incarnation(
19671            handle,
19672            "fixture-token".to_owned(),
19673            NodeId::new("node-env-collision-test").unwrap(),
19674            NodeIncarnationId::from_bytes([3; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19675            vec![workspace],
19676            vec![agent("claude")],
19677            ProviderRuntimeStatuses::default(),
19678            None,
19679            Vec::new(),
19680            Vec::new(),
19681            SpawnProfileRegistry::default(),
19682            Some(control),
19683            None,
19684            None,
19685            None,
19686            Vec::new(),
19687            Vec::new(),
19688            Vec::new(),
19689            Vec::new(),
19690            None,
19691            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
19692        );
19693
19694        let mut receipts = Vec::new();
19695        for (suffix, native_suffix) in [("a", "a-pty"), ("b", "b-pty")] {
19696            let profile_id =
19697                SpawnEnvironmentProfileId::new(format!("claude-config-dir-{suffix}")).unwrap();
19698            let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(format!(
19699                "claude-config-dir-{suffix}-r1"
19700            ))
19701            .unwrap();
19702            let materialization = NodeSessionMaterializationProfile::new(
19703                Vec::new(),
19704                vec![NodeSessionPathBinding::new(
19705                    "CLAUDE_CONFIG_DIR",
19706                    NodeSessionPathClass::ProviderHome,
19707                )
19708                .unwrap()],
19709                Vec::new(),
19710            )
19711            .unwrap();
19712            let node_profile = NodeEnvironmentProfile::new_with_materialization(
19713                profile_id.clone(),
19714                profile_revision.clone(),
19715                agent("claude"),
19716                [NativeLaunchProfile::new(
19717                    NativeLaunchProfileId::new(format!("claude-config-dir-{native_suffix}"))
19718                        .unwrap(),
19719                    agent("claude"),
19720                    TransportKind::Pty,
19721                    vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19722                    Arc::new(EmptyEnvironmentResolver),
19723                )
19724                .unwrap()],
19725                Some(materialization),
19726            )
19727            .unwrap();
19728            let (binding, native_profiles, materialization) = node_profile.into_parts();
19729            for native_profile in native_profiles {
19730                runtime.upsert_native_launch_profile(native_profile).unwrap();
19731            }
19732            shared
19733                .environment_profiles
19734                .write()
19735                .unwrap_or_else(|poisoned| poisoned.into_inner())
19736                .insert(binding.id.clone(), binding);
19737            shared
19738                .environment_materialization_profiles
19739                .write()
19740                .unwrap_or_else(|poisoned| poisoned.into_inner())
19741                .insert(profile_id.clone(), materialization.unwrap());
19742            receipts.push(ResolvedEnvironmentProfileReceipt {
19743                profile_id,
19744                profile_revision,
19745                network_allowlist: None,
19746                browser_profile_id: None,
19747            });
19748        }
19749        let profile_b = receipts.pop().unwrap();
19750        let profile_a = receipts.pop().unwrap();
19751        (shared, profile_a, profile_b)
19752    }
19753
19754    #[test]
19755    fn refuse_distinct_claude_config_dir_profiles_on_same_workspace_cwd() {
19756        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19757        let primary = WorkspaceId::new("primary").unwrap();
19758        let worktree = WorkspaceId::new("worktree-b").unwrap();
19759        let address = SessionAddress {
19760            workspace_id: primary.clone(),
19761            session: SessionKey {
19762                instance_id: AgentInstanceId(41),
19763                generation: SessionGeneration(1),
19764            },
19765        };
19766        shared.bind_session_with_policy(
19767            &address,
19768            ProviderRuntimePolicy::raw_pty(),
19769            Some(profile_a.clone()),
19770        );
19771
19772        let refused = shared
19773            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_b))
19774            .unwrap_err();
19775        assert_eq!(refused.code, NodeFailureCode::WorkspaceBusy);
19776
19777        // Some vs None on the same cwd is also a collision.
19778        let refused_none = shared
19779            .ensure_workspace_environment_profile_compatible(&primary, None)
19780            .unwrap_err();
19781        assert_eq!(refused_none.code, NodeFailureCode::WorkspaceBusy);
19782
19783        // Same profile on the same workspace remains allowed.
19784        shared
19785            .ensure_workspace_environment_profile_compatible(&primary, Some(&profile_a))
19786            .unwrap();
19787
19788        // Distinct workspace id (managed-worktree parallel path) allows profile B.
19789        shared
19790            .ensure_workspace_environment_profile_compatible(&worktree, Some(&profile_b))
19791            .unwrap();
19792    }
19793
19794    #[tokio::test]
19795    async fn spawn_session_refuses_conflicting_environment_profile_before_mutation() {
19796        let (shared, profile_a, profile_b) = claude_config_dir_collision_fixture();
19797        let primary = WorkspaceId::new("primary").unwrap();
19798        let address = SessionAddress {
19799            workspace_id: primary.clone(),
19800            session: SessionKey {
19801                instance_id: AgentInstanceId(41),
19802                generation: SessionGeneration(1),
19803            },
19804        };
19805        shared.bind_session_with_policy(
19806            &address,
19807            ProviderRuntimePolicy::raw_pty(),
19808            Some(profile_a),
19809        );
19810
19811        let failure = shared
19812            .spawn_session_with_deadline(
19813                primary,
19814                agent("claude"),
19815                SessionMode::Pty,
19816                gate4agent_types::TerminalSize {
19817                    rows: 24,
19818                    columns: 80,
19819                },
19820                None,
19821                Some(profile_b),
19822                None,
19823                None,
19824                None,
19825                None,
19826                SpawnRecordPolicy::ProviderIdentityOnly,
19827                Some(Instant::now() + Duration::from_secs(30)),
19828                &[],
19829                None,
19830                ApprovalLevel::default(),
19831                None,
19832                None,
19833            )
19834            .await
19835            .unwrap_err();
19836        assert_eq!(failure.code, NodeFailureCode::WorkspaceBusy);
19837        // Guard runs before instance id mint / bind.
19838        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
19839        assert!(shared.handle.snapshot().sessions.is_empty());
19840    }
19841}
19842
19843#[cfg(test)]
19844mod spawn_characterization_tests {
19845    use super::*;
19846    use crate::session_environment::{
19847        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
19848        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
19849        NodeSessionPathClass,
19850    };
19851    use gate4agent_catalog::EnvMutation;
19852    use gate4agent_runtime_native::{
19853        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
19854        NativeLaunchProfile, NativeLaunchProfileId,
19855    };
19856    use std::ffi::OsString;
19857    use std::sync::Arc;
19858
19859    struct EmptyEnvironmentResolver;
19860
19861    struct FixtureSecretResolver {
19862        deny: Arc<AtomicBool>,
19863    }
19864
19865    impl NodeSecretResolver for FixtureSecretResolver {
19866        fn resolve(
19867            &self,
19868            _reference: &NodeSecretReference,
19869        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
19870            if self.deny.load(Ordering::Acquire) {
19871                Err(NodeSecretResolveError::Denied)
19872            } else {
19873                NodeSecretValue::text("fixture-secret")
19874                    .map_err(|_| NodeSecretResolveError::Unavailable)
19875            }
19876        }
19877    }
19878
19879    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
19880        fn resolve_child_environment(
19881            &self,
19882        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
19883            Ok(vec![EnvMutation {
19884                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
19885                value: None,
19886            }])
19887        }
19888    }
19889
19890    fn agent(value: &str) -> AgentId {
19891        AgentId::new(value).unwrap()
19892    }
19893
19894    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
19895        let unique = std::time::SystemTime::now()
19896            .duration_since(std::time::UNIX_EPOCH)
19897            .unwrap()
19898            .as_nanos();
19899        std::env::temp_dir().join(format!(
19900            "gate4agent-node-{label}-{}-{unique}",
19901            std::process::id(),
19902        ))
19903    }
19904
19905    fn materialization_test_shared(
19906        deny_secret: bool,
19907    ) -> (
19908        NodeShared,
19909        NativeRuntime,
19910        ResolvedEnvironmentProfileReceipt,
19911        PathBuf,
19912        Arc<AtomicBool>,
19913    ) {
19914        let workspace = WorkspaceConfig::new(
19915            WorkspaceId::new("primary").unwrap(),
19916            std::env::current_dir().unwrap(),
19917        )
19918        .unwrap();
19919        materialization_test_shared_with_workspace(deny_secret, workspace)
19920    }
19921
19922    fn materialization_test_shared_with_workspace(
19923        deny_secret: bool,
19924        workspace: WorkspaceConfig,
19925    ) -> (
19926        NodeShared,
19927        NativeRuntime,
19928        ResolvedEnvironmentProfileReceipt,
19929        PathBuf,
19930        Arc<AtomicBool>,
19931    ) {
19932        let root = temporary_workspace_root(if deny_secret {
19933            "materialization-denied"
19934        } else {
19935            "materialization-ready"
19936        });
19937        std::fs::create_dir_all(&root).unwrap();
19938        let materialization_root = root.join("session-environments");
19939        let deny = Arc::new(AtomicBool::new(deny_secret));
19940        let resolver = Arc::new(FixtureSecretResolver {
19941            deny: Arc::clone(&deny),
19942        });
19943        let materializer = SessionEnvironmentMaterializer::new(
19944            materialization_root,
19945            resolver,
19946        )
19947        .unwrap();
19948        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
19949        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
19950            "materialized-claude-r1",
19951        )
19952        .unwrap();
19953        let materialization = NodeSessionMaterializationProfile::new(
19954            vec![NodeSessionEnvironmentMutation::SetSecret {
19955                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
19956                reference: NodeSecretReference::new("fixture-token").unwrap(),
19957            }],
19958            vec![NodeSessionPathBinding::new(
19959                "GATE4AGENT_PROVIDER_HOME",
19960                NodeSessionPathClass::ProviderHome,
19961            )
19962            .unwrap()],
19963            vec![NodeSessionFile::secret(
19964                NodeSessionPathClass::Config,
19965                "auth/token",
19966                NodeSecretReference::new("fixture-token").unwrap(),
19967            )
19968            .unwrap()],
19969        )
19970        .unwrap();
19971        let catalog = active_registry().unwrap();
19972        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
19973        let control = runtime.native_launch_profile_control();
19974        let node_profile = NodeEnvironmentProfile::new_with_materialization(
19975            profile_id.clone(),
19976            profile_revision.clone(),
19977            agent("claude"),
19978            [NativeLaunchProfile::new(
19979                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
19980                agent("claude"),
19981                TransportKind::Pty,
19982                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
19983                Arc::new(EmptyEnvironmentResolver),
19984            )
19985            .unwrap()],
19986            Some(materialization),
19987        )
19988        .unwrap();
19989        let (binding, native_profiles, materialization) = node_profile.into_parts();
19990        for native_profile in native_profiles {
19991            runtime.upsert_native_launch_profile(native_profile).unwrap();
19992        }
19993        let shared = NodeShared::new_with_incarnation(
19994            handle,
19995            "fixture-token".to_owned(),
19996            NodeId::new("node-materialization-test").unwrap(),
19997            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
19998            vec![workspace],
19999            vec![agent("claude")],
20000            ProviderRuntimeStatuses::default(),
20001            None,
20002            Vec::new(),
20003            Vec::new(),
20004            SpawnProfileRegistry::default(),
20005            Some(control),
20006            None,
20007            Some(materializer),
20008            None,
20009            Vec::new(),
20010            Vec::new(),
20011            Vec::new(),
20012            Vec::new(),
20013            None,
20014            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20015        );
20016        shared
20017            .environment_profiles
20018            .write()
20019            .unwrap_or_else(|poisoned| poisoned.into_inner())
20020            .insert(binding.id.clone(), binding);
20021        shared
20022            .environment_materialization_profiles
20023            .write()
20024            .unwrap_or_else(|poisoned| poisoned.into_inner())
20025            .insert(profile_id.clone(), materialization.unwrap());
20026        (
20027            shared,
20028            runtime,
20029            ResolvedEnvironmentProfileReceipt {
20030                profile_id,
20031                profile_revision,
20032                network_allowlist: None,
20033                browser_profile_id: None,
20034            },
20035            root,
20036            deny,
20037        )
20038    }
20039
20040
20041    // ---- Characterization of the spawn preparation block (pre-SpawnPreparer) ----
20042
20043    async fn characterization_spawn(
20044        shared: &NodeShared,
20045        mode: SessionMode,
20046        environment_profile: Option<ResolvedEnvironmentProfileReceipt>,
20047        required: &[ProviderRuntimeCapability],
20048        harness_mcp: Option<&PreparedHarnessMcpSpawn>,
20049    ) -> Result<(SessionAddress, ProviderRuntimePolicy), NodeFailure> {
20050        shared
20051            .spawn_session_with_deadline(
20052                WorkspaceId::new("primary").unwrap(),
20053                agent("claude"),
20054                mode,
20055                gate4agent_types::TerminalSize { rows: 24, columns: 80 },
20056                None,
20057                environment_profile,
20058                None,
20059                None,
20060                None,
20061                Some(ProviderRuntimePolicy::raw_pty()),
20062                SpawnRecordPolicy::ProviderIdentityOnly,
20063                Some(Instant::now() + Duration::from_secs(30)),
20064                required,
20065                harness_mcp,
20066                ApprovalLevel::default(),
20067                None,
20068                None,
20069            )
20070            .await
20071    }
20072
20073    fn assert_no_session_state(shared: &NodeShared) {
20074        assert!(shared.handle.snapshot().sessions.is_empty());
20075        assert!(shared
20076            .session_bindings
20077            .lock()
20078            .unwrap_or_else(|poisoned| poisoned.into_inner())
20079            .is_empty());
20080        assert!(shared
20081            .materializations
20082            .lock()
20083            .unwrap_or_else(|poisoned| poisoned.into_inner())
20084            .is_empty());
20085    }
20086
20087    #[tokio::test]
20088    async fn spawn_unadmitted_required_capability_refuses_before_id_mint() {
20089        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20090        let error = characterization_spawn(
20091            &shared,
20092            SessionMode::Pty,
20093            None,
20094            &[ProviderRuntimeCapability::SemanticReadiness],
20095            None,
20096        )
20097        .await
20098        .unwrap_err();
20099        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20100        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
20101        assert_no_session_state(&shared);
20102        drop(shared);
20103        drop(runtime);
20104        std::fs::remove_dir_all(root).unwrap();
20105    }
20106
20107    #[tokio::test]
20108    async fn spawn_denied_environment_materialization_fails_after_id_mint_with_no_state() {
20109        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
20110        let error = characterization_spawn(&shared, SessionMode::Pty, Some(receipt), &[], None)
20111            .await
20112            .unwrap_err();
20113        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
20114        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 2);
20115        assert_no_session_state(&shared);
20116        drop(shared);
20117        drop(runtime);
20118        std::fs::remove_dir_all(root).unwrap();
20119    }
20120
20121    #[test]
20122    fn remove_binding_returns_binding_and_is_idempotent_for_environment_session() {
20123        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
20124        let address = SessionAddress {
20125            workspace_id: WorkspaceId::new("primary").unwrap(),
20126            session: SessionKey {
20127                instance_id: AgentInstanceId(77),
20128                generation: SessionGeneration(1),
20129            },
20130        };
20131        shared.bind_session_with_policy(&address, ProviderRuntimePolicy::raw_pty(), Some(receipt));
20132        let removed = shared.remove_binding(&address).expect("binding removed");
20133        assert!(removed.environment_profile.is_some());
20134        assert!(shared.remove_binding(&address).is_none());
20135        assert_no_session_state(&shared);
20136        drop(shared);
20137        drop(runtime);
20138        std::fs::remove_dir_all(root).unwrap();
20139    }
20140
20141    async fn characterization_prepared_harness(provider: &str) -> PreparedHarnessMcpSpawn {
20142        use crate::harness_mcp_proxy::{HarnessMcpProxyRegistry, ReviewedHarnessMcpProgram};
20143        use crate::protocol::{HarnessMcpActivationDigest, HarnessMcpLaunchV1, HarnessMcpReservationId};
20144        let helper = ReviewedHarnessMcpProgram::review(std::env::current_exe().unwrap())
20145            .expect("review current test executable as fixture helper");
20146        let registry = HarnessMcpProxyRegistry::new(helper);
20147        let spec = crate::protocol::SpawnSpec {
20148            target: crate::protocol::SpawnTarget {
20149                node_id: NodeId::new("node-characterization").unwrap(),
20150                workspace_id: WorkspaceId::new("primary").unwrap(),
20151                worktree_id: None,
20152            },
20153            profile_id: crate::protocol::SpawnProfileId::new("claude").unwrap(),
20154            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("r1").unwrap(),
20155            overrides: crate::protocol::SpawnOverrides::default(),
20156            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20157            idempotency_key: crate::protocol::SpawnIdempotencyKey::new("characterization-harness").unwrap(),
20158            required_capabilities: crate::protocol::SpawnRequiredCapabilities::default(),
20159        };
20160        let reservation = HarnessMcpReservationId::new(format!("hmcpres_{}", "c".repeat(24))).unwrap();
20161        let digest = HarnessMcpActivationDigest::new(format!("sha256:{}", "c".repeat(64))).unwrap();
20162        let launch = HarnessMcpLaunchV1 {
20163            server_name: "fixture-harness".to_owned(),
20164            args: vec!["--session-proxy".to_owned()],
20165            endpoint_env: "FIXTURE_SESSION_ENDPOINT".to_owned(),
20166            token_env: "FIXTURE_SESSION_TOKEN".to_owned(),
20167            program_env: "FIXTURE_MCP_PROGRAM".to_owned(),
20168            trace: None,
20169            scrub_env: Vec::new(),
20170        };
20171        let expires = unix_time_ms().saturating_add(30_000);
20172        registry.arm(reservation.clone(), digest.clone(), spec.clone(), launch, expires).await.unwrap();
20173        registry
20174            .prepare_spawn(&reservation, &digest, &spec, expires, agent(provider))
20175            .unwrap()
20176    }
20177
20178    #[tokio::test]
20179    async fn spawn_harness_overlay_refuses_inline_and_provider_mismatch_with_no_state() {
20180        let (shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20181        let prepared = characterization_prepared_harness("claude").await;
20182        let error = characterization_spawn(&shared, SessionMode::Inline, None, &[], Some(&prepared))
20183            .await
20184            .unwrap_err();
20185        // Inline is stopped by runtime admission before the harness check runs.
20186        assert_eq!(error.code, NodeFailureCode::UnsupportedSpawnCapability);
20187        let mismatched = characterization_prepared_harness("codex").await;
20188        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&mismatched))
20189            .await
20190            .unwrap_err();
20191        assert_eq!(error.code, NodeFailureCode::BindingMismatch);
20192        assert_no_session_state(&shared);
20193        drop(shared);
20194        drop(runtime);
20195        std::fs::remove_dir_all(root).unwrap();
20196    }
20197
20198    #[tokio::test]
20199    async fn spawn_harness_overlay_without_launch_control_is_unavailable_with_no_state() {
20200        let (mut shared, runtime, _receipt, root, _deny) = materialization_test_shared(false);
20201        shared.native_launch_profile_control = None;
20202        let prepared = characterization_prepared_harness("claude").await;
20203        let error = characterization_spawn(&shared, SessionMode::Pty, None, &[], Some(&prepared))
20204            .await
20205            .unwrap_err();
20206        assert_eq!(error.code, NodeFailureCode::HarnessMcpUnavailable);
20207        assert_no_session_state(&shared);
20208        drop(shared);
20209        drop(runtime);
20210        std::fs::remove_dir_all(root).unwrap();
20211    }
20212}
20213
20214#[cfg(all(test, windows))]
20215mod tests {
20216    use super::*;
20217    use gate4agent_types::ProviderActivity;
20218    use crate::session_environment::{
20219        NodeSecretReference, NodeSecretResolveError, NodeSecretValue,
20220        NodeSessionEnvironmentMutation, NodeSessionFile, NodeSessionPathBinding,
20221        NodeSessionPathClass,
20222    };
20223    use gate4agent_catalog::EnvMutation;
20224    use gate4agent_runtime_native::{
20225        NativeChildEnvironmentResolveError, NativeChildEnvironmentResolver,
20226        NativeLaunchProfile, NativeLaunchProfileId,
20227    };
20228    use std::ffi::OsString;
20229    use std::sync::Arc;
20230
20231    struct EmptyEnvironmentResolver;
20232
20233    struct FixtureSecretResolver {
20234        deny: Arc<AtomicBool>,
20235    }
20236
20237    impl NodeSecretResolver for FixtureSecretResolver {
20238        fn resolve(
20239            &self,
20240            _reference: &NodeSecretReference,
20241        ) -> Result<NodeSecretValue, NodeSecretResolveError> {
20242            if self.deny.load(Ordering::Acquire) {
20243                Err(NodeSecretResolveError::Denied)
20244            } else {
20245                NodeSecretValue::text("fixture-secret")
20246                    .map_err(|_| NodeSecretResolveError::Unavailable)
20247            }
20248        }
20249    }
20250
20251    impl NativeChildEnvironmentResolver for EmptyEnvironmentResolver {
20252        fn resolve_child_environment(
20253            &self,
20254        ) -> Result<Vec<EnvMutation>, NativeChildEnvironmentResolveError> {
20255            Ok(vec![EnvMutation {
20256                key: OsString::from("GATE4AGENT_TEST_PROFILE"),
20257                value: None,
20258            }])
20259        }
20260    }
20261
20262    fn agent(value: &str) -> AgentId {
20263        AgentId::new(value).unwrap()
20264    }
20265
20266    fn terminal_test_shared_with_mode(mode: WorktreeServiceMode) -> NodeShared {
20267        let catalog = active_registry().unwrap();
20268        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20269        let workspace = WorkspaceConfig::new(
20270            WorkspaceId::new("primary").unwrap(),
20271            std::env::current_dir().unwrap(),
20272        )
20273        .unwrap()
20274        .with_worktree_service_mode(mode);
20275        NodeShared::new(
20276            handle,
20277            "fixture-token".to_owned(),
20278            NodeId::new("node-terminal-test").unwrap(),
20279            vec![workspace],
20280            vec![agent("claude")],
20281        )
20282    }
20283
20284    fn terminal_test_shared() -> NodeShared {
20285        terminal_test_shared_with_mode(WorktreeServiceMode::Manual)
20286    }
20287
20288    #[cfg(feature = "fixture")]
20289    #[test]
20290    fn managed_worktree_v2_failure_probe_keeps_only_the_latest_code() {
20291        let probe = SpawnManagedWorktreeV2FailureProbe::default();
20292        assert_eq!(probe.latest_code(), None);
20293
20294        probe.record(Some(NodeFailureCode::ManagedWorktreeOwnershipConflict));
20295        assert_eq!(
20296            probe.latest_code(),
20297            Some(NodeFailureCode::ManagedWorktreeOwnershipConflict),
20298        );
20299
20300        probe.record(Some(NodeFailureCode::SpawnDeadlineExceeded));
20301        assert_eq!(
20302            probe.latest_code(),
20303            Some(NodeFailureCode::SpawnDeadlineExceeded),
20304        );
20305
20306        probe.record(None);
20307        assert_eq!(probe.latest_code(), None);
20308    }
20309
20310    #[test]
20311    fn launch_snapshot_is_authoritative_bounded_and_path_free() {
20312        let source = temporary_workspace_root("launch-inventory-source");
20313        let allocation = temporary_workspace_root("launch-inventory-allocation");
20314        std::fs::create_dir_all(&source).unwrap();
20315        std::fs::create_dir_all(&allocation).unwrap();
20316        let workspace_id = WorkspaceId::new("primary").unwrap();
20317        let mut workspace = WorkspaceConfig::new(workspace_id.clone(), &source)
20318            .unwrap()
20319            .with_worktree_service_mode(WorktreeServiceMode::Managed);
20320        for index in 0..MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE {
20321            workspace = workspace
20322                .with_managed_worktree_profile(
20323                    ManagedWorktreeProfile::new(
20324                        WorktreeProfileId::new(format!("profile-{index}")).unwrap(),
20325                        crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20326                        &allocation,
20327                        "gate4agent",
20328                        "HEAD",
20329                        ManagedWorktreeRetention::RemoveWhenReleased,
20330                    )
20331                    .unwrap(),
20332                )
20333                .unwrap();
20334        }
20335        let overflow = workspace.clone().with_managed_worktree_profile(
20336            ManagedWorktreeProfile::new(
20337                WorktreeProfileId::new("overflow").unwrap(),
20338                crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
20339                &allocation,
20340                "gate4agent",
20341                "HEAD",
20342                ManagedWorktreeRetention::Retain,
20343            )
20344            .unwrap(),
20345        );
20346        assert!(matches!(
20347            overflow,
20348            Err(NodeServerError::ManagedWorktreeProfileCapacity {
20349                max: MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20350                ..
20351            }),
20352        ));
20353
20354        let catalog = active_registry().unwrap();
20355        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20356        let shared = NodeShared::new(
20357            handle,
20358            "fixture-token".to_owned(),
20359            NodeId::new("node-launch-inventory").unwrap(),
20360            vec![workspace],
20361            vec![agent("claude")],
20362        );
20363        let snapshot = shared.snapshot();
20364        let launch = snapshot.launch_inventory.as_ref().unwrap();
20365        assert_eq!(launch.spawn_profiles.as_ref().unwrap().len(), 1);
20366        assert!(launch.bundles.as_ref().unwrap().is_empty());
20367        let profiles = snapshot.workspaces[0]
20368            .managed_worktree_profiles
20369            .as_ref()
20370            .unwrap();
20371        assert_eq!(
20372            snapshot.workspaces[0].worktree_service_mode,
20373            Some(WorktreeServiceMode::Managed),
20374        );
20375        assert_eq!(
20376            profiles.profiles.len(),
20377            MAX_MANAGED_WORKTREE_PROFILES_PER_WORKSPACE,
20378        );
20379        let public_inventory = serde_json::to_string(&(launch, profiles)).unwrap();
20380        for forbidden in ["allocation_root", "branch_prefix", "base", "prompt", "environment", "home"] {
20381            assert!(!public_inventory.contains(forbidden), "launch inventory leaked {forbidden}");
20382        }
20383        let legacy = snapshot_for_wire(
20384            &shared,
20385            true,
20386            true,
20387            false,
20388            false,
20389            true,
20390            false,
20391            true,
20392            false,
20393            true,
20394        );
20395        assert!(legacy.launch_inventory.is_none());
20396        assert!(legacy.workspaces[0].managed_worktree_profiles.is_none());
20397
20398        drop(shared);
20399        std::fs::remove_dir_all(source).unwrap();
20400        std::fs::remove_dir_all(allocation).unwrap();
20401    }
20402
20403    #[test]
20404    fn agent_progress_projection_omits_sensitive_provider_payloads_and_is_capability_gated() {
20405        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
20406            capability.as_str() == NODE_AGENT_PROGRESS_SNAPSHOT_CAPABILITY
20407        }));
20408        let source = gate4agent_types::ProviderSource {
20409            family: AdapterFamily::PtySemantic,
20410            binding: AdapterBinding::new(
20411                gate4agent_types::AdapterId::new("claude").unwrap(),
20412                "fixture/v1",
20413                gate4agent_types::AdapterVerification::SyntheticFixture,
20414            )
20415            .unwrap(),
20416        };
20417        let provider = ProviderSnapshot {
20418            sequence: 17,
20419            session: Some(ProviderSessionIdentity {
20420                key: ProviderSessionKey::SessionId,
20421                id: "PRIVATE_PROVIDER_SESSION".to_owned(),
20422                transcript_path: Some(r"C:\PRIVATE_PROVIDER_PATH".to_owned()),
20423            }),
20424            model: Some("PRIVATE_MODEL".to_owned()),
20425            tools: vec!["PRIVATE_TOOL_INVENTORY".to_owned()],
20426            completed_turns: 3,
20427            usage: gate4agent_types::TokenUsage {
20428                input_tokens: 10,
20429                output_tokens: 20,
20430                cache_read_tokens: 30,
20431                cache_write_tokens: 40,
20432                reasoning_tokens: 50,
20433                context_window: Some(200_000),
20434            },
20435            lead_activity: ProviderActivity::WaitingForInput,
20436            activity: ProviderActivity::WaitingForInput,
20437            current_prompt: Some("PRIVATE_CURRENT_PROMPT".to_owned()),
20438            active_tools: (0..9)
20439                .map(|index| gate4agent_types::ActiveProviderTool {
20440                    id: format!("private-tool-id-{index}"),
20441                    name: if index == 0 {
20442                        format!("  Read\u{0000}{}", "x".repeat(80))
20443                    } else {
20444                        format!("Tool-{index}")
20445                    },
20446                    input_json: "PRIVATE_TOOL_INPUT_JSON".to_owned(),
20447                })
20448                .collect(),
20449            interactions: vec![gate4agent_types::ProviderInteraction {
20450                id: gate4agent_types::ProviderInteractionId(17),
20451                source: source.clone(),
20452                provider_request_id: Some("PRIVATE_PROVIDER_REQUEST".to_owned()),
20453                interaction_kind: ProviderInteractionKind::Approval,
20454                tool_name: " Approve\u{0007}Tool ".to_owned(),
20455                prompt: "PRIVATE_INTERACTION_PROMPT".to_owned(),
20456                agent_id: Some("PRIVATE_INTERACTION_AGENT".to_owned()),
20457                resume_lead_activity: Some(ProviderActivity::Working),
20458                status: ProviderInteractionStatus::Pending,
20459            }],
20460            subagents: vec![gate4agent_types::ProviderSubagent {
20461                source: source.clone(),
20462                provider_agent_id: "PRIVATE_SUBAGENT_ID".to_owned(),
20463                agent_type: Some("PRIVATE_SUBAGENT_TYPE".to_owned()),
20464                description: Some("PRIVATE_SUBAGENT_DESCRIPTION".to_owned()),
20465            }],
20466            sources: vec![gate4agent_types::ProviderSourceCursor {
20467                source,
20468                sequence: 17,
20469                gap_count: 2,
20470                stale: true,
20471            }],
20472            last_event: Some(ProviderEvent::Error {
20473                message: "PRIVATE_PROVIDER_ERROR".to_owned(),
20474            }),
20475            gap_count: 2,
20476            stale: true,
20477        };
20478        let address = terminal_address(3);
20479        let entry = agent_progress_from_provider_snapshot(address.clone(), &provider).unwrap();
20480        assert_eq!(entry.address, address);
20481        assert_eq!(entry.progress.provider_sequence, 17);
20482        assert_eq!(entry.progress.active_tool_count, 9);
20483        assert_eq!(
20484            entry.progress.active_tool_labels.len(),
20485            MAX_AGENT_PROGRESS_ACTIVE_TOOL_LABELS,
20486        );
20487        assert_eq!(entry.progress.subagent_count, 1);
20488        assert_eq!(entry.progress.last_event_kind, Some(AgentProgressEventKindV1::Error));
20489        assert!(entry.progress.truncated);
20490        let encoded = serde_json::to_string(&entry).unwrap();
20491        for forbidden in [
20492            "PRIVATE_PROVIDER_SESSION",
20493            "PRIVATE_PROVIDER_PATH",
20494            "PRIVATE_MODEL",
20495            "PRIVATE_TOOL_INVENTORY",
20496            "PRIVATE_CURRENT_PROMPT",
20497            "PRIVATE_TOOL_INPUT_JSON",
20498            "PRIVATE_PROVIDER_REQUEST",
20499            "PRIVATE_INTERACTION_PROMPT",
20500            "PRIVATE_INTERACTION_AGENT",
20501            "PRIVATE_SUBAGENT_ID",
20502            "PRIVATE_SUBAGENT_TYPE",
20503            "PRIVATE_SUBAGENT_DESCRIPTION",
20504            "PRIVATE_PROVIDER_ERROR",
20505            "input_json",
20506            "current_prompt",
20507            "session_id",
20508        ] {
20509            assert!(!encoded.contains(forbidden), "agent progress leaked {forbidden}");
20510        }
20511
20512        let snapshot = NodeSnapshot {
20513            node_id: NodeId::new("node-progress-test").unwrap(),
20514            enabled_providers: vec![agent("claude")],
20515            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
20516            workspaces: Vec::new(),
20517            session_records: Vec::new(),
20518            managed_worktrees: Vec::new(),
20519            launch_inventory: None,
20520            agent_progress: vec![entry],
20521        };
20522        let mut legacy_reply = ResponseEnvelope {
20523            request_id: 1,
20524            result: Ok(NodeResponse::Snapshot {
20525                event_sequence: 0,
20526                controller: None,
20527                snapshot: snapshot.clone(),
20528            }),
20529        };
20530        project_response_without_agent_progress(&mut legacy_reply);
20531        let Ok(NodeResponse::Snapshot { snapshot: legacy, .. }) = legacy_reply.result else {
20532            panic!("agent progress projection changed response kind");
20533        };
20534        assert!(legacy.agent_progress.is_empty());
20535        assert_eq!(snapshot.agent_progress.len(), 1);
20536    }
20537
20538    fn terminal_address(generation: u64) -> SessionAddress {
20539        SessionAddress {
20540            workspace_id: WorkspaceId::new("primary").unwrap(),
20541            session: SessionKey {
20542                instance_id: AgentInstanceId(41),
20543                generation: SessionGeneration(generation),
20544            },
20545        }
20546    }
20547
20548    fn spawn_spec_fixture() -> SpawnSpec {
20549        SpawnSpec {
20550            target: crate::protocol::SpawnTarget {
20551                node_id: NodeId::new("node-terminal-test").unwrap(),
20552                workspace_id: WorkspaceId::new("primary").unwrap(),
20553                worktree_id: None,
20554            },
20555            profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20556            expected_profile_revision: crate::protocol::SpawnProfileRevision::new("builtin-v1")
20557                .unwrap(),
20558            overrides: crate::protocol::SpawnOverrides::default(),
20559            deadline_ms: crate::protocol::SpawnDeadlineMs::new(30_000).unwrap(),
20560            idempotency_key: SpawnIdempotencyKey::new("spawn-spec-fixture").unwrap(),
20561            required_capabilities: SpawnRequiredCapabilities::default(),
20562        }
20563    }
20564
20565    fn environment_profile_test_shared(
20566    ) -> (NodeShared, NativeRuntime, ResolvedEnvironmentProfileReceipt) {
20567        let profile_id = SpawnEnvironmentProfileId::new("local-claude").unwrap();
20568        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20569            "local-claude-r1",
20570        )
20571        .unwrap();
20572        let spawn_profiles = SpawnProfileRegistry::new([
20573            crate::protocol::SpawnProfileDefaults {
20574                profile_id: crate::protocol::SpawnProfileId::new("default").unwrap(),
20575                revision: crate::protocol::SpawnProfileRevision::new("test-r1").unwrap(),
20576                provider: agent("claude"),
20577                mode: SessionMode::Pty,
20578                terminal_size: gate4agent_types::TerminalSize {
20579                    rows: 24,
20580                    columns: 80,
20581                },
20582                prompt: None,
20583                bundle_id: None,
20584                context_id: None,
20585                environment_profile_id: Some(profile_id.clone()),
20586            },
20587        ])
20588        .unwrap();
20589        let catalog = active_registry().unwrap();
20590        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20591        let control = runtime.native_launch_profile_control();
20592        let node_profile = NodeEnvironmentProfile::new(
20593            profile_id.clone(),
20594            profile_revision.clone(),
20595            agent("claude"),
20596            [NativeLaunchProfile::new(
20597                NativeLaunchProfileId::new("local-claude-pty").unwrap(),
20598                agent("claude"),
20599                TransportKind::Pty,
20600                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20601                Arc::new(EmptyEnvironmentResolver),
20602            )
20603            .unwrap()],
20604        )
20605        .unwrap();
20606        let (binding, native_profiles, _materialization) = node_profile.into_parts();
20607        for native_profile in native_profiles {
20608            runtime.upsert_native_launch_profile(native_profile).unwrap();
20609        }
20610        let workspace = WorkspaceConfig::new(
20611            WorkspaceId::new("primary").unwrap(),
20612            std::env::current_dir().unwrap(),
20613        )
20614        .unwrap();
20615        let shared = NodeShared::new_with_incarnation(
20616            handle,
20617            "fixture-token".to_owned(),
20618            NodeId::new("node-terminal-test").unwrap(),
20619            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20620            vec![workspace],
20621            vec![agent("claude")],
20622            ProviderRuntimeStatuses::default(),
20623            None,
20624            Vec::new(),
20625            Vec::new(),
20626            spawn_profiles,
20627            Some(control),
20628            None,
20629            None,
20630            None,
20631            Vec::new(),
20632            Vec::new(),
20633            Vec::new(),
20634            Vec::new(),
20635            None,
20636            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20637        );
20638        shared
20639            .environment_profiles
20640            .write()
20641            .unwrap_or_else(|poisoned| poisoned.into_inner())
20642            .insert(binding.id.clone(), binding);
20643        (
20644            shared,
20645            runtime,
20646            ResolvedEnvironmentProfileReceipt {
20647                profile_id,
20648                profile_revision,
20649                network_allowlist: None,
20650                browser_profile_id: None,
20651            },
20652        )
20653    }
20654
20655    fn materialization_test_shared(
20656        deny_secret: bool,
20657    ) -> (
20658        NodeShared,
20659        NativeRuntime,
20660        ResolvedEnvironmentProfileReceipt,
20661        PathBuf,
20662        Arc<AtomicBool>,
20663    ) {
20664        let workspace = WorkspaceConfig::new(
20665            WorkspaceId::new("primary").unwrap(),
20666            std::env::current_dir().unwrap(),
20667        )
20668        .unwrap();
20669        materialization_test_shared_with_workspace(deny_secret, workspace)
20670    }
20671
20672    fn materialization_test_shared_with_workspace(
20673        deny_secret: bool,
20674        workspace: WorkspaceConfig,
20675    ) -> (
20676        NodeShared,
20677        NativeRuntime,
20678        ResolvedEnvironmentProfileReceipt,
20679        PathBuf,
20680        Arc<AtomicBool>,
20681    ) {
20682        let root = temporary_workspace_root(if deny_secret {
20683            "materialization-denied"
20684        } else {
20685            "materialization-ready"
20686        });
20687        std::fs::create_dir_all(&root).unwrap();
20688        let materialization_root = root.join("session-environments");
20689        let deny = Arc::new(AtomicBool::new(deny_secret));
20690        let resolver = Arc::new(FixtureSecretResolver {
20691            deny: Arc::clone(&deny),
20692        });
20693        let materializer = SessionEnvironmentMaterializer::new(
20694            materialization_root,
20695            resolver,
20696        )
20697        .unwrap();
20698        let profile_id = SpawnEnvironmentProfileId::new("materialized-claude").unwrap();
20699        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20700            "materialized-claude-r1",
20701        )
20702        .unwrap();
20703        let materialization = NodeSessionMaterializationProfile::new(
20704            vec![NodeSessionEnvironmentMutation::SetSecret {
20705                key: "GATE4AGENT_SESSION_TOKEN".to_owned(),
20706                reference: NodeSecretReference::new("fixture-token").unwrap(),
20707            }],
20708            vec![NodeSessionPathBinding::new(
20709                "GATE4AGENT_PROVIDER_HOME",
20710                NodeSessionPathClass::ProviderHome,
20711            )
20712            .unwrap()],
20713            vec![NodeSessionFile::secret(
20714                NodeSessionPathClass::Config,
20715                "auth/token",
20716                NodeSecretReference::new("fixture-token").unwrap(),
20717            )
20718            .unwrap()],
20719        )
20720        .unwrap();
20721        let catalog = active_registry().unwrap();
20722        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
20723        let control = runtime.native_launch_profile_control();
20724        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20725            profile_id.clone(),
20726            profile_revision.clone(),
20727            agent("claude"),
20728            [NativeLaunchProfile::new(
20729                NativeLaunchProfileId::new("materialized-claude-pty").unwrap(),
20730                agent("claude"),
20731                TransportKind::Pty,
20732                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20733                Arc::new(EmptyEnvironmentResolver),
20734            )
20735            .unwrap()],
20736            Some(materialization),
20737        )
20738        .unwrap();
20739        let (binding, native_profiles, materialization) = node_profile.into_parts();
20740        for native_profile in native_profiles {
20741            runtime.upsert_native_launch_profile(native_profile).unwrap();
20742        }
20743        let shared = NodeShared::new_with_incarnation(
20744            handle,
20745            "fixture-token".to_owned(),
20746            NodeId::new("node-materialization-test").unwrap(),
20747            NodeIncarnationId::from_bytes([9; crate::protocol::NODE_INCARNATION_ID_BYTES]),
20748            vec![workspace],
20749            vec![agent("claude")],
20750            ProviderRuntimeStatuses::default(),
20751            None,
20752            Vec::new(),
20753            Vec::new(),
20754            SpawnProfileRegistry::default(),
20755            Some(control),
20756            None,
20757            Some(materializer),
20758            None,
20759            Vec::new(),
20760            Vec::new(),
20761            Vec::new(),
20762            Vec::new(),
20763            None,
20764            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
20765        );
20766        shared
20767            .environment_profiles
20768            .write()
20769            .unwrap_or_else(|poisoned| poisoned.into_inner())
20770            .insert(binding.id.clone(), binding);
20771        shared
20772            .environment_materialization_profiles
20773            .write()
20774            .unwrap_or_else(|poisoned| poisoned.into_inner())
20775            .insert(profile_id.clone(), materialization.unwrap());
20776        (
20777            shared,
20778            runtime,
20779            ResolvedEnvironmentProfileReceipt {
20780                profile_id,
20781                profile_revision,
20782                network_allowlist: None,
20783                browser_profile_id: None,
20784            },
20785            root,
20786            deny,
20787        )
20788    }
20789
20790    #[cfg(feature = "fixture")]
20791    #[derive(Clone, Copy)]
20792    enum CodexHomeProfile {
20793        Missing,
20794        WrongClass,
20795        Exact,
20796    }
20797
20798    #[cfg(feature = "fixture")]
20799    fn codex_bundle_test_shared(
20800        home_profile: CodexHomeProfile,
20801    ) -> (
20802        NodeShared,
20803        NativeRuntime,
20804        ResolvedEnvironmentProfileReceipt,
20805        NodeBundle,
20806        PathBuf,
20807    ) {
20808        const ROOT_MANIFEST: &[u8] = br#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"review-tools"}"#;
20809        const CLAUDE_MANIFEST: &[u8] =
20810            br#"{"name":"review-tools","version":"1.0.0","description":"Review helpers"}"#;
20811        const SKILL: &[u8] = b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n";
20812        const DIGEST: &str =
20813            "sha256:941f20868a6ef49b4329bf1bb1368515763f5b64d8279b05cd9700966083d707";
20814
20815        let (shared, mut runtime, _claude_receipt, root, _deny) =
20816            materialization_test_shared(false);
20817        let profile_id = SpawnEnvironmentProfileId::new(match home_profile {
20818            CodexHomeProfile::Missing => "codex-home-missing",
20819            CodexHomeProfile::WrongClass => "codex-home-wrong",
20820            CodexHomeProfile::Exact => "codex-home-exact",
20821        })
20822        .unwrap();
20823        let profile_revision = crate::protocol::SpawnEnvironmentProfileRevision::new(
20824            "codex-home-r1",
20825        )
20826        .unwrap();
20827        let materialization = match home_profile {
20828            CodexHomeProfile::Missing => None,
20829            CodexHomeProfile::WrongClass | CodexHomeProfile::Exact => {
20830                Some(NodeSessionMaterializationProfile::new(
20831                    Vec::new(),
20832                    vec![NodeSessionPathBinding::new(
20833                        "CODEX_HOME",
20834                        if matches!(home_profile, CodexHomeProfile::Exact) {
20835                            NodeSessionPathClass::ProviderHome
20836                        } else {
20837                            NodeSessionPathClass::Config
20838                        },
20839                    )
20840                    .unwrap()],
20841                    Vec::new(),
20842                )
20843                .unwrap())
20844            }
20845        };
20846        let node_profile = NodeEnvironmentProfile::new_with_materialization(
20847            profile_id.clone(),
20848            profile_revision.clone(),
20849            agent("codex"),
20850            [NativeLaunchProfile::new(
20851                NativeLaunchProfileId::new(format!("{profile_id}-pty")).unwrap(),
20852                agent("codex"),
20853                TransportKind::Pty,
20854                vec![OsString::from("GATE4AGENT_TEST_PROFILE")],
20855                Arc::new(EmptyEnvironmentResolver),
20856            )
20857            .unwrap()],
20858            materialization,
20859        )
20860        .unwrap();
20861        let (binding, native_profiles, materialization) = node_profile.into_parts();
20862        for native_profile in native_profiles {
20863            runtime.upsert_native_launch_profile(native_profile).unwrap();
20864        }
20865        shared
20866            .environment_profiles
20867            .write()
20868            .unwrap_or_else(|poisoned| poisoned.into_inner())
20869            .insert(binding.id.clone(), binding);
20870        if let Some(materialization) = materialization {
20871            shared
20872                .environment_materialization_profiles
20873                .write()
20874                .unwrap_or_else(|poisoned| poisoned.into_inner())
20875                .insert(profile_id.clone(), materialization);
20876        }
20877
20878        let bundle_source = root.join(format!("bundle-source-{profile_id}"));
20879        std::fs::create_dir_all(bundle_source.join(".claude-plugin")).unwrap();
20880        std::fs::create_dir_all(bundle_source.join("skills/review-code")).unwrap();
20881        std::fs::write(bundle_source.join("plugin.json"), ROOT_MANIFEST).unwrap();
20882        std::fs::write(
20883            bundle_source.join(".claude-plugin/plugin.json"),
20884            CLAUDE_MANIFEST,
20885        )
20886        .unwrap();
20887        std::fs::write(bundle_source.join("skills/review-code/SKILL.md"), SKILL).unwrap();
20888        crate::bundle_catalog::protect_bundle_source_tree_fixture(&bundle_source).unwrap();
20889        let bundle = NodeBundle::new(
20890            crate::protocol::SpawnBundleId::new("review-tools").unwrap(),
20891            crate::protocol::SpawnBundleRevision::new("review-tools-r1").unwrap(),
20892            crate::protocol::SpawnBundleDigest::new(DIGEST).unwrap(),
20893            &bundle_source,
20894        )
20895        .unwrap();
20896        *shared
20897            .bundle_catalog
20898            .write()
20899            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
20900            BundleCatalog::new([bundle.clone()]).unwrap();
20901        (
20902            shared,
20903            runtime,
20904            ResolvedEnvironmentProfileReceipt {
20905                profile_id,
20906                profile_revision,
20907                network_allowlist: None,
20908                browser_profile_id: None,
20909            },
20910            bundle,
20911            root,
20912        )
20913    }
20914
20915    #[cfg(feature = "fixture")]
20916    #[test]
20917    fn codex_bundle_preflight_rejects_missing_or_wrong_home_before_materialization() {
20918        for home_profile in [CodexHomeProfile::Missing, CodexHomeProfile::WrongClass] {
20919            let (shared, runtime, receipt, bundle, root) =
20920                codex_bundle_test_shared(home_profile);
20921            let error = shared
20922                .bundle_layout(
20923                    &agent("codex"),
20924                    SessionMode::Pty,
20925                    Some(&receipt),
20926                    &bundle,
20927                )
20928                .unwrap_err();
20929            assert_eq!(error.code, NodeFailureCode::BundleBindingMismatch);
20930            assert!(shared
20931                .materializations
20932                .lock()
20933                .unwrap_or_else(|poisoned| poisoned.into_inner())
20934                .is_empty());
20935            let materialization_root = root.join("session-environments");
20936            assert!(!std::fs::read_dir(&materialization_root)
20937                .unwrap()
20938                .filter_map(Result::ok)
20939                .any(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())));
20940            drop(shared);
20941            drop(runtime);
20942            let _ = std::fs::remove_dir_all(root);
20943        }
20944    }
20945
20946    #[cfg(feature = "fixture")]
20947    #[test]
20948    fn codex_bundle_exact_home_profile_materializes_only_the_skill_tree() {
20949        let (shared, runtime, receipt, bundle, root) =
20950            codex_bundle_test_shared(CodexHomeProfile::Exact);
20951        let address = SessionAddress {
20952            workspace_id: WorkspaceId::new("primary").unwrap(),
20953            session: SessionKey {
20954                instance_id: AgentInstanceId(310),
20955                generation: SessionGeneration(1),
20956            },
20957        };
20958        let (overlay, guard) = shared
20959            .prepare_session_materialization(
20960                &address,
20961                &agent("codex"),
20962                SessionMode::Pty,
20963                Some(&receipt),
20964                Some(&bundle.receipt()),
20965                None,
20966                None,
20967            )
20968            .unwrap()
20969            .unwrap();
20970        assert!(matches!(overlay, Some(PreparedNativeLaunchOverlay::Instance(_))));
20971        let ownership = shared
20972            .materializations
20973            .lock()
20974            .unwrap_or_else(|poisoned| poisoned.into_inner())
20975            .get(guard.id().unwrap())
20976            .unwrap()
20977            .clone();
20978        let (profile, _, _) = shared
20979            .materialization_profile(
20980                &agent("codex"),
20981                SessionMode::Pty,
20982                Some(&receipt),
20983                Some(&bundle.receipt()),
20984                None,
20985            )
20986            .unwrap()
20987            .unwrap();
20988        let environment = shared
20989            .session_environment_materializer
20990            .as_ref()
20991            .unwrap()
20992            .resolve_environment(&ownership, &profile)
20993            .unwrap();
20994        assert!(environment.iter().any(|mutation| {
20995            mutation.key == OsString::from("CODEX_HOME")
20996                && mutation.value.as_deref() == Some(ownership.provider_home().as_os_str())
20997        }));
20998        assert_eq!(
20999            std::fs::read(ownership.provider_home().join("skills/review-code/SKILL.md"))
21000                .unwrap(),
21001            b"---\nname: review-code\ndescription: Review code for correctness and safety.\n---\n\nReview the selected change.\n",
21002        );
21003        assert!(std::fs::read_dir(ownership.bundle_root())
21004            .unwrap()
21005            .next()
21006            .is_none());
21007        drop(guard);
21008        drop(shared);
21009        drop(runtime);
21010        let _ = std::fs::remove_dir_all(root);
21011    }
21012
21013    #[cfg(feature = "fixture")]
21014    #[test]
21015    fn codex_cold_record_resume_revalidates_the_isolated_skill_tree() {
21016        let (shared, runtime, receipt, bundle, root) =
21017            codex_bundle_test_shared(CodexHomeProfile::Exact);
21018        let bundle_receipt = bundle.receipt();
21019        let address = SessionAddress {
21020            workspace_id: WorkspaceId::new("primary").unwrap(),
21021            session: SessionKey {
21022                instance_id: AgentInstanceId(311),
21023                generation: SessionGeneration(1),
21024            },
21025        };
21026        let (_overlay, guard) = shared
21027            .prepare_session_materialization(
21028                &address,
21029                &agent("codex"),
21030                SessionMode::Pty,
21031                Some(&receipt),
21032                Some(&bundle_receipt),
21033                None,
21034                None,
21035            )
21036            .unwrap()
21037            .unwrap();
21038        let materialization_id = guard.id().unwrap().clone();
21039        let skill_path = shared
21040            .materializations
21041            .lock()
21042            .unwrap_or_else(|poisoned| poisoned.into_inner())
21043            .get(&materialization_id)
21044            .unwrap()
21045            .provider_home()
21046            .join("skills/review-code/SKILL.md");
21047        let record_id = shared
21048            .bind_spawn_session_with_materialization(
21049                &address,
21050                agent("codex"),
21051                SessionMode::Pty,
21052                ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap(),
21053                SpawnRecordPolicy::ProviderIdentityOnly,
21054                Some(receipt.clone()),
21055                Some(bundle_receipt.clone()),
21056                None,
21057                Some(materialization_id.clone()),
21058            )
21059            .unwrap()
21060            .unwrap();
21061        guard.retain();
21062        std::fs::write(skill_path, b"changed-after-record-bind").unwrap();
21063
21064        let error = match shared.resolve_record_materialization(
21065                &record_id,
21066                &agent("codex"),
21067                SessionMode::Pty,
21068                Some(&receipt),
21069                Some(&bundle_receipt),
21070                None,
21071            ) {
21072                Ok(_) => panic!("changed Codex skill tree unexpectedly revalidated"),
21073                Err(error) => error,
21074            };
21075        assert_eq!(error.code, NodeFailureCode::BundleMaterializationFailed);
21076        assert_eq!(
21077            shared
21078                .materializations
21079                .lock()
21080                .unwrap_or_else(|poisoned| poisoned.into_inner())
21081                .get(&materialization_id)
21082                .unwrap()
21083                .state(),
21084            MaterializationState::RecoveryRequired,
21085        );
21086        drop(shared);
21087        drop(runtime);
21088        let _ = std::fs::remove_dir_all(root);
21089    }
21090
21091    fn bind_record_owned_materialization_fixture(
21092        shared: &NodeShared,
21093        receipt: &ResolvedEnvironmentProfileReceipt,
21094        instance_id: u64,
21095    ) -> (SessionAddress, SessionRecordId, MaterializationId, PathBuf) {
21096        let address = SessionAddress {
21097            workspace_id: WorkspaceId::new("primary").unwrap(),
21098            session: SessionKey {
21099                instance_id: AgentInstanceId(instance_id),
21100                generation: SessionGeneration(1),
21101            },
21102        };
21103        let (overlay, materialization_guard) = shared
21104            .prepare_session_materialization(
21105                &address,
21106                &agent("claude"),
21107                SessionMode::Pty,
21108                Some(receipt),
21109                None,
21110                None,
21111                None,
21112            )
21113            .unwrap()
21114            .unwrap();
21115        let materialization_id = materialization_guard.id().unwrap().clone();
21116        let materialization_root = shared
21117            .materializations
21118            .lock()
21119            .unwrap_or_else(|poisoned| poisoned.into_inner())
21120            .get(&materialization_id)
21121            .unwrap()
21122            .root()
21123            .to_path_buf();
21124        let selection = shared
21125            .select_environment_profile(
21126                address.session.instance_id,
21127                &agent("claude"),
21128                SessionMode::Pty,
21129                Some(receipt),
21130            )
21131            .unwrap()
21132            .unwrap();
21133        assert!(shared
21134            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21135            .unwrap()
21136            .is_none());
21137        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21138        let record_id = shared
21139            .bind_spawn_session_with_materialization(
21140                &address,
21141                agent("claude"),
21142                SessionMode::Pty,
21143                policy,
21144                SpawnRecordPolicy::ProviderIdentityOnly,
21145                Some(receipt.clone()),
21146                None,
21147                None,
21148                Some(materialization_id.clone()),
21149            )
21150            .unwrap()
21151            .unwrap();
21152        selection.retain();
21153        materialization_guard.retain();
21154        (address, record_id, materialization_id, materialization_root)
21155    }
21156
21157    #[test]
21158    fn child_environment_profile_capability_is_state_aware_and_legacy_safe() {
21159        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21160        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21161            capability.as_str() == NODE_CHILD_ENVIRONMENT_PROFILE_CAPABILITY
21162        }));
21163        let support = node_compatibility_support_for_manifest(&[], &[]).unwrap();
21164        assert_eq!(support.state_schema.versions.maximum(), NODE_STATE_SCHEMA_V10);
21165        let mut spec = spawn_spec_fixture();
21166        spec.expected_profile_revision =
21167            crate::protocol::SpawnProfileRevision::new("test-r1").unwrap();
21168        assert!(matches!(
21169            &spec.overrides.environment_profile_id,
21170            crate::protocol::SpawnOverride::Inherit,
21171        ));
21172        assert!(!NodeRequest::SpawnSpec { spec: spec.clone() }
21173            .requires_child_environment_profile_capability());
21174        assert!(request_requires_child_environment_profile(
21175            &shared,
21176            &NodeRequest::SpawnSpec { spec: spec.clone() },
21177        ));
21178
21179        let address = terminal_address(1);
21180        shared.bind_session_with_policy(
21181            &address,
21182            ProviderRuntimePolicy::raw_pty(),
21183            Some(environment_profile.clone()),
21184        );
21185        assert!(request_requires_child_environment_profile(
21186            &shared,
21187            &NodeRequest::Input {
21188                session: address.clone(),
21189                text: "x".to_owned(),
21190            },
21191        ));
21192
21193        let mut record = record("claude", "profile-record");
21194        record.active_session = Some(address.clone());
21195        record.environment_profile = Some(environment_profile.clone());
21196        let record_id = record.record_id.clone();
21197        shared.insert_record(record.clone()).unwrap();
21198        assert!(request_requires_child_environment_profile(
21199            &shared,
21200            &NodeRequest::ResumeSessionRecord {
21201                record_id,
21202                terminal_size: gate4agent_types::TerminalSize {
21203                    rows: 24,
21204                    columns: 80,
21205                },
21206                initial_prompt: None,
21207            },
21208        ));
21209
21210        let snapshot = snapshot_for_wire(
21211            &shared, false, true, true, true, false, false, false, false, true,
21212        );
21213        assert_eq!(snapshot.session_records.len(), 1);
21214        assert!(snapshot.session_records[0].environment_profile.is_none());
21215        let event = project_event_without_child_environment_profile(NodeEventEnvelope {
21216            sequence: 1,
21217            event: NodeEvent::SessionRecordUpserted { record },
21218        });
21219        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21220            panic!("record projection changed the event kind");
21221        };
21222        assert!(record.environment_profile.is_none());
21223
21224        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21225        let mut reply = ResponseEnvelope {
21226            request_id: 1,
21227            result: Ok(NodeResponse::SpawnSpecAccepted {
21228                receipt: resolved.receipt_with_environment(
21229                    shared.incarnation_id,
21230                    address,
21231                    Some(environment_profile),
21232                ),
21233            }),
21234        };
21235        project_response_without_child_environment_profile(&mut reply);
21236        assert_eq!(
21237            reply.result.unwrap_err().code,
21238            NodeFailureCode::UnsupportedCapability,
21239        );
21240    }
21241
21242    #[test]
21243    fn spawn_profile_environment_authority_is_exact_and_legacy_safe() {
21244        let (shared, _runtime, environment_profile) = environment_profile_test_shared();
21245        let snapshot = shared.snapshot();
21246        let profiles = snapshot
21247            .launch_inventory
21248            .as_ref()
21249            .and_then(|inventory| inventory.spawn_profiles.as_ref())
21250            .unwrap();
21251        assert_eq!(profiles.len(), 1);
21252        assert_eq!(profiles[0].environment_profile, Some(environment_profile));
21253        assert!(snapshot.requires_child_environment_profile_capability());
21254
21255        let mut legacy = snapshot.clone();
21256        clear_snapshot_child_environment_profiles(&mut legacy);
21257        assert!(legacy
21258            .launch_inventory
21259            .as_ref()
21260            .unwrap()
21261            .spawn_profiles
21262            .as_ref()
21263            .unwrap()[0]
21264            .environment_profile
21265            .is_none());
21266        assert!(!legacy.requires_child_environment_profile_capability());
21267
21268        let profile = shared.spawn_profiles.iter().next().unwrap().clone();
21269        let bindings = shared
21270            .environment_profiles
21271            .read()
21272            .unwrap_or_else(|poisoned| poisoned.into_inner())
21273            .clone();
21274        let mut provider_mismatch = bindings.clone();
21275        provider_mismatch
21276            .values_mut()
21277            .next()
21278            .unwrap()
21279            .provider = agent("codex");
21280        assert!(resolved_spawn_profile_summary(&profile, &provider_mismatch).is_none());
21281        let mut mode_mismatch = profile;
21282        mode_mismatch.mode = SessionMode::Inline;
21283        assert!(resolved_spawn_profile_summary(&mode_mismatch, &bindings).is_none());
21284
21285        shared
21286            .environment_profiles
21287            .write()
21288            .unwrap_or_else(|poisoned| poisoned.into_inner())
21289            .clear();
21290        assert!(shared
21291            .snapshot()
21292            .launch_inventory
21293            .unwrap()
21294            .spawn_profiles
21295            .unwrap()
21296            .is_empty());
21297    }
21298
21299    #[test]
21300    fn session_bundle_capability_is_state_aware_and_legacy_safe() {
21301        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21302            capability.as_str() == NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY
21303        }));
21304        let shared = terminal_test_shared();
21305        let bundle = ResolvedBundleReceipt {
21306            id: crate::protocol::SpawnBundleId::new("review-bundle").unwrap(),
21307            revision: crate::protocol::SpawnBundleRevision::new("r1").unwrap(),
21308            digest: crate::protocol::SpawnBundleDigest::new(format!(
21309                "sha256:{}",
21310                "0".repeat(64),
21311            ))
21312            .unwrap(),
21313        };
21314        let mut bundled = record("claude", "bundle-record");
21315        bundled.bundle = Some(bundle.clone());
21316        let record_id = bundled.record_id.clone();
21317        shared.insert_record(bundled.clone()).unwrap();
21318        assert!(request_requires_session_bundle(
21319            &shared,
21320            &NodeRequest::ForgetSessionRecord {
21321                record_id: record_id.clone(),
21322            },
21323        ));
21324
21325        let snapshot = snapshot_for_wire(
21326            &shared, false, true, true, true, true, false, false, false, true,
21327        );
21328        assert!(snapshot.session_records[0].bundle.is_none());
21329        let launch = snapshot.launch_inventory.as_ref().unwrap();
21330        assert!(launch.spawn_profiles.is_some());
21331        assert!(launch.bundles.is_none());
21332        let event = project_event_without_session_bundle(NodeEventEnvelope {
21333            sequence: 1,
21334            event: NodeEvent::SessionRecordUpserted { record: bundled },
21335        });
21336        let NodeEvent::SessionRecordUpserted { record } = event.event else {
21337            panic!("record projection changed the event kind");
21338        };
21339        assert!(record.bundle.is_none());
21340
21341        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
21342        let mut reply = ResponseEnvelope {
21343            request_id: 1,
21344            result: Ok(NodeResponse::SpawnSpecAccepted {
21345                receipt: resolved.receipt_with_materialization(
21346                    shared.incarnation_id,
21347                    terminal_address(17),
21348                    None,
21349                    Some(bundle),
21350                    None,
21351                ),
21352            }),
21353        };
21354        project_response_without_session_bundle(&mut reply);
21355        assert_eq!(
21356            reply.result.unwrap_err().code,
21357            NodeFailureCode::UnsupportedCapability,
21358        );
21359    }
21360
21361    #[test]
21362    fn history_context_pack_capability_redacts_loaded_messages_and_legacy_metadata() {
21363        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
21364            capability.as_str() == NODE_HISTORY_CONTEXT_PACK_CAPABILITY
21365        }));
21366        let context_id = SpawnContextId::new("context-review").unwrap();
21367        let context = ResolvedContextPackReceipt {
21368            id: context_id.clone(),
21369            digest: crate::protocol::SpawnContextDigest::new(format!(
21370                "sha256:{}",
21371                "c".repeat(64),
21372            ))
21373            .unwrap(),
21374            lineage: ContextPackLineageReceipt {
21375                source_node_id: NodeId::new("node-source").unwrap(),
21376                source_session: terminal_address(1),
21377                source_provider: agent("kimi"),
21378            },
21379            source_message_count: 2,
21380            retained_message_count: 2,
21381            byte_len: 256,
21382            truncated: false,
21383        };
21384        assert!(context.is_valid());
21385        let mut bound_record = record("codex", "context-record");
21386        bound_record.context_id = Some(context_id);
21387        bound_record.context = Some(context.clone());
21388        let session = gate4agent_types::SessionSnapshot {
21389            instance_id: AgentInstanceId(91),
21390            agent_id: agent("kimi"),
21391            transport: TransportKind::Pty,
21392            generation: SessionGeneration(3),
21393            status: SessionStatus::Running,
21394            pending_operation: None,
21395            pending_input: None,
21396            process_id: Some(9001),
21397            terminal_size: None,
21398            terminal_frame: None,
21399            terminal_stale: None,
21400            session_options: None,
21401            capabilities: gate4agent_types::CapabilitySnapshot::default(),
21402            history: gate4agent_types::HistorySnapshot {
21403                pending: None,
21404                candidates: vec![HistoryCandidateSummary {
21405                    id: "candidate-opaque".to_owned(),
21406                    session_id_hint: "session-hint".to_owned(),
21407                    modified_at_unix_ms: Some(1),
21408                }],
21409                loaded_candidate_id: Some("candidate-opaque".to_owned()),
21410                loaded: Some(HistorySessionRecord {
21411                    session_id: "private-vendor-session".to_owned(),
21412                    title: Some("private title".to_owned()),
21413                    cwd: Some(r"C:\private\history-root".to_owned()),
21414                    model: Some("private-model".to_owned()),
21415                    message_count: 2,
21416                    completed_turn_count: None,
21417                    total_tokens: 19,
21418                    messages: vec![
21419                        gate4agent_types::HistoryMessageRecord {
21420                            role: gate4agent_types::HistoryMessageRole::User,
21421                            text: "F7_PRIVATE_HISTORY_MESSAGE".to_owned(),
21422                        },
21423                        gate4agent_types::HistoryMessageRecord {
21424                            role: gate4agent_types::HistoryMessageRole::Assistant,
21425                            text: "private answer".to_owned(),
21426                        },
21427                    ],
21428                }),
21429                last_error: None,
21430            },
21431            resume: gate4agent_types::ResumeSnapshot::default(),
21432            foreground: gate4agent_types::ForegroundSnapshot::default(),
21433            provider: gate4agent_types::ProviderSnapshot::default(),
21434            screen_state: gate4agent_types::PtyScreenState::default(),
21435        };
21436        let original = NodeSnapshot {
21437            node_id: NodeId::new("node-terminal-test").unwrap(),
21438            enabled_providers: vec![agent("kimi"), agent("codex")],
21439            provider_runtime_statuses: ProviderRuntimeStatuses::default(),
21440            workspaces: vec![WorkspaceSnapshot {
21441                workspace_id: WorkspaceId::new("primary").unwrap(),
21442                canonical_root: opaque_windows_path(
21443                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
21444                ),
21445                sessions: vec![session],
21446                worktree_service_mode: None,
21447                managed_worktree_profiles: None,
21448            }],
21449            session_records: vec![bound_record],
21450            managed_worktrees: Vec::new(),
21451            launch_inventory: None,
21452            agent_progress: Vec::new(),
21453        };
21454
21455        let mut negotiated = original.clone();
21456        project_snapshot_history_for_wire(&mut negotiated, true);
21457        assert_eq!(negotiated.workspaces[0].sessions[0].history.candidates.len(), 1);
21458        assert!(negotiated.workspaces[0].sessions[0].history.loaded.is_none());
21459        assert_eq!(negotiated.session_records[0].context.as_ref(), Some(&context));
21460        assert!(!serde_json::to_string(&negotiated)
21461            .unwrap()
21462            .contains("F7_PRIVATE_HISTORY_MESSAGE"));
21463
21464        let mut legacy = original;
21465        project_snapshot_history_for_wire(&mut legacy, false);
21466        clear_snapshot_context_packs(&mut legacy);
21467        assert_eq!(
21468            legacy.workspaces[0].sessions[0].history,
21469            gate4agent_types::HistorySnapshot::default(),
21470        );
21471        assert!(legacy.session_records[0].context_id.is_none());
21472        assert!(legacy.session_records[0].context.is_none());
21473        let encoded = serde_json::to_string(&legacy).unwrap();
21474        assert!(!encoded.contains("F7_PRIVATE_HISTORY_MESSAGE"));
21475        assert!(!encoded.contains("context-review"));
21476
21477        let mut reply = ResponseEnvelope {
21478            request_id: 7,
21479            result: Ok(NodeResponse::ContextPackExported { context }),
21480        };
21481        project_response_without_context_pack(&mut reply);
21482        assert_eq!(
21483            reply.result.unwrap_err().code,
21484            NodeFailureCode::UnsupportedCapability,
21485        );
21486    }
21487
21488    #[test]
21489    fn forgetting_unbound_context_pack_evicts_replay_but_retained_record_is_busy() {
21490        let shared = terminal_test_shared();
21491        let history = HistorySessionRecord {
21492            session_id: "fixture-history".to_owned(),
21493            title: None,
21494            cwd: None,
21495            model: None,
21496            message_count: 1,
21497            completed_turn_count: None,
21498            total_tokens: 0,
21499            messages: vec![gate4agent_types::HistoryMessageRecord {
21500                role: gate4agent_types::HistoryMessageRole::User,
21501                text: "bounded handoff".to_owned(),
21502            }],
21503        };
21504        let pack = NodeContextPack::export(
21505            ContextPackLineageReceipt {
21506                source_node_id: shared.node_id.clone(),
21507                source_session: terminal_address(1),
21508                source_provider: agent("claude"),
21509            },
21510            &history,
21511        )
21512        .unwrap();
21513        let context = shared
21514            .context_catalog
21515            .write()
21516            .unwrap_or_else(|poisoned| poisoned.into_inner())
21517            .insert(pack)
21518            .unwrap();
21519        let mut retained = record("codex", "context-retained-record");
21520        retained.context_id = Some(context.id.clone());
21521        retained.context = Some(context.clone());
21522        let record_id = retained.record_id.clone();
21523        shared.insert_record(retained).unwrap();
21524        assert_eq!(
21525            shared.forget_context_pack(&context.id).unwrap_err().code,
21526            NodeFailureCode::ContextPackBusy,
21527        );
21528        assert!(shared
21529            .context_catalog
21530            .read()
21531            .unwrap_or_else(|poisoned| poisoned.into_inner())
21532            .get(&context.id)
21533            .is_some());
21534        shared
21535            .session_records
21536            .lock()
21537            .unwrap_or_else(|poisoned| poisoned.into_inner())
21538            .records
21539            .remove(&record_id);
21540
21541        let mut spec = spawn_spec_fixture();
21542        spec.overrides.context_id = crate::protocol::SpawnOverride::Set {
21543            value: context.id.clone(),
21544        };
21545        let resolved = shared.resolve_spawn_spec(&spec).unwrap();
21546        let receipt = resolved.receipt_with_materialization(
21547            shared.incarnation_id,
21548            terminal_address(2),
21549            None,
21550            None,
21551            Some(context.clone()),
21552        );
21553        assert!(receipt.context_binding_is_valid());
21554        shared.remember_spawn_spec(spec.clone(), Ok(receipt), Instant::now());
21555
21556        shared.forget_context_pack(&context.id).unwrap();
21557        assert!(shared
21558            .context_catalog
21559            .read()
21560            .unwrap_or_else(|poisoned| poisoned.into_inner())
21561            .get(&context.id)
21562            .is_none());
21563        assert!(shared
21564            .replay_spawn_spec(&spec, Instant::now())
21565            .unwrap()
21566            .is_none());
21567    }
21568
21569    #[test]
21570    fn environment_profile_selection_is_owned_by_the_exact_session_binding() {
21571        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21572        let address = terminal_address(1);
21573        let selection = shared
21574            .select_environment_profile(
21575                address.session.instance_id,
21576                &agent("claude"),
21577                SessionMode::Pty,
21578                Some(&environment_profile),
21579            )
21580            .unwrap()
21581            .unwrap();
21582        shared.bind_session_with_policy(
21583            &address,
21584            ProviderRuntimePolicy::raw_pty(),
21585            Some(environment_profile.clone()),
21586        );
21587        selection.retain();
21588        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21589        assert!(matches!(
21590            runtime.remove_native_launch_profile(&native_id),
21591            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21592        ));
21593        assert!(shared.remove_binding(&address).is_some());
21594        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21595
21596        let mismatched = ResolvedEnvironmentProfileReceipt {
21597            profile_id: environment_profile.profile_id,
21598            profile_revision: crate::protocol::SpawnEnvironmentProfileRevision::new(
21599                "local-claude-r2",
21600            )
21601            .unwrap(),
21602            network_allowlist: None,
21603            browser_profile_id: None,
21604        };
21605        let error = match shared.select_environment_profile(
21606                AgentInstanceId(42),
21607                &agent("claude"),
21608                SessionMode::Pty,
21609                Some(&mismatched),
21610            ) {
21611            Ok(_) => panic!("mismatched environment profile revision was selected"),
21612            Err(error) => error,
21613        };
21614        assert_eq!(
21615            error.code,
21616            NodeFailureCode::EnvironmentProfileBindingMismatch,
21617        );
21618        assert!(!shared
21619            .session_bindings
21620            .lock()
21621            .unwrap_or_else(|poisoned| poisoned.into_inner())
21622            .contains_key(&AgentInstanceId(42)));
21623    }
21624
21625    #[test]
21626    fn combined_environment_and_bundle_overlay_clears_with_its_binding() {
21627        let (shared, mut runtime, environment_profile) = environment_profile_test_shared();
21628        let address = terminal_address(1);
21629        let selection = shared
21630            .select_environment_profile(
21631                address.session.instance_id,
21632                &agent("claude"),
21633                SessionMode::Pty,
21634                Some(&environment_profile),
21635            )
21636            .unwrap()
21637            .unwrap();
21638        let overlay = NativeInstanceLaunchOverlay::new(
21639            agent("claude"),
21640            TransportKind::Pty,
21641            vec![EnvMutation {
21642                key: OsString::from("GATE4AGENT_SESSION_TOKEN"),
21643                value: Some(OsString::from("fixture-secret")),
21644            }],
21645            vec![
21646                OsString::from("--plugin-dir"),
21647                std::env::current_dir().unwrap().into_os_string(),
21648            ],
21649        )
21650        .unwrap();
21651        let instance_overlay = shared
21652            .install_prepared_launch_overlay(
21653                address.session.instance_id,
21654                PreparedNativeLaunchOverlay::Instance(overlay),
21655            )
21656            .unwrap()
21657            .unwrap();
21658        let bundle = ResolvedBundleReceipt {
21659            id: crate::protocol::SpawnBundleId::new("combined-bundle").unwrap(),
21660            revision: crate::protocol::SpawnBundleRevision::new("combined-r1").unwrap(),
21661            digest: crate::protocol::SpawnBundleDigest::new(format!(
21662                "sha256:{}",
21663                "0".repeat(64),
21664            ))
21665            .unwrap(),
21666        };
21667        shared.bind_session_with_materialization(
21668            &address,
21669            ProviderRuntimePolicy::raw_pty(),
21670            Some(environment_profile),
21671            Some(bundle),
21672            None,
21673            None,
21674        );
21675        selection.retain();
21676        instance_overlay.retain();
21677
21678        let native_id = NativeLaunchProfileId::new("local-claude-pty").unwrap();
21679        assert!(matches!(
21680            runtime.remove_native_launch_profile(&native_id),
21681            Err(gate4agent_runtime_native::NativeLaunchProfileError::ProfileInUse),
21682        ));
21683        assert!(shared.remove_binding(&address).is_some());
21684        assert!(!shared
21685            .native_launch_profile_control
21686            .as_ref()
21687            .unwrap()
21688            .clear_native_instance_launch_overlay(address.session.instance_id));
21689        assert_eq!(runtime.remove_native_launch_profile(&native_id), Ok(true));
21690    }
21691
21692    #[test]
21693    fn session_environment_materialization_failure_is_pre_child_and_durable() {
21694        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(true);
21695        let address = SessionAddress {
21696            workspace_id: WorkspaceId::new("primary").unwrap(),
21697            session: SessionKey {
21698                instance_id: AgentInstanceId(201),
21699                generation: SessionGeneration(1),
21700            },
21701        };
21702        let error = match shared.prepare_session_materialization(
21703            &address,
21704            &agent("claude"),
21705            SessionMode::Pty,
21706            Some(&receipt),
21707            None,
21708            None,
21709            None,
21710        ) {
21711            Ok(_) => panic!("denied local reference unexpectedly materialized"),
21712            Err(error) => error,
21713        };
21714        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21715        assert!(shared.handle.snapshot().sessions.is_empty());
21716        assert!(shared
21717            .session_bindings
21718            .lock()
21719            .unwrap_or_else(|poisoned| poisoned.into_inner())
21720            .is_empty());
21721        assert!(shared
21722            .materializations
21723            .lock()
21724            .unwrap_or_else(|poisoned| poisoned.into_inner())
21725            .is_empty());
21726        drop(shared);
21727        drop(runtime);
21728        std::fs::remove_dir_all(root).unwrap();
21729    }
21730
21731    #[test]
21732    fn session_environment_binding_retains_on_stop_and_cleans_after_remove_reap() {
21733        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21734        let address = SessionAddress {
21735            workspace_id: WorkspaceId::new("primary").unwrap(),
21736            session: SessionKey {
21737                instance_id: AgentInstanceId(202),
21738                generation: SessionGeneration(1),
21739            },
21740        };
21741        let (overlay, materialization_guard) = shared
21742            .prepare_session_materialization(
21743                &address,
21744                &agent("claude"),
21745                SessionMode::Pty,
21746                Some(&receipt),
21747                None,
21748                None,
21749                None,
21750            )
21751            .unwrap()
21752            .unwrap();
21753        let materialization_id = materialization_guard.id().unwrap().clone();
21754        let materialization_root = shared
21755            .materializations
21756            .lock()
21757            .unwrap_or_else(|poisoned| poisoned.into_inner())
21758            .get(&materialization_id)
21759            .unwrap()
21760            .root()
21761            .to_path_buf();
21762        let selection = shared
21763            .select_environment_profile(
21764                address.session.instance_id,
21765                &agent("claude"),
21766                SessionMode::Pty,
21767                Some(&receipt),
21768            )
21769            .unwrap()
21770            .unwrap();
21771        assert!(shared
21772            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21773            .unwrap()
21774            .is_none());
21775        shared.bind_session_with_materialization(
21776            &address,
21777            ProviderRuntimePolicy::raw_pty(),
21778            Some(receipt),
21779            None,
21780            None,
21781            Some(materialization_id),
21782        );
21783        selection.retain();
21784        materialization_guard.retain();
21785
21786        assert!(materialization_root.is_dir());
21787        assert!(shared
21788            .session_bindings
21789            .lock()
21790            .unwrap_or_else(|poisoned| poisoned.into_inner())
21791            .contains_key(&address.session.instance_id));
21792
21793        let removed = shared.remove_binding(&address).unwrap();
21794        shared
21795            .cleanup_session_owned_materialization(&address, &removed)
21796            .unwrap();
21797        assert!(!materialization_root.exists());
21798        assert!(shared
21799            .materializations
21800            .lock()
21801            .unwrap_or_else(|poisoned| poisoned.into_inner())
21802            .is_empty());
21803        drop(shared);
21804        drop(runtime);
21805        std::fs::remove_dir_all(root).unwrap();
21806    }
21807
21808    #[tokio::test]
21809    async fn record_owned_session_environment_reuses_on_resume_and_forget_cleans() {
21810        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21811        let address = SessionAddress {
21812            workspace_id: WorkspaceId::new("primary").unwrap(),
21813            session: SessionKey {
21814                instance_id: AgentInstanceId(203),
21815                generation: SessionGeneration(1),
21816            },
21817        };
21818        let (overlay, materialization_guard) = shared
21819            .prepare_session_materialization(
21820                &address,
21821                &agent("claude"),
21822                SessionMode::Pty,
21823                Some(&receipt),
21824                None,
21825                None,
21826                None,
21827            )
21828            .unwrap()
21829            .unwrap();
21830        let materialization_id = materialization_guard.id().unwrap().clone();
21831        let materialization_root = shared
21832            .materializations
21833            .lock()
21834            .unwrap_or_else(|poisoned| poisoned.into_inner())
21835            .get(&materialization_id)
21836            .unwrap()
21837            .root()
21838            .to_path_buf();
21839        let selection = shared
21840            .select_environment_profile(
21841                address.session.instance_id,
21842                &agent("claude"),
21843                SessionMode::Pty,
21844                Some(&receipt),
21845            )
21846            .unwrap()
21847            .unwrap();
21848        assert!(shared
21849            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
21850            .unwrap()
21851            .is_none());
21852        let policy = ProviderRuntimePolicy::new(true, false, false, true, false, false).unwrap();
21853        let record_id = shared
21854            .bind_spawn_session_with_materialization(
21855                &address,
21856                agent("claude"),
21857                SessionMode::Pty,
21858                policy,
21859                SpawnRecordPolicy::ProviderIdentityOnly,
21860                Some(receipt.clone()),
21861                None,
21862                None,
21863                Some(materialization_id.clone()),
21864            )
21865            .unwrap()
21866            .unwrap();
21867        selection.retain();
21868        materialization_guard.retain();
21869        assert_eq!(
21870            shared
21871                .materializations
21872                .lock()
21873                .unwrap_or_else(|poisoned| poisoned.into_inner())
21874                .get(&materialization_id)
21875                .unwrap()
21876                .owner(),
21877            &MaterializationOwner::Record {
21878                record_id: record_id.clone(),
21879            },
21880        );
21881
21882        let removed = shared.remove_binding(&address).unwrap();
21883        shared
21884            .cleanup_session_owned_materialization(&address, &removed)
21885            .unwrap();
21886        assert!(materialization_root.is_dir());
21887        assert!(shared
21888            .resolve_record_materialization(
21889                &record_id,
21890                &agent("claude"),
21891                SessionMode::Pty,
21892                Some(&receipt),
21893                None,
21894                None,
21895            )
21896            .unwrap()
21897            .is_some());
21898        {
21899            let mut records = shared
21900                .session_records
21901                .lock()
21902                .unwrap_or_else(|poisoned| poisoned.into_inner());
21903            let record = records.records.get_mut(&record_id).unwrap();
21904            record.active_session = None;
21905            record.state = ManagedSessionState::Unavailable;
21906        }
21907        shared.forget_session_record(&record_id).await.unwrap();
21908        assert!(!materialization_root.exists());
21909        assert!(shared
21910            .materializations
21911            .lock()
21912            .unwrap_or_else(|poisoned| poisoned.into_inner())
21913            .is_empty());
21914        drop(shared);
21915        drop(runtime);
21916        std::fs::remove_dir_all(root).unwrap();
21917    }
21918
21919    #[test]
21920    fn record_materialization_revalidation_failure_persists_recovery_required() {
21921        let (mut shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
21922        let state_path = root.join("node-state-v6.json");
21923        shared.state_path = Some(state_path.clone());
21924        let (_address, _record_id, materialization_id, materialization_root) =
21925            bind_record_owned_materialization_fixture(&shared, &receipt, 205);
21926        std::fs::write(
21927            materialization_root.join(".gate4agent-materialization-owner"),
21928            b"tampered",
21929        )
21930        .unwrap();
21931
21932        let error = match shared.resolve_record_materialization(
21933            &_record_id,
21934            &agent("claude"),
21935            SessionMode::Pty,
21936            Some(&receipt),
21937            None,
21938            None,
21939        ) {
21940            Ok(_) => panic!("tampered owner marker unexpectedly revalidated"),
21941            Err(error) => error,
21942        };
21943        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21944        assert_eq!(
21945            shared
21946                .materializations
21947                .lock()
21948                .unwrap_or_else(|poisoned| poisoned.into_inner())
21949                .get(&materialization_id)
21950                .unwrap()
21951                .state(),
21952            MaterializationState::RecoveryRequired,
21953        );
21954        let loaded = session_registry::load(Some(&state_path), &shared.node_id).unwrap();
21955        assert_eq!(loaded.materializations.len(), 1);
21956        assert_eq!(
21957            loaded.materializations[0].state(),
21958            MaterializationState::RecoveryRequired,
21959        );
21960        drop(shared);
21961        drop(runtime);
21962        std::fs::remove_dir_all(root).unwrap();
21963    }
21964
21965    #[test]
21966    fn transient_record_materialization_resolution_failure_remains_ready() {
21967        let (shared, runtime, receipt, root, deny) = materialization_test_shared(false);
21968        let (_address, record_id, materialization_id, _materialization_root) =
21969            bind_record_owned_materialization_fixture(&shared, &receipt, 206);
21970        deny.store(true, Ordering::Release);
21971
21972        let error = match shared.resolve_record_materialization(
21973            &record_id,
21974            &agent("claude"),
21975            SessionMode::Pty,
21976            Some(&receipt),
21977            None,
21978            None,
21979        ) {
21980            Ok(_) => panic!("denied transient resolver unexpectedly succeeded"),
21981            Err(error) => error,
21982        };
21983        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
21984        assert_eq!(
21985            shared
21986                .materializations
21987                .lock()
21988                .unwrap_or_else(|poisoned| poisoned.into_inner())
21989                .get(&materialization_id)
21990                .unwrap()
21991                .state(),
21992            MaterializationState::Ready,
21993        );
21994        drop(shared);
21995        drop(runtime);
21996        std::fs::remove_dir_all(root).unwrap();
21997    }
21998
21999    #[test]
22000    fn startup_missing_record_materialization_is_not_resumable() {
22001        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22002        let record_id = SessionRecordId::new("sr-missing-materialization").unwrap();
22003        shared
22004            .insert_record(ManagedSessionRecord {
22005                record_id: record_id.clone(),
22006                display_name: "missing materialization".to_owned(),
22007                provider: agent("claude"),
22008                mode: SessionMode::Pty,
22009                state: ManagedSessionState::Dormant,
22010                workspace_id: WorkspaceId::new("primary").unwrap(),
22011                canonical_root: opaque_windows_path(
22012                    std::env::current_dir().unwrap().to_string_lossy().into_owned(),
22013                ),
22014                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
22015                    key: gate4agent_types::ProviderSessionKey::SessionId,
22016                    id: "provider-session-missing-materialization".to_owned(),
22017                    transcript_path: None,
22018                }),
22019                active_session: None,
22020                environment_profile: Some(receipt),
22021                bundle: None,
22022                context_id: None,
22023                context: None,
22024                exported_context: None,
22025                task_binding: None,
22026                created_at_unix_ms: 1,
22027                updated_at_unix_ms: 1,
22028                last_error: None,
22029            })
22030            .unwrap();
22031
22032        shared.reconcile_materializations();
22033        let record = shared.record(&record_id).unwrap();
22034        assert_eq!(record.state, ManagedSessionState::Unavailable);
22035        assert!(record.provider_session.is_none());
22036        assert_eq!(
22037            record.last_error.as_deref(),
22038            Some("environment-profile-unavailable"),
22039        );
22040        drop(shared);
22041        drop(runtime);
22042        std::fs::remove_dir_all(root).unwrap();
22043    }
22044
22045    #[tokio::test]
22046    async fn managed_worktree_cleanup_waits_for_session_environment_cleanup() {
22047        let (shared, runtime, receipt, root, _deny) = materialization_test_shared(false);
22048        let lease = install_managed_lease(&shared);
22049        let address = SessionAddress {
22050            workspace_id: WorkspaceId::new("primary").unwrap(),
22051            session: SessionKey {
22052                instance_id: AgentInstanceId(204),
22053                generation: SessionGeneration(1),
22054            },
22055        };
22056        let (overlay, materialization_guard) = shared
22057            .prepare_session_materialization(
22058                &address,
22059                &agent("claude"),
22060                SessionMode::Pty,
22061                Some(&receipt),
22062                None,
22063                None,
22064                Some(lease.lease_id.clone()),
22065            )
22066            .unwrap()
22067            .unwrap();
22068        let materialization_id = materialization_guard.id().unwrap().clone();
22069        let materialization_root = shared
22070            .materializations
22071            .lock()
22072            .unwrap_or_else(|poisoned| poisoned.into_inner())
22073            .get(&materialization_id)
22074            .unwrap()
22075            .root()
22076            .to_path_buf();
22077        let selection = shared
22078            .select_environment_profile(
22079                address.session.instance_id,
22080                &agent("claude"),
22081                SessionMode::Pty,
22082                Some(&receipt),
22083            )
22084            .unwrap()
22085            .unwrap();
22086        assert!(shared
22087            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22088            .unwrap()
22089            .is_none());
22090        shared.bind_session_with_materialization(
22091            &address,
22092            ProviderRuntimePolicy::raw_pty(),
22093            Some(receipt),
22094            None,
22095            None,
22096            Some(materialization_id),
22097        );
22098        selection.retain();
22099        materialization_guard.retain();
22100
22101        let blocked = shared
22102            .cleanup_managed_worktree(&lease.lease_id, false)
22103            .await
22104            .unwrap_err();
22105        assert_eq!(blocked.code, NodeFailureCode::ManagedWorktreeRecoveryRequired);
22106        assert!(materialization_root.is_dir());
22107        let removed = shared.remove_binding(&address).unwrap();
22108        shared
22109            .cleanup_session_owned_materialization(&address, &removed)
22110            .unwrap();
22111        assert!(!materialization_root.exists());
22112        drop(shared);
22113        drop(runtime);
22114        std::fs::remove_dir_all(root).unwrap();
22115    }
22116
22117    #[tokio::test]
22118    async fn raw_managed_record_is_inventory_only_and_does_not_block_session_cleanup() {
22119        let git_root = temporary_workspace_root("raw-managed-inventory");
22120        let source = git_root.join("source");
22121        let allocation = git_root.join("allocation");
22122        std::fs::create_dir_all(&source).unwrap();
22123        std::fs::create_dir_all(&allocation).unwrap();
22124        run_test_git(&source, &["init"]);
22125        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
22126        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
22127        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
22128        run_test_git(&source, &["add", "seed.txt"]);
22129        run_test_git(&source, &["commit", "-m", "seed"]);
22130        let profile = ManagedWorktreeProfile::new(
22131            WorktreeProfileId::new("default").unwrap(),
22132            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22133            &allocation,
22134            "gate4agent",
22135            "HEAD",
22136            ManagedWorktreeRetention::RemoveWhenReleased,
22137        )
22138        .unwrap();
22139        let workspace = WorkspaceConfig::new(
22140            WorkspaceId::new("primary").unwrap(),
22141            &source,
22142        )
22143        .unwrap()
22144        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22145        .with_managed_worktree_profile(profile.clone())
22146        .unwrap();
22147        let (shared, mut runtime, receipt, materialization_fixture_root, _deny) =
22148            materialization_test_shared_with_workspace(false, workspace);
22149        let target = PathBuf::from(profile.allocation_root()).join("mw-raw-inventory");
22150        let source_text = source.to_string_lossy().into_owned();
22151        let target_text = target.to_string_lossy().into_owned();
22152        let created = create_git_worktree(
22153            &source_text,
22154            &target_text,
22155            "gate4agent/mw-raw-inventory",
22156            Some("HEAD"),
22157        )
22158        .await
22159        .unwrap();
22160        let lease = ManagedWorktreeLeaseRecord {
22161            lease_id: ManagedWorktreeLeaseId::new("mw-raw-inventory").unwrap(),
22162            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22163            workspace_id: WorkspaceId::new("managed-raw-inventory").unwrap(),
22164            profile_id: profile.profile_id().clone(),
22165            profile_revision: profile.revision().clone(),
22166            target_root: created.path.clone(),
22167            branch: "gate4agent/mw-raw-inventory".to_owned(),
22168            base_commit: created.head.clone(),
22169            expected_head: Some(created.head.clone()),
22170            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22171            state: ManagedWorktreeLeaseState::Ready,
22172            session_holders: Vec::new(),
22173            record_holders: Vec::new(),
22174            cleanup_failure: None,
22175            created_at_unix_ms: 1,
22176            updated_at_unix_ms: 1,
22177        };
22178        shared
22179            .workspaces
22180            .write()
22181            .unwrap_or_else(|poisoned| poisoned.into_inner())
22182            .insert(lease.workspace_id.clone(), created.path.clone());
22183        *shared
22184            .managed_worktrees
22185            .lock()
22186            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22187            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22188        let address = SessionAddress {
22189            workspace_id: lease.workspace_id.clone(),
22190            session: SessionKey {
22191                instance_id: AgentInstanceId(205),
22192                generation: SessionGeneration(1),
22193            },
22194        };
22195        let (overlay, materialization_guard) = shared
22196            .prepare_session_materialization(
22197                &address,
22198                &agent("claude"),
22199                SessionMode::Pty,
22200                Some(&receipt),
22201                None,
22202                None,
22203                Some(lease.lease_id.clone()),
22204            )
22205            .unwrap()
22206            .unwrap();
22207        let materialization_id = materialization_guard.id().unwrap().clone();
22208        let materialization_root = shared
22209            .materializations
22210            .lock()
22211            .unwrap_or_else(|poisoned| poisoned.into_inner())
22212            .get(&materialization_id)
22213            .unwrap()
22214            .root()
22215            .to_path_buf();
22216        let selection = shared
22217            .select_environment_profile(
22218                address.session.instance_id,
22219                &agent("claude"),
22220                SessionMode::Pty,
22221                Some(&receipt),
22222            )
22223            .unwrap()
22224            .unwrap();
22225        assert!(shared
22226            .install_prepared_launch_overlay(address.session.instance_id, overlay.unwrap())
22227            .unwrap()
22228            .is_none());
22229        let record_id = shared
22230            .bind_spawn_session_with_materialization(
22231                &address,
22232                agent("claude"),
22233                SessionMode::Pty,
22234                ProviderRuntimePolicy::raw_pty(),
22235                SpawnRecordPolicy::Always,
22236                Some(receipt.clone()),
22237                None,
22238                None,
22239                Some(materialization_id.clone()),
22240            )
22241            .unwrap()
22242            .unwrap();
22243        selection.retain();
22244        materialization_guard.retain();
22245        let record = shared.record(&record_id).unwrap();
22246        assert_eq!(record.state, ManagedSessionState::Live);
22247        assert!(record.provider_session.is_none());
22248        assert!(record.environment_profile.is_none());
22249        assert!(record.bundle.is_none());
22250        assert!(record.context_id.is_none());
22251        assert!(record.context.is_none());
22252        assert_eq!(record.active_session.as_ref(), Some(&address));
22253        assert_eq!(
22254            shared
22255                .materializations
22256                .lock()
22257                .unwrap_or_else(|poisoned| poisoned.into_inner())
22258                .get(&materialization_id)
22259                .unwrap()
22260                .owner(),
22261            &MaterializationOwner::Session {
22262                incarnation_id: shared.incarnation_id,
22263                instance_id: address.session.instance_id,
22264                generation: address.session.generation,
22265            },
22266        );
22267        {
22268            let mut bindings = shared
22269                .session_bindings
22270                .lock()
22271                .unwrap_or_else(|poisoned| poisoned.into_inner());
22272            bindings
22273                .get_mut(&address.session.instance_id)
22274                .unwrap()
22275                .managed_worktree_lease_id = Some(lease.lease_id.clone());
22276        }
22277        let lease_snapshot = shared
22278            .managed_worktrees
22279            .lock()
22280            .unwrap_or_else(|poisoned| poisoned.into_inner())
22281            .bind_session(
22282                &lease.lease_id,
22283                ManagedWorktreeSessionHolder {
22284                    incarnation_id: shared.incarnation_id,
22285                    instance_id: address.session.instance_id,
22286                    generation: address.session.generation,
22287                },
22288                None,
22289                unix_time_ms(),
22290            )
22291            .unwrap();
22292        assert_eq!(lease_snapshot.active_session_count, 1);
22293        assert_eq!(lease_snapshot.managed_record_count, 0);
22294        shared.persist_state().unwrap();
22295        assert!(shared
22296            .managed_worktrees
22297            .lock()
22298            .unwrap_or_else(|poisoned| poisoned.into_inner())
22299            .get(&lease.lease_id)
22300            .unwrap()
22301            .record_holders
22302            .is_empty());
22303        shared
22304            .handle
22305            .dispatch(shared.prepare_command(ControlCommand::Register {
22306                instance_id: address.session.instance_id,
22307                agent_id: agent("claude"),
22308                transport: TransportKind::Pty,
22309            }))
22310            .unwrap();
22311        runtime.tick().await;
22312        shared.reconcile_managed_record(
22313            &record_id,
22314            &address,
22315            &ControlEvent {
22316                sequence: 1,
22317                command_id: None,
22318                instance_id: address.session.instance_id,
22319                generation: address.session.generation,
22320                event: ControlEventKind::Removed,
22321            },
22322            false,
22323            false,
22324        );
22325        let remove = shared.remove_session(&address);
22326        let drive_runtime = async {
22327            runtime.tick().await;
22328        };
22329        let (removed, ()) = tokio::join!(remove, drive_runtime);
22330        removed.unwrap();
22331
22332        assert!(!materialization_root.exists());
22333        assert!(!Path::new(&created.path).exists());
22334        assert_eq!(
22335            shared
22336                .managed_worktrees
22337                .lock()
22338                .unwrap_or_else(|poisoned| poisoned.into_inner())
22339                .get(&lease.lease_id)
22340                .unwrap()
22341                .state,
22342            ManagedWorktreeLeaseState::Removed,
22343        );
22344        let renamed = shared
22345            .rename_session_record(&record_id, "raw managed inventory".to_owned())
22346            .unwrap();
22347        assert_eq!(renamed.record_id, record_id);
22348        assert_eq!(renamed.display_name, "raw managed inventory");
22349        assert_eq!(renamed.state, ManagedSessionState::Unavailable);
22350        assert!(renamed.active_session.is_none());
22351
22352        drop(shared);
22353        drop(runtime);
22354        std::fs::remove_dir_all(materialization_fixture_root).unwrap();
22355        std::fs::remove_dir_all(git_root).unwrap();
22356    }
22357
22358    fn install_managed_lease(shared: &NodeShared) -> ManagedWorktreeLeaseRecord {
22359        let root = std::env::current_dir().unwrap()
22360            .join("managed-test-target")
22361            .to_string_lossy()
22362            .into_owned();
22363        let lease = ManagedWorktreeLeaseRecord {
22364            lease_id: ManagedWorktreeLeaseId::new("mw-test").unwrap(),
22365            source_workspace_id: WorkspaceId::new("primary").unwrap(),
22366            workspace_id: WorkspaceId::new("managed-test").unwrap(),
22367            profile_id: WorktreeProfileId::new("default").unwrap(),
22368            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
22369            target_root: root.clone(),
22370            branch: "gate4agent/mw-test".to_owned(),
22371            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
22372            expected_head: Some("0123456789abcdef0123456789abcdef01234567".to_owned()),
22373            retention: ManagedWorktreeRetention::RemoveWhenReleased,
22374            state: ManagedWorktreeLeaseState::Ready,
22375            session_holders: Vec::new(),
22376            record_holders: Vec::new(),
22377            cleanup_failure: None,
22378            created_at_unix_ms: 1,
22379            updated_at_unix_ms: 1,
22380        };
22381        shared.workspaces.write()
22382            .unwrap_or_else(|poisoned| poisoned.into_inner())
22383            .insert(lease.workspace_id.clone(), root);
22384        *shared.managed_worktrees.lock()
22385            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22386            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
22387        lease
22388    }
22389
22390    #[cfg(feature = "fixture")]
22391    #[test]
22392    fn monitoring_fixture_managed_spawn_retains_effective_record_holder_and_converges_live() {
22393        let mut shared = terminal_test_shared();
22394        shared.fixture_semantic_hook_policy = true;
22395        let lease = install_managed_lease(&shared);
22396        let address = SessionAddress {
22397            workspace_id: lease.workspace_id.clone(),
22398            session: SessionKey {
22399                instance_id: AgentInstanceId(206),
22400                generation: SessionGeneration(1),
22401            },
22402        };
22403        let pre_admission_policy = ProviderRuntimePolicy::raw_pty();
22404        let effective_runtime_policy =
22405            shared.effective_spawn_runtime_policy(pre_admission_policy);
22406        assert_ne!(effective_runtime_policy, pre_admission_policy);
22407        assert!(effective_runtime_policy.provider_session_identity);
22408
22409        let record_id = shared
22410            .bind_spawn_session(
22411                &address,
22412                agent("claude"),
22413                SessionMode::Pty,
22414                effective_runtime_policy,
22415                SpawnRecordPolicy::Always,
22416                None,
22417            )
22418            .unwrap()
22419            .unwrap();
22420        assert_eq!(
22421            shared.record(&record_id).unwrap().state,
22422            ManagedSessionState::IdentityPending,
22423        );
22424        let (record_holder, raw_inventory_record_id) = {
22425            let mut bindings = shared
22426                .session_bindings
22427                .lock()
22428                .unwrap_or_else(|poisoned| poisoned.into_inner());
22429            let binding = bindings.get_mut(&address.session.instance_id).unwrap();
22430            binding.managed_worktree_lease_id = Some(lease.lease_id.clone());
22431            managed_spawn_record_ownership(binding, effective_runtime_policy)
22432        };
22433        assert_eq!(record_holder.as_ref(), Some(&record_id));
22434        assert!(raw_inventory_record_id.is_none());
22435        let snapshot = shared
22436            .managed_worktrees
22437            .lock()
22438            .unwrap_or_else(|poisoned| poisoned.into_inner())
22439            .bind_session(
22440                &lease.lease_id,
22441                ManagedWorktreeSessionHolder {
22442                    incarnation_id: shared.incarnation_id,
22443                    instance_id: address.session.instance_id,
22444                    generation: address.session.generation,
22445                },
22446                record_holder,
22447                unix_time_ms(),
22448            )
22449            .unwrap();
22450        assert_eq!(snapshot.managed_record_count, 1);
22451        assert_eq!(
22452            shared
22453                .managed_worktrees
22454                .lock()
22455                .unwrap_or_else(|poisoned| poisoned.into_inner())
22456                .get(&lease.lease_id)
22457                .unwrap()
22458                .record_holders,
22459            vec![record_id.clone()],
22460        );
22461
22462        let identity = ProviderSessionIdentity {
22463            key: ProviderSessionKey::SessionId,
22464            id: "monitoring-fixture-managed-session".to_owned(),
22465            transcript_path: None,
22466        };
22467        shared.publish_control(ControlEvent {
22468            sequence: 1,
22469            command_id: None,
22470            instance_id: address.session.instance_id,
22471            generation: address.session.generation,
22472            event: ControlEventKind::ProviderEvent {
22473                sequence: 1,
22474                source: gate4agent_types::ProviderSource {
22475                    family: AdapterFamily::Hook,
22476                    binding: AdapterBinding::new(
22477                        gate4agent_types::AdapterId::new("claude-code").unwrap(),
22478                        "fixture/v1",
22479                        gate4agent_types::AdapterVerification::SyntheticFixture,
22480                    )
22481                    .unwrap(),
22482                },
22483                source_sequence: 1,
22484                event: ProviderEvent::SessionIdentityObserved {
22485                    identity: identity.clone(),
22486                },
22487            },
22488        });
22489
22490        let live = shared.record(&record_id).unwrap();
22491        assert_eq!(live.state, ManagedSessionState::Live);
22492        assert_eq!(live.provider_session.as_ref(), Some(&identity));
22493        assert_eq!(live.active_session.as_ref(), Some(&address));
22494        assert_eq!(
22495            shared
22496                .managed_worktrees
22497                .lock()
22498                .unwrap_or_else(|poisoned| poisoned.into_inner())
22499                .get(&lease.lease_id)
22500                .unwrap()
22501                .record_holders,
22502            vec![record_id],
22503        );
22504    }
22505
22506    fn managed_v2_test_shared(
22507        allocation_root: &Path,
22508        state_path: Option<PathBuf>,
22509        profile_revision: &str,
22510    ) -> NodeShared {
22511        let profile = ManagedWorktreeProfile::new(
22512            WorktreeProfileId::new("default").unwrap(),
22513            WorktreeProfileRevision::new(profile_revision).unwrap(),
22514            allocation_root,
22515            "gate4agent",
22516            "HEAD",
22517            ManagedWorktreeRetention::RemoveWhenReleased,
22518        )
22519        .unwrap();
22520        let workspace = WorkspaceConfig::new(
22521            WorkspaceId::new("primary").unwrap(),
22522            std::env::current_dir().unwrap(),
22523        )
22524        .unwrap()
22525        .with_worktree_service_mode(WorktreeServiceMode::Managed)
22526        .with_managed_worktree_profile(profile)
22527        .unwrap();
22528        let catalog = active_registry().unwrap();
22529        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
22530        let mut shared = NodeShared::new(
22531            handle,
22532            "fixture-token".to_owned(),
22533            NodeId::new("node-terminal-test").unwrap(),
22534            vec![workspace],
22535            vec![agent("claude")],
22536        );
22537        shared.state_path = state_path;
22538        shared
22539    }
22540
22541    fn managed_v2_request(idempotency_key: &str) -> ManagedWorktreeSpawnRequestV2 {
22542        let mut spawn_spec = spawn_spec_fixture();
22543        spawn_spec.idempotency_key = SpawnIdempotencyKey::new(idempotency_key).unwrap();
22544        ManagedWorktreeSpawnRequestV2 {
22545            spawn_spec,
22546            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22547            expected_profile_revision: WorktreeProfileRevision::new("v1").unwrap(),
22548        }
22549    }
22550
22551    #[test]
22552    fn managed_spawn_receipt_rebinds_allocated_worktree_and_roundtrips_wire() {
22553        let shared = terminal_test_shared();
22554        let mut lease = install_managed_lease(&shared);
22555        let session = SessionAddress {
22556            workspace_id: lease.workspace_id.clone(),
22557            session: SessionKey {
22558                instance_id: AgentInstanceId(73),
22559                generation: SessionGeneration(4),
22560            },
22561        };
22562        lease.state = ManagedWorktreeLeaseState::InUse;
22563        lease.session_holders.push(ManagedWorktreeSessionHolder {
22564            incarnation_id: shared.incarnation_id,
22565            instance_id: session.session.instance_id,
22566            generation: session.session.generation,
22567        });
22568        let resolved = shared.resolve_spawn_spec(&spawn_spec_fixture()).unwrap();
22569        assert!(resolved.target.worktree_id.is_none());
22570
22571        let receipt = managed_spawn_receipt(
22572            &resolved,
22573            shared.incarnation_id,
22574            session.clone(),
22575            lease.snapshot(),
22576            None,
22577            None,
22578            None,
22579        );
22580        assert!(resolved.target.worktree_id.is_none());
22581        assert_eq!(receipt.spawn.target.workspace_id, lease.source_workspace_id);
22582        assert_eq!(receipt.spawn.target.worktree_id.as_ref(), Some(&lease.workspace_id));
22583        assert_eq!(receipt.spawn.session, session);
22584        assert_eq!(receipt.lease.workspace_id, lease.workspace_id);
22585        let encoded = serde_json::to_vec(&receipt).unwrap();
22586        let decoded: ManagedWorktreeSpawnReceipt = serde_json::from_slice(&encoded).unwrap();
22587        assert_eq!(decoded, receipt);
22588    }
22589
22590    #[tokio::test]
22591    async fn managed_spawn_v2_rejects_profile_revision_before_allocation() {
22592        let root = temporary_workspace_root("managed-v2-revision");
22593        let allocation = root.join("allocation");
22594        std::fs::create_dir_all(&allocation).unwrap();
22595        let shared = managed_v2_test_shared(
22596            &allocation,
22597            Some(root.join("node-state.json")),
22598            "v1",
22599        );
22600        let mut request = managed_v2_request("managed-v2-revision");
22601        request.expected_profile_revision = WorktreeProfileRevision::new("v2").unwrap();
22602
22603        let failure = shared.spawn_managed_worktree_v2(request).await.unwrap_err();
22604
22605        assert_eq!(
22606            failure.code,
22607            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22608        );
22609        assert!(shared
22610            .managed_worktrees
22611            .lock()
22612            .unwrap_or_else(|poisoned| poisoned.into_inner())
22613            .records()
22614            .is_empty());
22615        assert!(shared
22616            .managed_spawn_replays
22617            .lock()
22618            .unwrap_or_else(|poisoned| poisoned.into_inner())
22619            .is_empty());
22620        std::fs::remove_dir_all(root).unwrap();
22621    }
22622
22623    #[test]
22624    fn managed_spawn_v2_concurrent_same_key_has_one_durable_reservation() {
22625        let root = temporary_workspace_root("managed-v2-concurrent");
22626        let allocation = root.join("allocation");
22627        std::fs::create_dir_all(&allocation).unwrap();
22628        let shared = Arc::new(managed_v2_test_shared(
22629            &allocation,
22630            Some(root.join("node-state.json")),
22631            "v1",
22632        ));
22633        let request = managed_v2_request("managed-v2-concurrent");
22634        let barrier = Arc::new(std::sync::Barrier::new(3));
22635        let mut joins = Vec::new();
22636        for _ in 0..2 {
22637            let shared = Arc::clone(&shared);
22638            let request = request.clone();
22639            let barrier = Arc::clone(&barrier);
22640            joins.push(std::thread::spawn(move || {
22641                let digest = managed_spawn_request_digest_v2(&request).unwrap();
22642                barrier.wait();
22643                shared.acquire_durable_managed_spawn_v2(&request, digest)
22644            }));
22645        }
22646        barrier.wait();
22647        let results = joins
22648            .into_iter()
22649            .map(|join| join.join().unwrap())
22650            .collect::<Vec<_>>();
22651        assert_eq!(
22652            results
22653                .iter()
22654                .filter(|result| {
22655                    matches!(result, Ok(DurableManagedSpawnReplayDecision::Reserved))
22656                })
22657                .count(),
22658            1,
22659        );
22660        assert_eq!(
22661            results
22662                .iter()
22663                .filter(|result| {
22664                    matches!(result, Err(error) if error.code == NodeFailureCode::BackendBusy)
22665                })
22666                .count(),
22667            1,
22668        );
22669        assert_eq!(
22670            shared
22671                .managed_spawn_replays
22672                .lock()
22673                .unwrap_or_else(|poisoned| poisoned.into_inner())
22674                .len(),
22675            1,
22676        );
22677        assert!(shared
22678            .managed_worktrees
22679            .lock()
22680            .unwrap_or_else(|poisoned| poisoned.into_inner())
22681            .records()
22682            .is_empty());
22683        drop(shared);
22684        std::fs::remove_dir_all(root).unwrap();
22685    }
22686
22687    #[tokio::test]
22688    async fn managed_spawn_v2_linked_pending_reopen_fails_closed_without_reallocation() {
22689        let root = temporary_workspace_root("managed-v2-linked-pending");
22690        let allocation = root.join("allocation");
22691        std::fs::create_dir_all(&allocation).unwrap();
22692        let state_path = root.join("node-state.json");
22693        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22694        let lease = install_managed_lease(&shared);
22695        let request = managed_v2_request("managed-v2-linked-pending");
22696        assert!(matches!(
22697            shared
22698                .acquire_durable_managed_spawn_v2(
22699                    &request,
22700                    managed_spawn_request_digest_v2(&request).unwrap(),
22701                )
22702                .unwrap(),
22703            DurableManagedSpawnReplayDecision::Reserved,
22704        ));
22705        {
22706            let _transaction = shared
22707                .state_transaction
22708                .lock()
22709                .unwrap_or_else(|poisoned| poisoned.into_inner());
22710            shared
22711                .link_durable_managed_spawn_v2_lease_locked(
22712                    &request.spawn_spec.idempotency_key,
22713                    &lease.lease_id,
22714                )
22715                .unwrap();
22716            shared.persist_state_locked().unwrap();
22717        }
22718        drop(shared);
22719
22720        let mut loaded = session_registry::load(
22721            Some(&state_path),
22722            &NodeId::new("node-terminal-test").unwrap(),
22723        )
22724        .unwrap();
22725        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22726        *reopened
22727            .managed_worktrees
22728            .lock()
22729            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22730            ManagedWorktreeRegistry::from_records(
22731                std::mem::take(&mut loaded.managed_worktrees),
22732                std::mem::take(&mut loaded.managed_worktree_tombstones),
22733            )
22734            .unwrap();
22735        *reopened
22736            .managed_spawn_replays
22737            .lock()
22738            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22739            .managed_spawn_replays
22740            .into_iter()
22741            .map(|record| (record.idempotency_key.clone(), record))
22742            .collect();
22743        let lease_count = reopened
22744            .managed_worktrees
22745            .lock()
22746            .unwrap_or_else(|poisoned| poisoned.into_inner())
22747            .records()
22748            .len();
22749
22750        let failure = reopened
22751            .spawn_managed_worktree_v2(request)
22752            .await
22753            .unwrap_err();
22754
22755        assert_eq!(
22756            failure.code,
22757            NodeFailureCode::ManagedWorktreeRecoveryRequired,
22758        );
22759        assert_eq!(
22760            reopened
22761                .managed_worktrees
22762                .lock()
22763                .unwrap_or_else(|poisoned| poisoned.into_inner())
22764                .records()
22765                .len(),
22766            lease_count,
22767        );
22768        std::fs::remove_dir_all(root).unwrap();
22769    }
22770
22771    #[tokio::test]
22772    async fn managed_spawn_v2_replays_exact_receipt_after_durable_reopen_and_conflicts() {
22773        let root = temporary_workspace_root("managed-v2-reopen");
22774        let allocation = root.join("allocation");
22775        std::fs::create_dir_all(&allocation).unwrap();
22776        let state_path = root.join("node-state.json");
22777        let shared = managed_v2_test_shared(&allocation, Some(state_path.clone()), "v1");
22778        let lease = install_managed_lease(&shared);
22779        let request = managed_v2_request("managed-v2-reopen");
22780        let session = SessionAddress {
22781            workspace_id: lease.workspace_id.clone(),
22782            session: SessionKey {
22783                instance_id: AgentInstanceId(91),
22784                generation: SessionGeneration(1),
22785            },
22786        };
22787        let snapshot = {
22788            let mut registry = shared
22789                .managed_worktrees
22790                .lock()
22791                .unwrap_or_else(|poisoned| poisoned.into_inner());
22792            let current = registry.get_mut(&lease.lease_id).unwrap();
22793            current.state = ManagedWorktreeLeaseState::InUse;
22794            current.session_holders.push(ManagedWorktreeSessionHolder {
22795                incarnation_id: shared.incarnation_id,
22796                instance_id: session.session.instance_id,
22797                generation: session.session.generation,
22798            });
22799            current.snapshot()
22800        };
22801        let resolved = shared.resolve_spawn_spec(&request.spawn_spec).unwrap();
22802        let receipt = managed_spawn_receipt(
22803            &resolved,
22804            shared.incarnation_id,
22805            session,
22806            snapshot,
22807            None,
22808            None,
22809            None,
22810        );
22811        assert!(matches!(
22812            shared
22813                .acquire_durable_managed_spawn_v2(
22814                    &request,
22815                    managed_spawn_request_digest_v2(&request).unwrap(),
22816                )
22817                .unwrap(),
22818            DurableManagedSpawnReplayDecision::Reserved,
22819        ));
22820        {
22821            let _transaction = shared
22822                .state_transaction
22823                .lock()
22824                .unwrap_or_else(|poisoned| poisoned.into_inner());
22825            shared
22826                .link_durable_managed_spawn_v2_lease_locked(
22827                    &request.spawn_spec.idempotency_key,
22828                    &lease.lease_id,
22829                )
22830                .unwrap();
22831            shared.persist_state_locked().unwrap();
22832        }
22833        shared
22834            .commit_durable_managed_spawn_v2(&request, receipt.clone())
22835            .unwrap();
22836        let legacy_same_key = ManagedWorktreeSpawnRequest {
22837            spawn_spec: request.spawn_spec.clone(),
22838            worktree_profile_id: request.worktree_profile_id.clone(),
22839        };
22840        shared.remember_managed_spawn_attempt(
22841            Some(&request.spawn_spec.idempotency_key),
22842            legacy_same_key.clone(),
22843            Ok(receipt.clone()),
22844            Instant::now(),
22845        );
22846        assert!(shared
22847            .replay_managed_spawn(&legacy_same_key, Instant::now())
22848            .unwrap()
22849            .is_none());
22850        drop(shared);
22851
22852        let mut loaded = session_registry::load(
22853            Some(&state_path),
22854            &NodeId::new("node-terminal-test").unwrap(),
22855        )
22856        .unwrap();
22857        assert_eq!(loaded.managed_spawn_replays.len(), 1);
22858        let reopened = managed_v2_test_shared(&allocation, Some(state_path), "v2");
22859        *reopened
22860            .managed_worktrees
22861            .lock()
22862            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
22863            ManagedWorktreeRegistry::from_records(
22864                std::mem::take(&mut loaded.managed_worktrees),
22865                std::mem::take(&mut loaded.managed_worktree_tombstones),
22866            )
22867            .unwrap();
22868        *reopened
22869            .managed_spawn_replays
22870            .lock()
22871            .unwrap_or_else(|poisoned| poisoned.into_inner()) = loaded
22872            .managed_spawn_replays
22873            .into_iter()
22874            .map(|record| (record.idempotency_key.clone(), record))
22875            .collect();
22876        let lease_count_before = reopened
22877            .managed_worktrees
22878            .lock()
22879            .unwrap_or_else(|poisoned| poisoned.into_inner())
22880            .records()
22881            .len();
22882
22883        assert_eq!(
22884            reopened
22885                .spawn_managed_worktree_v2(request.clone())
22886                .await
22887                .unwrap(),
22888            receipt,
22889        );
22890        assert_eq!(
22891            reopened
22892                .managed_worktrees
22893                .lock()
22894                .unwrap_or_else(|poisoned| poisoned.into_inner())
22895                .records()
22896                .len(),
22897            lease_count_before,
22898        );
22899        let mut changed = request;
22900        changed.spawn_spec.deadline_ms = crate::protocol::SpawnDeadlineMs::new(29_000).unwrap();
22901        let failure = reopened
22902            .spawn_managed_worktree_v2(changed)
22903            .await
22904            .unwrap_err();
22905        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
22906        assert_eq!(
22907            reopened
22908                .managed_worktrees
22909                .lock()
22910                .unwrap_or_else(|poisoned| poisoned.into_inner())
22911                .records()
22912                .len(),
22913            lease_count_before,
22914        );
22915        let mut new_key = managed_v2_request("managed-v2-new-after-profile-change");
22916        new_key.expected_profile_revision = WorktreeProfileRevision::new("v1").unwrap();
22917        let failure = reopened
22918            .spawn_managed_worktree_v2(new_key)
22919            .await
22920            .unwrap_err();
22921        assert_eq!(
22922            failure.code,
22923            NodeFailureCode::ManagedWorktreeProfileRevisionMismatch,
22924        );
22925        assert_eq!(
22926            reopened
22927                .managed_worktrees
22928                .lock()
22929                .unwrap_or_else(|poisoned| poisoned.into_inner())
22930                .records()
22931                .len(),
22932            lease_count_before,
22933        );
22934        assert_eq!(
22935            reopened
22936                .managed_spawn_replays
22937                .lock()
22938                .unwrap_or_else(|poisoned| poisoned.into_inner())
22939                .len(),
22940            1,
22941        );
22942        std::fs::remove_dir_all(root).unwrap();
22943    }
22944
22945    #[test]
22946    fn legacy_managed_spawn_idempotency_remains_transient_and_exact() {
22947        let shared = terminal_test_shared();
22948        let request = ManagedWorktreeSpawnRequest {
22949            spawn_spec: spawn_spec_fixture(),
22950            worktree_profile_id: WorktreeProfileId::new("default").unwrap(),
22951        };
22952        let expected = failure(NodeFailureCode::BackendBusy, "legacy fixture failure");
22953        shared.remember_managed_spawn(
22954            request.clone(),
22955            Err(expected.clone()),
22956            Instant::now(),
22957        );
22958
22959        assert_eq!(
22960            shared
22961                .replay_managed_spawn(&request, Instant::now())
22962                .unwrap()
22963                .unwrap(),
22964            Err(expected),
22965        );
22966        assert!(shared
22967            .managed_spawn_replays
22968            .lock()
22969            .unwrap_or_else(|poisoned| poisoned.into_inner())
22970            .is_empty());
22971    }
22972
22973    #[tokio::test]
22974    async fn ordinary_spawn_and_spawn_spec_cannot_bypass_managed_lease_holder_fence() {
22975        let shared = terminal_test_shared();
22976        let lease = install_managed_lease(&shared);
22977        let raw = shared.spawn_session(
22978            lease.workspace_id.clone(),
22979            agent("claude"),
22980            SessionMode::Pty,
22981            gate4agent_types::TerminalSize { rows: 24, columns: 80 },
22982            None,
22983        ).await.unwrap_err();
22984        assert_eq!(raw.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22985        let mut spec = spawn_spec_fixture();
22986        spec.target.workspace_id = lease.workspace_id.clone();
22987        let specified = shared.spawn_from_spec(spec).await.unwrap_err();
22988        assert_eq!(specified.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22989        let mut selected = spawn_spec_fixture();
22990        selected.idempotency_key = SpawnIdempotencyKey::new("managed-selected-bypass").unwrap();
22991        selected.target.worktree_id = Some(lease.workspace_id);
22992        let selected = shared.spawn_from_spec(selected).await.unwrap_err();
22993        assert_eq!(selected.code, NodeFailureCode::ManagedWorktreeOwnershipConflict);
22994        assert!(shared.handle.snapshot().sessions.is_empty());
22995    }
22996
22997    #[test]
22998    fn legacy_projection_omits_managed_snapshot_events_and_path_bearing_diagnostics() {
22999        let shared = terminal_test_shared();
23000        let lease = install_managed_lease(&shared);
23001        let snapshot = snapshot_for_wire(
23002            &shared, true, true, true, false, true, true, false, false, true,
23003        );
23004        assert!(snapshot.managed_worktrees.is_empty());
23005        assert!(snapshot.workspaces.iter().all(|workspace| {
23006            workspace.managed_worktree_profiles.is_none()
23007        }));
23008        let mut reply = ResponseEnvelope {
23009            request_id: 1,
23010            result: Ok(NodeResponse::Resync {
23011                event_sequence: 1,
23012                oldest_available_sequence: 1,
23013                snapshot: shared.snapshot(),
23014                events: vec![NodeEventEnvelope {
23015                    sequence: 1,
23016                    event: NodeEvent::ManagedWorktreeUpserted { lease: lease.snapshot() },
23017                }],
23018            }),
23019        };
23020        project_response_without_managed_worktrees(&mut reply);
23021        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
23022            panic!("projection changed the response kind");
23023        };
23024        assert!(snapshot.managed_worktrees.is_empty());
23025        assert!(events.is_empty());
23026        let mut git = git_snapshot_for_parser();
23027        git.branch = Some(lease.branch.clone());
23028        git.managed_worktree = Some(crate::protocol::ManagedWorktreeGitScope {
23029            lease_id: lease.lease_id.clone(),
23030            source_workspace_id: lease.source_workspace_id.clone(),
23031            branch: lease.branch.clone(),
23032            base_commit: GitObjectId::new(lease.base_commit.clone()).unwrap(),
23033            active_session_count: 1,
23034            managed_record_count: 0,
23035        });
23036        let mut inspection_reply = ResponseEnvelope {
23037            request_id: 2,
23038            result: Ok(NodeResponse::WorkspaceInspected {
23039                inspection: WorkspaceInspection {
23040                    workspace_id: lease.workspace_id.clone(),
23041                    entries: Vec::new(),
23042                    tree_truncated: false,
23043                    git,
23044                    truncation: None,
23045                },
23046            }),
23047        };
23048        project_response_without_managed_worktrees(&mut inspection_reply);
23049        let Ok(NodeResponse::WorkspaceInspected { inspection }) = inspection_reply.result else {
23050            panic!("projection changed the inspection response kind");
23051        };
23052        assert!(inspection.git.managed_worktree.is_none());
23053        let failure = managed_git_worktree_failure(GitWorktreeError {
23054            kind: GitWorktreeErrorKind::Failed,
23055            message: r"git failed at C:\private\managed-target".to_owned(),
23056        });
23057        assert!(!failure.message.contains("private"));
23058        assert!(!failure.message.contains("managed-target"));
23059    }
23060
23061    #[tokio::test]
23062    async fn restart_reconciliation_retains_exact_owned_worktree_and_explicit_cleanup_removes_it() {
23063        let root = temporary_workspace_root("managed-restart");
23064        let source = root.join("source");
23065        let allocation = root.join("allocation");
23066        std::fs::create_dir_all(&source).unwrap();
23067        std::fs::create_dir_all(&allocation).unwrap();
23068        run_test_git(&source, &["init"]);
23069        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23070        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23071        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23072        run_test_git(&source, &["add", "seed.txt"]);
23073        run_test_git(&source, &["commit", "-m", "seed"]);
23074        let profile = ManagedWorktreeProfile::new(
23075            WorktreeProfileId::new("default").unwrap(),
23076            crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
23077            &allocation,
23078            "gate4agent",
23079            "HEAD",
23080            ManagedWorktreeRetention::Retain,
23081        ).unwrap();
23082        let workspace = WorkspaceConfig::new(
23083            WorkspaceId::new("primary").unwrap(),
23084            &source,
23085        ).unwrap()
23086            .with_worktree_service_mode(WorktreeServiceMode::Managed)
23087            .with_managed_worktree_profile(profile.clone()).unwrap();
23088        let target = PathBuf::from(profile.allocation_root()).join("mw-restart");
23089        let source_text = workspace.canonical_root().to_owned();
23090        let target_text = target.to_string_lossy().into_owned();
23091        let created = create_git_worktree(
23092            &source_text,
23093            &target_text,
23094            "gate4agent/mw-restart",
23095            Some("HEAD"),
23096        ).await.unwrap();
23097        let lease = ManagedWorktreeLeaseRecord {
23098            lease_id: ManagedWorktreeLeaseId::new("mw-restart").unwrap(),
23099            source_workspace_id: WorkspaceId::new("primary").unwrap(),
23100            workspace_id: WorkspaceId::new("managed-restart").unwrap(),
23101            profile_id: profile.profile_id().clone(),
23102            profile_revision: profile.revision().clone(),
23103            target_root: created.path.clone(),
23104            branch: "gate4agent/mw-restart".to_owned(),
23105            base_commit: created.head.clone(),
23106            expected_head: None,
23107            retention: ManagedWorktreeRetention::Retain,
23108            state: ManagedWorktreeLeaseState::Allocating,
23109            session_holders: Vec::new(),
23110            record_holders: Vec::new(),
23111            cleanup_failure: None,
23112            created_at_unix_ms: 1,
23113            updated_at_unix_ms: 1,
23114        };
23115        let catalog = active_registry().unwrap();
23116        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23117        let shared = NodeShared::new_with_incarnation(
23118            handle,
23119            "fixture-token".to_owned(),
23120            NodeId::new("node-managed-restart").unwrap(),
23121            NodeIncarnationId::from_bytes([7; crate::protocol::NODE_INCARNATION_ID_BYTES]),
23122            vec![workspace],
23123            vec![agent("claude")],
23124            ProviderRuntimeStatuses::default(),
23125            None,
23126            Vec::new(),
23127            Vec::new(),
23128            SpawnProfileRegistry::default(),
23129            None,
23130            None,
23131            None,
23132            None,
23133            Vec::new(),
23134            vec![lease.clone()],
23135            Vec::new(),
23136            Vec::new(),
23137            None,
23138            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
23139        );
23140        shared.reconcile_managed_worktrees().await;
23141        let retained = shared.managed_worktrees.lock()
23142            .unwrap_or_else(|poisoned| poisoned.into_inner())
23143            .get(&lease.lease_id).unwrap().clone();
23144        assert_eq!(retained.state, ManagedWorktreeLeaseState::Retained);
23145        assert!(shared.workspaces.read()
23146            .unwrap_or_else(|poisoned| poisoned.into_inner())
23147            .contains_key(&lease.workspace_id));
23148        let removed = shared.cleanup_managed_worktree(&lease.lease_id, true).await.unwrap();
23149        assert_eq!(removed.state, ManagedWorktreeLeaseState::Removed);
23150        assert!(!Path::new(&created.path).exists());
23151        std::fs::remove_dir_all(root).unwrap();
23152    }
23153
23154    fn run_test_git(root: &Path, arguments: &[&str]) {
23155        let output = std::process::Command::new("git")
23156            .arg("-C").arg(root).args(arguments).output().unwrap();
23157        assert!(
23158            output.status.success(),
23159            "git {:?} failed: {}",
23160            arguments,
23161            String::from_utf8_lossy(&output.stderr),
23162        );
23163    }
23164
23165    #[tokio::test]
23166    async fn spawn_spec_validation_fails_before_session_mutation() {
23167        let shared = terminal_test_shared();
23168        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23169            capability.as_str() == NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY
23170        }));
23171        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23172            capability.as_str() == NODE_SPAWN_PROFILE_REVISION_CAPABILITY
23173        }));
23174        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23175            capability.as_str() == NODE_WORKTREE_SELECTION_CAPABILITY
23176        }));
23177
23178        let mut target_mismatch = spawn_spec_fixture();
23179        target_mismatch.target.node_id = NodeId::new("other-node").unwrap();
23180        let failure = shared.spawn_from_spec(target_mismatch).await.unwrap_err();
23181        assert_eq!(failure.code, NodeFailureCode::SpawnTargetMismatch);
23182
23183        let mut unknown_profile = spawn_spec_fixture();
23184        unknown_profile.idempotency_key = SpawnIdempotencyKey::new("unknown-profile").unwrap();
23185        unknown_profile.profile_id = crate::protocol::SpawnProfileId::new("missing").unwrap();
23186        let failure = shared.spawn_from_spec(unknown_profile).await.unwrap_err();
23187        assert_eq!(failure.code, NodeFailureCode::UnknownSpawnProfile);
23188
23189        let mut revision_mismatch = spawn_spec_fixture();
23190        revision_mismatch.idempotency_key =
23191            SpawnIdempotencyKey::new("profile-revision-mismatch").unwrap();
23192        revision_mismatch.expected_profile_revision =
23193            crate::protocol::SpawnProfileRevision::new("test-r2").unwrap();
23194        let failure = shared.spawn_from_spec(revision_mismatch).await.unwrap_err();
23195        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23196
23197        let mut unsupported_materializer = spawn_spec_fixture();
23198        unsupported_materializer.idempotency_key =
23199            SpawnIdempotencyKey::new("unsupported-materializer").unwrap();
23200        unsupported_materializer.overrides.bundle_id = crate::protocol::SpawnOverride::Set {
23201            value: crate::protocol::SpawnBundleId::new("bundle-v1").unwrap(),
23202        };
23203        let failure = shared
23204            .spawn_from_spec(unsupported_materializer)
23205            .await
23206            .unwrap_err();
23207        assert_eq!(failure.code, NodeFailureCode::UnknownBundle);
23208
23209        let failure = shared
23210            .spawn_session_with_deadline(
23211                WorkspaceId::new("primary").unwrap(),
23212                agent("claude"),
23213                SessionMode::Pty,
23214                gate4agent_types::TerminalSize {
23215                    rows: 24,
23216                    columns: 80,
23217                },
23218                None,
23219                None,
23220                None,
23221                None,
23222                None,
23223                None,
23224                SpawnRecordPolicy::ProviderIdentityOnly,
23225                Some(Instant::now()),
23226                &[],
23227                None,
23228                ApprovalLevel::default(),
23229                None,
23230                None,
23231            )
23232            .await
23233            .unwrap_err();
23234        assert_eq!(failure.code, NodeFailureCode::SpawnDeadlineExceeded);
23235        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23236        assert!(shared.handle.snapshot().sessions.is_empty());
23237    }
23238
23239    #[test]
23240    fn worktree_selection_requires_its_separate_negotiated_capability() {
23241        let mut spec = spawn_spec_fixture();
23242        spec.target.worktree_id = Some(WorkspaceId::new("selected-worktree").unwrap());
23243        let request = NodeRequest::SpawnSpec { spec };
23244        let spawn_spec_capability =
23245            CapabilityId::new(NODE_SPAWN_SPEC_DEFAULTS_OVERRIDES_CAPABILITY).unwrap();
23246        assert!(request_uses_unnegotiated_capability(
23247            &request,
23248            &[spawn_spec_capability.clone()],
23249        ));
23250        let worktree_capability =
23251            CapabilityId::new(NODE_WORKTREE_SELECTION_CAPABILITY).unwrap();
23252        assert!(request_uses_unnegotiated_capability(
23253            &request,
23254            &[spawn_spec_capability.clone(), worktree_capability.clone()],
23255        ));
23256        let profile_revision_capability =
23257            CapabilityId::new(NODE_SPAWN_PROFILE_REVISION_CAPABILITY).unwrap();
23258        assert!(request_uses_unnegotiated_capability(
23259            &request,
23260            &[
23261                spawn_spec_capability.clone(),
23262                worktree_capability.clone(),
23263                profile_revision_capability.clone(),
23264            ],
23265        ));
23266        // Default `SpawnOverrides` leaves `bundle_id`/`context_id` at
23267        // `Inherit`, not `Clear` — the profile's own defaults might still
23268        // materialize a bundle or a context pack, so both capabilities stay
23269        // conservatively required until negotiated (see
23270        // `requires_session_bundle_materialization_capability` /
23271        // `requires_history_context_pack_capability`).
23272        let bundle_materialization_capability =
23273            CapabilityId::new(NODE_SESSION_BUNDLE_MATERIALIZATION_CAPABILITY).unwrap();
23274        assert!(request_uses_unnegotiated_capability(
23275            &request,
23276            &[
23277                spawn_spec_capability.clone(),
23278                worktree_capability.clone(),
23279                profile_revision_capability.clone(),
23280                bundle_materialization_capability.clone(),
23281            ],
23282        ));
23283        let history_context_pack_capability =
23284            CapabilityId::new(NODE_HISTORY_CONTEXT_PACK_CAPABILITY).unwrap();
23285        assert!(!request_uses_unnegotiated_capability(
23286            &request,
23287            &[
23288                spawn_spec_capability,
23289                worktree_capability,
23290                profile_revision_capability,
23291                bundle_materialization_capability,
23292                history_context_pack_capability,
23293            ],
23294        ));
23295    }
23296
23297    #[tokio::test]
23298    async fn spawn_spec_idempotency_replays_receipt_and_conflicts_before_session_mutation() {
23299        let mut shared = terminal_test_shared();
23300        let spec = spawn_spec_fixture();
23301        let receipt = shared
23302            .resolve_spawn_spec(&spec)
23303            .unwrap()
23304            .receipt(shared.incarnation_id, terminal_address(1));
23305        shared.remember_spawn_spec(spec.clone(), Ok(receipt.clone()), Instant::now());
23306
23307        let mut revised = shared.spawn_profiles.get(&spec.profile_id).unwrap().clone();
23308        revised.revision = crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23309        shared.spawn_profiles = SpawnProfileRegistry::new([revised]).unwrap();
23310
23311        assert_eq!(shared.spawn_from_spec(spec.clone()).await.unwrap(), receipt);
23312
23313        let mut revision_conflicting = spec.clone();
23314        revision_conflicting.expected_profile_revision =
23315            crate::protocol::SpawnProfileRevision::new("builtin-v2").unwrap();
23316        let failure = shared.spawn_from_spec(revision_conflicting).await.unwrap_err();
23317        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23318
23319        let mut stale_cache_miss = spec.clone();
23320        stale_cache_miss.idempotency_key =
23321            SpawnIdempotencyKey::new("stale-profile-cache-miss").unwrap();
23322        let failure = shared.spawn_from_spec(stale_cache_miss).await.unwrap_err();
23323        assert_eq!(failure.code, NodeFailureCode::SpawnProfileRevisionMismatch);
23324
23325        let mut conflicting = spec;
23326        conflicting.deadline_ms = crate::protocol::SpawnDeadlineMs::new(31_000).unwrap();
23327        let failure = shared.spawn_from_spec(conflicting).await.unwrap_err();
23328        assert_eq!(failure.code, NodeFailureCode::SpawnIdempotencyConflict);
23329        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23330        assert!(shared.handle.snapshot().sessions.is_empty());
23331    }
23332
23333    #[tokio::test]
23334    async fn off_worktree_service_rejects_selection_and_mutation_before_git() {
23335        let shared = terminal_test_shared_with_mode(WorktreeServiceMode::Off);
23336        let mut spec = spawn_spec_fixture();
23337        spec.target.worktree_id = Some(WorkspaceId::new("unregistered-worktree").unwrap());
23338        let failure = shared.spawn_from_spec(spec).await.unwrap_err();
23339        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23340
23341        let failure = shared
23342            .create_worktree(
23343                WorkspaceId::new("primary").unwrap(),
23344                WorkspaceId::new("new-worktree").unwrap(),
23345                "not-an-absolute-path".to_owned(),
23346                "invalid branch".to_owned(),
23347                None,
23348            )
23349            .await
23350            .unwrap_err();
23351        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23352
23353        let failure = shared
23354            .remove_worktree(
23355                WorkspaceId::new("primary").unwrap(),
23356                "not-an-absolute-path".to_owned(),
23357            )
23358            .await
23359            .unwrap_err();
23360        assert_eq!(failure.code, NodeFailureCode::UnsupportedSpawnCapability);
23361        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
23362        assert!(shared.handle.snapshot().sessions.is_empty());
23363    }
23364
23365    #[tokio::test]
23366    async fn manual_worktree_registration_failure_removes_the_created_worktree() {
23367        let root = temporary_workspace_root("manual-worktree-compensation");
23368        let source = root.join("source");
23369        let target = root.join("target");
23370        let invalid_state_parent = root.join("state-parent-is-a-file");
23371        let invalid_state_path = invalid_state_parent.join("state.json");
23372        std::fs::create_dir_all(&source).unwrap();
23373        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
23374        run_test_git(&source, &["init"]);
23375        run_test_git(&source, &["config", "user.email", "gate4agent@example.invalid"]);
23376        run_test_git(&source, &["config", "user.name", "Gate4Agent Test"]);
23377        std::fs::write(source.join("seed.txt"), "seed\n").unwrap();
23378        run_test_git(&source, &["add", "seed.txt"]);
23379        run_test_git(&source, &["commit", "-m", "seed"]);
23380
23381        let catalog = active_registry().unwrap();
23382        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23383        let workspace = WorkspaceConfig::new(
23384            WorkspaceId::new("primary").unwrap(),
23385            &source,
23386        ).unwrap();
23387        let mut shared = NodeShared::new(
23388            handle,
23389            "fixture-token".to_owned(),
23390            NodeId::new("node-worktree-compensation").unwrap(),
23391            vec![workspace],
23392            vec![agent("claude")],
23393        );
23394        shared.state_path = Some(invalid_state_path);
23395
23396        let error = shared.create_worktree(
23397            WorkspaceId::new("primary").unwrap(),
23398            WorkspaceId::new("compensated").unwrap(),
23399            target.to_string_lossy().into_owned(),
23400            "gate4agent/compensated".to_owned(),
23401            Some("HEAD".to_owned()),
23402        ).await.unwrap_err();
23403
23404        assert_eq!(error.code, NodeFailureCode::BackendOperationFailed);
23405        assert!(!target.exists());
23406        assert!(list_git_worktrees(source.to_str().unwrap()).await.unwrap()
23407            .iter()
23408            .all(|worktree| !worktree_paths_equal(&worktree.path, &target.to_string_lossy())));
23409        std::fs::remove_dir_all(root).unwrap();
23410    }
23411
23412    #[test]
23413    fn selected_worktree_requires_authoritative_non_main_non_bare_entry() {
23414        fn worktree(path: &str, is_main: bool, is_bare: bool) -> NativeGitWorktreeSnapshot {
23415            NativeGitWorktreeSnapshot {
23416                path: path.to_owned(),
23417                head: "0123456789abcdef".to_owned(),
23418                branch: Some("refs/heads/test".to_owned()),
23419                is_bare,
23420                is_main,
23421                locked: false,
23422                lock_reason: None,
23423                prunable: false,
23424                prunable_reason: None,
23425                workspace_id: None,
23426            }
23427        }
23428
23429        let source = r"C:\repo";
23430        let selected = r"C:\repo-worktree";
23431        let listed = vec![
23432            worktree(source, true, false),
23433            worktree(selected, false, false),
23434        ];
23435        assert!(validate_selected_worktree(source, selected, &listed).is_ok());
23436
23437        let main_failure = validate_selected_worktree(source, source, &listed).unwrap_err();
23438        assert_eq!(main_failure.code, NodeFailureCode::WorktreeProtected);
23439
23440        let bare = vec![
23441            worktree(source, true, false),
23442            worktree(selected, false, true),
23443        ];
23444        let bare_failure = validate_selected_worktree(source, selected, &bare).unwrap_err();
23445        assert_eq!(bare_failure.code, NodeFailureCode::WorktreeProtected);
23446
23447        let foreign_failure =
23448            validate_selected_worktree(source, r"C:\foreign", &listed).unwrap_err();
23449        assert_eq!(foreign_failure.code, NodeFailureCode::WorktreeProtected);
23450    }
23451
23452    fn terminal_frame(sequence: u64) -> TerminalFrame {
23453        TerminalFrame {
23454            sequence,
23455            size: gate4agent_types::TerminalSize {
23456                rows: 24,
23457                columns: 80,
23458            },
23459            cursor_row: 2,
23460            cursor_column: 3,
23461            contents: format!("frame-{sequence}"),
23462            formatted: vec![1, 2, 3],
23463            scrollback_formatted: Vec::new(),
23464            alternate_screen: false,
23465            mouse_protocol_enabled: false,
23466            mouse_protocol_encoding: Default::default(),
23467            produced_at_unix_ms: 0,
23468            screen_state: gate4agent_types::PtyScreenState::default(),
23469            bracketed_paste: None,
23470        }
23471    }
23472
23473    #[test]
23474    fn terminal_frame_watermark_coalesces_and_resets_for_exact_session_address() {
23475        let shared = terminal_test_shared();
23476        let mut receiver = shared.terminal_event_tx.subscribe();
23477        let original = terminal_address(1);
23478
23479        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23480        let first = receiver.try_recv().unwrap();
23481        assert_eq!(first.len(), 1);
23482        assert!(matches!(
23483            &first[0].event,
23484            NodeEvent::TerminalFrame { address, frame }
23485                if address == &original && frame.sequence == 7
23486        ));
23487
23488        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(7))]);
23489        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(6))]);
23490        assert!(matches!(
23491            receiver.try_recv(),
23492            Err(broadcast::error::TryRecvError::Empty)
23493        ));
23494
23495        shared.publish_terminal_frame_candidates(vec![(original.clone(), terminal_frame(8))]);
23496        let advanced = receiver.try_recv().unwrap();
23497        assert_eq!(advanced[0].sequence, 2);
23498
23499        let resumed = terminal_address(2);
23500        shared.publish_terminal_frame_candidates(vec![(resumed.clone(), terminal_frame(1))]);
23501        let reset = receiver.try_recv().unwrap();
23502        assert_eq!(reset[0].sequence, 3);
23503        assert!(matches!(
23504            &reset[0].event,
23505            NodeEvent::TerminalFrame { address, frame }
23506                if address == &resumed && frame.sequence == 1
23507        ));
23508        shared.clear_terminal_frame_watermark(&resumed);
23509        assert!(!shared
23510            .terminal_frame_watermarks
23511            .lock()
23512            .unwrap_or_else(|poisoned| poisoned.into_inner())
23513            .contains_key(&resumed.session.instance_id));
23514    }
23515
23516    #[test]
23517    fn terminal_frame_batches_replace_without_entering_node_event_history() {
23518        let shared = terminal_test_shared();
23519        let mut receiver = shared.terminal_event_tx.subscribe();
23520        let address = terminal_address(1);
23521        let count = NODE_EVENT_HISTORY_MAX as u64 + 17;
23522
23523        for sequence in 1..=count {
23524            shared.publish_terminal_frame_candidates(vec![(
23525                address.clone(),
23526                terminal_frame(sequence),
23527            )]);
23528        }
23529
23530        let history = shared
23531            .history
23532            .lock()
23533            .unwrap_or_else(|poisoned| poisoned.into_inner());
23534        assert!(history.events.is_empty());
23535        assert_eq!(history.last_sequence, count);
23536        assert_eq!(history.replay_floor_sequence, 1);
23537        drop(history);
23538        let NodeResponse::Resync {
23539            event_sequence,
23540            oldest_available_sequence,
23541            events,
23542            ..
23543        } = shared.resync(0) else {
23544            unreachable!("resync helper returned another response")
23545        };
23546        assert_eq!(event_sequence, count);
23547        assert_eq!(oldest_available_sequence, 1);
23548        assert!(events.is_empty());
23549        assert!(matches!(
23550            resync_required_event(&shared).event,
23551            NodeEvent::ResyncRequired {
23552                oldest_available_sequence: 1,
23553            }
23554        ));
23555        assert!(matches!(
23556            receiver.try_recv(),
23557            Err(broadcast::error::TryRecvError::Lagged(skipped)) if skipped == count - 1
23558        ));
23559        let latest = receiver.try_recv().unwrap();
23560        assert_eq!(latest.len(), 1);
23561        assert_eq!(latest[0].sequence, count);
23562    }
23563
23564    #[test]
23565    fn real_durable_eviction_advances_authoritative_replay_floor() {
23566        let shared = terminal_test_shared();
23567        for _ in 0..=NODE_EVENT_HISTORY_MAX {
23568            shared.publish(NodeEvent::ControllerChanged { controller: None });
23569        }
23570
23571        let NodeResponse::Resync {
23572            event_sequence,
23573            oldest_available_sequence,
23574            events,
23575            ..
23576        } = shared.resync(0) else {
23577            unreachable!("resync helper returned another response")
23578        };
23579        assert_eq!(event_sequence, NODE_EVENT_HISTORY_MAX as u64 + 1);
23580        assert_eq!(oldest_available_sequence, 2);
23581        assert_eq!(events.first().map(|event| event.sequence), Some(2));
23582        assert_eq!(events.len(), NODE_EVENT_HISTORY_MAX);
23583        assert!(matches!(
23584            resync_required_event(&shared).event,
23585            NodeEvent::ResyncRequired {
23586                oldest_available_sequence: 2,
23587            }
23588        ));
23589    }
23590
23591    #[test]
23592    fn legacy_terminal_frame_connection_has_no_live_subscription() {
23593        let shared = terminal_test_shared();
23594        let without_capability = terminal_event_subscription(&shared, false);
23595        let with_capability = terminal_event_subscription(&shared, true);
23596        assert!(without_capability.is_none());
23597        assert!(with_capability.is_some());
23598        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
23599            capability.as_str() == NODE_TERMINAL_FRAME_EVENTS_CAPABILITY
23600        }));
23601    }
23602
23603    #[test]
23604    fn continuous_terminal_output_leaves_request_service_after_bounded_event_burst() {
23605        let mut pending = Vec::new();
23606        let mut event_sequence = 0_u64;
23607        for frame_sequence in 1..=4 {
23608            for instance in 1..=(NODE_CONNECTION_EVENT_BURST_MAX as u64 * 2) {
23609                event_sequence += 1;
23610                let mut address = terminal_address(1);
23611                address.session.instance_id = AgentInstanceId(instance);
23612                queue_connection_event(
23613                    &mut pending,
23614                    NodeEventEnvelope {
23615                        sequence: event_sequence,
23616                        event: NodeEvent::TerminalFrame {
23617                            address,
23618                            frame: terminal_frame(frame_sequence),
23619                        },
23620                    },
23621                    0,
23622                );
23623            }
23624        }
23625        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX * 2);
23626        pending.sort_unstable_by_key(|event| event.sequence);
23627
23628        let mut uninterrupted = pending.clone();
23629        let first_burst = connection_event_burst_len(uninterrupted.len());
23630        uninterrupted.drain(..first_burst);
23631        let second_burst = connection_event_burst_len(uninterrupted.len());
23632        uninterrupted.drain(..second_burst);
23633        assert!(uninterrupted.is_empty());
23634
23635        let (frame_tx, mut frame_rx) =
23636            mpsc::channel::<Result<ClientFrame, FrameError>>(1);
23637        frame_tx
23638            .try_send(Ok(ClientFrame::Request(RequestEnvelope {
23639                request_id: 77,
23640                request: NodeRequest::Snapshot,
23641            })))
23642            .unwrap();
23643        let burst_len = connection_event_burst_len(pending.len());
23644        assert_eq!(burst_len, NODE_CONNECTION_EVENT_BURST_MAX);
23645        pending.drain(..burst_len);
23646        assert_eq!(pending.len(), NODE_CONNECTION_EVENT_BURST_MAX);
23647        assert!(matches!(
23648            frame_rx.try_recv(),
23649            Ok(Ok(ClientFrame::Request(RequestEnvelope {
23650                request_id: 77,
23651                request: NodeRequest::Snapshot,
23652            })))
23653        ));
23654    }
23655
23656    #[test]
23657    fn terminal_lag_watermark_discards_retained_and_replayed_batches() {
23658        let mut pending = Vec::new();
23659        for sequence in [7_u64, 8] {
23660            let mut address = terminal_address(1);
23661            address.session.instance_id = AgentInstanceId(sequence);
23662            queue_connection_event(
23663                &mut pending,
23664                NodeEventEnvelope {
23665                    sequence,
23666                    event: NodeEvent::TerminalFrame {
23667                        address,
23668                        frame: terminal_frame(sequence),
23669                    },
23670                },
23671                0,
23672            );
23673        }
23674        let mut discard_through = 0;
23675        apply_connection_resync_watermark(&mut pending, &mut discard_through, 8);
23676        assert!(pending.is_empty());
23677
23678        let replayed = NodeEventEnvelope {
23679            sequence: 8,
23680            event: NodeEvent::TerminalFrame {
23681                address: terminal_address(1),
23682                frame: terminal_frame(8),
23683            },
23684        };
23685        let advanced = NodeEventEnvelope {
23686            sequence: 9,
23687            event: NodeEvent::TerminalFrame {
23688                address: terminal_address(2),
23689                frame: terminal_frame(1),
23690            },
23691        };
23692        queue_connection_event_batch(
23693            &mut pending,
23694            &[replayed, advanced.clone()],
23695            discard_through,
23696        );
23697        assert_eq!(pending, vec![advanced]);
23698    }
23699
23700    fn record(provider: &str, record_id: &str) -> ManagedSessionRecord {
23701        ManagedSessionRecord {
23702            record_id: SessionRecordId::new(record_id).unwrap(),
23703            display_name: format!("{provider} record"),
23704            provider: agent(provider),
23705            mode: SessionMode::Pty,
23706            state: ManagedSessionState::Unavailable,
23707            workspace_id: WorkspaceId::new("primary").unwrap(),
23708            canonical_root: opaque_windows_path(
23709                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23710            ),
23711            provider_session: None,
23712            active_session: None,
23713            environment_profile: None,
23714            bundle: None,
23715            context_id: None,
23716            context: None,
23717            exported_context: None,
23718            task_binding: None,
23719            created_at_unix_ms: 1,
23720            updated_at_unix_ms: 1,
23721            last_error: None,
23722        }
23723    }
23724
23725    fn retention_test_record(
23726        workspace_id: &str,
23727        record_id: &str,
23728        state: ManagedSessionState,
23729        updated_at_unix_ms: u64,
23730    ) -> ManagedSessionRecord {
23731        ManagedSessionRecord {
23732            record_id: SessionRecordId::new(record_id).unwrap(),
23733            display_name: format!("{record_id} display"),
23734            provider: agent("claude"),
23735            mode: SessionMode::Pty,
23736            state,
23737            workspace_id: WorkspaceId::new(workspace_id).unwrap(),
23738            canonical_root: opaque_windows_path(
23739                std::env::current_dir().unwrap().to_string_lossy().into_owned(),
23740            ),
23741            provider_session: None,
23742            active_session: None,
23743            environment_profile: None,
23744            bundle: None,
23745            context_id: None,
23746            context: None,
23747            exported_context: None,
23748            task_binding: None,
23749            created_at_unix_ms: updated_at_unix_ms,
23750            updated_at_unix_ms,
23751            last_error: None,
23752        }
23753    }
23754
23755    /// Two workspaces of three `Unavailable` records each, staggered by
23756    /// age, plus one never-touchable decoy per non-`Unavailable` state in
23757    /// `workspace-a` (all as old as that workspace's oldest real record --
23758    /// if the sweep's state gate ever leaked, these would be the first
23759    /// thing it wrongly retired). Ages, oldest to newest:
23760    /// `workspace-a`: a1 (500s), a2 (300s), a3 (100s).
23761    /// `workspace-b`: b1 (400s), b2 (200s), b3 (50s).
23762    fn retention_fixture_records(now: u64) -> Vec<ManagedSessionRecord> {
23763        vec![
23764            retention_test_record("workspace-a", "a1", ManagedSessionState::Unavailable, now - 500_000),
23765            retention_test_record("workspace-a", "a2", ManagedSessionState::Unavailable, now - 300_000),
23766            retention_test_record("workspace-a", "a3", ManagedSessionState::Unavailable, now - 100_000),
23767            retention_test_record("workspace-b", "b1", ManagedSessionState::Unavailable, now - 400_000),
23768            retention_test_record("workspace-b", "b2", ManagedSessionState::Unavailable, now - 200_000),
23769            retention_test_record("workspace-b", "b3", ManagedSessionState::Unavailable, now - 50_000),
23770            retention_test_record("workspace-a", "decoy-live", ManagedSessionState::Live, now - 500_000),
23771            retention_test_record(
23772                "workspace-a",
23773                "decoy-pending",
23774                ManagedSessionState::IdentityPending,
23775                now - 500_000,
23776            ),
23777            retention_test_record(
23778                "workspace-a",
23779                "decoy-dormant",
23780                ManagedSessionState::Dormant,
23781                now - 500_000,
23782            ),
23783        ]
23784    }
23785
23786    fn retention_fixture_shared() -> (NodeShared, NativeRuntime, PathBuf, u64) {
23787        let root = temporary_workspace_root("session-record-retention");
23788        std::fs::create_dir_all(&root).unwrap();
23789        let catalog = active_registry().unwrap();
23790        let (handle, runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
23791        let workspace_a =
23792            WorkspaceConfig::new(WorkspaceId::new("workspace-a").unwrap(), &root).unwrap();
23793        let workspace_b =
23794            WorkspaceConfig::new(WorkspaceId::new("workspace-b").unwrap(), &root).unwrap();
23795        let mut shared = NodeShared::new(
23796            handle,
23797            "fixture-token".to_owned(),
23798            NodeId::new("node-retention-test").unwrap(),
23799            vec![workspace_a, workspace_b],
23800            vec![agent("claude")],
23801        );
23802        shared.state_path = Some(root.join("node-state-v10.json"));
23803        let now = unix_time_ms();
23804        for record in retention_fixture_records(now) {
23805            shared.insert_record(record).unwrap();
23806        }
23807        shared
23808            .persist_state_locked()
23809            .expect("fixture-only, empty-history persist against a fresh temp state path");
23810        (shared, runtime, root, now)
23811    }
23812
23813    fn retention_removed_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23814        shared
23815            .history
23816            .lock()
23817            .unwrap_or_else(|poisoned| poisoned.into_inner())
23818            .events
23819            .iter()
23820            .filter_map(|envelope| match &envelope.event {
23821                NodeEvent::SessionRecordRemoved { record_id } => {
23822                    Some(record_id.as_str().to_owned())
23823                }
23824                _ => None,
23825            })
23826            .collect()
23827    }
23828
23829    fn retention_remaining_record_ids(shared: &NodeShared) -> std::collections::BTreeSet<String> {
23830        shared
23831            .session_records
23832            .lock()
23833            .unwrap_or_else(|poisoned| poisoned.into_inner())
23834            .records
23835            .keys()
23836            .map(|record_id| record_id.as_str().to_owned())
23837            .collect()
23838    }
23839
23840    fn owned(values: &[&str]) -> std::collections::BTreeSet<String> {
23841        values.iter().map(|value| (*value).to_owned()).collect()
23842    }
23843
23844    #[tokio::test]
23845    async fn session_record_retention_keep_n_only_keeps_the_newest_per_workspace() {
23846        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23847        shared.session_record_retention = SessionRecordRetentionConfig {
23848            age_ms: 0,
23849            keep_per_workspace: 1,
23850        };
23851        shared.retire_unavailable_session_records().await;
23852
23853        assert_eq!(
23854            retention_remaining_record_ids(&shared),
23855            owned(&["a3", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23856        );
23857        assert_eq!(
23858            retention_removed_record_ids(&shared),
23859            owned(&["a1", "a2", "b1", "b2"]),
23860        );
23861        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 4);
23862
23863        drop(shared);
23864        drop(runtime);
23865        std::fs::remove_dir_all(root).unwrap();
23866    }
23867
23868    /// Age-only would retire {a1, a2, b1}; keep-N-only would retire
23869    /// {a1, a2, b1, b2}. With both flags set the sweep must retire exactly
23870    /// the intersection {a1, a2, b1} -- `b2` fails the age test (it is only
23871    /// 200s old against a 250s threshold) even though it fails the keep-N
23872    /// test too, proving retirement needs BOTH, not either.
23873    #[tokio::test]
23874    async fn session_record_retention_both_set_retires_only_the_intersection() {
23875        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23876        shared.session_record_retention = SessionRecordRetentionConfig {
23877            age_ms: 250_000,
23878            keep_per_workspace: 1,
23879        };
23880        shared.retire_unavailable_session_records().await;
23881
23882        assert_eq!(
23883            retention_remaining_record_ids(&shared),
23884            owned(&["a3", "b2", "b3", "decoy-live", "decoy-pending", "decoy-dormant"]),
23885        );
23886        assert_eq!(retention_removed_record_ids(&shared), owned(&["a1", "a2", "b1"]));
23887        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 3);
23888
23889        drop(shared);
23890        drop(runtime);
23891        std::fs::remove_dir_all(root).unwrap();
23892    }
23893
23894    #[tokio::test]
23895    async fn session_record_retention_both_disabled_is_a_no_op() {
23896        let (mut shared, runtime, root, _now) = retention_fixture_shared();
23897        shared.session_record_retention = SessionRecordRetentionConfig::default();
23898        shared.retire_unavailable_session_records().await;
23899
23900        assert_eq!(
23901            retention_remaining_record_ids(&shared),
23902            owned(&[
23903                "a1", "a2", "a3", "b1", "b2", "b3",
23904                "decoy-live", "decoy-pending", "decoy-dormant",
23905            ]),
23906        );
23907        assert!(retention_removed_record_ids(&shared).is_empty());
23908        assert_eq!(shared.retired_records_total.load(Ordering::Relaxed), 0);
23909
23910        drop(shared);
23911        drop(runtime);
23912        std::fs::remove_dir_all(root).unwrap();
23913    }
23914
23915    #[test]
23916    fn session_record_context_export_selects_one_exact_history_identity() {
23917        let candidates = vec![
23918            HistoryCandidateSummary {
23919                id: "candidate-other".to_owned(),
23920                session_id_hint: "provider-other".to_owned(),
23921                modified_at_unix_ms: Some(1),
23922            },
23923            HistoryCandidateSummary {
23924                id: "candidate-exact".to_owned(),
23925                session_id_hint: "provider-exact".to_owned(),
23926                modified_at_unix_ms: Some(2),
23927            },
23928        ];
23929        assert_eq!(
23930            select_history_candidate(&candidates, Some("provider-exact")).unwrap(),
23931            "candidate-exact",
23932        );
23933    }
23934
23935    #[test]
23936    fn session_record_context_export_rejects_missing_and_ambiguous_history_identity() {
23937        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23938            id: id.to_owned(),
23939            session_id_hint: hint.to_owned(),
23940            modified_at_unix_ms: None,
23941        };
23942        let missing = select_history_candidate(
23943            &[candidate("candidate-other", "provider-other")],
23944            Some("provider-exact"),
23945        )
23946        .unwrap_err();
23947        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23948        let ambiguous = select_history_candidate(
23949            &[
23950                candidate("candidate-a", "provider-exact"),
23951                candidate("candidate-b", "provider-exact"),
23952            ],
23953            Some("provider-exact"),
23954        )
23955        .unwrap_err();
23956        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23957    }
23958
23959    /// The PTY-sidecar-observation shape (no fleet provider today, but
23960    /// capability-keyed rather than vendor-locked) never carries a verified
23961    /// `provider_session`; its record-bound export instead falls back to
23962    /// "exactly one discovered candidate" (see
23963    /// `export_context_pack_for_session_record_inner`). This is the same
23964    /// ambiguity refusal as the identity-filtered path, just without an
23965    /// identity to filter by.
23966    #[test]
23967    fn session_record_context_export_selects_sole_history_candidate_without_identity() {
23968        let candidate = |id: &str, hint: &str| HistoryCandidateSummary {
23969            id: id.to_owned(),
23970            session_id_hint: hint.to_owned(),
23971            modified_at_unix_ms: None,
23972        };
23973        assert_eq!(
23974            select_history_candidate(&[candidate("candidate-sole", "sidecar-session")], None)
23975                .unwrap(),
23976            "candidate-sole",
23977        );
23978        let missing = select_history_candidate(&[], None).unwrap_err();
23979        assert_eq!(missing.code, NodeFailureCode::SessionRecordConflict);
23980        let ambiguous = select_history_candidate(
23981            &[
23982                candidate("candidate-a", "sidecar-session-a"),
23983                candidate("candidate-b", "sidecar-session-b"),
23984            ],
23985            None,
23986        )
23987        .unwrap_err();
23988        assert_eq!(ambiguous.code, NodeFailureCode::SessionRecordConflict);
23989    }
23990
23991    #[test]
23992    fn session_record_context_export_revalidation_rejects_record_change() {
23993        let session = terminal_address(1);
23994        let identity = ProviderSessionIdentity {
23995            key: ProviderSessionKey::SessionId,
23996            id: "provider-exact".to_owned(),
23997            transcript_path: Some(r"C:\private\provider.jsonl".to_owned()),
23998        };
23999        let mut expected = record("claude", "record-context-export");
24000        expected.state = ManagedSessionState::Live;
24001        expected.provider_session = Some(identity.clone());
24002        expected.active_session = Some(session.clone());
24003        let binding = SessionBinding {
24004            workspace_id: session.workspace_id.clone(),
24005            generation: session.session.generation,
24006            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24007            pending_resume: None,
24008            record_id: Some(expected.record_id.clone()),
24009            managed_worktree_lease_id: None,
24010            environment_profile: None,
24011            bundle: None,
24012            context: None,
24013            materialization_id: None,
24014        };
24015        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24016        assert!(session_record_context_export_binding_is_exact(
24017            &expected,
24018            &expected,
24019            Some(&identity),
24020            &session,
24021            &binding,
24022            &expected.provider,
24023            &current_root,
24024            false,
24025        ));
24026        let mut changed = expected.clone();
24027        changed.updated_at_unix_ms += 1;
24028        assert!(!session_record_context_export_binding_is_exact(
24029            &expected,
24030            &changed,
24031            Some(&identity),
24032            &session,
24033            &binding,
24034            &expected.provider,
24035            &current_root,
24036            false,
24037        ));
24038        // A same-instant timestamp bump alone (state and active_session
24039        // otherwise unchanged) is not the clean-detachment transition
24040        // either, even with the reactive flag on: only the exact Live ->
24041        // Dormant + active_session cleared shape qualifies.
24042        assert!(!session_record_context_export_binding_is_exact(
24043            &expected,
24044            &changed,
24045            Some(&identity),
24046            &session,
24047            &binding,
24048            &expected.provider,
24049            &current_root,
24050            true,
24051        ));
24052        let mut detached = expected.clone();
24053        detached.state = ManagedSessionState::Dormant;
24054        detached.active_session = None;
24055        detached.updated_at_unix_ms += 1;
24056        assert!(!session_record_context_export_binding_is_exact(
24057            &expected,
24058            &detached,
24059            Some(&identity),
24060            &session,
24061            &binding,
24062            &expected.provider,
24063            &current_root,
24064            false,
24065        ));
24066        assert!(session_record_context_export_binding_is_exact(
24067            &expected,
24068            &detached,
24069            Some(&identity),
24070            &session,
24071            &binding,
24072            &expected.provider,
24073            &current_root,
24074            true,
24075        ));
24076    }
24077
24078    /// Mirrors `session_record_context_export_revalidation_rejects_record_change`
24079    /// for a record whose provider never carries a verified identity (the
24080    /// PTY-sidecar-observation shape -- capability-keyed, no fleet provider
24081    /// declares one today): `identity` is `None` throughout, and the
24082    /// exactness check falls back to comparing `provider_session` itself,
24083    /// which must still be `None` on both sides.
24084    #[test]
24085    fn session_record_context_export_binding_is_exact_without_identity() {
24086        let session = terminal_address(1);
24087        let mut expected = record("pty-sidecar-fixture", "record-context-export-no-identity");
24088        expected.state = ManagedSessionState::Live;
24089        expected.active_session = Some(session.clone());
24090        let binding = SessionBinding {
24091            workspace_id: session.workspace_id.clone(),
24092            generation: session.session.generation,
24093            runtime_policy: ProviderRuntimePolicy::raw_pty(),
24094            pending_resume: None,
24095            record_id: Some(expected.record_id.clone()),
24096            managed_worktree_lease_id: None,
24097            environment_profile: None,
24098            bundle: None,
24099            context: None,
24100            materialization_id: None,
24101        };
24102        let current_root = windows_path_text(&expected.canonical_root).to_owned();
24103        assert!(session_record_context_export_binding_is_exact(
24104            &expected,
24105            &expected,
24106            None,
24107            &session,
24108            &binding,
24109            &expected.provider,
24110            &current_root,
24111            false,
24112        ));
24113        // A record that gained an identity between the initial lookup and
24114        // this recheck no longer matches the `None` this shape expects to
24115        // keep seeing -- treated the same as any other mid-export drift.
24116        let mut drifted = expected.clone();
24117        drifted.provider_session = Some(ProviderSessionIdentity {
24118            key: ProviderSessionKey::SessionId,
24119            id: "unexpected-identity".to_owned(),
24120            transcript_path: None,
24121        });
24122        assert!(!session_record_context_export_binding_is_exact(
24123            &expected,
24124            &drifted,
24125            None,
24126            &session,
24127            &binding,
24128            &expected.provider,
24129            &current_root,
24130            false,
24131        ));
24132    }
24133
24134    #[test]
24135    fn session_record_context_export_preflight_requires_live_usable_source() {
24136        let mut source = record("claude", "record-context-export-preflight");
24137        source.state = ManagedSessionState::Live;
24138        assert!(session_record_context_export_source_is_usable(
24139            &source,
24140            &SessionStatus::Running,
24141            false,
24142        ));
24143        assert!(session_record_context_export_source_is_usable(
24144            &source,
24145            &SessionStatus::Exited { exit_code: Some(0) },
24146            false,
24147        ));
24148
24149        source.state = ManagedSessionState::Dormant;
24150        assert!(!session_record_context_export_source_is_usable(
24151            &source,
24152            &SessionStatus::Running,
24153            false,
24154        ));
24155        // The reactive auto-export-at-exit path (allow_clean_detachment) is
24156        // the only caller that may treat a Dormant, cleanly-exited record as
24157        // still usable — the on-demand path above never does.
24158        assert!(session_record_context_export_source_is_usable(
24159            &source,
24160            &SessionStatus::Exited { exit_code: Some(0) },
24161            true,
24162        ));
24163        assert!(!session_record_context_export_source_is_usable(
24164            &source,
24165            &SessionStatus::Exited { exit_code: Some(0) },
24166            false,
24167        ));
24168        source.state = ManagedSessionState::Live;
24169        assert!(!session_record_context_export_source_is_usable(
24170            &source,
24171            &SessionStatus::Starting,
24172            false,
24173        ));
24174    }
24175
24176    #[test]
24177    fn session_record_context_export_final_conflict_leaves_catalog_empty() {
24178        let catalog = active_registry().unwrap();
24179        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24180        let workspace = WorkspaceConfig::new(
24181            WorkspaceId::new("primary").unwrap(),
24182            std::env::current_dir().unwrap(),
24183        )
24184        .unwrap();
24185        let shared = NodeShared::new(
24186            handle,
24187            "fixture-token".to_owned(),
24188            NodeId::new("node-context-export-conflict").unwrap(),
24189            vec![workspace],
24190            vec![agent("claude")],
24191        );
24192        let history = HistorySessionRecord {
24193            session_id: "provider-exact".to_owned(),
24194            title: None,
24195            cwd: None,
24196            model: None,
24197            message_count: 1,
24198            completed_turn_count: None,
24199            total_tokens: 0,
24200            messages: vec![gate4agent_types::HistoryMessageRecord {
24201                role: gate4agent_types::HistoryMessageRole::User,
24202                text: "bounded context".to_owned(),
24203            }],
24204        };
24205        let pack = NodeContextPack::export(
24206            ContextPackLineageReceipt {
24207                source_node_id: shared.node_id.clone(),
24208                source_session: terminal_address(1),
24209                source_provider: agent("claude"),
24210            },
24211            &history,
24212        )
24213        .unwrap();
24214        let context_id = pack.receipt().id.clone();
24215        let session = terminal_address(1);
24216        let identity = ProviderSessionIdentity {
24217            key: ProviderSessionKey::SessionId,
24218            id: history.session_id.clone(),
24219            transcript_path: None,
24220        };
24221        let mut expected = record("claude", "record-context-export-final-conflict");
24222        expected.state = ManagedSessionState::Live;
24223        expected.provider_session = Some(identity.clone());
24224        expected.active_session = Some(session.clone());
24225        let mut changed = expected.clone();
24226        changed.updated_at_unix_ms += 1;
24227        shared.insert_record(changed).unwrap();
24228
24229        let error = shared
24230            .commit_context_pack_for_session_record(
24231                &expected,
24232                Some(&identity),
24233                &session,
24234                "candidate-exact",
24235                &history,
24236                pack,
24237                false,
24238            )
24239            .unwrap_err();
24240
24241        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
24242        assert!(shared
24243            .context_catalog
24244            .read()
24245            .unwrap_or_else(|poisoned| poisoned.into_inner())
24246            .get(&context_id)
24247            .is_none());
24248    }
24249
24250    #[tokio::test]
24251    async fn session_task_binding_mints_assigns_clears_with_cas_and_persists_v9() {
24252        let root = temporary_workspace_root("session-task-binding");
24253        std::fs::create_dir_all(&root).unwrap();
24254        let state_path = root.join("node-state.json");
24255        let catalog = active_registry().unwrap();
24256        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
24257        let workspace = WorkspaceConfig::new(WorkspaceId::new("primary").unwrap(), &root).unwrap();
24258        let node_id = NodeId::new("node-1").unwrap();
24259        let mut shared = NodeShared::new(
24260            handle,
24261            "fixture-token".to_owned(),
24262            node_id.clone(),
24263            vec![workspace],
24264            vec![agent("claude")],
24265        );
24266        shared.state_path = Some(state_path.clone());
24267        let record = record("claude", "sr-task");
24268        let record_id = record.record_id.clone();
24269        shared.insert_record(record).unwrap();
24270        shared.persist_state().unwrap();
24271        shared
24272            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
24273            .unwrap();
24274
24275        let minted = process_request_inner(
24276            &shared,
24277            77,
24278            ClientRole::Operator,
24279            NodeRequest::SetSessionTask {
24280                record_id: record_id.clone(),
24281                expected_revision: 0,
24282                target: SessionTaskTargetV1::New,
24283            },
24284        )
24285        .await
24286        .unwrap();
24287        let NodeResponse::SessionRecordUpdated { record: minted } = minted else {
24288            panic!("session task mutation returned another response");
24289        };
24290        let minted_binding = minted.task_binding.clone().unwrap();
24291        let minted_task_id = minted_binding.task_id.clone().unwrap();
24292        assert_eq!(minted_binding.revision, 1);
24293        assert!(minted_task_id.as_str().starts_with("task-"));
24294        assert_eq!(minted_task_id.as_str().len(), 29);
24295
24296        let event_count = shared.current_sequence();
24297        let repeated = process_request_inner(
24298            &shared,
24299            77,
24300            ClientRole::Operator,
24301            NodeRequest::SetSessionTask {
24302                record_id: record_id.clone(),
24303                expected_revision: 1,
24304                target: SessionTaskTargetV1::Existing {
24305                    task_id: minted_task_id.clone(),
24306                },
24307            },
24308        )
24309        .await
24310        .unwrap();
24311        assert_eq!(repeated, NodeResponse::SessionRecordUpdated { record: minted.clone() });
24312        assert_eq!(shared.current_sequence(), event_count);
24313
24314        let stale = process_request_inner(
24315            &shared,
24316            77,
24317            ClientRole::Operator,
24318            NodeRequest::SetSessionTask {
24319                record_id: record_id.clone(),
24320                expected_revision: 0,
24321                target: SessionTaskTargetV1::Existing {
24322                    task_id: minted_task_id,
24323                },
24324            },
24325        )
24326        .await
24327        .unwrap_err();
24328        assert_eq!(stale.code, NodeFailureCode::SessionRecordConflict);
24329
24330        let assigned_id: TaskId = "task-ffeeddccbbaa998877665544".parse().unwrap();
24331        let assigned = process_request_inner(
24332            &shared,
24333            77,
24334            ClientRole::Operator,
24335            NodeRequest::SetSessionTask {
24336                record_id: record_id.clone(),
24337                expected_revision: 1,
24338                target: SessionTaskTargetV1::Existing {
24339                    task_id: assigned_id.clone(),
24340                },
24341            },
24342        )
24343        .await
24344        .unwrap();
24345        let NodeResponse::SessionRecordUpdated { record: assigned } = assigned else {
24346            panic!("session task assignment returned another response");
24347        };
24348        assert_eq!(assigned.task_binding.as_ref().unwrap().revision, 2);
24349        assert_eq!(assigned.task_binding.as_ref().unwrap().task_id.as_ref(), Some(&assigned_id));
24350
24351        let cleared = process_request_inner(
24352            &shared,
24353            77,
24354            ClientRole::Operator,
24355            NodeRequest::SetSessionTask {
24356                record_id: record_id.clone(),
24357                expected_revision: 2,
24358                target: SessionTaskTargetV1::Clear,
24359            },
24360        )
24361        .await
24362        .unwrap();
24363        let NodeResponse::SessionRecordUpdated { record: cleared } = cleared else {
24364            panic!("session task clear returned another response");
24365        };
24366        assert_eq!(cleared.task_binding.as_ref().unwrap().revision, 3);
24367        assert!(cleared.task_binding.as_ref().unwrap().task_id.is_none());
24368        assert!(cleared.task_binding.as_ref().unwrap().changed_at_unix_ms > 0);
24369        assert_eq!(
24370            cleared.task_binding.as_ref().unwrap().changed_at_unix_ms,
24371            cleared.updated_at_unix_ms,
24372        );
24373
24374        let persisted = std::fs::read(&state_path).unwrap();
24375        assert_eq!(serde_json::from_slice::<serde_json::Value>(&persisted).unwrap()["version"], NODE_STATE_SCHEMA_V10);
24376        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
24377        assert_eq!(loaded.records[0].task_binding, cleared.task_binding);
24378        std::fs::remove_dir_all(root).unwrap();
24379    }
24380
24381    #[test]
24382    fn session_task_capability_strips_snapshots_events_and_replies() {
24383        let mut bound = record("claude", "sr-task-projection");
24384        bound.updated_at_unix_ms = 2;
24385        bound.task_binding = Some(SessionTaskBindingV1 {
24386            revision: 1,
24387            task_id: Some("task-00112233445566778899aaff".parse().unwrap()),
24388            changed_at_unix_ms: 2,
24389        });
24390        let request = NodeRequest::SetSessionTask {
24391            record_id: bound.record_id.clone(),
24392            expected_revision: 1,
24393            target: SessionTaskTargetV1::Clear,
24394        };
24395        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
24396            capability.as_str() == NODE_SESSION_TASK_CORRELATION_CAPABILITY
24397        }));
24398        assert!(!request_uses_unnegotiated_capability(
24399            &request,
24400            &baseline_capabilities().unwrap(),
24401        ));
24402        assert!(request_uses_unnegotiated_capability(&request, &[]));
24403
24404        let mut snapshot = terminal_test_shared().snapshot();
24405        snapshot.session_records.push(bound.clone());
24406        let mut reply = ResponseEnvelope {
24407            request_id: 1,
24408            result: Ok(NodeResponse::Resync {
24409                event_sequence: 1,
24410                oldest_available_sequence: 1,
24411                snapshot,
24412                events: vec![NodeEventEnvelope {
24413                    sequence: 1,
24414                    event: NodeEvent::SessionRecordUpserted {
24415                        record: bound.clone(),
24416                    },
24417                }],
24418            }),
24419        };
24420        project_response_without_session_task_binding(&mut reply);
24421        let Ok(NodeResponse::Resync { snapshot, events, .. }) = reply.result else {
24422            panic!("task projection changed the response kind");
24423        };
24424        assert!(snapshot.session_records[0].task_binding.is_none());
24425        let NodeEvent::SessionRecordUpserted { record } = &events[0].event else {
24426            panic!("task projection changed the event kind");
24427        };
24428        assert!(record.task_binding.is_none());
24429
24430        let projected = project_event_without_session_task_binding(NodeEventEnvelope {
24431            sequence: 2,
24432            event: NodeEvent::SessionRecordUpserted { record: bound.clone() },
24433        });
24434        let NodeEvent::SessionRecordUpserted { record } = projected.event else {
24435            panic!("task event projection changed the event kind");
24436        };
24437        assert!(record.task_binding.is_none());
24438
24439        let mut direct = ResponseEnvelope {
24440            request_id: 2,
24441            result: Ok(NodeResponse::SessionRecordUpdated { record: bound }),
24442        };
24443        project_response_without_session_task_binding(&mut direct);
24444        let Ok(NodeResponse::SessionRecordUpdated { record }) = direct.result else {
24445            panic!("task reply projection changed the response kind");
24446        };
24447        assert!(record.task_binding.is_none());
24448    }
24449
24450    #[test]
24451    fn windows_runtime_default_node_endpoint_is_exact_and_valid() {
24452        assert_eq!(DEFAULT_NODE_ENDPOINT, r"\\.\pipe\gate4agent-node");
24453        let workspace = WorkspaceConfig::new(
24454            WorkspaceId::new("primary").unwrap(),
24455            std::env::current_dir().unwrap(),
24456        )
24457        .unwrap();
24458        let config = NodeServerConfig::new(
24459            DEFAULT_NODE_ENDPOINT,
24460            "fixture-token",
24461            NodeId::new("node-1").unwrap(),
24462            [workspace],
24463        )
24464        .unwrap();
24465        assert_eq!(config.endpoint, DEFAULT_NODE_ENDPOINT);
24466    }
24467
24468    #[cfg(all(feature = "fixture", windows))]
24469    #[test]
24470    fn provider_bundle_argv_hold_fixture_is_parent_bounded_and_waits_after_proof() {
24471        let root = temporary_workspace_root("provider-bundle-argv-hold");
24472        let outside = temporary_workspace_root("provider-bundle-argv-hold-outside");
24473        std::fs::create_dir_all(&root).unwrap();
24474        std::fs::create_dir_all(&outside).unwrap();
24475        let proof_path = root.join("provider.proof");
24476        let release_signal = root.join("release.signal");
24477        let config = NodeServerConfig::new(
24478            format!(
24479                r"\\.\pipe\gate4agent-provider-bundle-hold-{}",
24480                std::process::id(),
24481            ),
24482            "fixture-token",
24483            NodeId::new("provider-bundle-hold-node").unwrap(),
24484            [WorkspaceConfig::new(
24485                WorkspaceId::new("primary").unwrap(),
24486                std::env::current_dir().unwrap(),
24487            )
24488            .unwrap()],
24489        )
24490        .unwrap()
24491        .with_spawn_profiles(SpawnProfileRegistry::new([
24492            crate::protocol::SpawnProfileDefaults {
24493                profile_id: crate::protocol::SpawnProfileId::new("kimi-bundle-hold").unwrap(),
24494                revision: crate::protocol::SpawnProfileRevision::new("kimi-bundle-hold-r1")
24495                    .unwrap(),
24496                provider: agent("kimi"),
24497                mode: SessionMode::Pty,
24498                terminal_size: gate4agent_types::TerminalSize {
24499                    rows: 24,
24500                    columns: 80,
24501                },
24502                prompt: None,
24503                bundle_id: None,
24504                context_id: None,
24505                environment_profile_id: None,
24506            },
24507        ])
24508        .unwrap());
24509        let server = NodeServer::new_provider_bundle_argv_hold_fixture(
24510            config,
24511            agent("kimi"),
24512            proof_path.clone(),
24513            release_signal.clone(),
24514        )
24515        .unwrap();
24516        assert_eq!(server.shared.enabled_providers, [agent("kimi")]);
24517
24518        let spec = NodeServer::provider_bundle_argv_fixture_spec(
24519            agent("kimi"),
24520            proof_path.clone(),
24521            Some(release_signal.clone()),
24522        )
24523        .unwrap();
24524        let wrapper = &spec.launch.fixed_args[spec.launch.fixed_args.len() - 3];
24525        let proof_write = wrapper.find("WriteAllLines($proofPath").unwrap();
24526        let release_wait = wrapper
24527            .find("while (-not (Test-Path -LiteralPath $releaseSignal -PathType Leaf))")
24528            .unwrap();
24529        let fixture_ready = wrapper.find("fixture-ready>").unwrap();
24530        assert!(proof_write < release_wait);
24531        assert!(release_wait < fixture_ready);
24532        assert!(wrapper.contains("[DateTime]::UtcNow.AddSeconds(45)"));
24533        assert!(wrapper.contains("Start-Sleep -Milliseconds 20"));
24534        assert_eq!(
24535            &spec.launch.fixed_args[spec.launch.fixed_args.len() - 2..],
24536            &[
24537                proof_path.to_string_lossy().into_owned(),
24538                release_signal.to_string_lossy().into_owned(),
24539            ],
24540        );
24541
24542        let error = NodeServer::provider_bundle_argv_fixture_spec(
24543            agent("kimi"),
24544            proof_path,
24545            Some(outside.join("release.signal")),
24546        )
24547        .unwrap_err();
24548        assert!(matches!(
24549            error,
24550            NodeServerError::Registry(message)
24551                if message == "provider bundle argv release path must share the proof parent"
24552        ));
24553        drop(server);
24554        std::fs::remove_dir_all(root).unwrap();
24555        std::fs::remove_dir_all(outside).unwrap();
24556    }
24557
24558    #[cfg(feature = "fixture")]
24559    #[test]
24560    fn four_provider_context_fixture_registry_is_unambiguous_and_history_bound() {
24561        let root = temporary_workspace_root("context-fixture-registry");
24562        std::fs::create_dir_all(&root).unwrap();
24563        let proof_path = root.join("context.proof");
24564        let config = NodeServerConfig::new(
24565            format!(
24566                r"\\.\pipe\gate4agent-context-fixture-registry-{}",
24567                std::process::id(),
24568            ),
24569            "fixture-token",
24570            NodeId::new("context-fixture-node").unwrap(),
24571            [WorkspaceConfig::new(
24572                WorkspaceId::new("primary").unwrap(),
24573                std::env::current_dir().unwrap(),
24574            )
24575            .unwrap()],
24576        )
24577        .unwrap();
24578        let server = NodeServer::new_context_pack_fixture(config, proof_path).unwrap();
24579        let mut enabled = server
24580            .shared
24581            .enabled_providers
24582            .iter()
24583            .map(AgentId::as_str)
24584            .collect::<Vec<_>>();
24585        enabled.sort_unstable();
24586        assert_eq!(enabled, ["claude", "codex", "grok", "kimi"]);
24587        for provider in &server.shared.enabled_providers {
24588            assert!(server
24589                .shared
24590                .provider_adapter_contracts
24591                .iter()
24592                .any(|contract| {
24593                    &contract.provider == provider
24594                        && contract.family == AdapterFamily::History
24595                }));
24596        }
24597        drop(server);
24598        std::fs::remove_dir_all(root).unwrap();
24599    }
24600
24601    #[cfg(feature = "fixture")]
24602    #[test]
24603    fn monitoring_context_fixture_combines_claude_and_codex_hooks_with_history() {
24604        let root = temporary_workspace_root("monitoring-context-fixture");
24605        std::fs::create_dir_all(&root).unwrap();
24606        let proof_path = root.join("context.proof");
24607        let standard = NodeServer::context_pack_fixture_catalog(
24608            proof_path.clone(),
24609            None,
24610            false,
24611        ).unwrap();
24612        let monitoring = NodeServer::context_pack_fixture_catalog(
24613            proof_path.clone(),
24614            None,
24615            true,
24616        ).unwrap();
24617        let claude = AgentId::new("claude").unwrap();
24618        let standard_claude = standard.get(&claude).unwrap();
24619        let monitoring_claude = monitoring.get(&claude).unwrap();
24620        // Lifecycle hooks are retired (owner ruling 2026-09-25): neither the
24621        // standard nor the monitoring fixture declares a hook adapter
24622        // anymore.
24623        assert!(monitoring_claude.capabilities.adapters.hook.is_none());
24624        assert_eq!(
24625            monitoring_claude.capabilities.adapters.history,
24626            standard_claude.capabilities.adapters.history,
24627        );
24628        assert_ne!(monitoring_claude.launch, standard_claude.launch);
24629        let codex = AgentId::new("codex").unwrap();
24630        let standard_codex = standard.get(&codex).unwrap();
24631        let monitoring_codex = monitoring.get(&codex).unwrap();
24632        assert!(monitoring_codex.capabilities.adapters.hook.is_none());
24633        assert_eq!(
24634            monitoring_codex.capabilities.adapters.history,
24635            standard_codex.capabilities.adapters.history,
24636        );
24637        assert_ne!(monitoring_codex.launch, standard_codex.launch);
24638        let codex_validation = monitoring_codex.launch.fixed_args.iter()
24639            .find(|argument| argument.contains("F7_CODEX_BUNDLE_CONTEXT_VALIDATED"))
24640            .unwrap();
24641        for required in [
24642            "$allowedTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'repository', 'truncated')",
24643            "$requiredTopLevel = @('schema', 'source_provider', 'source_message_count', 'retained_messages', 'truncated')",
24644            "@('role', 'text')",
24645            "g4a-private-provider-session-canary",
24646            "private-provider-login-identity",
24647            "$rawProof",
24648            "$env:GATE4AGENT_HOOK_URL",
24649            "g4a-private-provider-session-canary",
24650        ] {
24651            assert!(codex_validation.contains(required), "missing privacy validation: {required}");
24652        }
24653        for provider in ["grok", "kimi"] {
24654            let provider = AgentId::new(provider).unwrap();
24655            assert_eq!(monitoring.get(&provider), standard.get(&provider));
24656        }
24657
24658        let config = NodeServerConfig::new(
24659            format!(
24660                r"\\.\pipe\gate4agent-monitoring-context-fixture-{}",
24661                std::process::id(),
24662            ),
24663            "fixture-token",
24664            NodeId::new("monitoring-context-fixture-node").unwrap(),
24665            [WorkspaceConfig::new(
24666                WorkspaceId::new("primary").unwrap(),
24667                std::env::current_dir().unwrap(),
24668            )
24669            .unwrap()],
24670        )
24671        .unwrap();
24672        let server = NodeServer::new_monitoring_context_pack_fixture(config, proof_path).unwrap();
24673        assert!(server.shared.fixture_semantic_hook_policy);
24674        drop(server);
24675        std::fs::remove_dir_all(root).unwrap();
24676    }
24677
24678    #[cfg(feature = "fixture")]
24679    #[test]
24680    fn kimi_context_only_fixture_is_bounded_and_preserves_codex_validation() {
24681        let root = temporary_workspace_root("kimi-context-only-fixture");
24682        std::fs::create_dir_all(&root).unwrap();
24683        let proof_path = root.join("context.proof");
24684        let standard = NodeServer::context_pack_fixture_catalog(
24685            proof_path.clone(),
24686            None,
24687            false,
24688        ).unwrap();
24689        let kimi = AgentId::new("kimi").unwrap();
24690        let context_only = NodeServer::context_pack_fixture_catalog(
24691            proof_path,
24692            Some(&kimi),
24693            false,
24694        )
24695        .unwrap();
24696
24697        let codex = AgentId::new("codex").unwrap();
24698        assert_eq!(
24699            standard.get(&codex).unwrap().launch.fixed_args,
24700            context_only.get(&codex).unwrap().launch.fixed_args,
24701        );
24702        let script = context_only
24703            .get(&kimi)
24704            .unwrap()
24705            .launch
24706            .fixed_args
24707            .iter()
24708            .find(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED"))
24709            .expect("Kimi context-only validation script is missing");
24710        for required in [
24711            "$bundleArgs.Count -ne 0",
24712            "GATE4AGENT_CONTEXT_ROOT",
24713            "context-pack.json",
24714            "$entries.Count -ne 1",
24715            "g4a-context-pack-v1",
24716            "$document.PSObject.Properties['cwd']",
24717            "'claude', 'codex', 'grok', 'kimi'",
24718            "$hasUser",
24719            "$hasAssistant",
24720            "SHA256",
24721            "context-only",
24722        ] {
24723            assert!(script.contains(required), "missing validation: {required}");
24724        }
24725        for forbidden in ["CODEX_HOME", "USERPROFILE", "auth", "token", "global config"] {
24726            assert!(!script.contains(forbidden), "forbidden home/auth access: {forbidden}");
24727        }
24728        assert!(!standard
24729            .get(&kimi)
24730            .unwrap()
24731            .launch
24732            .fixed_args
24733            .iter()
24734            .any(|argument| argument.contains("F7_KIMI_CONTEXT_ONLY_VALIDATED")));
24735
24736        std::fs::remove_dir_all(root).unwrap();
24737    }
24738
24739    #[cfg(all(feature = "fixture", windows))]
24740    #[tokio::test]
24741    async fn kimi_context_only_fixture_spawns_catalogued_context_pack_through_spawn_spec() {
24742        let root = temporary_workspace_root("kimi-context-only-spawn");
24743        let workspace_root = root.join("workspace");
24744        std::fs::create_dir_all(&workspace_root).unwrap();
24745        let materialization_root = root.join("private-materializations");
24746        let proof_path = root.join("context.proof");
24747        let node_id = NodeId::new("node-kimi-context-only-spawn").unwrap();
24748        let workspace_id = WorkspaceId::new("primary").unwrap();
24749        let profile_id = crate::protocol::SpawnProfileId::new("target-kimi").unwrap();
24750        let endpoint = format!(
24751            r"\\.\pipe\gate4agent-kimi-context-only-spawn-{}-{}",
24752            std::process::id(),
24753            unix_time_ms(),
24754        );
24755        let spawn_profiles = SpawnProfileRegistry::new([
24756            crate::protocol::SpawnProfileDefaults {
24757                profile_id: profile_id.clone(),
24758                revision: crate::protocol::SpawnProfileRevision::new("target-kimi-r1").unwrap(),
24759                provider: agent("kimi"),
24760                mode: SessionMode::Pty,
24761                terminal_size: gate4agent_types::TerminalSize {
24762                    rows: 30,
24763                    columns: 120,
24764                },
24765                prompt: None,
24766                bundle_id: None,
24767                context_id: None,
24768                environment_profile_id: None,
24769            },
24770        ])
24771        .unwrap();
24772        let mut config = NodeServerConfig::new(
24773            &endpoint,
24774            "fixture-token",
24775            node_id.clone(),
24776            [WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap()],
24777        )
24778        .unwrap()
24779        .with_spawn_profiles(spawn_profiles)
24780        .with_session_environment_materialization(
24781            materialization_root,
24782            Arc::new(FixtureSecretResolver {
24783                deny: Arc::new(AtomicBool::new(false)),
24784            }),
24785        )
24786        .unwrap();
24787        config.fixture_raw_pty_runtime = true;
24788        let server = NodeServer::new_context_only_proof_fixture(
24789            config,
24790            agent("kimi"),
24791            proof_path.clone(),
24792        )
24793        .unwrap();
24794        let shared = Arc::clone(&server.shared);
24795        let shutdown = server.shutdown_handle();
24796
24797        let history = HistorySessionRecord {
24798            session_id: "context-only-source-history".to_owned(),
24799            title: None,
24800            cwd: None,
24801            model: None,
24802            message_count: 2,
24803            completed_turn_count: None,
24804            total_tokens: 0,
24805            messages: vec![
24806                gate4agent_types::HistoryMessageRecord {
24807                    role: gate4agent_types::HistoryMessageRole::User,
24808                    text: "continue the bounded parent run through native C2".to_owned(),
24809                },
24810                gate4agent_types::HistoryMessageRecord {
24811                    role: gate4agent_types::HistoryMessageRole::Assistant,
24812                    text: "the exact parent context is ready for the child run".to_owned(),
24813                },
24814            ],
24815        };
24816        let pack = NodeContextPack::export(
24817            ContextPackLineageReceipt {
24818                source_node_id: node_id.clone(),
24819                source_session: SessionAddress {
24820                    workspace_id: workspace_id.clone(),
24821                    session: SessionKey {
24822                        instance_id: AgentInstanceId(71),
24823                        generation: SessionGeneration(1),
24824                    },
24825                },
24826                source_provider: agent("codex"),
24827            },
24828            &history,
24829        )
24830        .unwrap();
24831        let expected_context_hash = digest(&SHA256, pack.bytes())
24832            .as_ref()
24833            .iter()
24834            .map(|byte| format!("{byte:02x}"))
24835            .collect::<String>();
24836        let context = shared
24837            .context_catalog
24838            .write()
24839            .unwrap_or_else(|poisoned| poisoned.into_inner())
24840            .insert(pack)
24841            .unwrap();
24842        let spec = SpawnSpec {
24843            target: crate::protocol::SpawnTarget {
24844                node_id,
24845                workspace_id: workspace_id.clone(),
24846                worktree_id: None,
24847            },
24848            profile_id,
24849            expected_profile_revision: crate::protocol::SpawnProfileRevision::new(
24850                "target-kimi-r1",
24851            ).unwrap(),
24852            overrides: crate::protocol::SpawnOverrides {
24853                context_id: crate::protocol::SpawnOverride::Set {
24854                    value: context.id.clone(),
24855                },
24856                ..crate::protocol::SpawnOverrides::default()
24857            },
24858            deadline_ms: crate::protocol::SpawnDeadlineMs::new(10_000).unwrap(),
24859            idempotency_key: SpawnIdempotencyKey::new("kimi-context-only-spawn").unwrap(),
24860            required_capabilities: SpawnRequiredCapabilities::default(),
24861        };
24862        let resolved = match shared.resolve_spawn_spec(&spec) {
24863            Ok(resolved) => resolved,
24864            Err(error) => panic!(
24865                "Kimi context-only resolve rejected: exact_code={:?} message={}",
24866                error.code,
24867                error.message,
24868            ),
24869        };
24870        let resolved_context = match shared.resolve_context(&resolved) {
24871            Ok(Some(resolved_context)) => resolved_context,
24872            Ok(None) => panic!("Kimi context-only resolve omitted the exact ContextPack"),
24873            Err(error) => panic!(
24874                "Kimi context catalog resolve rejected: exact_code={:?} message={}",
24875                error.code,
24876                error.message,
24877            ),
24878        };
24879        assert_eq!(resolved_context, context);
24880        let runtime_policy = match shared
24881            .admit_provider_runtime(&agent("kimi"), ProviderRuntimeRequirement::RawPty)
24882            .await
24883        {
24884            Ok(runtime_policy) => runtime_policy,
24885            Err(error) => panic!(
24886                "Kimi RawPty admission rejected: exact_code={:?} message={}",
24887                error.code,
24888                error.message,
24889            ),
24890        };
24891        assert!(runtime_policy.raw_pty_lifecycle);
24892
24893        let launch_control = shared.native_launch_profile_control.as_ref().unwrap();
24894        let generic_overlay = NativeLaunchEnvironmentOverlay::new(
24895            agent("kimi"),
24896            TransportKind::Pty,
24897            vec![EnvMutation {
24898                key: OsString::from("GATE4AGENT_UNPROFILED_GENERIC"),
24899                value: Some(OsString::from("must-remain-rejected")),
24900            }],
24901        )
24902        .unwrap();
24903        assert_eq!(
24904            launch_control
24905                .install_native_launch_environment_overlay(
24906                    AgentInstanceId(72),
24907                    generic_overlay,
24908                )
24909                .unwrap_err(),
24910            gate4agent_runtime_native::NativeLaunchProfileError::EnvironmentOverlaySelectionMissing,
24911        );
24912
24913        let diagnostic_address = SessionAddress {
24914            workspace_id: workspace_id.clone(),
24915            session: SessionKey {
24916                instance_id: AgentInstanceId(73),
24917                generation: SessionGeneration(1),
24918            },
24919        };
24920        let (diagnostic_overlay, diagnostic_materialization) = match shared
24921            .prepare_session_materialization(
24922                &diagnostic_address,
24923                &agent("kimi"),
24924                SessionMode::Pty,
24925                None,
24926                None,
24927                Some(&resolved_context),
24928                None,
24929            )
24930        {
24931            Ok(Some(prepared)) => prepared,
24932            Ok(None) => panic!("Kimi context-only materialization produced no launch overlay"),
24933            Err(error) => panic!(
24934                "Kimi context-only materialization rejected: exact_code={:?} message={}",
24935                error.code,
24936                error.message,
24937            ),
24938        };
24939        let diagnostic_overlay = diagnostic_overlay
24940            .expect("Kimi context-only materialization omitted its environment overlay");
24941        assert!(matches!(
24942            &diagnostic_overlay,
24943            PreparedNativeLaunchOverlay::Environment(_),
24944        ));
24945        match shared.install_prepared_launch_overlay(
24946            diagnostic_address.session.instance_id,
24947            diagnostic_overlay,
24948        ) {
24949            Ok(None) => {}
24950            Ok(Some(_)) => panic!("Kimi context-only overlay unexpectedly retained argv authority"),
24951            Err(error) => panic!(
24952                "Kimi context-only overlay installation rejected: exact_code={:?} message={}",
24953                error.code,
24954                error.message,
24955            ),
24956        }
24957        assert!(launch_control.clear_native_instance_launch_overlay(
24958            diagnostic_address.session.instance_id,
24959        ));
24960        drop(diagnostic_materialization);
24961        assert!(shared
24962            .materializations
24963            .lock()
24964            .unwrap_or_else(|poisoned| poisoned.into_inner())
24965            .is_empty());
24966
24967        let node_task = tokio::spawn(server.run());
24968        let ready_client = timeout(Duration::from_secs(5), async {
24969            loop {
24970                match gate4agent_node_wire::LocalNodeClient::connect(
24971                    &endpoint,
24972                    &shared.node_id,
24973                    ClientRole::Observer,
24974                    "fixture-token",
24975                )
24976                .await
24977                {
24978                    Ok(client) => break client,
24979                    Err(_) if !node_task.is_finished() => sleep(Duration::from_millis(10)).await,
24980                    Err(error) => panic!("Kimi context-only Node stopped before readiness: {error}"),
24981                }
24982            }
24983        })
24984        .await
24985        .expect("Kimi context-only Node did not become ready");
24986        assert_eq!(
24987            ready_client
24988                .hello()
24989                .snapshot
24990                .provider_runtime_statuses
24991                .iter()
24992                .find(|status| status.provider().as_str() == "kimi")
24993                .expect("Kimi runtime status is missing")
24994                .mode(),
24995            crate::protocol::ProviderRuntimeMode::RawPassthrough,
24996        );
24997        drop(ready_client);
24998
24999        let receipt = match shared.spawn_from_spec(spec).await {
25000            Ok(receipt) => receipt,
25001            Err(error) => panic!(
25002                "Kimi context-only SpawnSpec rejected: exact_code={:?} message={}",
25003                error.code,
25004                error.message,
25005            ),
25006        };
25007        assert_eq!(receipt.provider, agent("kimi"));
25008        assert_eq!(receipt.context.as_ref(), Some(&context));
25009        assert!(receipt.context_binding_is_valid());
25010        timeout(Duration::from_secs(5), async {
25011            while !proof_path.is_file() {
25012                sleep(Duration::from_millis(10)).await;
25013            }
25014        })
25015        .await
25016        .expect("Kimi context-only fixture did not write its proof");
25017
25018        let proof = std::fs::read_to_string(&proof_path).unwrap();
25019        let lines = proof.lines().collect::<Vec<_>>();
25020        assert_eq!(lines.len(), 7);
25021        assert_eq!(lines[0], "context-only");
25022        assert!(Path::new(lines[1]).is_absolute());
25023        assert_ne!(
25024            std::fs::canonicalize(Path::new(lines[1])).unwrap(),
25025            std::fs::canonicalize(&workspace_root).unwrap(),
25026        );
25027        assert_eq!(
25028            std::fs::canonicalize(Path::new(lines[2])).unwrap(),
25029            std::fs::canonicalize(&workspace_root).unwrap(),
25030        );
25031        assert_eq!(lines[3], expected_context_hash);
25032        assert_eq!(lines[4], "g4a-context-pack-v1");
25033        assert_eq!(lines[5], "codex");
25034        assert_eq!(lines[6], "2");
25035        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25036            session.instance_id == receipt.session.session.instance_id
25037                && session.agent_id.as_str() == "kimi"
25038        }));
25039
25040        shutdown.request_shutdown().await.unwrap();
25041        timeout(Duration::from_secs(5), node_task)
25042            .await
25043            .unwrap()
25044            .unwrap()
25045            .unwrap();
25046        drop(shutdown);
25047        drop(shared);
25048        std::fs::remove_dir_all(root).unwrap();
25049    }
25050
25051    #[cfg(feature = "fixture")]
25052    #[test]
25053    fn clean_exit_fixture_constructor_is_fixture_scoped_and_path_bounded() {
25054        let root = temporary_workspace_root("clean-exit-constructor");
25055        std::fs::create_dir_all(&root).unwrap();
25056        let config = construction_test_config("clean-exit-constructor")
25057            .with_state_path(root.join("node-state.json"))
25058            .unwrap();
25059        let server = NodeServer::new_clean_exit_fixture(
25060            config,
25061            root.clone(),
25062            root.join("started.marker"),
25063            root.join("release.signal"),
25064        )
25065        .unwrap();
25066        assert_eq!(
25067            server
25068                .shared
25069                .enabled_providers
25070                .iter()
25071                .map(AgentId::as_str)
25072                .collect::<Vec<_>>(),
25073            ["claude"],
25074        );
25075        drop(server);
25076        std::fs::remove_dir_all(root).unwrap();
25077    }
25078
25079    #[test]
25080    fn production_registry_provider_contract_manifest_is_exact() {
25081        let registry = active_registry().unwrap();
25082        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25083        assert_eq!(
25084            providers
25085                .iter()
25086                .map(|contract| (contract.provider.as_str(), contract.revision.as_str()))
25087                .collect::<Vec<_>>(),
25088            vec![
25089                ("claude", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25090                ("codex", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25091                ("grok", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25092                ("kimi", "orca:d8629c41c832436463d5f0b4e4deb95f867fdc42"),
25093            ]
25094        );
25095        assert_eq!(
25096            adapters
25097                .iter()
25098                .map(|contract| (
25099                    contract.provider.as_str(),
25100                    contract.family,
25101                    contract.adapter_id.as_str(),
25102                    contract.revision.as_str(),
25103                ))
25104                .collect::<Vec<_>>(),
25105            vec![
25106                ("claude", AdapterFamily::PtySemantic, "claude-code", "gate4agent-adapter/v1"),
25107                ("claude", AdapterFamily::Pipe, "claude-code", "gate4agent-adapter/v1"),
25108                ("claude", AdapterFamily::Acp, "claude-code", "gate4agent-adapter/v1"),
25109                ("claude", AdapterFamily::OneShot, "claude", "gate4agent-inline/claude-code-2.1/v1"),
25110                ("claude", AdapterFamily::History, "claude-code", "gate4agent-adapter/v1"),
25111                ("claude", AdapterFamily::Resume, "claude-code", "gate4agent-adapter/v1"),
25112                ("claude", AdapterFamily::SessionOptions, "claude-code", "gate4agent-session-options/orca-d8629c4/v1"),
25113                ("codex", AdapterFamily::PtySemantic, "codex", "gate4agent-adapter/v1"),
25114                ("codex", AdapterFamily::Pipe, "codex", "gate4agent-adapter/v1"),
25115                ("codex", AdapterFamily::Acp, "codex", "gate4agent-adapter/v1"),
25116                ("codex", AdapterFamily::OneShot, "codex", "gate4agent-inline/codex-cli-0.144/v1"),
25117                ("codex", AdapterFamily::History, "codex", "gate4agent-adapter/v1"),
25118                ("codex", AdapterFamily::Resume, "codex", "gate4agent-adapter/v1"),
25119                ("codex", AdapterFamily::SessionOptions, "codex", "gate4agent-session-options/orca-d8629c4/v1"),
25120                ("grok", AdapterFamily::Acp, "grok", "gate4agent-adapter/v1"),
25121                ("grok", AdapterFamily::History, "grok", "gate4agent-adapter/v1"),
25122                ("grok", AdapterFamily::Resume, "grok", "gate4agent-adapter/v1"),
25123                ("kimi", AdapterFamily::PtySemantic, "kimi", "gate4agent-adapter/v1"),
25124                ("kimi", AdapterFamily::Pipe, "kimi", "gate4agent-adapter/v1"),
25125                ("kimi", AdapterFamily::Acp, "kimi", "gate4agent-adapter/v1"),
25126                ("kimi", AdapterFamily::OneShot, "kimi", "gate4agent-inline/kimi-code-0.31/v1"),
25127                ("kimi", AdapterFamily::History, "kimi", "gate4agent-adapter/v1"),
25128                ("kimi", AdapterFamily::Resume, "kimi", "gate4agent-adapter/v1"),
25129            ]
25130        );
25131    }
25132
25133    fn construction_test_config(name: &str) -> NodeServerConfig {
25134        let workspace = WorkspaceConfig::new(
25135            WorkspaceId::new("primary").unwrap(),
25136            std::env::current_dir().unwrap(),
25137        )
25138        .unwrap();
25139        NodeServerConfig::new(
25140            format!(r"\\.\pipe\gate4agent-{name}"),
25141            "fixture-token",
25142            NodeId::new(name).unwrap(),
25143            [workspace],
25144        )
25145        .unwrap()
25146    }
25147
25148    fn maximum_size_provider_manifest_registry() -> AgentRegistry {
25149        let adapter = AdapterBinding::new(
25150            gate4agent_types::AdapterId::new("a".repeat(96)).unwrap(),
25151            "r".repeat(crate::protocol::MAX_ADAPTER_CONTRACT_REVISION_BYTES),
25152            gate4agent_types::AdapterVerification::SyntheticFixture,
25153        )
25154        .unwrap();
25155        let agent_ids = ["claude", "codex", "kimi"]
25156            .into_iter()
25157            .map(|agent_id| AgentId::new(agent_id).unwrap())
25158            .collect::<Vec<_>>();
25159        let adapters = gate4agent_catalog::AdapterRegistry::new(
25160            [
25161                AdapterFamily::PtySemantic,
25162                AdapterFamily::Pipe,
25163                AdapterFamily::OneShot,
25164                AdapterFamily::Acp,
25165                AdapterFamily::Hook,
25166                AdapterFamily::ManagedHook,
25167                AdapterFamily::History,
25168                AdapterFamily::Resume,
25169                AdapterFamily::SessionOptions,
25170                AdapterFamily::CapabilityProbe,
25171            ]
25172            .into_iter()
25173            .map(|family| gate4agent_catalog::AdapterDescriptor {
25174                family,
25175                binding: adapter.clone(),
25176                agents: agent_ids.clone(),
25177            }),
25178        )
25179        .unwrap();
25180        let specs = agent_ids
25181            .iter()
25182            .map(|agent_id| {
25183                let agent_id = agent_id.as_str();
25184                let mut spec = builtin_registry().get_by_id(agent_id).unwrap().clone();
25185                spec.revision =
25186                    "r".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES);
25187                spec.capabilities.transports.pty_adapter = Some(adapter.clone());
25188                spec.capabilities.transports.pipe = Some(gate4agent_types::PipeTransportSpec {
25189                    adapter: adapter.clone(),
25190                    protocol: gate4agent_types::PipeProtocol::SemanticNdjson,
25191                    launch_override: None,
25192                    prompt_delivery: gate4agent_types::PipePromptDelivery::None,
25193                });
25194                spec.capabilities.transports.acp = Some(gate4agent_types::AcpTransportSpec {
25195                    adapter: adapter.clone(),
25196                    launch_override: None,
25197                });
25198                spec.capabilities.adapters.hook = Some(adapter.clone());
25199                spec.capabilities.adapters.managed_hook = Some(adapter.clone());
25200                spec.capabilities.adapters.one_shot = Some(adapter.clone());
25201                spec.capabilities.adapters.history = Some(adapter.clone());
25202                spec.capabilities.adapters.resume = Some(adapter.clone());
25203                spec.capabilities.adapters.session_options = Some(adapter.clone());
25204                spec.capabilities.adapters.capability_probe = Some(adapter.clone());
25205                spec
25206            })
25207            .collect::<Vec<_>>();
25208        AgentRegistry::new_with_adapters(specs, &adapters).unwrap()
25209    }
25210
25211    /// The real fleet's manifest is accepted by a real server construction.
25212    ///
25213    /// The count is asserted against `active_registry` itself rather than a
25214    /// literal, so this stays a test that construction succeeds for whatever
25215    /// the fleet is -- which is the property worth guarding -- instead of a
25216    /// second place the fleet size is written down and has to be kept in
25217    /// step with the first.
25218    #[test]
25219    fn node_server_construction_accepts_the_production_manifest() {
25220        let registry = active_registry().unwrap();
25221        let (providers, _) = provider_contract_manifest(&registry).unwrap();
25222        assert_eq!(providers.len(), registry.iter().count());
25223        assert!(!providers.is_empty());
25224        let server = NodeServer::new_with_registry(
25225            construction_test_config("manifest-production-bound"),
25226            registry,
25227        )
25228        .unwrap();
25229        drop(server);
25230    }
25231
25232    #[test]
25233    fn node_server_construction_rejects_count_valid_manifest_over_handshake_capacity() {
25234        let registry = maximum_size_provider_manifest_registry();
25235        let (providers, adapters) = provider_contract_manifest(&registry).unwrap();
25236        assert_eq!(providers.len(), 3);
25237        assert_eq!(adapters.len(), 30);
25238        assert!(adapters.len() <= crate::protocol::MAX_PROVIDER_ADAPTER_CONTRACTS);
25239        let error = match NodeServer::new_with_registry(
25240            construction_test_config("manifest-handshake-overflow"),
25241            registry,
25242        ) {
25243            Ok(_) => panic!("oversized negotiated manifest was accepted"),
25244            Err(error) => error,
25245        };
25246        assert!(matches!(
25247            error,
25248            NodeServerError::ProviderContractManifest(message)
25249                if message.contains("authentication binding length")
25250                    && message.contains("8192-byte limit")
25251        ));
25252    }
25253
25254    #[test]
25255    fn provider_contract_manifest_rejects_wire_revision_overflow_before_startup() {
25256        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25257        spec.revision = "x".repeat(crate::protocol::MAX_PROVIDER_CONTRACT_REVISION_BYTES + 1);
25258        let registry = AgentRegistry::new([spec]).unwrap();
25259        assert!(matches!(
25260            provider_contract_manifest(&registry),
25261            Err(NodeServerError::ProviderContractManifest(message))
25262                if message.contains("exceeds the 128-byte limit")
25263        ));
25264    }
25265
25266    #[test]
25267    fn provider_contract_manifest_rejects_invalid_wire_revision_before_startup() {
25268        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25269        spec.revision = "invalid revision".to_owned();
25270        let registry = AgentRegistry::new([spec]).unwrap();
25271        assert!(matches!(
25272            provider_contract_manifest(&registry),
25273            Err(NodeServerError::ProviderContractManifest(message))
25274                if message.contains("bounded lowercase ASCII")
25275        ));
25276    }
25277
25278    #[test]
25279    fn provider_contract_manifest_rejects_conflicting_duplicate_family_before_startup() {
25280        let mut spec = builtin_registry().get_by_id("claude").unwrap().clone();
25281        let transport_binding = spec.capabilities.adapters.one_shot.clone().unwrap();
25282        let conflicting_binding = builtin_registry()
25283            .get_by_id("codex")
25284            .unwrap()
25285            .capabilities
25286            .adapters
25287            .one_shot
25288            .clone()
25289            .unwrap();
25290        let pipe = spec.capabilities.transports.pipe.as_mut().unwrap();
25291        pipe.protocol = gate4agent_types::PipeProtocol::OneShotText;
25292        pipe.adapter = transport_binding;
25293        spec.capabilities.adapters.one_shot = Some(conflicting_binding);
25294        let registry = AgentRegistry::new([spec]).unwrap();
25295        assert!(matches!(
25296            provider_contract_manifest(&registry),
25297            Err(NodeServerError::ProviderContractManifest(message))
25298                if message.contains("conflicting OneShot adapter bindings")
25299        ));
25300    }
25301
25302    #[test]
25303    fn public_node_failures_expose_only_fixed_categories() {
25304        let provider = failure(
25305            NodeFailureCode::BackendOperationFailed,
25306            r"provider bearer-secret failed at C:\private\session.jsonl",
25307        );
25308        assert_eq!(provider.message, "backend-operation-failed");
25309        assert!(!provider.message.contains("bearer-secret"));
25310        assert!(!provider.message.contains(r"C:\private"));
25311        let persistence = persistence_failure(io::Error::new(
25312            io::ErrorKind::Other,
25313            r"state write failed at C:\private\state-v1.json",
25314        ));
25315        assert_eq!(persistence.message, DURABLE_STATE_COMMIT_FAILED_ERROR);
25316        assert!(!persistence.message.contains(r"C:\private"));
25317
25318        for kind in [io::ErrorKind::PermissionDenied, io::ErrorKind::Unsupported] {
25319            let generic = durable_state_load_error(io::Error::new(kind, "host I/O failure"));
25320            assert_eq!(
25321                generic.to_string(),
25322                "node durable state failed: durable-state-load-failed",
25323            );
25324        }
25325    }
25326
25327    #[test]
25328    fn terminal_byte_sequences_are_bounded_before_dispatch() {
25329        let sequence = b"\x1b[1;5D".to_vec();
25330        assert_eq!(
25331            terminal_bytes_action(sequence.clone()).unwrap(),
25332            InputAction::TerminalBytes(sequence),
25333        );
25334        assert_eq!(
25335            terminal_bytes_action(Vec::new()).unwrap_err().code,
25336            NodeFailureCode::InvalidRequest,
25337        );
25338        assert_eq!(
25339            terminal_bytes_action(vec![0; MAX_NODE_TERMINAL_BYTES + 1])
25340                .unwrap_err()
25341                .code,
25342            NodeFailureCode::InvalidRequest,
25343        );
25344    }
25345
25346    #[test]
25347    fn node_server_rejects_a_second_owner_of_the_same_durable_state_path() {
25348        let root = temporary_workspace_root("exclusive-state-owner");
25349        std::fs::create_dir_all(&root).unwrap();
25350        let workspace = WorkspaceConfig::new(
25351            WorkspaceId::new("primary").unwrap(),
25352            &root,
25353        )
25354        .unwrap();
25355        let state_path = root.join("state-v1.json");
25356        let endpoint = format!(
25357            r"\\.\pipe\gate4agent-node-state-lock-{}-{}",
25358            std::process::id(),
25359            std::time::SystemTime::now()
25360                .duration_since(std::time::UNIX_EPOCH)
25361                .unwrap()
25362                .as_nanos(),
25363        );
25364        let config = NodeServerConfig::new(
25365            endpoint,
25366            "fixture-token",
25367            NodeId::new("node-1").unwrap(),
25368            [workspace],
25369        )
25370        .unwrap()
25371        .with_state_path(&state_path)
25372        .unwrap();
25373
25374        let first = NodeServer::new(config.clone()).unwrap();
25375        let error = match NodeServer::new(config.clone()) {
25376            Ok(_) => panic!("a second node server acquired the same durable state path"),
25377            Err(error) => error,
25378        };
25379        assert!(matches!(
25380            &error,
25381            NodeServerError::DurableState(error)
25382                if error.kind() == io::ErrorKind::WouldBlock
25383        ));
25384        assert_eq!(
25385            error.to_string(),
25386            "node durable state failed: durable-state-lock-failed",
25387        );
25388        assert!(!error.to_string().contains(&state_path.to_string_lossy().into_owned()));
25389        drop(first);
25390        let second = NodeServer::new(config).unwrap();
25391        drop(second);
25392        std::fs::remove_dir_all(root).unwrap();
25393    }
25394
25395    #[test]
25396    fn session_binding_is_workspace_scoped_and_removed_fail_closed() {
25397        let catalog = active_registry().unwrap();
25398        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25399        let workspace_id = WorkspaceId::new("primary").unwrap();
25400        let workspace = WorkspaceConfig::new(
25401            workspace_id.clone(),
25402            std::env::current_dir().unwrap(),
25403        )
25404        .unwrap();
25405        let shared = NodeShared::new(
25406            handle,
25407            "fixture-token".to_owned(),
25408            NodeId::new("node-1").unwrap(),
25409            vec![workspace.clone()],
25410            vec![agent("claude"), agent("codex"), agent("kimi")],
25411        );
25412        let instance_id = AgentInstanceId(17);
25413        let generation = SessionGeneration(3);
25414        assert!(shared.address_for(instance_id, generation).is_none());
25415        shared.bind_session(instance_id, workspace_id.clone(), generation);
25416        let address = SessionAddress {
25417            workspace_id,
25418            session: SessionKey { instance_id, generation },
25419        };
25420        assert_eq!(
25421            shared.workspace_root(&address.workspace_id).unwrap(),
25422            workspace.canonical_root,
25423        );
25424        assert_eq!(shared.address_for(instance_id, generation), Some(address.clone()));
25425        shared.remove_binding(&address);
25426        assert!(shared.address_for(instance_id, generation).is_none());
25427    }
25428
25429    #[test]
25430    fn open_provider_request_gate_is_state_aware_and_fail_closed() {
25431        let address = SessionAddress {
25432            workspace_id: WorkspaceId::new("primary").unwrap(),
25433            session: SessionKey {
25434                instance_id: AgentInstanceId(7),
25435                generation: SessionGeneration(2),
25436            },
25437        };
25438        let record_id = SessionRecordId::new("record-a").unwrap();
25439        let size = gate4agent_types::TerminalSize {
25440            rows: 24,
25441            columns: 80,
25442        };
25443        let address_requests = [
25444            NodeRequest::Resume {
25445                session: address.clone(),
25446                terminal_size: size,
25447                initial_prompt: None,
25448            },
25449            NodeRequest::Prompt { session: address.clone(), text: "p".to_owned() },
25450            NodeRequest::Paste { session: address.clone(), text: "p".to_owned() },
25451            NodeRequest::Input { session: address.clone(), text: "p".to_owned() },
25452            NodeRequest::TerminalBytes { session: address.clone(), bytes: vec![1] },
25453            NodeRequest::TerminalControl {
25454                session: address.clone(),
25455                control: TerminalControl::Enter,
25456            },
25457            NodeRequest::Resize { session: address.clone(), size },
25458            NodeRequest::Interrupt { session: address.clone() },
25459            NodeRequest::Stop { session: address.clone(), force: false },
25460            NodeRequest::Remove { session: address },
25461        ];
25462        for request in &address_requests {
25463            assert!(request_requires_open_provider_ids_with(
25464                request,
25465                |_| Some(agent("third-party-agent")),
25466                |_| Some(agent("claude")),
25467            ));
25468            assert!(!request_requires_open_provider_ids_with(
25469                request,
25470                |_| Some(agent("claude")),
25471                |_| Some(agent("claude")),
25472            ));
25473            assert!(request_requires_open_provider_ids_with(
25474                request,
25475                |_| None,
25476                |_| Some(agent("claude")),
25477            ));
25478        }
25479
25480        let record_requests = [
25481            NodeRequest::RenameSessionRecord {
25482                record_id: record_id.clone(),
25483                display_name: "renamed".to_owned(),
25484            },
25485            NodeRequest::ResumeSessionRecord {
25486                record_id: record_id.clone(),
25487                terminal_size: size,
25488                initial_prompt: None,
25489            },
25490            NodeRequest::ForgetSessionRecord { record_id },
25491        ];
25492        for request in &record_requests {
25493            assert!(request_requires_open_provider_ids_with(
25494                request,
25495                |_| Some(agent("claude")),
25496                |_| Some(agent("grok")),
25497            ));
25498            assert!(!request_requires_open_provider_ids_with(
25499                request,
25500                |_| Some(agent("claude")),
25501                |_| Some(agent("kimi")),
25502            ));
25503            assert!(request_requires_open_provider_ids_with(
25504                request,
25505                |_| Some(agent("claude")),
25506                |_| None,
25507            ));
25508        }
25509
25510        assert!(request_requires_open_provider_ids_with(
25511            &NodeRequest::Spawn {
25512                workspace_id: WorkspaceId::new("primary").unwrap(),
25513                provider: agent("third-party-agent"),
25514                mode: SessionMode::Pty,
25515                terminal_size: size,
25516                initial_prompt: None,
25517            },
25518            |_| None,
25519            |_| None,
25520        ));
25521        assert!(!request_requires_open_provider_ids_with(
25522            &NodeRequest::Snapshot,
25523            |_| None,
25524            |_| None,
25525        ));
25526        assert!(request_requires_open_provider_ids_with(
25527            &NodeRequest::ForgetContextPack {
25528                context_id: SpawnContextId::new("ctx-forget").unwrap(),
25529            },
25530            |_| None,
25531            |_| None,
25532        ));
25533    }
25534
25535    #[test]
25536    fn legacy_projection_filters_open_record_replies_and_event_history() {
25537        let catalog = active_registry().unwrap();
25538        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25539        let workspace = WorkspaceConfig::new(
25540            WorkspaceId::new("primary").unwrap(),
25541            std::env::current_dir().unwrap(),
25542        )
25543        .unwrap();
25544        let shared = NodeShared::new(
25545            handle,
25546            "fixture-token".to_owned(),
25547            NodeId::new("node-1").unwrap(),
25548            vec![workspace],
25549            vec![agent("claude"), agent("grok")],
25550        );
25551        let legacy = record("claude", "legacy-record");
25552        let open = record("grok", "open-record");
25553        let legacy_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25554            record: legacy.clone(),
25555        });
25556        let open_upsert = shared.publish(NodeEvent::SessionRecordUpserted {
25557            record: open.clone(),
25558        });
25559        let legacy_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25560            record_id: legacy.record_id.clone(),
25561        });
25562        let open_removed = shared.publish(NodeEvent::SessionRecordRemoved {
25563            record_id: open.record_id.clone(),
25564        });
25565        assert!(project_event_legacy_provider_ids(&shared, legacy_upsert.clone()).is_some());
25566        assert!(project_event_legacy_provider_ids(&shared, open_upsert.clone()).is_none());
25567        assert!(project_event_legacy_provider_ids(&shared, legacy_removed.clone()).is_some());
25568        assert!(project_event_legacy_provider_ids(&shared, open_removed.clone()).is_none());
25569
25570        let mut record_reply = ResponseEnvelope {
25571            request_id: 1,
25572            result: Ok(NodeResponse::SessionRecordUpdated { record: open }),
25573        };
25574        project_response_legacy_provider_ids(&shared, &mut record_reply);
25575        assert!(matches!(
25576            record_reply.result,
25577            Err(NodeFailure { code: NodeFailureCode::UnsupportedCapability, .. })
25578        ));
25579
25580        let mut resync = ResponseEnvelope {
25581            request_id: 2,
25582            result: Ok(NodeResponse::Resync {
25583                event_sequence: open_removed.sequence,
25584                oldest_available_sequence: 1,
25585                snapshot: shared.snapshot(),
25586                events: vec![legacy_upsert, open_upsert, legacy_removed, open_removed],
25587            }),
25588        };
25589        project_response_legacy_provider_ids(&shared, &mut resync);
25590        let Ok(NodeResponse::Resync { snapshot, events, .. }) = resync.result else {
25591            panic!("legacy projection changed the resync response kind");
25592        };
25593        assert!(snapshot.session_records.iter().all(|item| {
25594            provider_id_is_legacy(&item.provider)
25595        }));
25596        assert_eq!(events.len(), 2);
25597        assert!(events.iter().all(|event| matches!(
25598            &event.event,
25599            NodeEvent::SessionRecordUpserted { record }
25600                if provider_id_is_legacy(&record.provider)
25601        ) || matches!(&event.event, NodeEvent::SessionRecordRemoved { .. })));
25602    }
25603
25604    #[test]
25605    fn session_binding_capacity_is_bounded_by_the_control_session_limit() {
25606        let catalog = active_registry().unwrap();
25607        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25608        let workspace_id = WorkspaceId::new("primary").unwrap();
25609        let workspace = WorkspaceConfig::new(
25610            workspace_id.clone(),
25611            std::env::current_dir().unwrap(),
25612        )
25613        .unwrap();
25614        let shared = NodeShared::new(
25615            handle,
25616            "fixture-token".to_owned(),
25617            NodeId::new("node-1").unwrap(),
25618            vec![workspace],
25619            vec![agent("claude")],
25620        );
25621        for index in 0..CONTROL_SESSIONS_MAX {
25622            shared.bind_session(
25623                AgentInstanceId((index + 1) as u64),
25624                workspace_id.clone(),
25625                SessionGeneration(1),
25626            );
25627        }
25628        let error = shared.ensure_binding_capacity().unwrap_err();
25629        assert_eq!(error.code, NodeFailureCode::BackendBusy);
25630    }
25631
25632    #[tokio::test]
25633    async fn provider_spawn_admission_rejects_before_session_mutation() {
25634        let catalog = active_registry().unwrap();
25635        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25636        let workspace = WorkspaceConfig::new(
25637            WorkspaceId::new("primary").unwrap(),
25638            std::env::current_dir().unwrap(),
25639        )
25640        .unwrap();
25641        let mut shared = NodeShared::new(
25642            handle,
25643            "fixture-token".to_owned(),
25644            NodeId::new("node-provider-admission").unwrap(),
25645            vec![workspace],
25646            vec![agent("claude")],
25647        );
25648        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25649            crate::protocol::ProviderRuntimeStatus::unavailable(agent("claude")),
25650        ])
25651        .unwrap();
25652        let terminal_size = gate4agent_types::TerminalSize {
25653            rows: 24,
25654            columns: 80,
25655        };
25656
25657        let unavailable = shared
25658            .spawn_session(
25659                WorkspaceId::new("primary").unwrap(),
25660                agent("claude"),
25661                SessionMode::Pty,
25662                terminal_size,
25663                None,
25664            )
25665            .await
25666            .unwrap_err();
25667        assert_eq!(unavailable.code, NodeFailureCode::BackendOperationFailed);
25668        assert_eq!(unavailable.message, "backend-operation-failed");
25669
25670        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25671            crate::protocol::ProviderRuntimeStatus::raw_passthrough(
25672                agent("claude"),
25673                Some(crate::protocol::ProviderRuntimeVersion::new("999.0.0").unwrap()),
25674            ),
25675        ])
25676        .unwrap();
25677        let semantic = shared
25678            .spawn_session(
25679                WorkspaceId::new("primary").unwrap(),
25680                agent("claude"),
25681                SessionMode::Inline,
25682                terminal_size,
25683                None,
25684            )
25685            .await
25686            .unwrap_err();
25687        assert_eq!(semantic.code, NodeFailureCode::UnsupportedCapability);
25688        assert_eq!(semantic.message, "unsupported-capability");
25689
25690        assert_eq!(shared.next_instance_id.load(Ordering::Acquire), 1);
25691        assert!(shared
25692            .session_bindings
25693            .lock()
25694            .unwrap_or_else(|poisoned| poisoned.into_inner())
25695            .is_empty());
25696        assert!(shared
25697            .session_records
25698            .lock()
25699            .unwrap_or_else(|poisoned| poisoned.into_inner())
25700            .records
25701            .is_empty());
25702        assert!(shared.handle.snapshot().sessions.is_empty());
25703    }
25704
25705    #[tokio::test]
25706    async fn raw_spawn_binding_is_visible_with_a_durable_live_record() {
25707        let catalog = active_registry().unwrap();
25708        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25709        let workspace_id = WorkspaceId::new("primary").unwrap();
25710        let workspace = WorkspaceConfig::new(
25711            workspace_id.clone(),
25712            std::env::current_dir().unwrap(),
25713        )
25714        .unwrap();
25715        let shared = NodeShared::new(
25716            handle,
25717            "fixture-token".to_owned(),
25718            NodeId::new("node-raw-spawn-record").unwrap(),
25719            vec![workspace],
25720            vec![agent("claude")],
25721        );
25722        let address = SessionAddress {
25723            workspace_id,
25724            session: SessionKey {
25725                instance_id: AgentInstanceId(71),
25726                generation: SessionGeneration::default(),
25727            },
25728        };
25729
25730        let record_id = shared
25731            .bind_spawn_session(
25732                &address,
25733                agent("claude"),
25734                SessionMode::Pty,
25735                ProviderRuntimePolicy::raw_pty(),
25736                SpawnRecordPolicy::Always,
25737                None,
25738            )
25739            .unwrap()
25740            .unwrap();
25741        shared
25742            .handle
25743            .dispatch(shared.prepare_command(ControlCommand::Register {
25744                instance_id: address.session.instance_id,
25745                agent_id: agent("claude"),
25746                transport: TransportKind::Pty,
25747            }))
25748            .unwrap();
25749        runtime.tick().await;
25750
25751        assert!(shared.handle.snapshot().sessions.iter().any(|session| {
25752            session.instance_id == address.session.instance_id
25753                && session.generation == address.session.generation
25754        }));
25755        let record = shared.record(&record_id).unwrap();
25756        assert_eq!(record.display_name, "claude #1");
25757        assert_eq!(record.state, ManagedSessionState::Live);
25758        assert_eq!(record.active_session.as_ref(), Some(&address));
25759        assert!(record.provider_session.is_none());
25760        let binding_record_id = shared
25761            .session_bindings
25762            .lock()
25763            .unwrap_or_else(|poisoned| poisoned.into_inner())
25764            .get(&address.session.instance_id)
25765            .and_then(|binding| binding.record_id.clone());
25766        assert_eq!(binding_record_id.as_ref(), Some(&record_id));
25767
25768        let renamed = shared
25769            .rename_session_record(&record_id, "raw review".to_owned())
25770            .unwrap();
25771        assert_eq!(renamed.record_id, record_id);
25772        assert_eq!(renamed.display_name, "raw review");
25773        shared.reconcile_managed_record(
25774            &record_id,
25775            &address,
25776            &ControlEvent {
25777                sequence: 1,
25778                command_id: None,
25779                instance_id: address.session.instance_id,
25780                generation: address.session.generation,
25781                event: ControlEventKind::Running { process_id: None },
25782            },
25783            false,
25784            false,
25785        );
25786        let running = shared.record(&record_id).unwrap();
25787        assert_eq!(running.state, ManagedSessionState::Live);
25788        assert_eq!(running.active_session.as_ref(), Some(&address));
25789        assert_eq!(running.display_name, "raw review");
25790
25791        shared.reconcile_managed_record(
25792            &record_id,
25793            &address,
25794            &ControlEvent {
25795                sequence: 2,
25796                command_id: None,
25797                instance_id: address.session.instance_id,
25798                generation: address.session.generation,
25799                event: ControlEventKind::Exited {
25800                    exit_code: Some(0),
25801                    forced: false,
25802                },
25803            },
25804            false,
25805            false,
25806        );
25807        let detached = shared.record(&record_id).unwrap();
25808        assert_eq!(detached.state, ManagedSessionState::Unavailable);
25809        assert!(detached.active_session.is_none());
25810        assert_eq!(detached.display_name, "raw review");
25811        let renamed_detached = shared
25812            .rename_session_record(&record_id, "raw review detached".to_owned())
25813            .unwrap();
25814        assert_eq!(renamed_detached.record_id, record_id);
25815        assert_eq!(renamed_detached.state, ManagedSessionState::Unavailable);
25816        assert!(renamed_detached.active_session.is_none());
25817        assert_eq!(renamed_detached.display_name, "raw review detached");
25818        assert_eq!(shared.snapshot().session_records, vec![renamed_detached]);
25819    }
25820
25821    #[test]
25822    fn legacy_raw_spawn_binding_remains_untracked() {
25823        let catalog = active_registry().unwrap();
25824        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25825        let workspace_id = WorkspaceId::new("primary").unwrap();
25826        let workspace = WorkspaceConfig::new(
25827            workspace_id.clone(),
25828            std::env::current_dir().unwrap(),
25829        )
25830        .unwrap();
25831        let shared = NodeShared::new(
25832            handle,
25833            "fixture-token".to_owned(),
25834            NodeId::new("node-legacy-raw-spawn").unwrap(),
25835            vec![workspace],
25836            vec![agent("claude")],
25837        );
25838        let address = SessionAddress {
25839            workspace_id,
25840            session: SessionKey {
25841                instance_id: AgentInstanceId(72),
25842                generation: SessionGeneration::default(),
25843            },
25844        };
25845
25846        let record_id = shared
25847            .bind_spawn_session(
25848                &address,
25849                agent("claude"),
25850                SessionMode::Pty,
25851                ProviderRuntimePolicy::raw_pty(),
25852                SpawnRecordPolicy::ProviderIdentityOnly,
25853                None,
25854            )
25855            .unwrap();
25856
25857        assert!(record_id.is_none());
25858        let binding_record_id = shared
25859            .session_bindings
25860            .lock()
25861            .unwrap_or_else(|poisoned| poisoned.into_inner())
25862            .get(&address.session.instance_id)
25863            .and_then(|binding| binding.record_id.clone());
25864        assert!(binding_record_id.is_none());
25865        assert!(shared.snapshot().session_records.is_empty());
25866    }
25867
25868    #[tokio::test]
25869    async fn raw_runtime_policy_rejects_prompt_paste_and_resume_before_mutation() {
25870        let catalog = active_registry().unwrap();
25871        let (handle, mut runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25872        let workspace_id = WorkspaceId::new("primary").unwrap();
25873        let workspace = WorkspaceConfig::new(
25874            workspace_id.clone(),
25875            std::env::current_dir().unwrap(),
25876        )
25877        .unwrap();
25878        let mut shared = NodeShared::new(
25879            handle,
25880            "fixture-token".to_owned(),
25881            NodeId::new("node-raw-semantic-gate").unwrap(),
25882            vec![workspace],
25883            vec![agent("claude")],
25884        );
25885        shared.provider_runtime_statuses = ProviderRuntimeStatuses::new([
25886            crate::protocol::ProviderRuntimeStatus::raw_passthrough(agent("claude"), None),
25887        ])
25888        .unwrap();
25889        shared
25890            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25891            .unwrap();
25892        let address = SessionAddress {
25893            workspace_id,
25894            session: SessionKey {
25895                instance_id: AgentInstanceId(72),
25896                generation: SessionGeneration::default(),
25897            },
25898        };
25899        shared.bind_session_with_policy(
25900            &address,
25901            ProviderRuntimePolicy::raw_pty(),
25902            None,
25903        );
25904        shared
25905            .handle
25906            .dispatch(shared.prepare_command(ControlCommand::Register {
25907                instance_id: address.session.instance_id,
25908                agent_id: agent("claude"),
25909                transport: TransportKind::Pty,
25910            }))
25911            .unwrap();
25912        runtime.tick().await;
25913        let command_id_before = shared.next_command_id.load(Ordering::Acquire);
25914        let revision_before = shared.handle.snapshot().revision;
25915        let history_sequence_before = shared.current_sequence();
25916        let terminal_size = gate4agent_types::TerminalSize {
25917            rows: 24,
25918            columns: 80,
25919        };
25920
25921        for request in [
25922            NodeRequest::Prompt {
25923                session: address.clone(),
25924                text: "prompt".to_owned(),
25925            },
25926            NodeRequest::Paste {
25927                session: address.clone(),
25928                text: "paste".to_owned(),
25929            },
25930            // `initial_prompt: Some(_)` selects `ResumeWithPrompt`, which needs
25931            // the same semantic capabilities as `Prompt`/`Paste`. A bare
25932            // (promptless) resume is a raw PTY relaunch and IS admitted under
25933            // `raw_pty()` — see `raw_pty_admits_provider_native_resume_without_prompt_only`
25934            // in `provider_runtime.rs`; it is deliberately not exercised here.
25935            NodeRequest::Resume {
25936                session: address.clone(),
25937                terminal_size,
25938                initial_prompt: Some("resume".to_owned()),
25939            },
25940        ] {
25941            let error = process_request_inner(
25942                &shared,
25943                77,
25944                ClientRole::Operator,
25945                request,
25946            )
25947            .await
25948            .unwrap_err();
25949            assert_eq!(error.code, NodeFailureCode::UnsupportedCapability);
25950        }
25951
25952        assert_eq!(shared.next_command_id.load(Ordering::Acquire), command_id_before);
25953        assert_eq!(shared.handle.snapshot().revision, revision_before);
25954        assert_eq!(shared.current_sequence(), history_sequence_before);
25955        let bindings = shared
25956            .session_bindings
25957            .lock()
25958            .unwrap_or_else(|poisoned| poisoned.into_inner());
25959        let binding = bindings.get(&address.session.instance_id).unwrap();
25960        assert_eq!(binding.generation, address.session.generation);
25961        assert!(binding.pending_resume.is_none());
25962        assert!(binding.record_id.is_none());
25963    }
25964
25965    #[tokio::test]
25966    async fn windows_rejects_unix_bytes_workspace_path_before_filesystem_access() {
25967        let catalog = active_registry().unwrap();
25968        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
25969        let primary = WorkspaceConfig::new(
25970            WorkspaceId::new("primary").unwrap(),
25971            std::env::current_dir().unwrap(),
25972        )
25973        .unwrap();
25974        let shared = NodeShared::new(
25975            handle,
25976            "fixture-token".to_owned(),
25977            NodeId::new("node-1").unwrap(),
25978            vec![primary],
25979            vec![agent("claude")],
25980        );
25981        shared
25982            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
25983            .unwrap();
25984        let before = shared.snapshot();
25985        let error = process_request_inner(
25986            &shared,
25987            77,
25988            ClientRole::Operator,
25989            NodeRequest::RegisterWorkspace {
25990                workspace_id: WorkspaceId::new("foreign").unwrap(),
25991                root: OpaqueHostPath::unix_bytes(b"/srv/repo".to_vec()).unwrap(),
25992            },
25993        )
25994        .await
25995        .unwrap_err();
25996        assert_eq!(error.code, NodeFailureCode::InvalidRequest);
25997        assert_eq!(error.message, "invalid-request");
25998        assert_eq!(shared.snapshot().workspaces, before.workspaces);
25999    }
26000
26001    #[tokio::test]
26002    async fn dynamic_workspaces_reject_duplicates_busy_and_last_then_publish_ordered_events() {
26003        let catalog = active_registry().unwrap();
26004        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26005        let primary_id = WorkspaceId::new("primary").unwrap();
26006        let primary = WorkspaceConfig::new(
26007            primary_id.clone(),
26008            std::env::current_dir().unwrap(),
26009        )
26010        .unwrap();
26011        let shared = NodeShared::new(
26012            handle,
26013            "fixture-token".to_owned(),
26014            NodeId::new("node-1").unwrap(),
26015            vec![primary.clone()],
26016            vec![agent("claude")],
26017        );
26018        let secondary_id = WorkspaceId::new("secondary").unwrap();
26019        let secondary = shared
26020            .register_workspace(
26021                secondary_id.clone(),
26022                std::env::temp_dir().to_string_lossy().into_owned(),
26023            )
26024            .await
26025            .unwrap();
26026        assert_eq!(secondary.workspace_id, secondary_id);
26027        assert!(secondary.sessions.is_empty());
26028        assert_eq!(shared.snapshot().workspaces.len(), 2);
26029
26030        let duplicate_id = shared
26031            .register_workspace(
26032                secondary_id.clone(),
26033                primary.canonical_root().to_owned(),
26034            )
26035            .await
26036            .unwrap_err();
26037        assert_eq!(duplicate_id.code, NodeFailureCode::DuplicateWorkspaceId);
26038        let duplicate_root = shared
26039            .register_workspace(
26040                WorkspaceId::new("duplicate-root").unwrap(),
26041                windows_path_text(&secondary.canonical_root).to_owned(),
26042            )
26043            .await
26044            .unwrap_err();
26045        assert_eq!(duplicate_root.code, NodeFailureCode::DuplicateWorkspaceRoot);
26046
26047        let address = SessionAddress {
26048            workspace_id: secondary_id.clone(),
26049            session: SessionKey {
26050                instance_id: AgentInstanceId(41),
26051                generation: SessionGeneration(1),
26052            },
26053        };
26054        shared.bind_session(
26055            address.session.instance_id,
26056            secondary_id.clone(),
26057            address.session.generation,
26058        );
26059        let busy = shared.unregister_workspace(&secondary_id).unwrap_err();
26060        assert_eq!(busy.code, NodeFailureCode::WorkspaceBusy);
26061        shared.remove_binding(&address);
26062        shared.unregister_workspace(&secondary_id).unwrap();
26063        assert_eq!(shared.snapshot().workspaces.len(), 1);
26064
26065        let last = shared.unregister_workspace(&primary_id).unwrap_err();
26066        assert_eq!(last.code, NodeFailureCode::LastWorkspace);
26067        let history = shared
26068            .history
26069            .lock()
26070            .unwrap_or_else(|poisoned| poisoned.into_inner());
26071        assert!(matches!(
26072            &history.events[0],
26073            NodeEventEnvelope {
26074                sequence: 1,
26075                event: NodeEvent::WorkspaceAdded { workspace },
26076            } if workspace.workspace_id == secondary_id
26077        ));
26078        assert!(matches!(
26079            &history.events[1],
26080            NodeEventEnvelope {
26081                sequence: 2,
26082                event: NodeEvent::WorkspaceRemoved { workspace_id },
26083            } if workspace_id == &secondary_id
26084        ));
26085    }
26086
26087    #[tokio::test]
26088    async fn provider_session_reference_index_is_controller_gated_and_idempotent() {
26089        let secondary_root = temporary_workspace_root("provider-session-reference-index");
26090        std::fs::create_dir_all(&secondary_root).unwrap();
26091        let catalog = active_registry().unwrap();
26092        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26093        let primary_id = WorkspaceId::new("primary").unwrap();
26094        let secondary_id = WorkspaceId::new("secondary").unwrap();
26095        let shared = NodeShared::new(
26096            handle,
26097            "fixture-token".to_owned(),
26098            NodeId::new("node-session-reference-index").unwrap(),
26099            vec![
26100                WorkspaceConfig::new(primary_id.clone(), std::env::current_dir().unwrap())
26101                    .unwrap(),
26102                WorkspaceConfig::new(secondary_id.clone(), &secondary_root).unwrap(),
26103            ],
26104            vec![agent("claude"), agent("codex")],
26105        );
26106        let identity = ProviderSessionIdentity {
26107            key: gate4agent_types::ProviderSessionKey::SessionId,
26108            id: "provider-session-42".to_owned(),
26109            transcript_path: None,
26110        };
26111        let index_request = |workspace_id: WorkspaceId,
26112                             provider: AgentId,
26113                             identity: ProviderSessionIdentity,
26114                             display_name: &str| {
26115            NodeRequest::IndexProviderSession {
26116                workspace_id,
26117                provider,
26118                identity,
26119                display_name: display_name.to_owned(),
26120            }
26121        };
26122        let request = index_request(
26123            primary_id.clone(),
26124            agent("claude"),
26125            identity.clone(),
26126            "release shepherd",
26127        );
26128        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26129            capability.as_str() == NODE_PROVIDER_SESSION_REFERENCE_INDEX_CAPABILITY
26130        }));
26131        let observer = process_request_inner(
26132            &shared,
26133            77,
26134            ClientRole::Observer,
26135            request.clone(),
26136        )
26137        .await
26138        .unwrap_err();
26139        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26140        let uncontrolled = process_request_inner(
26141            &shared,
26142            77,
26143            ClientRole::Operator,
26144            request.clone(),
26145        )
26146        .await
26147        .unwrap_err();
26148        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26149        shared
26150            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26151            .unwrap();
26152
26153        let indexed = process_request_inner(
26154            &shared,
26155            77,
26156            ClientRole::Operator,
26157            request.clone(),
26158        )
26159        .await
26160        .unwrap();
26161        let NodeResponse::ProviderSessionIndexed { record } = indexed else {
26162            panic!("provider session reference index returned another response");
26163        };
26164        assert_eq!(record.state, ManagedSessionState::Dormant);
26165        assert_eq!(record.mode, SessionMode::Pty);
26166        assert_eq!(record.workspace_id, primary_id);
26167        assert_eq!(record.provider_session.as_ref(), Some(&identity));
26168        assert!(record.active_session.is_none());
26169        assert!(record.environment_profile.is_none());
26170        assert!(record.bundle.is_none());
26171        assert!(record.context.is_none());
26172
26173        let repeated = process_request_inner(
26174            &shared,
26175            77,
26176            ClientRole::Operator,
26177            index_request(
26178                primary_id.clone(),
26179                agent("claude"),
26180                identity.clone(),
26181                "ignored replacement",
26182            ),
26183        )
26184        .await
26185        .unwrap();
26186        assert!(matches!(
26187            repeated,
26188            NodeResponse::ProviderSessionIndexed { record: ref existing }
26189                if existing.record_id == record.record_id
26190                    && existing.display_name == "release shepherd"
26191        ));
26192        let conflicting_workspace = process_request_inner(
26193            &shared,
26194            77,
26195            ClientRole::Operator,
26196            index_request(
26197                secondary_id,
26198                agent("claude"),
26199                identity.clone(),
26200                "release shepherd",
26201            ),
26202        )
26203        .await
26204        .unwrap_err();
26205        assert_eq!(conflicting_workspace.code, NodeFailureCode::SessionRecordConflict);
26206        let conflicting_provider = process_request_inner(
26207            &shared,
26208            77,
26209            ClientRole::Operator,
26210            index_request(
26211                primary_id.clone(),
26212                agent("codex"),
26213                identity.clone(),
26214                "release shepherd",
26215            ),
26216        )
26217        .await
26218        .unwrap_err();
26219        assert_eq!(conflicting_provider.code, NodeFailureCode::SessionRecordConflict);
26220        let transcript_rejected = process_request_inner(
26221            &shared,
26222            77,
26223            ClientRole::Operator,
26224            index_request(
26225                primary_id,
26226                agent("claude"),
26227                ProviderSessionIdentity {
26228                    transcript_path: Some("C:/provider/transcript.jsonl".to_owned()),
26229                    ..identity
26230                },
26231                "release shepherd",
26232            ),
26233        )
26234        .await
26235        .unwrap_err();
26236        assert_eq!(transcript_rejected.code, NodeFailureCode::InvalidRequest);
26237        let upserts = shared
26238            .history
26239            .lock()
26240            .unwrap_or_else(|poisoned| poisoned.into_inner())
26241            .events
26242            .iter()
26243            .filter(|event| matches!(
26244                &event.event,
26245                NodeEvent::SessionRecordUpserted { record: published }
26246                    if published.record_id == record.record_id
26247            ))
26248            .count();
26249        assert_eq!(upserts, 1);
26250        std::fs::remove_dir_all(secondary_root).unwrap();
26251    }
26252
26253    #[tokio::test]
26254    async fn standalone_workspace_is_independent_registered_and_controller_gated() {
26255        let fixture_root = temporary_workspace_root("standalone-independent");
26256        let target = fixture_root.join("independent");
26257        std::fs::create_dir_all(&fixture_root).unwrap();
26258        let catalog = active_registry().unwrap();
26259        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26260        let selected = WorkspaceConfig::new(
26261            WorkspaceId::new("primary").unwrap(),
26262            std::env::current_dir().unwrap(),
26263        )
26264        .unwrap();
26265        let selected_root = selected.canonical_root().to_owned();
26266        let shared = NodeShared::new(
26267            handle,
26268            "fixture-token".to_owned(),
26269            NodeId::new("node-standalone").unwrap(),
26270            vec![selected],
26271            vec![agent("claude")],
26272        );
26273        let workspace_id = WorkspaceId::new("independent").unwrap();
26274        let request = NodeRequest::CreateStandaloneWorkspace {
26275            workspace_id: workspace_id.clone(),
26276            root: opaque_windows_path(target.to_string_lossy().into_owned()),
26277            initial_branch: Some("main".to_owned()),
26278        };
26279        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
26280            capability.as_str() == NODE_STANDALONE_WORKSPACE_LIFECYCLE_CAPABILITY
26281        }));
26282        assert!(!request_uses_unnegotiated_capability(
26283            &request,
26284            &baseline_capabilities().unwrap(),
26285        ));
26286        assert!(request_uses_unnegotiated_capability(&request, &[]));
26287
26288        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
26289            .await
26290            .unwrap_err();
26291        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
26292        assert!(!target.exists());
26293        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
26294            .await
26295            .unwrap_err();
26296        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
26297        assert!(!target.exists());
26298        shared
26299            .acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS)
26300            .unwrap();
26301
26302        let response = process_request_inner(&shared, 77, ClientRole::Operator, request)
26303            .await
26304            .unwrap();
26305        let NodeResponse::StandaloneWorkspaceCreated { workspace } = response else {
26306            panic!("standalone workspace creation returned another response");
26307        };
26308        assert_eq!(workspace.workspace_id, workspace_id);
26309        assert!(target.join(".git").is_dir());
26310        let branch = run_git_bounded(
26311            target.to_str().unwrap(),
26312            &["branch", "--show-current"],
26313            GIT_OUTPUT_MAX_BYTES,
26314        )
26315        .await
26316        .unwrap();
26317        assert!(branch.success);
26318        assert_eq!(String::from_utf8(branch.stdout).unwrap().trim(), "main");
26319        let worktrees = list_git_worktrees(target.to_str().unwrap()).await.unwrap();
26320        assert_eq!(worktrees.len(), 1);
26321        let listed_root = WorkspaceConfig::new(
26322            WorkspaceId::new("listed-independent").unwrap(),
26323            &worktrees[0].path,
26324        )
26325        .unwrap();
26326        assert!(platform::roots_equal(
26327            listed_root.canonical_root(),
26328            windows_path_text(&workspace.canonical_root),
26329        ));
26330        assert!(!platform::roots_equal(listed_root.canonical_root(), &selected_root));
26331        assert!(shared.snapshot().workspaces.iter().any(|snapshot| {
26332            snapshot.workspace_id == workspace_id
26333                && snapshot.canonical_root == workspace.canonical_root
26334        }));
26335        assert!(shared
26336            .history
26337            .lock()
26338            .unwrap_or_else(|poisoned| poisoned.into_inner())
26339            .events
26340            .iter()
26341            .any(|event| matches!(
26342                &event.event,
26343                NodeEvent::WorkspaceAdded { workspace }
26344                    if workspace.workspace_id == workspace_id
26345            )));
26346        std::fs::remove_dir_all(fixture_root).unwrap();
26347    }
26348
26349    #[tokio::test]
26350    async fn standalone_workspace_registration_failure_preserves_guarded_repository_for_recovery() {
26351        let fixture_root = temporary_workspace_root("standalone-compensation");
26352        let created_target = fixture_root.join("created-target");
26353        let existing_target = fixture_root.join("existing-target");
26354        let invalid_state_parent = fixture_root.join("state-parent-is-a-file");
26355        let invalid_state_path = invalid_state_parent.join("state.json");
26356        std::fs::create_dir_all(&existing_target).unwrap();
26357        std::fs::write(&invalid_state_parent, "not a directory").unwrap();
26358        let catalog = active_registry().unwrap();
26359        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26360        let primary = WorkspaceConfig::new(
26361            WorkspaceId::new("primary").unwrap(),
26362            std::env::current_dir().unwrap(),
26363        )
26364        .unwrap();
26365        let mut shared = NodeShared::new(
26366            handle,
26367            "fixture-token".to_owned(),
26368            NodeId::new("node-standalone-compensation").unwrap(),
26369            vec![primary],
26370            vec![agent("claude")],
26371        );
26372        shared.state_path = Some(invalid_state_path);
26373
26374        let created_recovery = shared
26375            .create_standalone_workspace(
26376                WorkspaceId::new("compensated").unwrap(),
26377                created_target.to_string_lossy().into_owned(),
26378                Some("main".to_owned()),
26379            )
26380            .await
26381            .unwrap_err();
26382        assert_eq!(
26383            created_recovery.code,
26384            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26385        );
26386        assert!(created_target.join(".git").is_dir());
26387        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26388            workspace.workspace_id.as_str() != "compensated"
26389        }));
26390
26391        let existing_recovery = shared
26392            .create_standalone_workspace(
26393                WorkspaceId::new("restored").unwrap(),
26394                existing_target.to_string_lossy().into_owned(),
26395                None,
26396            )
26397            .await
26398            .unwrap_err();
26399        assert_eq!(
26400            existing_recovery.code,
26401            NodeFailureCode::StandaloneWorkspaceRecoveryRequired,
26402        );
26403        assert!(existing_target.is_dir());
26404        assert!(existing_target.join(".git").is_dir());
26405        assert!(shared.snapshot().workspaces.iter().all(|workspace| {
26406            workspace.workspace_id.as_str() != "restored"
26407        }));
26408
26409        let registered_empty = fixture_root.join("registered-empty");
26410        std::fs::create_dir(&registered_empty).unwrap();
26411        let catalog = active_registry().unwrap();
26412        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26413        let registered = WorkspaceConfig::new(
26414            WorkspaceId::new("registered-empty").unwrap(),
26415            &registered_empty,
26416        )
26417        .unwrap();
26418        let duplicate_shared = NodeShared::new(
26419            handle,
26420            "fixture-token".to_owned(),
26421            NodeId::new("node-standalone-duplicate-root").unwrap(),
26422            vec![registered],
26423            vec![agent("claude")],
26424        );
26425        let duplicate = duplicate_shared
26426            .create_standalone_workspace(
26427                WorkspaceId::new("duplicate-root").unwrap(),
26428                registered_empty.to_string_lossy().into_owned(),
26429                None,
26430            )
26431            .await
26432            .unwrap_err();
26433        assert_eq!(duplicate.code, NodeFailureCode::DuplicateWorkspaceRoot);
26434        assert!(std::fs::read_dir(&registered_empty).unwrap().next().is_none());
26435        std::fs::remove_dir_all(fixture_root).unwrap();
26436    }
26437
26438    #[test]
26439    fn managed_record_id_collision_preserves_the_existing_record() {
26440        let catalog = active_registry().unwrap();
26441        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26442        let workspace = WorkspaceConfig::new(
26443            WorkspaceId::new("primary").unwrap(),
26444            std::env::current_dir().unwrap(),
26445        )
26446        .unwrap();
26447        let shared = NodeShared::new(
26448            handle,
26449            "fixture-token".to_owned(),
26450            NodeId::new("node-1").unwrap(),
26451            vec![workspace.clone()],
26452            vec![agent("claude")],
26453        );
26454        let record_id = SessionRecordId::new("sr-collision").unwrap();
26455        let original = ManagedSessionRecord {
26456            record_id: record_id.clone(),
26457            display_name: "original".to_owned(),
26458            provider: agent("claude"),
26459            mode: SessionMode::Pty,
26460            state: ManagedSessionState::Dormant,
26461            workspace_id: workspace.workspace_id,
26462            canonical_root: opaque_windows_path(workspace.canonical_root),
26463            provider_session: None,
26464            active_session: None,
26465            environment_profile: None,
26466            bundle: None,
26467            context_id: None,
26468            context: None,
26469            exported_context: None,
26470            task_binding: None,
26471            created_at_unix_ms: 1,
26472            updated_at_unix_ms: 1,
26473            last_error: None,
26474        };
26475        shared.insert_record(original.clone()).unwrap();
26476        let mut collision = original.clone();
26477        collision.display_name = "replacement".to_owned();
26478        let error = shared.insert_record(collision).unwrap_err();
26479        assert_eq!(error.code, NodeFailureCode::SessionRecordConflict);
26480        assert_eq!(shared.record(&record_id).unwrap(), original);
26481    }
26482
26483    #[test]
26484    fn managed_record_runtime_errors_are_exposed_only_as_allowlisted_categories() {
26485        let catalog = active_registry().unwrap();
26486        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26487        let workspace = WorkspaceConfig::new(
26488            WorkspaceId::new("primary").unwrap(),
26489            std::env::current_dir().unwrap(),
26490        )
26491        .unwrap();
26492        let shared = NodeShared::new(
26493            handle,
26494            "fixture-token".to_owned(),
26495            NodeId::new("node-1").unwrap(),
26496            vec![workspace.clone()],
26497            vec![agent("claude")],
26498        );
26499        let record_id = SessionRecordId::new("sr-runtime-error").unwrap();
26500        shared
26501            .insert_record(ManagedSessionRecord {
26502                record_id: record_id.clone(),
26503                display_name: "runtime error".to_owned(),
26504                provider: agent("claude"),
26505                mode: SessionMode::Pty,
26506                state: ManagedSessionState::IdentityPending,
26507                workspace_id: workspace.workspace_id,
26508                canonical_root: opaque_windows_path(workspace.canonical_root),
26509                provider_session: None,
26510                active_session: None,
26511                environment_profile: None,
26512                bundle: None,
26513                context_id: None,
26514                context: None,
26515                exported_context: None,
26516                task_binding: None,
26517                created_at_unix_ms: 1,
26518                updated_at_unix_ms: 1,
26519                last_error: None,
26520            })
26521            .unwrap();
26522
26523        shared
26524            .mark_record_error(
26525                &record_id,
26526                r"provider failed with bearer-secret-123 at C:\private\session.jsonl",
26527            )
26528            .unwrap();
26529        let record = shared.record(&record_id).unwrap();
26530        assert_eq!(record.state, ManagedSessionState::Unavailable);
26531        assert_eq!(
26532            record.last_error.as_deref(),
26533            Some("provider-runtime-failed"),
26534        );
26535        let snapshot = shared.snapshot();
26536        assert_eq!(snapshot.session_records, vec![record]);
26537    }
26538
26539    /// Regression coverage for the "operator sees `deadline` instead of the
26540    /// kernel's own named rejection" defect: a spawn-dispatch waiter must be
26541    /// able to learn a command's asynchronous kernel rejection (here,
26542    /// specifically classified as `UnsupportedTransport`) exactly once, and
26543    /// must not still find it after that -- proving the fast path this
26544    /// mechanism exists for (`spawn_session_with_deadline`'s commit-wait
26545    /// loop) drains the reason instead of leaving it to be rediscovered (or
26546    /// missed) on a later poll.
26547    #[test]
26548    fn rejected_command_reason_is_classified_and_consumed_exactly_once() {
26549        let catalog = active_registry().unwrap();
26550        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26551        let workspace = WorkspaceConfig::new(
26552            WorkspaceId::new("primary").unwrap(),
26553            std::env::current_dir().unwrap(),
26554        )
26555        .unwrap();
26556        let shared = NodeShared::new(
26557            handle,
26558            "fixture-token".to_owned(),
26559            NodeId::new("node-1").unwrap(),
26560            vec![workspace],
26561            vec![agent("claude")],
26562        );
26563
26564        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26565
26566        shared.record_rejected_command(
26567            CommandId(1),
26568            "agent 'claude' does not support transport Acp".to_owned(),
26569            true,
26570        );
26571        // An unrelated command_id must not see another command's reason.
26572        assert!(shared.take_rejected_command_reason(CommandId(2)).is_none());
26573
26574        let reason = shared.take_rejected_command_reason(CommandId(1)).unwrap();
26575        assert_eq!(reason.message, "agent 'claude' does not support transport Acp");
26576        assert!(reason.unsupported_transport);
26577
26578        // Consumed: a second read for the same command_id finds nothing.
26579        assert!(shared.take_rejected_command_reason(CommandId(1)).is_none());
26580
26581        // A rejection unrelated to transport support keeps its own
26582        // classification distinct, so the reader can choose the generic
26583        // `BackendOperationFailed` code instead of misreporting
26584        // `UnsupportedTransport`.
26585        shared.record_rejected_command(
26586            CommandId(3),
26587            "backend busy".to_owned(),
26588            false,
26589        );
26590        let unclassified = shared.take_rejected_command_reason(CommandId(3)).unwrap();
26591        assert!(!unclassified.unsupported_transport);
26592    }
26593
26594    #[test]
26595    fn provider_identity_update_is_ignored_without_exact_session_policy() {
26596        let catalog = active_registry().unwrap();
26597        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26598        let workspace = WorkspaceConfig::new(
26599            WorkspaceId::new("primary").unwrap(),
26600            std::env::current_dir().unwrap(),
26601        )
26602        .unwrap();
26603        let shared = NodeShared::new(
26604            handle,
26605            "fixture-token".to_owned(),
26606            NodeId::new("node-identity-policy").unwrap(),
26607            vec![workspace.clone()],
26608            vec![agent("claude")],
26609        );
26610        let address = SessionAddress {
26611            workspace_id: workspace.workspace_id.clone(),
26612            session: SessionKey {
26613                instance_id: AgentInstanceId(90),
26614                generation: SessionGeneration(1),
26615            },
26616        };
26617        let record_id = SessionRecordId::new("sr-policy-denied").unwrap();
26618        shared
26619            .insert_record(ManagedSessionRecord {
26620                record_id: record_id.clone(),
26621                display_name: "policy denied".to_owned(),
26622                provider: agent("claude"),
26623                mode: SessionMode::Pty,
26624                state: ManagedSessionState::IdentityPending,
26625                workspace_id: workspace.workspace_id,
26626                canonical_root: opaque_windows_path(workspace.canonical_root),
26627                provider_session: None,
26628                active_session: Some(address.clone()),
26629                environment_profile: None,
26630                bundle: None,
26631                context_id: None,
26632                context: None,
26633                exported_context: None,
26634                task_binding: None,
26635                created_at_unix_ms: 1,
26636                updated_at_unix_ms: 1,
26637                last_error: None,
26638            })
26639            .unwrap();
26640        let event = ControlEvent {
26641            sequence: 1,
26642            command_id: None,
26643            instance_id: address.session.instance_id,
26644            generation: address.session.generation,
26645            event: ControlEventKind::ProviderEvent {
26646                sequence: 1,
26647                source: gate4agent_types::ProviderSource {
26648                    family: AdapterFamily::PtySemantic,
26649                    binding: AdapterBinding::new(
26650                        gate4agent_types::AdapterId::new("claude").unwrap(),
26651                        "fixture/v1",
26652                        gate4agent_types::AdapterVerification::SyntheticFixture,
26653                    )
26654                    .unwrap(),
26655                },
26656                source_sequence: 1,
26657                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26658                    identity: gate4agent_types::ProviderSessionIdentity {
26659                        key: gate4agent_types::ProviderSessionKey::SessionId,
26660                        id: "provider-session".to_owned(),
26661                        transcript_path: None,
26662                    },
26663                },
26664            },
26665        };
26666
26667        shared.reconcile_managed_record(&record_id, &address, &event, false, false);
26668
26669        let record = shared.record(&record_id).unwrap();
26670        assert!(record.provider_session.is_none());
26671        assert_eq!(record.state, ManagedSessionState::IdentityPending);
26672        assert_eq!(record.active_session.as_ref(), Some(&address));
26673    }
26674
26675    #[test]
26676    fn provider_identity_cannot_rebind_a_record_across_workspaces() {
26677        let secondary_root = temporary_workspace_root("identity-scope");
26678        std::fs::create_dir_all(&secondary_root).unwrap();
26679        let catalog = active_registry().unwrap();
26680        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26681        let primary = WorkspaceConfig::new(
26682            WorkspaceId::new("primary").unwrap(),
26683            std::env::current_dir().unwrap(),
26684        )
26685        .unwrap();
26686        let secondary = WorkspaceConfig::new(
26687            WorkspaceId::new("secondary").unwrap(),
26688            &secondary_root,
26689        )
26690        .unwrap();
26691        let shared = NodeShared::new(
26692            handle,
26693            "fixture-token".to_owned(),
26694            NodeId::new("node-1").unwrap(),
26695            vec![primary.clone(), secondary.clone()],
26696            vec![agent("claude")],
26697        );
26698        let identity = gate4agent_types::ProviderSessionIdentity {
26699            key: gate4agent_types::ProviderSessionKey::SessionId,
26700            id: "provider-session".to_owned(),
26701            transcript_path: None,
26702        };
26703        let dormant_id = SessionRecordId::new("sr-dormant").unwrap();
26704        shared
26705            .insert_record(ManagedSessionRecord {
26706                record_id: dormant_id.clone(),
26707                display_name: "dormant".to_owned(),
26708                provider: agent("claude"),
26709                mode: SessionMode::Pty,
26710                state: ManagedSessionState::Dormant,
26711                workspace_id: primary.workspace_id,
26712                canonical_root: opaque_windows_path(primary.canonical_root),
26713                provider_session: Some(identity.clone()),
26714                active_session: None,
26715                environment_profile: None,
26716                bundle: None,
26717                context_id: None,
26718                context: None,
26719                exported_context: None,
26720                task_binding: None,
26721                created_at_unix_ms: 1,
26722                updated_at_unix_ms: 1,
26723                last_error: None,
26724            })
26725            .unwrap();
26726        let address = SessionAddress {
26727            workspace_id: secondary.workspace_id.clone(),
26728            session: SessionKey {
26729                instance_id: AgentInstanceId(91),
26730                generation: SessionGeneration(1),
26731            },
26732        };
26733        let current_id = SessionRecordId::new("sr-current").unwrap();
26734        shared
26735            .insert_record(ManagedSessionRecord {
26736                record_id: current_id.clone(),
26737                display_name: "current".to_owned(),
26738                provider: agent("claude"),
26739                mode: SessionMode::Pty,
26740                state: ManagedSessionState::IdentityPending,
26741                workspace_id: secondary.workspace_id,
26742                canonical_root: opaque_windows_path(secondary.canonical_root),
26743                provider_session: None,
26744                active_session: Some(address.clone()),
26745                environment_profile: None,
26746                bundle: None,
26747                context_id: None,
26748                context: None,
26749                exported_context: None,
26750                task_binding: None,
26751                created_at_unix_ms: 2,
26752                updated_at_unix_ms: 2,
26753                last_error: None,
26754            })
26755            .unwrap();
26756        shared.bind_managed_session(
26757            &address,
26758            current_id.clone(),
26759            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26760            None,
26761        );
26762        let event = ControlEvent {
26763            sequence: 1,
26764            command_id: None,
26765            instance_id: address.session.instance_id,
26766            generation: address.session.generation,
26767            event: ControlEventKind::ProviderEvent {
26768                sequence: 1,
26769                source: gate4agent_types::ProviderSource {
26770                    family: gate4agent_types::AdapterFamily::PtySemantic,
26771                    binding: gate4agent_types::AdapterBinding::new(
26772                        gate4agent_types::AdapterId::new("claude").unwrap(),
26773                        "fixture/v1",
26774                        gate4agent_types::AdapterVerification::SyntheticFixture,
26775                    )
26776                    .unwrap(),
26777                },
26778                source_sequence: 1,
26779                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26780                    identity,
26781                },
26782            },
26783        };
26784        shared.reconcile_managed_record(&current_id, &address, &event, true, false);
26785
26786        let binding = shared
26787            .session_bindings
26788            .lock()
26789            .unwrap_or_else(|poisoned| poisoned.into_inner())
26790            .get(&address.session.instance_id)
26791            .cloned()
26792            .unwrap();
26793        assert_eq!(binding.record_id.as_ref(), Some(&current_id));
26794        assert_eq!(shared.record(&dormant_id).unwrap().state, ManagedSessionState::Dormant);
26795        let current = shared.record(&current_id).unwrap();
26796        assert_eq!(current.state, ManagedSessionState::Unavailable);
26797        assert_eq!(current.active_session.as_ref(), Some(&address));
26798        assert_eq!(
26799            current.last_error.as_deref(),
26800            Some(PROVIDER_SESSION_SCOPE_CONFLICT_ERROR),
26801        );
26802        std::fs::remove_dir_all(secondary_root).unwrap();
26803    }
26804
26805    #[tokio::test]
26806    async fn semantic_identity_with_transcript_path_reuses_record_and_survives_reload() {
26807        let root = temporary_workspace_root("semantic-provider-session");
26808        std::fs::create_dir_all(&root).unwrap();
26809        let state_path = root.join("state-v1.json");
26810        let catalog = active_registry().unwrap();
26811        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26812        let workspace = WorkspaceConfig::new(
26813            WorkspaceId::new("primary").unwrap(),
26814            &root,
26815        )
26816        .unwrap();
26817        let node_id = NodeId::new("node-semantic-identity").unwrap();
26818        let shared = NodeShared::new_with_incarnation(
26819            handle,
26820            "fixture-token".to_owned(),
26821            node_id.clone(),
26822            NodeIncarnationId::from_bytes([0; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26823            vec![workspace.clone()],
26824            vec![agent("claude")],
26825            ProviderRuntimeStatuses::default(),
26826            None,
26827            Vec::new(),
26828            Vec::new(),
26829            SpawnProfileRegistry::default(),
26830            None,
26831            None,
26832            None,
26833            Some(state_path.clone()),
26834            Vec::new(),
26835            Vec::new(),
26836            Vec::new(),
26837            Vec::new(),
26838            None,
26839            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
26840        );
26841        let persisted_identity = gate4agent_types::ProviderSessionIdentity {
26842            key: gate4agent_types::ProviderSessionKey::SessionId,
26843            id: "provider-session".to_owned(),
26844            transcript_path: None,
26845        };
26846        let durable_id = SessionRecordId::new("sr-durable").unwrap();
26847        shared
26848            .insert_record(ManagedSessionRecord {
26849                record_id: durable_id.clone(),
26850                display_name: "durable".to_owned(),
26851                provider: agent("claude"),
26852                mode: SessionMode::Pty,
26853                state: ManagedSessionState::Dormant,
26854                workspace_id: workspace.workspace_id.clone(),
26855                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26856                provider_session: Some(persisted_identity.clone()),
26857                active_session: None,
26858                environment_profile: None,
26859                bundle: None,
26860                context_id: None,
26861                context: None,
26862                exported_context: None,
26863                task_binding: None,
26864                created_at_unix_ms: 1,
26865                updated_at_unix_ms: 1,
26866                last_error: None,
26867            })
26868            .unwrap();
26869        let address = SessionAddress {
26870            workspace_id: workspace.workspace_id.clone(),
26871            session: SessionKey {
26872                instance_id: AgentInstanceId(92),
26873                generation: SessionGeneration(2),
26874            },
26875        };
26876        let pending_id = SessionRecordId::new("sr-pending").unwrap();
26877        shared
26878            .insert_record(ManagedSessionRecord {
26879                record_id: pending_id.clone(),
26880                display_name: "pending".to_owned(),
26881                provider: agent("claude"),
26882                mode: SessionMode::Pty,
26883                state: ManagedSessionState::IdentityPending,
26884                workspace_id: workspace.workspace_id.clone(),
26885                canonical_root: opaque_windows_path(workspace.canonical_root.clone()),
26886                provider_session: None,
26887                active_session: Some(address.clone()),
26888                environment_profile: None,
26889                bundle: None,
26890                context_id: None,
26891                context: None,
26892                exported_context: None,
26893                task_binding: None,
26894                created_at_unix_ms: 2,
26895                updated_at_unix_ms: 2,
26896                last_error: None,
26897            })
26898            .unwrap();
26899        shared.bind_managed_session(
26900            &address,
26901            pending_id.clone(),
26902            ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
26903            None,
26904        );
26905        let observed_identity = gate4agent_types::ProviderSessionIdentity {
26906            transcript_path: Some(r"C:\private\provider-transcript.jsonl".to_owned()),
26907            ..persisted_identity
26908        };
26909        let event = ControlEvent {
26910            sequence: 1,
26911            command_id: None,
26912            instance_id: address.session.instance_id,
26913            generation: address.session.generation,
26914            event: ControlEventKind::ProviderEvent {
26915                sequence: 1,
26916                source: gate4agent_types::ProviderSource {
26917                    family: gate4agent_types::AdapterFamily::PtySemantic,
26918                    binding: gate4agent_types::AdapterBinding::new(
26919                        gate4agent_types::AdapterId::new("claude").unwrap(),
26920                        "fixture/v1",
26921                        gate4agent_types::AdapterVerification::SyntheticFixture,
26922                    )
26923                    .unwrap(),
26924                },
26925                source_sequence: 1,
26926                event: gate4agent_types::ProviderEvent::SessionIdentityObserved {
26927                    identity: observed_identity.clone(),
26928                },
26929            },
26930        };
26931        shared.reconcile_managed_record(&pending_id, &address, &event, true, false);
26932
26933        let snapshot = shared.snapshot();
26934        assert_eq!(snapshot.session_records.len(), 1);
26935        let live = &snapshot.session_records[0];
26936        assert_eq!(live.record_id, durable_id);
26937        assert_eq!(live.state, ManagedSessionState::Live);
26938        assert_eq!(live.active_session.as_ref(), Some(&address));
26939        assert_eq!(live.provider_session.as_ref(), Some(&observed_identity));
26940        let binding = shared
26941            .session_bindings
26942            .lock()
26943            .unwrap_or_else(|poisoned| poisoned.into_inner())
26944            .get(&address.session.instance_id)
26945            .cloned()
26946            .unwrap();
26947        assert_eq!(binding.record_id.as_ref(), Some(&durable_id));
26948
26949        let mut duplicate = live.clone();
26950        duplicate.record_id = SessionRecordId::new("sr-duplicate").unwrap();
26951        duplicate.display_name = "duplicate".to_owned();
26952        duplicate.state = ManagedSessionState::Dormant;
26953        duplicate.active_session = None;
26954        duplicate.provider_session.as_mut().unwrap().transcript_path = None;
26955        let duplicate_error = shared.insert_record(duplicate).unwrap_err();
26956        assert_eq!(duplicate_error.code, NodeFailureCode::SessionRecordConflict);
26957        assert_eq!(shared.snapshot().session_records.len(), 1);
26958
26959        let terminal_size = gate4agent_types::TerminalSize {
26960            rows: 24,
26961            columns: 80,
26962        };
26963        let (first_busy, second_busy) = tokio::join!(
26964            shared.resume_session_record(&durable_id, terminal_size, None),
26965            shared.resume_session_record(&durable_id, terminal_size, None),
26966        );
26967        for busy in [first_busy.unwrap_err(), second_busy.unwrap_err()] {
26968            assert_eq!(busy.code, NodeFailureCode::SessionRecordBusy);
26969            assert_eq!(busy.message, "session-record-busy");
26970        }
26971
26972        let loaded = session_registry::load(Some(&state_path), &node_id).unwrap();
26973        assert_eq!(loaded.records.len(), 1);
26974        assert_eq!(loaded.records[0].record_id, durable_id);
26975        assert_eq!(loaded.records[0].state, ManagedSessionState::Dormant);
26976        assert_eq!(
26977            loaded.records[0]
26978                .provider_session
26979                .as_ref()
26980                .unwrap()
26981                .transcript_path,
26982            None,
26983        );
26984        let catalog = active_registry().unwrap();
26985        let (reloaded_handle, _reloaded_runtime) =
26986            NativeRuntime::new(catalog, NativeRuntimeConfig::default());
26987        let reloaded = NodeShared::new_with_incarnation(
26988            reloaded_handle,
26989            "fixture-token".to_owned(),
26990            node_id,
26991            NodeIncarnationId::from_bytes([1; crate::protocol::NODE_INCARNATION_ID_BYTES]),
26992            vec![workspace],
26993            vec![agent("claude")],
26994            ProviderRuntimeStatuses::default(),
26995            None,
26996            Vec::new(),
26997            Vec::new(),
26998            SpawnProfileRegistry::default(),
26999            None,
27000            None,
27001            None,
27002            None,
27003            loaded.records,
27004            loaded.managed_worktrees,
27005            loaded.managed_worktree_tombstones,
27006            loaded.materializations,
27007            loaded.warning,
27008            MUTATION_SETTLE_TIMEOUT_MS.saturating_add(READINESS_SETTLE_HEADROOM_MS),
27009        );
27010        assert_eq!(reloaded.snapshot().session_records.len(), 1);
27011        assert_eq!(reloaded.snapshot().session_records[0].record_id, durable_id);
27012        std::fs::remove_dir_all(root).unwrap();
27013    }
27014
27015    #[tokio::test]
27016    async fn workspace_reregistration_requires_the_recorded_root_and_restores_records() {
27017        let secondary_root = temporary_workspace_root("workspace-restore");
27018        let other_root = temporary_workspace_root("workspace-conflict");
27019        std::fs::create_dir_all(&secondary_root).unwrap();
27020        std::fs::create_dir_all(&other_root).unwrap();
27021        let catalog = active_registry().unwrap();
27022        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27023        let primary = WorkspaceConfig::new(
27024            WorkspaceId::new("primary").unwrap(),
27025            std::env::current_dir().unwrap(),
27026        )
27027        .unwrap();
27028        let secondary = WorkspaceConfig::new(
27029            WorkspaceId::new("secondary").unwrap(),
27030            &secondary_root,
27031        )
27032        .unwrap();
27033        let shared = NodeShared::new(
27034            handle,
27035            "fixture-token".to_owned(),
27036            NodeId::new("node-1").unwrap(),
27037            vec![primary, secondary.clone()],
27038            vec![agent("claude")],
27039        );
27040        let record_id = SessionRecordId::new("sr-workspace").unwrap();
27041        shared
27042            .insert_record(ManagedSessionRecord {
27043                record_id: record_id.clone(),
27044                display_name: "workspace record".to_owned(),
27045                provider: agent("claude"),
27046                mode: SessionMode::Pty,
27047                state: ManagedSessionState::Dormant,
27048                workspace_id: secondary.workspace_id.clone(),
27049                canonical_root: opaque_windows_path(secondary.canonical_root.clone()),
27050                provider_session: Some(gate4agent_types::ProviderSessionIdentity {
27051                    key: gate4agent_types::ProviderSessionKey::SessionId,
27052                    id: "provider-session".to_owned(),
27053                    transcript_path: None,
27054                }),
27055                active_session: None,
27056                environment_profile: None,
27057                bundle: None,
27058                context_id: None,
27059                context: None,
27060                exported_context: None,
27061                task_binding: None,
27062                created_at_unix_ms: 1,
27063                updated_at_unix_ms: 1,
27064                last_error: None,
27065            })
27066            .unwrap();
27067        shared.unregister_workspace(&secondary.workspace_id).unwrap();
27068        let unavailable = shared.record(&record_id).unwrap();
27069        assert_eq!(unavailable.state, ManagedSessionState::Unavailable);
27070        assert_eq!(windows_path_text(&unavailable.canonical_root), secondary.canonical_root);
27071
27072        let conflict = shared
27073            .register_workspace(
27074                secondary.workspace_id.clone(),
27075                other_root.to_string_lossy().into_owned(),
27076            )
27077            .await
27078            .unwrap_err();
27079        assert_eq!(conflict.code, NodeFailureCode::SessionRecordConflict);
27080        shared
27081            .register_workspace(
27082                secondary.workspace_id,
27083                secondary_root.to_string_lossy().into_owned(),
27084            )
27085            .await
27086            .unwrap();
27087        let restored = shared.record(&record_id).unwrap();
27088        assert_eq!(restored.state, ManagedSessionState::Dormant);
27089        assert!(restored.last_error.is_none());
27090        std::fs::remove_dir_all(secondary_root).unwrap();
27091        std::fs::remove_dir_all(other_root).unwrap();
27092    }
27093
27094    #[test]
27095    fn dynamic_workspace_root_is_rejected_before_filesystem_canonicalization() {
27096        let error = validate_workspace_request_root(
27097            &"x".repeat(MAX_WORKSPACE_ROOT_BYTES + 1),
27098        )
27099        .unwrap_err();
27100        assert_eq!(error.code, NodeFailureCode::InvalidWorkspaceRoot);
27101        let control = validate_workspace_request_root("C:\\repo\nchild").unwrap_err();
27102        assert_eq!(control.code, NodeFailureCode::InvalidWorkspaceRoot);
27103    }
27104
27105    #[test]
27106    fn concurrent_publish_and_resync_never_acknowledge_an_unappended_event() {
27107        let catalog = active_registry().unwrap();
27108        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27109        let workspace = WorkspaceConfig::new(
27110            WorkspaceId::new("primary").unwrap(),
27111            std::env::current_dir().unwrap(),
27112        )
27113        .unwrap();
27114        let shared = NodeShared::new(
27115            handle,
27116            "fixture-token".to_owned(),
27117            NodeId::new("node-1").unwrap(),
27118            vec![workspace],
27119            vec![agent("claude")],
27120        );
27121        let publishers = 4_u64;
27122        let events_per_publisher = 256_u64;
27123        let start = std::sync::Barrier::new(publishers as usize + 1);
27124        let finished = AtomicU64::new(0);
27125
27126        std::thread::scope(|scope| {
27127            for _ in 0..publishers {
27128                scope.spawn(|| {
27129                    start.wait();
27130                    for _ in 0..events_per_publisher {
27131                        shared.publish(NodeEvent::ControllerChanged { controller: None });
27132                    }
27133                    finished.fetch_add(1, Ordering::Release);
27134                });
27135            }
27136            start.wait();
27137            while finished.load(Ordering::Acquire) != publishers {
27138                assert_resync_history_is_linearized(shared.resync(0));
27139                std::thread::yield_now();
27140            }
27141        });
27142        let response = shared.resync(0);
27143        assert_resync_history_is_linearized(response.clone());
27144        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27145            unreachable!("resync helper returned another response");
27146        };
27147        assert_eq!(event_sequence, publishers * events_per_publisher);
27148        assert_eq!(events.len() as u64, event_sequence);
27149    }
27150
27151    fn assert_resync_history_is_linearized(response: NodeResponse) {
27152        let NodeResponse::Resync { event_sequence, events, .. } = response else {
27153            unreachable!("resync helper returned another response");
27154        };
27155        assert!(events.windows(2).all(|pair| {
27156            pair[0].sequence.checked_add(1) == Some(pair[1].sequence)
27157        }));
27158        assert_eq!(events.last().map_or(0, |event| event.sequence), event_sequence);
27159    }
27160
27161    #[test]
27162    fn canonical_workspace_roots_are_safe_for_cmd_exe() {
27163        assert_eq!(
27164            platform::normalize_canonical_root(r"\\?\C:\repo\worktree".to_owned()),
27165            r"C:\repo\worktree",
27166        );
27167        assert_eq!(
27168            platform::normalize_canonical_root(r"\\?\UNC\server\share\repo".to_owned()),
27169            r"\\server\share\repo",
27170        );
27171        assert!(validate_workspace_root(
27172            &WorkspaceId::new("network").unwrap(),
27173            r"\\server\share\repo",
27174        )
27175        .is_err());
27176        let workspace = WorkspaceConfig::new(
27177            WorkspaceId::new("primary").unwrap(),
27178            std::env::current_dir().unwrap(),
27179        )
27180        .unwrap();
27181        assert!(!workspace.canonical_root.starts_with(r"\\?\"));
27182    }
27183
27184    #[test]
27185    fn resume_rebinds_only_on_the_expected_authorized_generation() {
27186        let catalog = active_registry().unwrap();
27187        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27188        let workspace_id = WorkspaceId::new("primary").unwrap();
27189        let workspace = WorkspaceConfig::new(
27190            workspace_id.clone(),
27191            std::env::current_dir().unwrap(),
27192        )
27193        .unwrap();
27194        let shared = NodeShared::new(
27195            handle,
27196            "fixture-token".to_owned(),
27197            NodeId::new("node-1").unwrap(),
27198            vec![workspace],
27199            vec![agent("claude")],
27200        );
27201        let instance_id = AgentInstanceId(17);
27202        let original = SessionAddress {
27203            workspace_id,
27204            session: SessionKey {
27205                instance_id,
27206                generation: SessionGeneration(3),
27207            },
27208        };
27209        shared.bind_session(
27210            instance_id,
27211            original.workspace_id.clone(),
27212            original.session.generation,
27213        );
27214        shared
27215            .arm_resume(
27216                &original,
27217                CommandId(41),
27218                ProviderRuntimePolicy::new(true, true, true, true, true, false).unwrap(),
27219            )
27220            .unwrap();
27221
27222        shared.publish_control(ControlEvent {
27223            sequence: 1,
27224            command_id: None,
27225            instance_id,
27226            generation: SessionGeneration(4),
27227            event: ControlEventKind::Running { process_id: None },
27228        });
27229        assert_eq!(
27230            shared.address_for(instance_id, SessionGeneration(3)),
27231            Some(original.clone()),
27232        );
27233        assert!(shared.address_for(instance_id, SessionGeneration(4)).is_none());
27234
27235        shared.publish_control(ControlEvent {
27236            sequence: 2,
27237            command_id: Some(CommandId(42)),
27238            instance_id,
27239            generation: SessionGeneration(3),
27240            event: ControlEventKind::CommandRejected {
27241                message: "unrelated command".to_owned(),
27242            },
27243        });
27244
27245        shared.publish_control(ControlEvent {
27246            sequence: 3,
27247            command_id: None,
27248            instance_id,
27249            generation: SessionGeneration(4),
27250            event: ControlEventKind::ResumeAuthorized {
27251                session: gate4agent_types::ResumeSessionSummary {
27252                    key: gate4agent_types::ProviderSessionKey::SessionId,
27253                    id: "fixture-session".to_owned(),
27254                },
27255            },
27256        });
27257        assert!(shared.address_for(instance_id, SessionGeneration(3)).is_none());
27258        let rebound = shared.address_for(instance_id, SessionGeneration(4)).unwrap();
27259        assert_eq!(rebound.workspace_id, original.workspace_id);
27260        let history = shared
27261            .history
27262            .lock()
27263            .unwrap_or_else(|poisoned| poisoned.into_inner());
27264        assert!(history.events.iter().any(|envelope| matches!(
27265            &envelope.event,
27266            NodeEvent::Control { address, event }
27267                if address == &rebound
27268                    && matches!(&event.event, ControlEventKind::ResumeAuthorized { .. })
27269        )));
27270    }
27271
27272    #[test]
27273    fn all_node_pty_prompts_use_bracketed_paste_framing() {
27274        for agent in ["claude", "codex", "kimi"] {
27275            assert_eq!(
27276                prompt_framing(&AgentId::new(agent).unwrap()),
27277                PromptFraming::BracketedPaste
27278            );
27279        }
27280    }
27281
27282    fn generous_walk_deadline() -> Instant {
27283        Instant::now() + Duration::from_secs(30)
27284    }
27285
27286    #[test]
27287    fn workspace_tree_is_relative_and_skips_heavy_directories() {
27288        let root = temporary_workspace_root("tree");
27289        std::fs::create_dir_all(root.join("src")).unwrap();
27290        std::fs::create_dir_all(root.join(".git/objects")).unwrap();
27291        std::fs::create_dir_all(root.join("target/debug")).unwrap();
27292        std::fs::create_dir_all(root.join("node_modules/package")).unwrap();
27293        std::fs::write(root.join("src/lib.rs"), b"pub fn fixture() {}\n").unwrap();
27294        std::fs::write(root.join("README.md"), b"fixture\n").unwrap();
27295        // A renamed/agent-scoped build directory (`target-a2`-style, per the
27296        // real repository's own build layout) that carries the Cache
27297        // Directory Tagging Specification marker cargo writes into every
27298        // `--target-dir` it creates: must be skipped even though its name
27299        // does not exactly match `target`.
27300        std::fs::create_dir_all(root.join("target-a2/debug")).unwrap();
27301        std::fs::write(root.join("target-a2/CACHEDIR.TAG"), b"Signature: 8a477f597d28d172789f06886806bc55\n").unwrap();
27302        // A directory that merely *starts with* `target` but carries no
27303        // CACHEDIR.TAG: the new prefix-adjacent check must not false-match
27304        // on the name alone, so this stays in the response.
27305        std::fs::create_dir_all(root.join("target-notes")).unwrap();
27306        std::fs::write(root.join("target-notes/plan.md"), b"not a build directory\n").unwrap();
27307
27308        let (entries, truncated, _budget) =
27309            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27310        assert!(!truncated);
27311        assert!(entries.iter().any(|entry| {
27312            entry.relative_path.as_utf8() == Some("src")
27313                && entry.kind == WorkspaceEntryKind::Directory
27314        }));
27315        assert!(entries.iter().any(|entry| {
27316            entry.relative_path.as_utf8() == Some("src/lib.rs")
27317                && entry.kind == WorkspaceEntryKind::File
27318        }));
27319        assert!(entries.iter().any(|entry| {
27320            entry.relative_path.as_utf8() == Some("target-notes")
27321                && entry.kind == WorkspaceEntryKind::Directory
27322        }));
27323        assert!(entries.iter().any(|entry| {
27324            entry.relative_path.as_utf8() == Some("target-notes/plan.md")
27325                && entry.kind == WorkspaceEntryKind::File
27326        }));
27327        assert!(entries.iter().all(|entry| {
27328            let path = entry.relative_path.as_utf8().unwrap();
27329            !path.starts_with(".git")
27330                && !(path == "target" || path.starts_with("target/"))
27331                && !(path == "target-a2" || path.starts_with("target-a2/"))
27332                && !path.starts_with("node_modules")
27333        }));
27334
27335        std::fs::remove_dir_all(&root).unwrap();
27336    }
27337
27338    #[test]
27339    fn workspace_walk_stops_at_its_entry_cap_and_marks_a_partial_result() {
27340        let root = temporary_workspace_root("tree-entry-cap");
27341        std::fs::create_dir_all(&root).unwrap();
27342        for index in 0..20 {
27343            std::fs::write(root.join(format!("file-{index:02}.txt")), b"fixture\n").unwrap();
27344        }
27345
27346        let (entries, truncated, budget) =
27347            collect_workspace_entries(&root, generous_walk_deadline(), 5);
27348        assert!(truncated);
27349        assert!(budget.entry_cap_exceeded);
27350        assert!(!budget.time_budget_exceeded);
27351        assert!(entries.len() <= 5);
27352
27353        let inspection_truncation = workspace_inspection_truncation(&budget, false, Duration::from_millis(1))
27354            .expect("an entry-cap-truncated walk must report a truncation marker");
27355        assert!(inspection_truncation.walk_entry_cap_exceeded);
27356        assert!(!inspection_truncation.walk_time_budget_exceeded);
27357        assert!(!inspection_truncation.git_time_budget_exceeded);
27358        assert!(inspection_truncation.entries_visited >= 5);
27359
27360        std::fs::remove_dir_all(&root).unwrap();
27361    }
27362
27363    #[test]
27364    fn workspace_walk_within_budget_reports_no_truncation() {
27365        let root = temporary_workspace_root("tree-no-truncation");
27366        std::fs::create_dir_all(&root).unwrap();
27367        std::fs::write(root.join("only-file.txt"), b"fixture\n").unwrap();
27368
27369        let (_entries, truncated, budget) =
27370            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27371        assert!(!truncated);
27372        assert!(workspace_inspection_truncation(&budget, false, Duration::from_millis(1)).is_none());
27373
27374        std::fs::remove_dir_all(&root).unwrap();
27375    }
27376
27377    #[test]
27378    fn workspace_walk_emits_strict_global_byte_order_for_prefix_named_siblings() {
27379        let root = temporary_workspace_root("tree-prefix-siblings");
27380        std::fs::create_dir_all(root.join("gate4agent-c2/src")).unwrap();
27381        std::fs::write(root.join("gate4agent-c2/src/lib.rs"), b"fixture\n").unwrap();
27382        std::fs::create_dir_all(root.join("gate4agent-c2-client")).unwrap();
27383        std::fs::write(root.join("gate4agent-c2-client/lib.rs"), b"fixture\n").unwrap();
27384
27385        let (entries, truncated, _budget) =
27386            collect_workspace_entries(&root, generous_walk_deadline(), WORKSPACE_INSPECTION_ENTRY_CAP_DEFAULT);
27387        assert!(!truncated);
27388        let paths = entries
27389            .iter()
27390            .map(|entry| entry.relative_path.as_utf8().unwrap().to_owned())
27391            .collect::<Vec<_>>();
27392        assert!(paths.windows(2).all(|pair| pair[0] < pair[1]));
27393        assert_eq!(
27394            paths,
27395            vec![
27396                "gate4agent-c2".to_owned(),
27397                "gate4agent-c2-client".to_owned(),
27398                "gate4agent-c2-client/lib.rs".to_owned(),
27399                "gate4agent-c2/src".to_owned(),
27400                "gate4agent-c2/src/lib.rs".to_owned(),
27401            ],
27402        );
27403
27404        std::fs::remove_dir_all(&root).unwrap();
27405    }
27406
27407    #[test]
27408    fn git_parsers_cap_status_and_commit_results() {
27409        let mut status_output = Vec::new();
27410        for index in 0..=GIT_STATUS_MAX_ENTRIES {
27411            status_output.extend_from_slice(format!(" M src/file-{index}.rs\0").as_bytes());
27412        }
27413        let mut snapshot = git_snapshot_for_parser();
27414        parse_git_status(&status_output, &mut snapshot);
27415        assert_eq!(snapshot.status.len(), GIT_STATUS_MAX_ENTRIES);
27416        assert!(snapshot.truncated);
27417        assert_eq!(snapshot.status[0].index_status, " ");
27418        assert_eq!(snapshot.status[0].worktree_status, "M");
27419
27420        snapshot.truncated = false;
27421        let commit_output = (0..=GIT_COMMIT_MAX_ENTRIES)
27422            .map(|index| format!("abc{index}\u{1f}commit {index}\n"))
27423            .collect::<String>();
27424        parse_git_commits(commit_output.as_bytes(), &mut snapshot);
27425        assert_eq!(snapshot.recent_commits.len(), GIT_COMMIT_MAX_ENTRIES);
27426        assert!(snapshot.truncated);
27427    }
27428
27429    #[test]
27430    fn porcelain_v1_z_parser_preserves_current_then_previous_rename_and_copy_order() {
27431        let mut snapshot = git_snapshot_for_parser();
27432        parse_git_status(
27433            b"R  src/current name.rs\0src/previous name.rs\0 C src/copy.rs\0src/original.rs\0?? src/line\nname.rs\0",
27434            &mut snapshot,
27435        );
27436
27437        assert!(!snapshot.truncated);
27438        assert_eq!(snapshot.status.len(), 3);
27439        assert_eq!(snapshot.status[0].index_status, "R");
27440        assert_eq!(snapshot.status[0].worktree_status, " ");
27441        assert_eq!(snapshot.status[0].path.as_utf8(), Some("src/current name.rs"));
27442        assert_eq!(
27443            snapshot.status[0]
27444                .previous_path
27445                .as_ref()
27446                .and_then(RepositoryPath::as_utf8),
27447            Some("src/previous name.rs"),
27448        );
27449        assert_eq!(snapshot.status[1].index_status, " ");
27450        assert_eq!(snapshot.status[1].worktree_status, "C");
27451        assert_eq!(snapshot.status[1].path.as_utf8(), Some("src/copy.rs"));
27452        assert_eq!(
27453            snapshot.status[1]
27454                .previous_path
27455                .as_ref()
27456                .and_then(RepositoryPath::as_utf8),
27457            Some("src/original.rs"),
27458        );
27459        assert_eq!(snapshot.status[2].path.as_utf8(), Some("src/line\nname.rs"));
27460        assert!(snapshot.status[2].previous_path.is_none());
27461    }
27462
27463    #[test]
27464    fn porcelain_v1_z_parser_never_invents_invalid_or_incomplete_path_identity() {
27465        let mut snapshot = git_snapshot_for_parser();
27466        parse_git_status(
27467            b"?? valid.rs\0?? invalid-\xff.rs\0?? invalid\\windows.rs\0R  current.rs\0",
27468            &mut snapshot,
27469        );
27470
27471        assert!(snapshot.truncated);
27472        assert_eq!(snapshot.status.len(), 1);
27473        assert_eq!(snapshot.status[0].path.as_utf8(), Some("valid.rs"));
27474        assert!(snapshot.status[0].previous_path.is_none());
27475    }
27476
27477    #[tokio::test]
27478    async fn observer_can_browse_host_directories_without_controller() {
27479        let root = temporary_workspace_root("host-directory-observer");
27480        std::fs::create_dir_all(root.join("directory-b")).unwrap();
27481        std::fs::create_dir_all(root.join("directory-a")).unwrap();
27482        std::fs::write(root.join("ordinary-file"), b"not a directory").unwrap();
27483        let workspace_id = WorkspaceId::new("primary").unwrap();
27484        let workspace = WorkspaceConfig::new(workspace_id, &root).unwrap();
27485        let catalog = active_registry().unwrap();
27486        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27487        let shared = NodeShared::new(
27488            handle,
27489            "fixture-token".to_owned(),
27490            NodeId::new("node-1").unwrap(),
27491            vec![workspace],
27492            vec![agent("claude")],
27493        );
27494        let request = NodeRequest::BrowseHostDirectories {
27495            directory: Some(OpaqueHostPath::utf8(root.to_string_lossy().into_owned()).unwrap()),
27496            after: None,
27497        };
27498        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
27499            capability.as_str() == CAPABILITY_HOST_DIRECTORY_BROWSE_V1
27500        }));
27501        assert!(!request_uses_unnegotiated_capability(
27502            &request,
27503            &baseline_capabilities().unwrap(),
27504        ));
27505        assert!(request_uses_unnegotiated_capability(&request, &[]));
27506
27507        let response = process_request_inner(&shared, 77, ClientRole::Observer, request)
27508            .await
27509            .unwrap();
27510        let NodeResponse::HostDirectoriesBrowsed { listing } = response else {
27511            panic!("host directory browse returned another response");
27512        };
27513        assert_eq!(
27514            listing
27515                .entries
27516                .iter()
27517                .map(|entry| entry.display_name.as_str())
27518                .collect::<Vec<_>>(),
27519            vec!["directory-a", "directory-b"],
27520        );
27521        assert!(listing.entries.iter().all(|entry| !entry.is_link));
27522        assert!(!listing.incomplete);
27523        std::fs::remove_dir_all(root).unwrap();
27524    }
27525
27526    #[tokio::test]
27527    async fn observer_can_inspect_only_a_registered_workspace() {
27528        let root = temporary_workspace_root("observer");
27529        std::fs::create_dir_all(root.join("src")).unwrap();
27530        std::fs::write(root.join("src/main.rs"), b"fn main() {}\n").unwrap();
27531        let workspace_id = WorkspaceId::new("primary").unwrap();
27532        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
27533        let catalog = active_registry().unwrap();
27534        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27535        let shared = NodeShared::new(
27536            handle,
27537            "fixture-token".to_owned(),
27538            NodeId::new("node-1").unwrap(),
27539            vec![workspace],
27540            vec![agent("claude")],
27541        );
27542
27543        let response = process_request_inner(
27544            &shared,
27545            77,
27546            ClientRole::Observer,
27547            NodeRequest::InspectWorkspace {
27548                workspace_id: workspace_id.clone(),
27549            },
27550        )
27551        .await
27552        .unwrap();
27553        let NodeResponse::WorkspaceInspected { inspection } = response else {
27554            panic!("workspace inspection returned another response");
27555        };
27556        assert_eq!(inspection.workspace_id, workspace_id);
27557        assert!(inspection.entries.iter().any(|entry| {
27558            entry.relative_path.as_utf8() == Some("src/main.rs")
27559        }));
27560
27561        let unknown = process_request_inner(
27562            &shared,
27563            77,
27564            ClientRole::Observer,
27565            NodeRequest::InspectWorkspace {
27566                workspace_id: WorkspaceId::new("unknown").unwrap(),
27567            },
27568        )
27569        .await
27570        .unwrap_err();
27571        assert_eq!(unknown.code, NodeFailureCode::UnknownWorkspace);
27572        std::fs::remove_dir_all(&root).unwrap();
27573    }
27574
27575    #[test]
27576    fn context_pack_source_accepts_only_running_or_successful_exit() {
27577        assert!(context_pack_source_status_is_usable(&SessionStatus::Running));
27578        assert!(context_pack_source_status_is_usable(&SessionStatus::Exited {
27579            exit_code: Some(0),
27580        }));
27581        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27582            exit_code: Some(1),
27583        }));
27584        assert!(!context_pack_source_status_is_usable(&SessionStatus::Exited {
27585            exit_code: None,
27586        }));
27587        assert!(!context_pack_source_status_is_usable(&SessionStatus::Failed {
27588            message: "provider failed".to_owned(),
27589        }));
27590        assert!(!context_pack_source_status_is_usable(&SessionStatus::Registered));
27591        assert!(!context_pack_source_status_is_usable(&SessionStatus::Starting));
27592        assert!(!context_pack_source_status_is_usable(&SessionStatus::Stopping));
27593    }
27594
27595    #[test]
27596    fn context_pack_repository_rejects_changed_head_with_same_git_snapshot() {
27597        let git = git_snapshot_for_parser();
27598        let first = ContextPackRepositoryObservation {
27599            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27600            git: git.clone(),
27601            files: Vec::new(),
27602        };
27603        let second = ContextPackRepositoryObservation {
27604            head: ContextPackRepositoryHead::Commit("b".repeat(40)),
27605            git,
27606            files: Vec::new(),
27607        };
27608
27609        let error = stable_context_pack_repository(first, second).unwrap_err();
27610
27611        assert_eq!(
27612            error.code,
27613            NodeFailureCode::ContextPackMaterializationFailed,
27614        );
27615    }
27616
27617    #[test]
27618    fn context_pack_repository_rejects_changed_file_with_same_git_snapshot() {
27619        let git = git_snapshot_for_parser();
27620        let first = ContextPackRepositoryObservation {
27621            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27622            git: git.clone(),
27623            files: vec![ContextPackRepositoryFileSource::utf8(
27624                "README.md",
27625                "first\n".to_owned(),
27626                6,
27627            )],
27628        };
27629        let second = ContextPackRepositoryObservation {
27630            head: ContextPackRepositoryHead::Commit("a".repeat(40)),
27631            git,
27632            files: vec![ContextPackRepositoryFileSource::utf8(
27633                "README.md",
27634                "other\n".to_owned(),
27635                6,
27636            )],
27637        };
27638
27639        let error = stable_context_pack_repository(first, second).unwrap_err();
27640
27641        assert_eq!(
27642            error.code,
27643            NodeFailureCode::ContextPackMaterializationFailed,
27644        );
27645    }
27646
27647    #[tokio::test]
27648    async fn native_session_catalog_zero_active_session_fixture() {
27649        let workspace_root = temporary_workspace_root("native-catalog-workspace");
27650        let history_root = temporary_workspace_root("native-catalog-history");
27651        let outside_root = temporary_workspace_root("native-catalog-outside");
27652        std::fs::create_dir_all(&workspace_root).unwrap();
27653        std::fs::create_dir_all(&history_root).unwrap();
27654        std::fs::create_dir_all(&outside_root).unwrap();
27655        let transcript = |session_id: &str, cwd: &Path, title: &str| {
27656            [
27657                serde_json::json!({
27658                    "type": "user",
27659                    "sessionId": session_id,
27660                    "cwd": cwd.to_str().unwrap(),
27661                    "message": { "content": "raw user text must not cross the catalog wire" }
27662                }),
27663                serde_json::json!({
27664                    "type": "assistant",
27665                    "sessionId": session_id,
27666                    "cwd": cwd.to_str().unwrap(),
27667                    "message": { "model": "claude-sonnet", "content": "raw answer" }
27668                }),
27669                serde_json::json!({
27670                    "type": "ai-title",
27671                    "sessionId": session_id,
27672                    "aiTitle": title
27673                }),
27674            ]
27675            .into_iter()
27676            .map(|record| record.to_string())
27677            .collect::<Vec<_>>()
27678            .join("\n")
27679        };
27680        std::fs::write(
27681            history_root.join("stable-session.jsonl"),
27682            transcript("stable-session", &workspace_root, "Workspace review"),
27683        )
27684        .unwrap();
27685        std::fs::write(
27686            history_root.join("outside-session.jsonl"),
27687            transcript("outside-session", &outside_root, "Outside review"),
27688        )
27689        .unwrap();
27690        let history = NativeHistoryConfig::new(vec![
27691            NativeHistoryRoot::new(
27692                gate4agent_types::AdapterId::new("claude-code").unwrap(),
27693                HistorySourceLayout::SingleNdjson,
27694                history_root.clone(),
27695            )
27696            .unwrap(),
27697        ])
27698        .unwrap();
27699        let catalog = active_registry().unwrap();
27700        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
27701        assert!(handle.snapshot().sessions.is_empty());
27702        let workspace_id = WorkspaceId::new("primary").unwrap();
27703        let workspace = WorkspaceConfig::new(workspace_id.clone(), &workspace_root).unwrap();
27704        let mut shared = NodeShared::new(
27705            handle,
27706            "fixture-token".to_owned(),
27707            NodeId::new("node-native-catalog").unwrap(),
27708            vec![workspace],
27709            vec![agent("claude")],
27710        );
27711        shared.native_session_catalog = Some(Arc::new(Mutex::new(
27712            NativeSessionCatalogAuthority::new(history),
27713        )));
27714        let route = NativeSessionCatalogRoute::workspace(
27715            workspace_id.clone(),
27716            agent("claude"),
27717        );
27718        let request = NodeRequest::CatalogNativeSessions {
27719            route: route.clone(),
27720            limit: 2,
27721        };
27722        let observer = process_request_inner(&shared, 7, ClientRole::Observer, request.clone())
27723            .await
27724            .unwrap_err();
27725        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27726        let response = process_request_inner(&shared, 7, ClientRole::Operator, request)
27727            .await
27728            .unwrap();
27729        let NodeResponse::NativeSessionsCataloged { entries, summary, .. } = response else {
27730            panic!("native catalog returned a different response");
27731        };
27732        let summary = summary.expect("node must emit native session catalog summary metadata");
27733        assert_eq!(entries.len(), 1);
27734        assert!(gate4agent_types::validate_candidate_id(&entries[0].selection_id).is_ok());
27735        assert_eq!(entries[0].external_group, None);
27736        assert_eq!(entries[0].record_id, None);
27737        assert_eq!(entries[0].title, None);
27738        assert_eq!(entries[0].model, None);
27739        assert_eq!(entries[0].message_count, 0);
27740        let encoded_entries = serde_json::to_string(&entries).unwrap();
27741        assert!(!encoded_entries.contains("stable-session"));
27742        assert!(!encoded_entries.contains(history_root.to_string_lossy().as_ref()));
27743        let page_request = NodeRequest::PageNativeSessions {
27744            route: route.clone(),
27745            window: crate::protocol::NativeSessionCatalogWindow::Recent,
27746            catalog_revision: summary.catalog_revision,
27747            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27748            after_selection_id: None,
27749            limit: 2,
27750        };
27751        let observer = process_request_inner(
27752            &shared,
27753            7,
27754            ClientRole::Observer,
27755            page_request.clone(),
27756        )
27757        .await
27758        .unwrap_err();
27759        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27760        let page_response = process_request_inner(
27761            &shared,
27762            7,
27763            ClientRole::Operator,
27764            page_request.clone(),
27765        )
27766        .await
27767        .unwrap();
27768        assert!(matches!(
27769            page_response,
27770            NodeResponse::NativeSessionsPaged { page, .. }
27771                if page.entries.len() == 1 && !page.has_more && page.remaining_count == 0
27772        ));
27773        let stale = process_request_inner(
27774            &shared,
27775            7,
27776            ClientRole::Operator,
27777            NodeRequest::PageNativeSessions {
27778                route: route.clone(),
27779                window: crate::protocol::NativeSessionCatalogWindow::Recent,
27780                catalog_revision: summary.catalog_revision.wrapping_add(1),
27781                recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27782                after_selection_id: None,
27783                limit: 2,
27784            },
27785        )
27786        .await
27787        .unwrap_err();
27788        assert_eq!(stale.code, NodeFailureCode::StaleNativeSessionCatalog);
27789        let selection = NativeSessionSelection {
27790            route: route.clone(),
27791            catalog_revision: summary.catalog_revision,
27792            recent_cutoff_unix_ms: summary.recent_cutoff_unix_ms,
27793            selection_id: entries[0].selection_id.clone(),
27794        };
27795        let preview_response = process_request_inner(
27796            &shared,
27797            7,
27798            ClientRole::Operator,
27799            NodeRequest::PreviewNativeSession {
27800                selection: selection.clone(),
27801                message_limit: 1,
27802            },
27803        )
27804        .await
27805        .unwrap();
27806        let NodeResponse::NativeSessionPreviewed { preview, .. } = preview_response else {
27807            panic!("native preview returned a different response");
27808        };
27809        assert_eq!(preview.messages.len(), 1);
27810        assert!(preview.message_count_exact);
27811        assert_eq!(preview.messages[0].text, "raw answer");
27812        assert!(preview.truncated);
27813
27814        let index_request = NodeRequest::IndexNativeSession {
27815            selection: selection.clone(),
27816            display_name: "Saved Claude".to_owned(),
27817        };
27818        let observer = process_request_inner(
27819            &shared,
27820            7,
27821            ClientRole::Observer,
27822            index_request.clone(),
27823        )
27824        .await
27825        .unwrap_err();
27826        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
27827        let without_controller = process_request_inner(
27828            &shared,
27829            7,
27830            ClientRole::Operator,
27831            index_request.clone(),
27832        )
27833        .await
27834        .unwrap_err();
27835        assert_eq!(without_controller.code, NodeFailureCode::ControllerRequired);
27836        let acquired = process_request_inner(
27837            &shared,
27838            7,
27839            ClientRole::Operator,
27840            NodeRequest::AcquireController { lease_ms: 5_000 },
27841        )
27842        .await
27843        .unwrap();
27844        assert!(matches!(acquired, NodeResponse::Controller { controller: Some(_) }));
27845        let indexed = process_request_inner(
27846            &shared,
27847            7,
27848            ClientRole::Operator,
27849            index_request,
27850        )
27851        .await
27852        .unwrap();
27853        assert!(indexed.native_session_index_contract_is_valid());
27854        let NodeResponse::NativeSessionIndexed {
27855            selection: echoed_selection,
27856            record,
27857        } = indexed else {
27858            panic!("native session index returned a different response");
27859        };
27860        assert_eq!(echoed_selection, selection);
27861        let recataloged = process_request_inner(
27862            &shared,
27863            7,
27864            ClientRole::Operator,
27865            NodeRequest::CatalogNativeSessions {
27866                route,
27867                limit: 2,
27868            },
27869        )
27870        .await
27871        .unwrap();
27872        let NodeResponse::NativeSessionsCataloged { entries, .. } = recataloged else {
27873            panic!("native catalog returned a different response after indexing");
27874        };
27875        assert_eq!(entries[0].record_id.as_ref(), Some(&record.record_id));
27876
27877        let unregistered_route = NativeSessionCatalogRoute::unregistered(agent("claude"));
27878        let external_catalog = process_request_inner(
27879            &shared,
27880            7,
27881            ClientRole::Operator,
27882            NodeRequest::CatalogNativeSessions {
27883                route: unregistered_route.clone(),
27884                limit: 2,
27885            },
27886        )
27887        .await
27888        .unwrap();
27889        let external_json = serde_json::to_string(&external_catalog).unwrap();
27890        assert!(!external_json.contains("outside-session"));
27891        assert!(!external_json.contains(outside_root.to_string_lossy().as_ref()));
27892        assert!(!external_json.contains(history_root.to_string_lossy().as_ref()));
27893        assert!(!external_json.contains("\"session_id\""));
27894        assert!(!external_json.contains("\"cwd\""));
27895        assert!(!external_json.contains("\"path\""));
27896        let NodeResponse::NativeSessionsCataloged {
27897            route: echoed_route,
27898            entries: external_entries,
27899            summary: Some(external_summary),
27900        } = external_catalog
27901        else {
27902            panic!("unregistered native catalog returned a different response");
27903        };
27904        assert_eq!(echoed_route, unregistered_route);
27905        assert_eq!(external_entries.len(), 1);
27906        assert_eq!(external_entries[0].record_id, None);
27907        let external_group = external_entries[0]
27908            .external_group
27909            .as_ref()
27910            .expect("unregistered session must have an external group");
27911        assert_eq!(
27912            external_group.kind,
27913            gate4agent_types::NativeSessionExternalGroupKind::Project,
27914        );
27915        assert!(external_group.group_id.starts_with("external-"));
27916        assert_eq!(
27917            external_group.display_name,
27918            outside_root.file_name().unwrap().to_string_lossy(),
27919        );
27920        let external_selection = NativeSessionSelection {
27921            route: unregistered_route,
27922            catalog_revision: external_summary.catalog_revision,
27923            recent_cutoff_unix_ms: external_summary.recent_cutoff_unix_ms,
27924            selection_id: external_entries[0].selection_id.clone(),
27925        };
27926        let external_preview = process_request_inner(
27927            &shared,
27928            7,
27929            ClientRole::Operator,
27930            NodeRequest::PreviewNativeSession {
27931                selection: external_selection.clone(),
27932                message_limit: 1,
27933            },
27934        )
27935        .await
27936        .unwrap();
27937        let external_preview_json = serde_json::to_string(&external_preview).unwrap();
27938        assert!(!external_preview_json.contains("outside-session"));
27939        assert!(!external_preview_json.contains(outside_root.to_string_lossy().as_ref()));
27940        assert!(!external_preview_json.contains("\"session_id\""));
27941        let NodeResponse::NativeSessionPreviewed { preview, .. } = external_preview else {
27942            panic!("unregistered native preview returned a different response");
27943        };
27944        assert_eq!(preview.messages.len(), 1);
27945        let external_index = process_request_inner(
27946            &shared,
27947            7,
27948            ClientRole::Operator,
27949            NodeRequest::IndexNativeSession {
27950                selection: external_selection,
27951                display_name: "External Claude".to_owned(),
27952            },
27953        )
27954        .await
27955        .unwrap_err();
27956        assert_eq!(
27957            external_index.code,
27958            NodeFailureCode::WorkspaceRegistrationRequired,
27959        );
27960        let record_response = process_request_inner(
27961            &shared,
27962            7,
27963            ClientRole::Operator,
27964            NodeRequest::PreviewSessionRecord {
27965                record_id: record.record_id.clone(),
27966                message_limit: 2,
27967            },
27968        )
27969        .await
27970        .unwrap();
27971        let NodeResponse::SessionRecordPreviewed { record_id, preview } = record_response else {
27972            panic!("record preview returned a different response");
27973        };
27974        assert_eq!(record_id, record.record_id);
27975        assert_eq!(preview.messages.len(), 2);
27976        let encoded = serde_json::to_string(&preview).unwrap();
27977        assert!(!encoded.contains("stable-session"));
27978        assert!(!encoded.contains(history_root.to_string_lossy().as_ref()));
27979        let NodeResponse::Resync { events, .. } = shared.resync(0) else {
27980            panic!("record preview history summary returned a different response");
27981        };
27982        let summaries = events
27983            .iter()
27984            .filter_map(|envelope| match &envelope.event {
27985                NodeEvent::SessionRecordHistorySummarized { record_id, summary }
27986                    if record_id == &record.record_id =>
27987                {
27988                    Some(*summary)
27989                }
27990                _ => None,
27991            })
27992            .collect::<Vec<_>>();
27993        assert_eq!(summaries, vec![SessionHistorySummaryV1::from(&preview)]);
27994        let summary_json = serde_json::to_string(&summaries).unwrap();
27995        for private in [
27996            "stable-session",
27997            "raw user text must not cross the catalog wire",
27998            "raw answer",
27999            "Workspace review",
28000            "claude-sonnet",
28001        ] {
28002            assert!(!summary_json.contains(private), "{private}");
28003        }
28004        let identity = record.provider_session.clone().unwrap();
28005        assert_eq!(
28006            shared.revalidate_session_record_preview(&record, &identity),
28007            Ok(())
28008        );
28009        shared.remove_record_memory(&record.record_id);
28010        assert_eq!(
28011            shared
28012                .revalidate_session_record_preview(&record, &identity)
28013                .unwrap_err()
28014                .code,
28015            NodeFailureCode::UnknownSessionRecord
28016        );
28017        let released = process_request_inner(
28018            &shared,
28019            7,
28020            ClientRole::Operator,
28021            NodeRequest::ReleaseController,
28022        )
28023        .await
28024        .unwrap();
28025        assert!(matches!(released, NodeResponse::Controller { controller: None }));
28026        assert!(shared.controller_state().is_none());
28027        std::fs::remove_dir_all(workspace_root).unwrap();
28028        std::fs::remove_dir_all(history_root).unwrap();
28029        std::fs::remove_dir_all(outside_root).unwrap();
28030    }
28031
28032    #[tokio::test]
28033    async fn native_session_catalog_concurrent_operation_fails_fast_without_waiting_on_mutex() {
28034        let history_root = temporary_workspace_root("native-catalog-concurrent-operation");
28035        std::fs::create_dir_all(&history_root).unwrap();
28036        let history = NativeHistoryConfig::new(vec![
28037            NativeHistoryRoot::new(
28038                gate4agent_types::AdapterId::new("claude-code").unwrap(),
28039                HistorySourceLayout::SingleNdjson,
28040                history_root.clone(),
28041            )
28042            .unwrap(),
28043        ])
28044        .unwrap();
28045        let catalog = Arc::new(Mutex::new(NativeSessionCatalogAuthority::new(history)));
28046        let held = catalog.lock().unwrap();
28047        let result = timeout(
28048            Duration::from_secs(1),
28049            run_native_session_catalog_operation(
28050                Arc::clone(&catalog),
28051                "native session catalog",
28052                |_| Ok::<(), std::convert::Infallible>(()),
28053            ),
28054        )
28055        .await
28056        .expect("concurrent native catalog request must not wait for the operation deadline")
28057        .unwrap_err();
28058        assert_eq!(result.code, NodeFailureCode::BackendBusy);
28059        drop(held);
28060        std::fs::remove_dir_all(history_root).unwrap();
28061    }
28062
28063    fn temporary_workspace_root(label: &str) -> std::path::PathBuf {
28064        let unique = std::time::SystemTime::now()
28065            .duration_since(std::time::UNIX_EPOCH)
28066            .unwrap()
28067            .as_nanos();
28068        std::env::temp_dir().join(format!(
28069            "gate4agent-node-{label}-{}-{unique}",
28070            std::process::id(),
28071        ))
28072    }
28073
28074    fn git_snapshot_for_parser() -> GitSnapshot {
28075        GitSnapshot {
28076            is_repository: true,
28077            branch: Some("main".to_owned()),
28078            status: Vec::new(),
28079            recent_commits: Vec::new(),
28080            worktrees: Vec::new(),
28081            managed_worktree: None,
28082            truncated: false,
28083            diagnostic: None,
28084        }
28085    }
28086
28087    #[tokio::test]
28088    async fn workspace_inspection_scopes_only_exact_active_managed_branch() {
28089        let root = temporary_workspace_root("managed-git-scope");
28090        std::fs::create_dir_all(&root).unwrap();
28091        run_test_git(&root, &["init"]);
28092        run_test_git(&root, &["config", "user.email", "gate4agent@example.invalid"]);
28093        run_test_git(&root, &["config", "user.name", "Gate4Agent Test"]);
28094        std::fs::write(root.join("seed.txt"), "seed\n").unwrap();
28095        run_test_git(&root, &["add", "seed.txt"]);
28096        run_test_git(&root, &["commit", "-m", "seed"]);
28097        run_test_git(&root, &["checkout", "-b", "gate4agent/a"]);
28098
28099        let workspace_id = WorkspaceId::new("managed-a").unwrap();
28100        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28101        let catalog = active_registry().unwrap();
28102        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28103        let shared = NodeShared::new(
28104            handle,
28105            "fixture-token".to_owned(),
28106            NodeId::new("node-1").unwrap(),
28107            vec![workspace],
28108            vec![agent("claude")],
28109        );
28110        let mut lease = ManagedWorktreeLeaseRecord {
28111            lease_id: ManagedWorktreeLeaseId::new("mw-a").unwrap(),
28112            source_workspace_id: WorkspaceId::new("primary").unwrap(),
28113            workspace_id: workspace_id.clone(),
28114            profile_id: WorktreeProfileId::new("default").unwrap(),
28115            profile_revision: crate::protocol::WorktreeProfileRevision::new("v1").unwrap(),
28116            target_root: root.to_string_lossy().into_owned(),
28117            branch: "gate4agent/a".to_owned(),
28118            base_commit: "0123456789abcdef0123456789abcdef01234567".to_owned(),
28119            expected_head: None,
28120            retention: ManagedWorktreeRetention::Retain,
28121            state: ManagedWorktreeLeaseState::InUse,
28122            session_holders: vec![ManagedWorktreeSessionHolder {
28123                incarnation_id: shared.incarnation_id,
28124                instance_id: AgentInstanceId(7),
28125                generation: SessionGeneration(1),
28126            }],
28127            record_holders: Vec::new(),
28128            cleanup_failure: None,
28129            created_at_unix_ms: 1,
28130            updated_at_unix_ms: 1,
28131        };
28132        *shared
28133            .managed_worktrees
28134            .lock()
28135            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28136            ManagedWorktreeRegistry::from_records(vec![lease.clone()], Vec::new()).unwrap();
28137
28138        let inspection = shared.inspect_workspace(workspace_id.clone()).await.unwrap();
28139        let scope = inspection.git.managed_worktree.unwrap();
28140        assert_eq!(scope.lease_id, lease.lease_id);
28141        assert_eq!(scope.branch, "gate4agent/a");
28142        assert_eq!(scope.active_session_count, 1);
28143
28144        lease.branch = "gate4agent/b".to_owned();
28145        *shared
28146            .managed_worktrees
28147            .lock()
28148            .unwrap_or_else(|poisoned| poisoned.into_inner()) =
28149            ManagedWorktreeRegistry::from_records(vec![lease], Vec::new()).unwrap();
28150        let mismatch = shared.inspect_workspace(workspace_id).await.unwrap();
28151        assert!(mismatch.git.managed_worktree.is_none());
28152        std::fs::remove_dir_all(root).unwrap();
28153    }
28154
28155    #[tokio::test]
28156    async fn workspace_file_write_requires_controller_and_rejects_stale_revision() {
28157        let root = temporary_workspace_root("workspace-file-write-controller");
28158        std::fs::create_dir_all(root.join("src")).unwrap();
28159        std::fs::write(root.join("src/lib.rs"), b"old\n").unwrap();
28160        let workspace_id = WorkspaceId::new("primary").unwrap();
28161        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28162        let catalog = active_registry().unwrap();
28163        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28164        let shared = NodeShared::new(
28165            handle,
28166            "fixture-token".to_owned(),
28167            NodeId::new("node-1").unwrap(),
28168            vec![workspace],
28169            vec![agent("claude")],
28170        );
28171        let path = RepositoryPath::utf8("src/lib.rs".to_owned()).unwrap();
28172        let expected_revision = workspace_file_revision(b"old\n");
28173        let request = NodeRequest::WriteWorkspaceFile {
28174            workspace_id: workspace_id.clone(),
28175            path: path.clone(),
28176            expected_revision: expected_revision.clone(),
28177            text: "new\n".to_owned(),
28178        };
28179        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28180            .await
28181            .unwrap_err();
28182        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28183        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28184            .await
28185            .unwrap_err();
28186        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28187        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28188        let response = process_request_inner(&shared, 77, ClientRole::Operator, request).await.unwrap();
28189        let NodeResponse::WorkspaceFileWritten { file } = response else {
28190            panic!("workspace file write returned another response");
28191        };
28192        assert_eq!(file.revision, Some(workspace_file_revision(b"new\n")));
28193        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28194        let stale = process_request_inner(
28195            &shared,
28196            77,
28197            ClientRole::Operator,
28198            NodeRequest::WriteWorkspaceFile {
28199                workspace_id,
28200                path,
28201                expected_revision,
28202                text: "lost update\n".to_owned(),
28203            },
28204        )
28205        .await
28206        .unwrap_err();
28207        assert_eq!(stale.code, NodeFailureCode::RepositoryFileRevisionConflict);
28208        assert_eq!(std::fs::read(root.join("src/lib.rs")).unwrap(), b"new\n");
28209        std::fs::remove_dir_all(root).unwrap();
28210    }
28211
28212    #[tokio::test]
28213    async fn workspace_entry_create_requires_controller_and_never_overwrites() {
28214        let root = temporary_workspace_root("workspace-entry-create-controller");
28215        std::fs::create_dir_all(root.join("src")).unwrap();
28216        let workspace_id = WorkspaceId::new("primary").unwrap();
28217        let workspace = WorkspaceConfig::new(workspace_id.clone(), &root).unwrap();
28218        let catalog = active_registry().unwrap();
28219        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28220        let shared = NodeShared::new(
28221            handle,
28222            "fixture-token".to_owned(),
28223            NodeId::new("node-1").unwrap(),
28224            vec![workspace],
28225            vec![agent("claude")],
28226        );
28227        let path = RepositoryPath::utf8("src/new.rs".to_owned()).unwrap();
28228        let request = NodeRequest::CreateWorkspaceFile {
28229            workspace_id: workspace_id.clone(),
28230            path: path.clone(),
28231        };
28232        assert!(baseline_capabilities().unwrap().iter().any(|capability| {
28233            capability.as_str() == NODE_WORKSPACE_ENTRY_CREATE_CAPABILITY
28234        }));
28235        assert!(!request_uses_unnegotiated_capability(
28236            &request,
28237            &baseline_capabilities().unwrap(),
28238        ));
28239        assert!(request_uses_unnegotiated_capability(&request, &[]));
28240        let observer = process_request_inner(&shared, 77, ClientRole::Observer, request.clone())
28241            .await
28242            .unwrap_err();
28243        assert_eq!(observer.code, NodeFailureCode::ObserverReadOnly);
28244        let uncontrolled = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28245            .await
28246            .unwrap_err();
28247        assert_eq!(uncontrolled.code, NodeFailureCode::ControllerRequired);
28248        shared.acquire_controller(77, ClientRole::Operator, DEFAULT_CONTROLLER_LEASE_MS).unwrap();
28249
28250        let response = process_request_inner(&shared, 77, ClientRole::Operator, request.clone())
28251            .await
28252            .unwrap();
28253        let NodeResponse::WorkspaceFileCreated { file } = response else {
28254            panic!("workspace file create returned another response");
28255        };
28256        assert_eq!(file.workspace_id, workspace_id);
28257        assert_eq!(file.path, path);
28258        assert_eq!(file.revision, Some(workspace_file_revision(&[])));
28259        assert_eq!(std::fs::read(root.join("src/new.rs")).unwrap(), Vec::<u8>::new());
28260
28261        let duplicate = process_request_inner(&shared, 77, ClientRole::Operator, request)
28262            .await
28263            .unwrap_err();
28264        assert_eq!(duplicate.code, NodeFailureCode::RepositoryEntryAlreadyExists);
28265
28266        let directory_path = RepositoryPath::utf8("src/nested".to_owned()).unwrap();
28267        let response = process_request_inner(
28268            &shared,
28269            77,
28270            ClientRole::Operator,
28271            NodeRequest::CreateWorkspaceDirectory {
28272                workspace_id: workspace_id.clone(),
28273                path: directory_path.clone(),
28274            },
28275        )
28276        .await
28277        .unwrap();
28278        assert_eq!(
28279            response,
28280            NodeResponse::WorkspaceDirectoryCreated {
28281                workspace_id: workspace_id.clone(),
28282                entry: WorkspaceEntry {
28283                    relative_path: directory_path,
28284                    kind: WorkspaceEntryKind::Directory,
28285                },
28286            },
28287        );
28288        assert!(root.join("src/nested").is_dir());
28289
28290        let missing_parent = process_request_inner(
28291            &shared,
28292            77,
28293            ClientRole::Operator,
28294            NodeRequest::CreateWorkspaceFile {
28295                workspace_id: workspace_id.clone(),
28296                path: RepositoryPath::utf8("missing/new.rs".to_owned()).unwrap(),
28297            },
28298        )
28299        .await
28300        .unwrap_err();
28301        assert_eq!(missing_parent.code, NodeFailureCode::RepositoryParentNotFound);
28302        let non_directory_parent = process_request_inner(
28303            &shared,
28304            77,
28305            ClientRole::Operator,
28306            NodeRequest::CreateWorkspaceFile {
28307                workspace_id,
28308                path: RepositoryPath::utf8("src/new.rs/child".to_owned()).unwrap(),
28309            },
28310        )
28311        .await
28312        .unwrap_err();
28313        assert_eq!(
28314            non_directory_parent.code,
28315            NodeFailureCode::RepositoryParentNotDirectory,
28316        );
28317        std::fs::remove_dir_all(root).unwrap();
28318    }
28319
28320    #[tokio::test]
28321    async fn workspace_entry_create_timeout_cancels_before_commit_without_late_mutation() {
28322        let root = temporary_workspace_root("workspace-entry-create-timeout-cancel");
28323        std::fs::create_dir_all(root.join("src")).unwrap();
28324        #[cfg(windows)]
28325        let root = std::fs::canonicalize(root).unwrap();
28326        let path = RepositoryPath::utf8("src/late.rs".to_owned()).unwrap();
28327        let commit_state = Arc::new(AtomicU8::new(WORKSPACE_ENTRY_CREATE_PENDING));
28328        let barrier = Arc::new(std::sync::Barrier::new(2));
28329        let done = Arc::new(AtomicBool::new(false));
28330        let task_commit_state = Arc::clone(&commit_state);
28331        let task_barrier = Arc::clone(&barrier);
28332        let task_done = Arc::clone(&done);
28333        let task_root = root.clone();
28334        let task = tokio::task::spawn_blocking(move || {
28335            task_barrier.wait();
28336            let result = create_workspace_file_on_disk(
28337                &task_root,
28338                &path,
28339                &task_commit_state,
28340            );
28341            task_done.store(true, Ordering::Release);
28342            result
28343        });
28344
28345        let error = settle_workspace_entry_create(
28346            task,
28347            Arc::clone(&commit_state),
28348            Duration::from_millis(10),
28349            "fixture deadline",
28350            "fixture task failed",
28351        )
28352        .await
28353        .unwrap_err();
28354        assert_eq!(error.code, NodeFailureCode::RepositoryEntryCreateTimedOut);
28355        assert_eq!(
28356            commit_state.load(Ordering::Acquire),
28357            WORKSPACE_ENTRY_CREATE_CANCELED,
28358        );
28359
28360        barrier.wait();
28361        timeout(Duration::from_secs(1), async {
28362            while !done.load(Ordering::Acquire) {
28363                tokio::task::yield_now().await;
28364            }
28365        })
28366        .await
28367        .expect("canceled blocking create did not settle");
28368        assert!(!root.join("src/late.rs").exists());
28369        std::fs::remove_dir_all(root).unwrap();
28370    }
28371}
28372
28373#[cfg(test)]
28374mod provisional_spawn_tests {
28375    use super::*;
28376    use std::sync::Arc;
28377
28378    fn shared() -> NodeShared {
28379        let catalog = active_registry().unwrap();
28380        let (handle, _runtime) = NativeRuntime::new(catalog, NativeRuntimeConfig::default());
28381        let workspace = WorkspaceConfig::new(
28382            WorkspaceId::new("primary").unwrap(),
28383            std::env::current_dir().unwrap(),
28384        )
28385        .unwrap()
28386        .with_worktree_service_mode(WorktreeServiceMode::Manual);
28387        NodeShared::new(
28388            handle,
28389            "fixture-token".to_owned(),
28390            NodeId::new("node-provisional-test").unwrap(),
28391            vec![workspace],
28392            vec![AgentId::new("claude").unwrap()],
28393        )
28394    }
28395
28396    /// Provisional draft map: sessionize by instance id only; 50 sequential
28397    /// mark/claim cycles never collide, and a claimed draft is never reaped.
28398    #[test]
28399    fn provisional_spawn_keys_are_unique_and_sessionize_prevents_reap() {
28400        let shared = shared();
28401        let ws = WorkspaceId::new("primary").unwrap();
28402        let mut ids = Vec::with_capacity(50);
28403        for i in 1u64..=50 {
28404            let id = AgentInstanceId(i);
28405            ids.push(id);
28406            let address = SessionAddress {
28407                workspace_id: ws.clone(),
28408                session: SessionKey {
28409                    instance_id: id,
28410                    generation: SessionGeneration(1),
28411                },
28412            };
28413            shared.mark_spawn_provisional(address);
28414        }
28415        assert_eq!(
28416            shared
28417                .provisional_spawns
28418                .lock()
28419                .unwrap_or_else(|p| p.into_inner())
28420                .len(),
28421            50
28422        );
28423        for id in ids.iter().step_by(2) {
28424            shared.sessionize_spawn(*id);
28425        }
28426        assert_eq!(
28427            shared
28428                .provisional_spawns
28429                .lock()
28430                .unwrap_or_else(|p| p.into_inner())
28431                .len(),
28432            25
28433        );
28434        assert!(shared.take_due_provisional_spawns().is_empty());
28435        {
28436            let mut map = shared
28437                .provisional_spawns
28438                .lock()
28439                .unwrap_or_else(|p| p.into_inner());
28440            let past = Instant::now() - Duration::from_secs(91);
28441            for (_, (_, at)) in map.iter_mut() {
28442                *at = past;
28443            }
28444        }
28445        let due = shared.take_due_provisional_spawns();
28446        assert_eq!(due.len(), 25);
28447        let due_ids: std::collections::BTreeSet<_> =
28448            due.iter().map(|a| a.session.instance_id.0).collect();
28449        let expected: std::collections::BTreeSet<_> =
28450            ids.iter().skip(1).step_by(2).map(|id| id.0).collect();
28451        assert_eq!(due_ids, expected);
28452        assert!(shared
28453            .provisional_spawns
28454            .lock()
28455            .unwrap_or_else(|p| p.into_inner())
28456            .is_empty());
28457    }
28458
28459    /// Twenty parallel provisional marks must each get their own instance key;
28460    /// killing/sessionizing one id must not touch the others.
28461    #[test]
28462    fn provisional_spawn_parallel_marks_isolate_kills_by_instance_id() {
28463        let shared = Arc::new(shared());
28464        let ws = WorkspaceId::new("primary").unwrap();
28465        let mut handles = Vec::new();
28466        for i in 1u64..=20 {
28467            let shared = Arc::clone(&shared);
28468            let ws = ws.clone();
28469            handles.push(std::thread::spawn(move || {
28470                let id = AgentInstanceId(i);
28471                let address = SessionAddress {
28472                    workspace_id: ws,
28473                    session: SessionKey {
28474                        instance_id: id,
28475                        generation: SessionGeneration(1),
28476                    },
28477                };
28478                shared.mark_spawn_provisional(address);
28479                id
28480            }));
28481        }
28482        let mut ids = Vec::new();
28483        for h in handles {
28484            ids.push(h.join().unwrap());
28485        }
28486        ids.sort_by_key(|id| id.0);
28487        assert_eq!(ids.len(), 20);
28488        ids.dedup();
28489        assert_eq!(ids.len(), 20, "parallel marks must not share instance keys");
28490        assert_eq!(
28491            shared
28492                .provisional_spawns
28493                .lock()
28494                .unwrap_or_else(|p| p.into_inner())
28495                .len(),
28496            20
28497        );
28498        shared.sessionize_spawn(AgentInstanceId(7));
28499        let left: Vec<_> = shared
28500            .provisional_spawns
28501            .lock()
28502            .unwrap_or_else(|p| p.into_inner())
28503            .keys()
28504            .map(|id| id.0)
28505            .collect();
28506        assert!(!left.contains(&7));
28507        assert_eq!(left.len(), 19);
28508    }
28509}
28510
28511
28512#[cfg(unix)]
28513extern "C" {
28514    #[link_name = "geteuid"]
28515    fn libc_geteuid() -> u32;
28516}
28517
28518#[cfg(test)]
28519mod call_home_link_tests {
28520    use super::*;
28521
28522    #[test]
28523    fn call_home_backoff_is_capped_and_monotonic() {
28524        let mut last = Duration::ZERO;
28525        for attempt in 1..20 {
28526            let next = call_home_backoff(attempt);
28527            assert!(next >= last);
28528            assert!(next <= Duration::from_secs(8));
28529            last = next;
28530        }
28531        assert_eq!(call_home_backoff(0), Duration::from_millis(500));
28532    }
28533
28534    #[tokio::test]
28535    async fn call_home_socket_gets_keepalive() {
28536        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
28537        let addr = listener.local_addr().unwrap();
28538        let (client, _server) = tokio::join!(tokio::net::TcpStream::connect(addr), listener.accept());
28539        let client = client.unwrap();
28540        enable_call_home_keepalive(&client).unwrap();
28541        let sock = socket2::SockRef::from(&client);
28542        assert!(sock.keepalive().unwrap());
28543        #[cfg(any(target_os = "linux", target_os = "macos"))]
28544        assert_eq!(sock.tcp_keepalive_time().unwrap(), CALL_HOME_KEEPALIVE_IDLE);
28545    }
28546}
28547
28548/// A provider transcript for this session id exists in a known provider home.
28549fn provider_session_exists(provider: &str, id: &str) -> bool {
28550    if provider != "claude" { return false; }
28551    let mut homes: Vec<PathBuf> = std::env::var_os("HOME").map(PathBuf::from).into_iter().collect();
28552    if let Some(extra) = std::env::var_os("HATCHERY_PROVIDER_HOMES") { homes.extend(std::env::split_paths(&extra)); }
28553    let name = format!("{id}.jsonl");
28554    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())))
28555}
28556
28557/// Append launch args to a prepared overlay (or create an instance overlay for them).
28558fn overlay_with_extra_args(
28559    provider: &AgentId,
28560    mode: SessionMode,
28561    overlay: Option<PreparedNativeLaunchOverlay>,
28562    args: Vec<OsString>,
28563) -> Result<Option<PreparedNativeLaunchOverlay>, NodeFailure> {
28564    let bad = || failure(NodeFailureCode::BackendOperationFailed, "resume launch args overlay is invalid");
28565    Ok(match (overlay, args) {
28566        (prepared, args) if args.is_empty() => prepared,
28567        (Some(PreparedNativeLaunchOverlay::Instance(mut o)), args) => { o.append_extra_args(args).map_err(|_| bad())?; Some(PreparedNativeLaunchOverlay::Instance(o)) }
28568        (Some(PreparedNativeLaunchOverlay::Environment(o)), args) => Some(PreparedNativeLaunchOverlay::Instance(o.into_instance_with_extra_args(args).map_err(|_| bad())?)),
28569        (None, args) => Some(PreparedNativeLaunchOverlay::Instance(NativeInstanceLaunchOverlay::new(
28570            provider.clone(),
28571            match mode { SessionMode::Pty => TransportKind::Pty, SessionMode::Acp => TransportKind::Acp, SessionMode::Inline => TransportKind::Pipe },
28572            Vec::new(),
28573            args,
28574        ).map_err(|_| bad())?)),
28575    })
28576}